Performance Enhancement Implementation Plan

Spec: docs/superpowers/specs/2026-03-14-performance-enhancement-design.md Date: 2026-03-14 Branch: performance-enhancement


Phase 1: Critical N+1 Fixes (Highest Impact)

Step 1.1: Achievement Checker - Batch Stats Loading

Files: app/models/gamification/achievement_checker.rb New file: app/models/gamification/stats_cache.rb

Changes:

  1. Create Gamification::StatsCache model class that pre-fetches ALL stats needed by calculate_progress in ~5 queries:
    • Habit counts (total, mastered, daily), HabitCompletion count, max streak
    • Goal counts (completed, total), Expense count, Note count
    • Birthday count, Account sum, XpTransaction distinct dates
    • MarketList with items (eager loaded)
    • Feature usage flags
  2. Modify check_all to:
    • Load all user achievement IDs in one query: UserAchievement.where(membership_id:).pluck(:achievement_id).to_set
    • Create a StatsCache instance, pass to calculate_progress
    • Replace all individual queries in calculate_progress with hash lookups
  3. Modify calculate_all_daily_streak to use a single query for all habits' completions

Tests: Run existing gamification/achievement_checker_test.rb, verify same behavior with fewer queries.

Estimated query reduction: ~400 queries -> ~10 queries per check_all call


Step 1.2: Move Achievement Checking to Background Job

Files: app/models/gamification/xp_awarder.rb New file: app/jobs/gamification/achievement_check_job.rb

Changes:

  1. Create Gamification::AchievementCheckJob < ApplicationJob that calls AchievementChecker.check_all
  2. In XpAwarder.award (line 84), replace inline AchievementChecker.check_all(membership) with AchievementCheckJob.perform_later(membership.id)
  3. The job should use perform_later to debounce multiple awards in quick succession

Tests: Verify XP awards no longer block, achievements still get checked asynchronously.

Estimated impact: Habit toggle response drops from 10,273ms to ~200ms (achievement checking moves to background)


Step 1.3: HabitCompletion Preloader

Files: app/models/habit.rb, app/models/habit/completion_tracker.rb Changes in: app/controllers/organizations/dashboard_controller.rb, app/controllers/organizations/habits_controller.rb, app/controllers/organizations/gamification_controller.rb

Changes:

  1. Add class method Habit.preload_completions_today(habits):
    def self.preload_completions_today(habits)
      ids = habits.map(&:id)
      completed_ids = HabitCompletion.where(habit_id: ids, date: Date.current, completed: true).pluck(:habit_id).to_set
      habits.each { |h| h.instance_variable_set(:@_completed_today_preloaded, completed_ids.include?(h.id)) }
    end
    
  2. Modify completed_today? to check @_completed_today_preloaded first:
    def completed_today?
      return @_completed_today_preloaded unless @_completed_today_preloaded.nil?
      habit_completions.exists?(date: Date.current, completed: true)
    end
    
  3. Call Habit.preload_completions_today(@habits) in dashboard, habits index, and gamification controllers after loading habits

Tests: Existing habit tests should pass. Add test for preloader.

Estimated query reduction: 8-24 queries -> 1 query per page


Step 1.4: Streak Recalculation - Single Query

File: app/models/habit/completion_tracker.rb

Changes:

  1. Replace day-by-day loop in recalculate_streak! (line 35) with:
    def recalculate_streak!
      dates = habit.habit_completions.where(completed: true)
        .where("date <= ?", Date.current)
        .order(date: :desc).pluck(:date)
      streak = 0
      expected = Date.current
      # For daily habits, check consecutive days
      # For weekly habits, check consecutive weeks
      dates.each do |date|
        break unless date == expected
        streak += 1
        expected = expected - frequency_interval
      end
      habit.update_column(:current_streak, streak)
    end
    

Tests: Existing streak tests must pass.

Estimated query reduction: O(streak_length) queries -> 1 query


Step 1.5: BalanceRegistry Single Load in DataAggregator

File: app/models/dashboard/data_aggregator.rb

Changes:

  1. Add memoized method:
    def all_balance_registries
      @all_balance_registries ||= organization.balance_registries.order(:date).to_a
    end
    
  2. Refactor monthly_growth, total_growth_pct, avg_monthly_growth, mom_normalized_series, patrimonial_history to use all_balance_registries instead of separate queries
  3. Apply same pattern to Organization::AnalyticsCalculator – memoize balance registries

Tests: Existing data aggregator tests must pass.

Estimated query reduction: 4-5 queries -> 1 query per aggregator instance


Step 1.6: FamilyAggregator kpis Memoization

File: app/models/dashboard/family_aggregator.rb, app/models/dashboard/data_aggregator.rb

Changes:

  1. Memoize DataAggregator#kpis: @kpis ||= { ... build hash ... }
  2. This prevents recomputation when FamilyAggregator accesses a.kpis[:key] repeatedly

