Goals Enhancement: Generic/Polymorphic Tracking

Context

Goals are currently financial-only with manual current_amount contributions. We want goals to track progress from multiple sources automatically: activity logs, habits, investments, accounts, construction projects, and total patrimony โ€“ while keeping manual/financial goals working as-is.

Architecture: Hybrid Approach (enum + optional polymorphic)

A tracking_mode enum defines HOW the goal tracks progress. An optional polymorphic trackable association points to a specific source record. A tracking_config JSON column stores filters (sport_id, period, scope).

tracking_mode trackable tracking_config example
manual nil nil
activity_log_count Sport (optional) {"sport_id": 5, "period": "all_time"}
activity_log_duration Sport (optional) {"sport_id": 5, "period": "month"}
habit_streak Habit nil
habit_completions Habit {"period": "month"}
investment_value Investment nil
account_balance Account nil
construction_budget ConstructionProject nil
total_money nil {"scope": "family"}

Implementation Steps

Step 1: Migration

New file: db/migrate/XXX_add_tracking_fields_to_goals.rb

Add columns: tracking_mode (string, default "manual"), trackable_type (string), trackable_id (integer), tracking_config (json), unit (string, default "currency"). Add indexes on [trackable_type, trackable_id] and [organization_id, tracking_mode]. Zero data migration โ€“ existing goals default to manual.

Step 2: Model changes to app/models/goal.rb

  • Add tracking_mode enum (9 values)
  • Add unit enum: currency, count, minutes, days, sessions
  • Add belongs_to :trackable, polymorphic: true, optional: true
  • Add validation: trackable required for single-record modes (habit_streak, investment_value, etc.)
  • Add tracked?, auto_tracked?, unit_label, trackable_name helpers
  • Add refresh_progress! method that calls Goal::SourceCalculator
  • Guard add_contribution to reject auto-tracked goals

Step 3: New app/models/goal/source_calculator.rb

Namespaced class (follows existing Goal::ProgressCalculator pattern) that computes current_amount from the source:

  • activity_log_count -> organization.activity_logs.where(sport_id: config).count (filtered by optional period: all_time/week/month/year)
  • activity_log_duration -> .sum(:duration_minutes) (filtered by optional period: all_time/week/month/year)
  • habit_streak -> trackable.current_streak
  • habit_completions -> trackable.habit_completions.completed.where(date: period_range).count
  • investment_value -> trackable.current_value
  • account_balance -> trackable.balance
  • construction_budget -> trackable.total_spent
  • total_money -> org.accounts.sum(:balance) + org.investments.sum("shares * current_price")

Step 4: Goalable concern (app/models/concerns/goalable.rb)

Shared concern for trackable models. Adds has_many :tracked_goals, as: :trackable and after_save :refresh_tracked_goals.

Include in: Habit, Investment, Account, ConstructionProject

Step 5: Callbacks on child models

  • HabitCompletion after_save/destroy -> refresh habit's tracked goals
  • ActivityLog after_save/destroy -> refresh org's activity_log goals
  • ConstructionExpense after_save/destroy -> refresh project's tracked goals
  • Account/Investment after_save -> also refresh total_money goals for the org

Step 6: Controller (app/controllers/organizations/goals_controller.rb)

  • Add to goal_params: :tracking_mode, :trackable_type, :trackable_id, :unit, tracking_config: {}
  • Add refresh action (POST) for manual sync of auto-tracked goals
  • Add load_trackable_options helper for form (loads habits, investments, accounts, sports, construction_projects)
  • Guard contribute action against auto-tracked goals

Step 7: Routes (config/routes.rb)

Add post :refresh to goals member routes.

Step 8: Stimulus controller (app/javascript/controllers/goal_form_controller.js)

Handles dynamic form: shows/hides field groups based on tracking_mode selection. Sets trackable_type hidden field. Hides current_amount for auto-tracked modes. Hides currency for non-financial modes.

Step 9: Form (app/views/organizations/goals/_form.html.erb)

  • Add tracking_mode <select> at the top
  • Keep goal_type as a separate category selector (emergency, retirement, travel, etc.)
  • Conditional field groups (each with data-goal-form-target):
    • Habit selector (for habit_streak, habit_completions)
    • Investment selector (for investment_value)
    • Account selector (for account_balance)
    • Sport selector (for activity_log_count/duration, optional)
    • Construction project selector (for construction_budget)
    • Period radio (for habit_completions AND activity_log_count/duration: all_time/week/month/year, default all_time)
    • Scope radio (for total_money: family/personal)
  • Hide current_amount when not manual
  • Hide currency when not financial

Step 10: Views

  • _goal.html.erb (index card): Use format_goal_amount helper instead of hardcoded BRL format. Show tracking mode badge for auto-tracked goals.
  • show.html.erb: Hide contribution form for auto-tracked goals. Show "Refresh" button instead. Display source info in details.
  • Helper format_goal_amount(goal, amount): formats as currency or plain number based on tracking_mode.

Step 11: I18n (config/locales/pt-BR.yml)

Add translations for tracking_modes, units, periods, scopes, error messages.

Step 12: Background job (app/jobs/goals/refresh_total_money_job.rb)

Hourly job via Solid Queue to refresh total_money goals (fallback for missed callbacks).

Critical Files

  • app/models/goal.rb - core model changes
  • app/models/goal/source_calculator.rb - new file
  • app/models/concerns/goalable.rb - new concern
  • app/controllers/organizations/goals_controller.rb - params, actions
  • app/views/organizations/goals/_form.html.erb - dynamic form
  • app/views/organizations/goals/_goal.html.erb - card display
  • app/views/organizations/goals/show.html.erb - detail page
  • app/javascript/controllers/goal_form_controller.js - new Stimulus controller
  • config/routes.rb - add refresh route

Verification

  1. Create a manual goal -> works exactly as before (contributions, history, projections)
  2. Create an activity_log_count goal filtered by a sport -> current_amount = count of logs for that sport
  3. Log a new activity -> goal's current_amount auto-updates
  4. Create a habit_streak goal -> tracks current_streak of the habit
  5. Create a total_money goal (family scope) -> sums all accounts + investments
  6. Edit a tracked goal -> form shows correct source selector pre-filled
  7. Try to contribute to an auto-tracked goal -> blocked with error message