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

@@ -12,4 +12,29 @@ class RetirementController < ApplicationController
[ t("breadcrumbs.retirement"), nil ]
]
end
def update
if @plan.update(retirement_params: merged_plan_params)
redirect_to retirement_path, notice: t(".updated")
else
redirect_to retirement_path, alert: @plan.errors.full_messages.to_sentence
end
end
# Live what-if: recompute against transient inputs WITHOUT persisting, and
# stream the KPI cards back. The plan is only saved via #update.
def forecast
@plan.assign_attributes(retirement_params: merged_plan_params)
render turbo_stream: turbo_stream.replace(
"retirement_kpis", partial: "retirement/kpis", locals: { plan: @plan }
)
end
private
def merged_plan_params
raw = params.fetch(:retirement, {}).permit(
:birth_year, :retire_age, :target_spend, :monthly_savings, :real_return_pct
).to_h
(@plan.retirement_params || {}).merge(raw.reject { |_, v| v.to_s.strip.empty? })
end
end

View File

@@ -0,0 +1,33 @@
import { Controller } from "@hotwired/stimulus"
// Live "what-if": debounce input changes and POST the current plan inputs
// to the forecast endpoint, which streams back the recomputed KPI cards
// without persisting. Saving is a separate form submit (#update).
export default class extends Controller {
static targets = ["form"]
static values = { url: String, debounce: { type: Number, default: 300 } }
preview() {
clearTimeout(this.timer)
this.timer = setTimeout(() => this.fetchPreview(), this.debounceValue)
}
async fetchPreview() {
const response = await fetch(this.urlValue, {
method: "PATCH",
body: new FormData(this.formTarget),
headers: {
Accept: "text/vnd.turbo-stream.html",
"X-CSRF-Token": document.querySelector("meta[name='csrf-token']")?.content
}
})
if (response.ok) {
window.Turbo.renderStreamMessage(await response.text())
}
}
disconnect() {
clearTimeout(this.timer)
}
}

View File

@@ -0,0 +1,32 @@
<%# locals: (plan:) %>
<div id="retirement_kpis" class="grid grid-cols-1 sm:grid-cols-3 gap-4">
<% forecast = plan.forecast %>
<% if forecast.nil? %>
<div class="sm:col-span-3 bg-container shadow-border-xs rounded-xl p-5">
<p class="text-primary text-sm font-medium"><%= t("retirement.kpis.set_birth_year_heading") %></p>
<p class="text-secondary text-sm"><%= t("retirement.kpis.set_birth_year_body") %></p>
</div>
<% else %>
<div class="bg-container shadow-border-xs rounded-xl p-5">
<p class="text-secondary text-xs uppercase tracking-wide"><%= t("retirement.kpis.freedom_date") %></p>
<p class="text-primary text-2xl font-semibold"><%= plan.freedom_date&.year || "—" %></p>
<p class="text-secondary text-xs"><%= t("retirement.kpis.retire_at_age", age: plan.effective_retire_age) %></p>
</div>
<div class="bg-container shadow-border-xs rounded-xl p-5">
<p class="text-secondary text-xs uppercase tracking-wide"><%= t("retirement.kpis.coast_fire") %></p>
<p class="text-primary text-2xl font-semibold"><%= plan.coast_fire_date&.year || t("retirement.kpis.not_yet") %></p>
<p class="text-secondary text-xs"><%= t("retirement.kpis.coast_hint") %></p>
</div>
<div class="bg-container shadow-border-xs rounded-xl p-5">
<p class="text-secondary text-xs uppercase tracking-wide"><%= t("retirement.kpis.money_lasts_to") %></p>
<p class="text-primary text-2xl font-semibold">
<%= forecast.lasts_past_terminal? ? t("retirement.kpis.past_age", age: forecast.terminal_age) : t("retirement.kpis.age", age: forecast.money_lasts_to_age) %>
</p>
<p class="text-secondary text-xs privacy-sensitive">
<%= t("retirement.kpis.terminal", amount: Money.new(forecast.terminal_value, plan.currency).format(precision: 0)) %>
</p>
</div>
<% end %>
</div>

