Performance Enhancement Results
Date: 2026-03-14 Branch: performance-enhancement Status: Complete - All 1008 tests passing
Executive Summary
Comprehensive performance optimization across the entire Lifehub application. The worst page (habit toggle) went from 10,273ms / 1,532 queries down to an estimated ~200ms / ~15 queries — a 98% reduction in database queries. Every major page in the app was optimized.
Changes Implemented
Phase 1: Critical N+1 Fixes
1. Achievement Checker Cascade (Biggest Single Impact)
- Problem:
AchievementChecker.check_allran inline on every XP award, loading all ~46 achievements and running individual queries per achievement. On habit toggle, this cascaded 3+ times = ~400+ queries. - Solution:
- Created
Gamification::StatsCache— pre-fetches all stats in ~8 batch queries instead of 25+ individual ones - Pre-fetches unlocked achievement IDs in single query (eliminates ~46
EXISTS?queries) - Moved
check_alltoGamification::AchievementCheckJob(SolidQueue background job)
- Created
- Files:
app/models/gamification/stats_cache.rb(new),achievement_checker.rb,xp_awarder.rb,app/jobs/gamification/achievement_check_job.rb(new) - Impact: Habit toggle response no longer blocked by achievement checking
2. HabitCompletion N+1 on completed_today?
- Problem: Each habit triggered a separate
EXISTS?query. Called across dashboard, habits index, hub partial, gamification, and XpAwarder — 24+ queries per page. - Solution: Added
Habit.preload_completions_today(habits)class method that batch-loads all completions in 1 query. Applied in:DashboardController#indexHabitsController#index_hub_habits.html.erbpartialXpAwarder.check_all_daily_habits_bonus
- Files:
app/models/habit.rb,dashboard_controller.rb,habits_controller.rb,_hub_habits.html.erb,xp_awarder.rb - Impact: 8-24 queries → 1 query per page
3. Streak Recalculation Single Query
- Problem:
CompletionTracker#recalculate_streak!iterated day-by-day with oneEXISTS?query per day. A 30-day streak = 31 queries. - Solution: Single query fetching all completion dates, streak calculated in Ruby.
- Files:
app/models/habit/completion_tracker.rb - Impact: O(streak_length) queries → 1 query
4. BalanceRegistry Memoization in DataAggregator
- Problem:
DataAggregatorqueriedbalance_registries4-5 times with differentORDER BY/LIMITclauses across methods likemonthly_growth,total_growth_pct,avg_monthly_growth,mom_normalized_series,patrimonial_history. - Solution: Single memoized
all_balance_registriesmethod, all derived calculations use in-memory array. - Files:
app/models/dashboard/data_aggregator.rb - Impact: 4-5 queries → 1 query per aggregator instance
5. DataAggregator#kpis Memoization
- Problem:
FamilyAggregatoraccesseda.kpis[:key]repeatedly per field, recomputing the full hash each time. - Solution: Added
@kpis ||=memoization. - Files:
app/models/dashboard/data_aggregator.rb - Impact: Eliminates N*M redundant query sets in family mode
Phase 2: Query Optimization
6. Accounts Index — Hash Lookup Instead of find_by Loop
- Problem: View called
@organization.accounts.find_by(id: entry["account_id"])inside a loop over every balance registry entry. With 7 accounts × 22 registries = ~154 queries. - Solution: Pre-built
@accounts_by_id = @accounts.index_by(&:id)in controller, O(1) hash lookup in view. - Files:
accounts_controller.rb,accounts/index.html.erb - Impact: 154 queries → 0 extra queries
7. Gamification Weekly XP — Grouped Queries
- Problem: 8 individual
SUMqueries for weekly totals + 16 for category breakdowns = 24 queries. - Solution: 2 grouped queries using
strftime('%Y-%W', datetime(created_at)), results processed in Ruby. - Files:
app/controllers/organizations/gamification_controller.rb - Impact: 24 queries → 2 queries
8. Expense Monthly Trend — Grouped Query
- Problem: 6 individual
SUM(:amount)queries (one per month) in expenses index. - Solution: Single grouped query using
strftime('%Y-%m', date). - Files:
app/controllers/organizations/expenses_controller.rb - Impact: 6 queries → 1 query
9. Analytics expense_monthly_totals — Grouped Query
- Problem: N per-month
SUMqueries inAnalyticsCalculator#expense_monthly_totals. - Solution: Single grouped query, same pattern as expenses controller.
- Files:
app/models/organization/analytics_calculator.rb - Impact: 6-12 queries → 1 query
10. Analytics Balance Registries Memoization
- Problem:
month_normalized_patrimony_seriesloaded all registries fresh each call (called 4+ times). - Solution: Memoized via
cached_balance_registries. - Files:
app/models/organization/analytics_calculator.rb - Impact: 4+ queries → 1 query
11. Sidebar SQL SUM
- Problem:
set_sidebar_contextcreatedBalanceCalculatoron every authenticated request, loading ALL accounts into memory and summing in Ruby. - Solution: Replaced with
sidebar_org.accounts.sum(:balance)— single SQL SUM. - Files:
app/controllers/application_controller.rb - Impact: Full table load eliminated on every request
12. BalanceCalculator SQL Aggregation
- Problem:
BalanceCalculator#totalused Ruby-levelsum(&:balance)on loaded records. - Solution: SQL-level
sum(:balance)with proper scoping. - Files:
app/models/account/balance_calculator.rb - Impact: Database-level aggregation instead of Ruby
13. set_locale Redundant Query
- Problem:
set_localeloaded the organization from DB again even though already loaded byset_sidebar_context. - Solution: Reuse
@organizationalready set by before_actions. - Files:
app/controllers/application_controller.rb - Impact: 1 fewer query per authenticated request
Phase 3: Infrastructure
14. Composite Database Index
- Added:
(habit_id, completed, date)onhabit_completionstable - Purpose: Optimizes the frequent
EXISTS?(date: X, completed: true)query pattern - Files:
db/migrate/20260314151508_add_index_to_habit_completions_on_completed.rb
15. Bullet Gem Enabled
- Problem: Bullet was installed but not configured —
bullet.logwas empty. - Solution: Enabled with
bullet_logger: trueandrails_logger: truein development. - Files:
config/environments/development.rb - Purpose: Catches future N+1 regressions automatically
Results Summary
Before vs After
| Page | Before (ms) | Before (queries) | After (est. ms) | After (est. queries) | Query Reduction |
|---|---|---|---|---|---|
| POST /habits/toggle | 10,273 | 1,532 | ~200 | ~15 | 99% |
| GET /planning | 8,001 | 545 | ~300 | ~20 | 96% |
| GET /accounts | 3,388 | 545 (154 N+1) | ~200 | ~10 | 98% |
| GET /simulator | ~2,595 | 117 | ~300 | ~20 | 83% |
| GET /habits | 1,817 | 97 | ~150 | ~10 | 90% |
| GET /gamification | 1,451 | 145 | ~200 | ~15 | 90% |
| GET /investments | 1,327 | 92 | ~200 | ~15 | 84% |
| GET /expenses | 1,172 | 21 | ~100 | ~8 | 62% |
| GET /dashboard | 922 | 48 | ~100 | ~15 | 69% |
| GET /goals | 658 | 88 | ~150 | ~15 | 83% |
| Every auth request (sidebar) | +full table load | +2-3 queries | SQL SUM | +0 extra | ~eliminated |
DRY Patterns Applied
Gamification::StatsCache— single class for batch-loading all achievement statsHabit.preload_completions_today— reusable preloader across 4 call sites- Memoized balance registries — shared by 8+ methods in DataAggregator and 4+ in AnalyticsCalculator
- Grouped query pattern — consistently applied across expenses, XP transactions, and analytics
New Files Created
| File | Purpose |
|——|———|
| app/models/gamification/stats_cache.rb | Batch stats loader for achievement checking |
| app/jobs/gamification/achievement_check_job.rb | Background job for async achievement checking |
| db/migrate/20260314151508_add_index_to_habit_completions_on_completed.rb | Composite index for habit completion queries |
Files Modified (29)
Controllers (10):
app/controllers/application_controller.rb— sidebar SQL SUM + locale reuseapp/controllers/organizations/accounts_controller.rb— hash lookup for accountsapp/controllers/organizations/balance_registries_controller.rb— hash lookup for accountsapp/controllers/organizations/construction_projects_controller.rb— eager-load construction_phaseapp/controllers/organizations/dashboard_controller.rb— habit preloadingapp/controllers/organizations/expenses_controller.rb— grouped monthly queryapp/controllers/organizations/finance_dashboard_controller.rb— SQL ordering for goalsapp/controllers/organizations/gamification_controller.rb— grouped XP queriesapp/controllers/organizations/habits_controller.rb— habit preloadingapp/controllers/organizations/sports_controller.rb— precomputed stats + per-sport countsapp/controllers/users/notification_preferences_controller.rb— batch-load preferences
Models (13):
app/models/account/balance_calculator.rb— SQL aggregationapp/models/construction_phase.rb— memoize total_spentapp/models/construction_project.rb— memoize total_spent + completed_phases_countapp/models/dashboard/data_aggregator.rb— memoized registries + kpisapp/models/gamification/achievement_checker.rb— StatsCache + batch unlocked checkapp/models/gamification/xp_awarder.rb— background job + preloadingapp/models/habit.rb— preload_completions_today class methodapp/models/habit/completion_tracker.rb— single-query streak recalculationapp/models/habit/dashboard_aggregator.rb— preload completionsapp/models/habit/streak_calculator.rb— preload completionsapp/models/organization/analytics_calculator.rb— grouped expenses + memoized registriesapp/models/todo_board.rb— memoize JSON parsing (columns, labels)app/models/todo_card.rb— memoize JSON parsing (checklists, label_ids, assignee_ids)
Views (4):
app/views/organizations/accounts/index.html.erb— hash lookupapp/views/organizations/balance_registries/index.html.erb— hash lookupapp/views/organizations/dashboard/_hub_habits.html.erb— preloadingapp/views/organizations/sports/index.html.erb— use precomputed statsapp/views/users/notification_preferences/index.html.erb— batch-loaded preferences
Config (1):
config/environments/development.rb— Bullet gem configuration
Test Results
- 1008 tests, 2212 assertions, 0 failures, 0 errors
- All existing functionality preserved
- No breaking changes