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_modeenum (9 values) - Add
unitenum: 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_namehelpers - Add
refresh_progress!method that callsGoal::SourceCalculator - Guard
add_contributionto 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_streakhabit_completions->trackable.habit_completions.completed.where(date: period_range).countinvestment_value->trackable.current_valueaccount_balance->trackable.balanceconstruction_budget->trackable.total_spenttotal_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
HabitCompletionafter_save/destroy -> refresh habit's tracked goalsActivityLogafter_save/destroy -> refresh org's activity_log goalsConstructionExpenseafter_save/destroy -> refresh project's tracked goalsAccount/Investmentafter_save -> also refreshtotal_moneygoals 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
refreshaction (POST) for manual sync of auto-tracked goals - Add
load_trackable_optionshelper for form (loads habits, investments, accounts, sports, construction_projects) - Guard
contributeaction 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_typeas 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): Useformat_goal_amounthelper 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 changesapp/models/goal/source_calculator.rb- new fileapp/models/concerns/goalable.rb- new concernapp/controllers/organizations/goals_controller.rb- params, actionsapp/views/organizations/goals/_form.html.erb- dynamic formapp/views/organizations/goals/_goal.html.erb- card displayapp/views/organizations/goals/show.html.erb- detail pageapp/javascript/controllers/goal_form_controller.js- new Stimulus controllerconfig/routes.rb- add refresh route
Verification
- Create a manual goal -> works exactly as before (contributions, history, projections)
- Create an activity_log_count goal filtered by a sport -> current_amount = count of logs for that sport
- Log a new activity -> goal's current_amount auto-updates
- Create a habit_streak goal -> tracks current_streak of the habit
- Create a total_money goal (family scope) -> sums all accounts + investments
- Edit a tracked goal -> form shows correct source selector pre-filled
- Try to contribute to an auto-tracked goal -> blocked with error message