Estimated impact: Eliminates N*M redundant query sets in family mode


Phase 2: Query Optimization

Step 2.1: Add Missing Database Index

New file: Migration

Changes:

  1. Add composite index on habit_completions: (habit_id, completed, date) for the frequent exists? query pattern

Step 2.2: XpTransaction action_type Column

New file: Migration Files: app/models/gamification/xp_awarder.rb, app/models/gamification/achievement_checker.rb

Changes:

  1. Add migration: add_column :xp_transactions, :action_type, :string + index on (membership_id, action_type, created_at)
  2. Populate action_type from existing description patterns via migration
  3. Update XpAwarder to set action_type when creating transactions
  4. Replace all description LIKE '%...' queries with where(action_type: '...')

Step 2.3: Gamification Weekly XP - Grouped Query

File: app/controllers/organizations/gamification_controller.rb

Changes:

  1. Replace 24 individual queries (lines 30-50) with single grouped query:
    raw = xp_scope.where(created_at: 8.weeks.ago..)
      .group("strftime('%W', datetime(created_at))", :category)
      .sum(:amount)
    
  2. Process raw hash in Ruby to build @weekly_xp and @category_weekly

Estimated query reduction: 24 queries -> 1 query


Step 2.4: Expense Monthly Totals - Grouped Query

Files: app/models/organization/analytics_calculator.rb, app/controllers/organizations/expenses_controller.rb

Changes:

  1. Replace per-month sum(:amount) loop with:
    Expense.where(organization_id:, date: range).group("strftime('%Y-%m', date)").sum(:amount)
    
  2. Apply in both analytics_calculator.rb and expenses_controller.rb

Estimated query reduction: 6 queries -> 1 query per location


Step 2.5: Dashboard - Eliminate Redundant Exists? + Load

File: app/controllers/organizations/dashboard_controller.rb

Changes:

  1. Remove separate count queries (@accounts_count, @investments_count, @goals_count)
  2. Use .size on already-loaded collections or load counts from the same query
  3. Remove any Exists? checks in views that are followed by Load – use .any? on loaded data

Estimated query reduction: 6+ queries eliminated


Step 2.6: Sidebar BalanceCalculator Optimization

File: app/controllers/application_controller.rb

Changes:

  1. Replace Account::BalanceCalculator.new(...).total in set_sidebar_context with:
    @total_patrimony = Current.organization.accounts.sum(:balance)
    
  2. This uses SQL SUM instead of loading all accounts into memory

Estimated impact: Eliminates full account load on every authenticated request


Step 2.7: BalanceCalculator SQL Aggregation

File: app/models/account/balance_calculator.rb

Changes:

  1. Add SQL-based total that uses sum(:balance) at the DB level
  2. Keep Ruby-based methods for when individual account data is needed

Phase 3: Caching & Infrastructure

Step 3.1: Enable Bullet Gem

File: config/environments/development.rb, Gemfile (if not present)

Changes:

  1. Ensure bullet gem is in Gemfile (development group)
  2. Configure in development.rb:
    config.after_initialize do
      Bullet.enable = true
      Bullet.bullet_logger = true
      Bullet.rails_logger = true
      Bullet.add_safelist type: :unused_eager_loading, ...
    end
    

Step 3.2: Counter Caches

Files: app/models/market_list_item.rb, migrations

Changes:

  1. Add market_list_items_count counter cache on MarketList
  2. Add migration with backfill

Step 3.3: Dashboard Fragment Caching (Optional)

Files: Dashboard view partials

Changes:

  1. Wrap dashboard sections in cache blocks with appropriate keys
  2. Add touch: true on relevant associations for cache invalidation

Execution Order

  1. Step 1.1 (StatsCache) + Step 1.2 (Background Job) – biggest single impact
  2. Step 1.3 (HabitCompletion Preloader) – affects multiple pages
  3. Step 1.4 (Streak single query) – quick win
  4. Step 1.5 (BalanceRegistry single load) – affects finance pages
  5. Step 1.6 (FamilyAggregator memoization) – quick win
  6. Step 2.1 (Index) + Step 2.2 (action_type) – migrations together
  7. Step 2.3 (Gamification grouped query) – depends on 2.2
  8. Step 2.4 (Expense grouped query) – independent
  9. Step 2.5 (Dashboard redundant queries) – independent
  10. Step 2.6 + Step 2.7 (Sidebar + BalanceCalculator) – quick wins
  11. Step 3.1 (Bullet) – infrastructure
  12. Step 3.2 (Counter caches) – independent
  13. Step 3.3 (Fragment caching) – optional, after all else is done

Review Checkpoints

  • After Phase 1: Run full test suite, compare development.log query counts on key pages
  • After Phase 2: Run full test suite, verify new indexes are used via EXPLAIN
  • After Phase 3: Run full test suite, verify Bullet reports no new N+1s