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:
- Create
Gamification::StatsCachemodel class that pre-fetches ALL stats needed bycalculate_progressin ~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
- Modify
check_allto:- Load all user achievement IDs in one query:
UserAchievement.where(membership_id:).pluck(:achievement_id).to_set - Create a
StatsCacheinstance, pass tocalculate_progress - Replace all individual queries in
calculate_progresswith hash lookups
- Load all user achievement IDs in one query:
- Modify
calculate_all_daily_streakto 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:
- Create
Gamification::AchievementCheckJob < ApplicationJobthat callsAchievementChecker.check_all - In
XpAwarder.award(line 84), replace inlineAchievementChecker.check_all(membership)withAchievementCheckJob.perform_later(membership.id) - The job should use
perform_laterto 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:
- 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 - Modify
completed_today?to check@_completed_today_preloadedfirst:def completed_today? return @_completed_today_preloaded unless @_completed_today_preloaded.nil? habit_completions.exists?(date: Date.current, completed: true) end - 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:
- 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:
- Add memoized method:
def all_balance_registries @all_balance_registries ||= organization.balance_registries.order(:date).to_a end - Refactor
monthly_growth,total_growth_pct,avg_monthly_growth,mom_normalized_series,patrimonial_historyto useall_balance_registriesinstead of separate queries - 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:
- Memoize
DataAggregator#kpis:@kpis ||= { ... build hash ... } - This prevents recomputation when
FamilyAggregatoraccessesa.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:
- Add composite index on
habit_completions:(habit_id, completed, date)for the frequentexists?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:
- Add migration:
add_column :xp_transactions, :action_type, :string+ index on(membership_id, action_type, created_at) - Populate
action_typefrom existingdescriptionpatterns via migration - Update
XpAwarderto setaction_typewhen creating transactions - Replace all
description LIKE '%...'queries withwhere(action_type: '...')
Step 2.3: Gamification Weekly XP - Grouped Query
File: app/controllers/organizations/gamification_controller.rb
Changes:
- 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) - Process
rawhash in Ruby to build@weekly_xpand@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:
- Replace per-month
sum(:amount)loop with:Expense.where(organization_id:, date: range).group("strftime('%Y-%m', date)").sum(:amount) - Apply in both
analytics_calculator.rbandexpenses_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:
- Remove separate
countqueries (@accounts_count,@investments_count,@goals_count) - Use
.sizeon already-loaded collections or load counts from the same query - Remove any
Exists?checks in views that are followed byLoadβ use.any?on loaded data
Estimated query reduction: 6+ queries eliminated
Step 2.6: Sidebar BalanceCalculator Optimization
File: app/controllers/application_controller.rb
Changes:
- Replace
Account::BalanceCalculator.new(...).totalinset_sidebar_contextwith:@total_patrimony = Current.organization.accounts.sum(:balance) - 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:
- Add SQL-based
totalthat usessum(:balance)at the DB level - 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:
- Ensure
bulletgem is in Gemfile (development group) - 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:
- Add
market_list_items_countcounter cache onMarketList - Add migration with backfill
Step 3.3: Dashboard Fragment Caching (Optional)
Files: Dashboard view partials
Changes:
- Wrap dashboard sections in
cacheblocks with appropriate keys - Add
touch: trueon relevant associations for cache invalidation
Execution Order
- Step 1.1 (StatsCache) + Step 1.2 (Background Job) β biggest single impact
- Step 1.3 (HabitCompletion Preloader) β affects multiple pages
- Step 1.4 (Streak single query) β quick win
- Step 1.5 (BalanceRegistry single load) β affects finance pages
- Step 1.6 (FamilyAggregator memoization) β quick win
- Step 2.1 (Index) + Step 2.2 (action_type) β migrations together
- Step 2.3 (Gamification grouped query) β depends on 2.2
- Step 2.4 (Expense grouped query) β independent
- Step 2.5 (Dashboard redundant queries) β independent
- Step 2.6 + Step 2.7 (Sidebar + BalanceCalculator) β quick wins
- Step 3.1 (Bullet) β infrastructure
- Step 3.2 (Counter caches) β independent
- 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