feat(retirement): PR3b what-if KPIs + live forecast Turbo Stream

Surfaces the forecast on the page and makes the levers live.

- KPI cards (_kpis): Freedom date, Coast FIRE, Money-lasts-to + terminal
  value, with a "set your birth year" prompt until a plan is projectable.
  Wrapped in #retirement_kpis for Turbo Stream replacement; money carries
  privacy-sensitive.
- What-if form: birth_year / retire_age / target_spend / monthly_savings /
  real_return_pct. On input, retirement_what_if_controller debounces and
  POSTs the current values to PATCH /retirement/forecast, which recomputes
  against transient inputs and streams the KPI cards back WITHOUT
  persisting. "Save plan" submits to #update to persist retirement_params.
- RetirementController gains #update (persist) and #forecast (transient
  recompute → turbo_stream). Both reuse merged_plan_params, which drops
  blank fields so a partial what-if doesn't clobber stored values.

Tests: KPI section renders; update persists params; forecast streams
#retirement_kpis without writing the slider value back. Rubocop +
erb_lint + biome clean.

PR4 replaces this minimal form with the designed slider rail + glide
chart; the #forecast endpoint and the engine stay.
This commit is contained in:
Guillem Arias
2026-05-29 11:35:34 +02:00
parent 36a43f3a35
commit 174dd66914
7 changed files with 187 additions and 2 deletions

View File

@@ -55,4 +55,41 @@ class RetirementControllerTest < ActionDispatch::IntegrationTest
assert_select "a[href=?]", retirement_path, count: 0
end
test "show renders the KPI section" do
get retirement_url
assert_response :success
assert_select "#retirement_kpis"
end
test "show uses real translations, not humanized i18n keys" do
get retirement_url
assert_response :success
assert_match I18n.t("retirement.show.sources_title"), response.body
assert_no_match(/Sources Title/, response.body)
end
test "update persists retirement params" do
patch retirement_url, params: { retirement: {
birth_year: 1985, retire_age: 62, monthly_savings: 1500, target_spend: 2800, real_return_pct: 5
} }
assert_redirected_to retirement_path
plan = Goal::Retirement.for_owner(@user)
assert_equal "1985", plan.birth_year.to_s
assert_equal "62", plan.retire_age.to_s
end
test "forecast streams KPIs without persisting" do
Goal::Retirement.for_owner(@user).update!(retirement_params: { "birth_year" => 1980 })
patch forecast_retirement_url,
params: { retirement: { retire_age: 70 } },
headers: { "Accept" => "text/vnd.turbo-stream.html" }
assert_response :success
assert_match "retirement_kpis", response.body
# transient: the slider value is not written back to the plan
assert_nil Goal::Retirement.for_owner(@user).retire_age
end
end