Performance Enhancement Design Spec
Date: 2026-03-14 Branch: performance-enhancement Status: Approved
Problem Statement
The application has severe performance issues across multiple pages. The worst offender (POST /habits/toggle) takes 10,273ms and executes 1,532 queries. Several pages exceed 1-3 seconds with hundreds of redundant queries. Root causes: N+1 queries, achievement checker cascade, missing eager loading, redundant database hits, and no caching.
Current Performance Baseline
| Page | Response Time | Query Count | Worst Issue |
|---|---|---|---|
| POST /habits/toggle | 10,273ms | 1,532 | Achievement checker cascade |
| GET /planning | 8,001ms | 545 | Achievement checker + GC |
| GET /accounts | 3,388ms | 545 | N+1 on balance_registries |
| GET /simulator | ~2,595ms | 117 | Analytics calculator queries |
| GET /habits | 1,817ms | 97 | HabitCompletion N+1 |
| GET /gamification | 1,451ms | 145 | Achievement checker N+1 |
| GET /investments | 1,327ms | 92 | N+1 pattern |
| GET /expenses | 1,172ms | 21 | Monthly trend N+1 |
| GET /dashboard | 674ms | 59 | HabitCompletion N+1 |
| GET /goals | 658ms | 88 | N+1 pattern |
Architecture Overview
Phase 1: Critical N+1 Fixes (Highest Impact)
1.1 Achievement Checker Optimization
Problem: AchievementChecker.check_all is called on every XP award via XpAwarder.award (line 84). Each call loads ALL achievements (~46), runs UserAchievement Exists? per achievement (~46 queries), then runs calculate_progress with 25+ case branches, each executing its own query. On habit toggle, this cascades 3+ times (habit XP + achievement unlock XP + second achievement unlock XP) = ~400+ queries per toggle.
Fix:
- Pre-fetch all user achievements in a single query and check in-memory
- Pre-compute all stats needed by
calculate_progressinto a single stats hash - Only check achievements relevant to the action context (category-scoped)
- Move
check_allfrom inline to a background job (SolidQueue) - Add a
Gamification::StatsCachemodel class to batch-load all counts
1.2 HabitCompletion N+1 on completed_today?
Problem: Habit#completed_today? (line 133) runs habit_completions.exists?(date: Date.current, completed: true) per habit. With 8 habits across dashboard, gamification, and hub partials, this generates 24+ queries.
Fix:
- Add
Habit::CompletionPreloaderthat batch-loads today's completions:def self.preload_today(habits) completed_ids = HabitCompletion.where(habit_id: habits.map(&:id), date: Date.current, completed: true).pluck(:habit_id).to_set habits.each { |h| h.instance_variable_set(:@completed_today, completed_ids.include?(h.id)) } end - Modify
completed_today?to check@completed_todayif preloaded, else fallback to query
1.3 Streak Recalculation Optimization
Problem: CompletionTracker#recalculate_streak! (line 35) iterates day-by-day, running exists? per day. A 30-day streak = 31 queries.
Fix:
- Replace with single query fetching all recent completion dates, calculate streak in Ruby:
dates = habit.habit_completions.where(completed: true).where("date <= ?", Date.current).order(date: :desc).pluck(:date)
1.4 BalanceRegistry Scattered Queries
Problem: DataAggregator queries balance_registries 4-5 times with different ORDER BY/LIMIT clauses. Methods: monthly_growth, total_growth_pct, avg_monthly_growth, mom_normalized_series, patrimonial_history.
Fix:
- Load all balance registries once, memoize, derive all calculations from the in-memory array:
def balance_registries @balance_registries ||= organization.balance_registries.order(:date).to_a end
Phase 2: Query Optimization
2.1 Missing Database Indexes
Add composite index:
habit_completions:(habit_id, completed, date)– optimizes the frequentexists?(date: X, completed: true)query
2.2 XpTransaction LIKE Queries -> action_type Column
Problem: XpAwarder and AchievementChecker use description LIKE '%Daily login%' and description LIKE '%Reviewed analytics%' for dedup checks. LIKE with leading wildcard cannot use indexes.
Fix:
- Add
action_typestring column toxp_transactionswith values likedaily_login,habit_completion,streak_milestone,analytics_review, etc. - Add index on
(membership_id, action_type, created_at) - Replace LIKE queries with exact match on
action_type
2.3 Gamification Weekly XP Breakdown
Problem: GamificationController#show runs 8 separate Sum queries for weekly totals + 16 for category breakdowns = 24 queries.
Fix:
- Single grouped query:
XpTransaction.where(membership_id: m.id, created_at: 8.weeks.ago..) .group("strftime('%W', created_at)", :category) .sum(:amount) - Process result hash in Ruby to build weekly/category breakdowns
2.4 Expense Monthly Totals N+1
Problem: AnalyticsCalculator#expense_monthly_totals and ExpensesController#index run separate sum(:amount) per month (6+ queries).
Fix:
- Single grouped query:
Expense.where(...).group("strftime('%Y-%m', date)").sum(:amount)
2.5 Redundant Exists? + Load in Dashboard
Problem: Dashboard views check Exists? then immediately Load the same records (6+ extra queries).
Fix:
- Load records once, check
.any?on loaded collection instead of separate existence query
2.6 set_sidebar_context Optimization
Problem: ApplicationController#set_sidebar_context creates BalanceCalculator on EVERY authenticated request, which loads ALL accounts into memory and sums in Ruby.
Fix:
- Replace
BalanceCalculator.new(...).totalwithorganization.accounts.sum(:balance)(single SQL SUM) - Or cache the total balance, invalidated on account updates
2.7 FamilyAggregator kpis Memoization
Problem: FamilyAggregator#kpis calls a.kpis repeatedly per field, recomputing the full hash each time.
Fix:
- Memoize
DataAggregator#kpiswith@kpis ||= { ... }
2.8 Account::BalanceCalculator SQL Aggregation
Problem: BalanceCalculator#total uses scoped_accounts.sum(&:balance) (Ruby sum on loaded records) instead of SQL aggregation.
Fix:
- Use
scoped_accounts_relation.sum(:balance)for SQL-level aggregation when only the total is needed
Phase 3: Caching & Infrastructure
3.1 Enable and Configure Bullet Gem
Problem: bullet.log is empty – Bullet is not detecting N+1s in development.
Fix:
- Verify Bullet is enabled in
config/environments/development.rb - Configure with
alert: true,bullet_logger: true,rails_logger: true - Add Bullet safelist for known acceptable patterns
3.2 Achievement Checking as Background Job
Problem: Achievement checking runs inline on every XP award, blocking the response.
Fix:
- Create
Gamification::AchievementCheckJobthat runs via SolidQueue XpAwarder.awardenqueues the job instead of callingcheck_allinline- The job batches all pending checks for the membership
3.3 Dashboard Fragment Caching
Fix:
- Cache dashboard partials (habits summary, goals summary, accounts summary)
- Invalidate on relevant model updates using
touch: true
3.4 Counter Caches
Add counter caches where count queries are frequent:
MarketList->market_list_items_count- Consider
habits_counton Membership if displayed frequently
Expected Impact
| Page | Before | After (Estimated) | Reduction |
|---|---|---|---|
| POST /habits/toggle | 10,273ms / 1532q | ~200ms / ~15q | 98% queries |
| GET /planning | 8,001ms / 545q | ~300ms / ~20q | 96% queries |
| GET /accounts | 3,388ms / 545q | ~200ms / ~10q | 98% queries |
| GET /habits | 1,817ms / 97q | ~150ms / ~10q | 90% queries |
| GET /gamification | 1,451ms / 145q | ~200ms / ~15q | 90% queries |
| GET /dashboard | 674ms / 59q | ~100ms / ~15q | 75% queries |
| GET /expenses | 1,172ms / 21q | ~100ms / ~8q | 62% queries |
| All pages (sidebar) | +1 account load | SQL SUM or cached | ~eliminated |
DRY Patterns
Gamification::StatsCache– single class for batch-loading all gamification stats, reused by AchievementChecker and GamificationControllerHabit::CompletionPreloader– reused across dashboard, habits index, gamification, hub partial- Memoized
balance_registriesin DataAggregator – single load for all derived calculations - Grouped query pattern for XP and expense monthly breakdowns
Testing Strategy
- Add performance assertions in integration tests (query count limits)
- Verify all existing tests still pass after each phase
- Benchmark before/after with development log comparison