diff --git a/app/controllers/retirement_controller.rb b/app/controllers/retirement_controller.rb index c898c52ae..5df15ed97 100644 --- a/app/controllers/retirement_controller.rb +++ b/app/controllers/retirement_controller.rb @@ -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 diff --git a/app/javascript/controllers/retirement_what_if_controller.js b/app/javascript/controllers/retirement_what_if_controller.js new file mode 100644 index 000000000..116385bab --- /dev/null +++ b/app/javascript/controllers/retirement_what_if_controller.js @@ -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) + } +} diff --git a/app/views/retirement/_kpis.html.erb b/app/views/retirement/_kpis.html.erb new file mode 100644 index 000000000..c2c953e5b --- /dev/null +++ b/app/views/retirement/_kpis.html.erb @@ -0,0 +1,32 @@ +<%# locals: (plan:) %> +
+ <% forecast = plan.forecast %> + <% if forecast.nil? %> +
+

<%= t("retirement.kpis.set_birth_year_heading") %>

+

<%= t("retirement.kpis.set_birth_year_body") %>

+
+ <% else %> +
+

<%= t("retirement.kpis.freedom_date") %>

+

<%= plan.freedom_date&.year || "—" %>

+

<%= t("retirement.kpis.retire_at_age", age: plan.effective_retire_age) %>

+
+ +
+

<%= t("retirement.kpis.coast_fire") %>

+

<%= plan.coast_fire_date&.year || t("retirement.kpis.not_yet") %>

+

<%= t("retirement.kpis.coast_hint") %>

+
+ +
+

<%= t("retirement.kpis.money_lasts_to") %>

+

+ <%= forecast.lasts_past_terminal? ? t("retirement.kpis.past_age", age: forecast.terminal_age) : t("retirement.kpis.age", age: forecast.money_lasts_to_age) %> +

+

+ <%= t("retirement.kpis.terminal", amount: Money.new(forecast.terminal_value, plan.currency).format(precision: 0)) %> +

+
+ <% end %> +
diff --git a/app/views/retirement/show.html.erb b/app/views/retirement/show.html.erb index 3849c295d..ea6ef40ca 100644 --- a/app/views/retirement/show.html.erb +++ b/app/views/retirement/show.html.erb @@ -9,6 +9,39 @@

<%= t("retirement.show.preview_note") %>

+ <%= render "retirement/kpis", plan: @plan %> + + <%# What-if — live recompute on input, persist on save %> +
+
+

<%= t("retirement.what_if.title") %>

+

<%= t("retirement.what_if.hint") %>

+
+ + <%= form_with url: retirement_path, method: :patch, data: { retirement_what_if_target: "form" }, class: "space-y-3" do |form| %> +
+ <% [ + [ "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| %> + + <% end %> +
+ <%= form.submit t("retirement.what_if.save"), class: "text-sm font-medium text-primary underline cursor-pointer" %> + <% end %> +
+ <%# Pension sources %>
diff --git a/config/locales/views/retirement/en.yml b/config/locales/views/retirement/en.yml index eb7b8c09c..d10f5263a 100644 --- a/config/locales/views/retirement/en.yml +++ b/config/locales/views/retirement/en.yml @@ -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 diff --git a/config/routes.rb b/config/routes.rb index e115dff1f..300cf0e1f 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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] diff --git a/test/controllers/retirement_controller_test.rb b/test/controllers/retirement_controller_test.rb index 0f2f0e449..22add1116 100644 --- a/test/controllers/retirement_controller_test.rb +++ b/test/controllers/retirement_controller_test.rb @@ -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