View File

@@ -9,6 +9,39 @@
<p class="text-subdued text-xs"><%= t("retirement.show.preview_note") %></p>
</header>
<%= render "retirement/kpis", plan: @plan %>
<%# What-if — live recompute on input, persist on save %>
<section data-controller="retirement-what-if"
data-retirement-what-if-url-value="<%= forecast_retirement_path %>"
class="bg-container shadow-border-xs rounded-xl p-5 space-y-4">
<div class="flex items-center justify-between">
<h2 class="text-primary font-medium"><%= t("retirement.what_if.title") %></h2>
<p class="text-secondary text-xs"><%= t("retirement.what_if.hint") %></p>
</div>
<%= form_with url: retirement_path, method: :patch, data: { retirement_what_if_target: "form" }, class: "space-y-3" do |form| %>
<div class="grid grid-cols-2 sm:grid-cols-5 gap-3">
<% [
[ "birth_year", @plan.birth_year ],
[ "retire_age", @plan.retire_age ],
[ "target_spend", @plan.target_spend ],
[ "monthly_savings", @plan.monthly_savings ],
[ "real_return_pct", @plan.real_return_pct ]
].each do |field, value| %>
<label class="text-xs text-secondary space-y-1">
<span class="block"><%= t("retirement.what_if.fields.#{field}") %></span>
<%= number_field_tag "retirement[#{field}]", value, step: "any",
autocomplete: "off",
class: "form-field__input w-full",
data: { action: "input->retirement-what-if#preview" } %>
</label>
<% end %>
</div>
<%= form.submit t("retirement.what_if.save"), class: "text-sm font-medium text-primary underline cursor-pointer" %>
<% end %>
</section>
<%# Pension sources %>
<section class="bg-container shadow-border-xs rounded-xl p-5 space-y-4">
<div class="flex items-center justify-between">

View File

@@ -4,7 +4,7 @@ en:
show:
title: Retirement
subtitle: Plan when you can stop working — and what you'll need to get there.
preview_note: Preview — data entry only for now. Projections and the plan dashboard arrive in a later update.
preview_note: Preview — projections are a single deterministic path in today's money. The polished dashboard arrives in a later update.
sources_title: Pension sources
add_source: Add source
no_sources: No pension sources yet.
@@ -24,6 +24,30 @@ en:
delete: Delete
edit: Edit
delete_confirm: Are you sure? This cannot be undone.
update:
updated: Plan updated.
kpis:
set_birth_year_heading: Add your birth year to see projections
set_birth_year_body: Set your birth year (and tweak the levers below) to project your freedom date, Coast FIRE point, and how long your money lasts.
freedom_date: Freedom date
retire_at_age: Age %{age}
coast_fire: Coast FIRE
coast_hint: Stop saving and still retire on time.
not_yet: Not yet
money_lasts_to: Money lasts to
age: "age %{age}"
past_age: "past %{age}"
terminal: "~%{amount} left at the end"
what_if:
title: What-if
hint: Change a lever — the projection updates live.
save: Save plan
fields:
birth_year: Birth year
retire_age: Retire at age
target_spend: Target spend / mo
monthly_savings: Save / mo
real_return_pct: Real return %
pension_sources:
new_title: Add pension source
edit_title: Edit pension source

View File

@@ -317,7 +317,8 @@ Rails.application.routes.draw do
end
end
resource :retirement, only: %i[show], controller: "retirement" do
resource :retirement, only: %i[show update], controller: "retirement" do
patch :forecast, on: :member
scope module: :retirement do
resources :pension_sources, only: %i[new create edit update destroy]
resources :statements, only: %i[new create destroy]

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