mirror of
https://github.com/we-promise/sure.git
synced 2026-05-29 15:34:58 +00:00
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:
@@ -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
|
||||
|
||||
33
app/javascript/controllers/retirement_what_if_controller.js
Normal file
33
app/javascript/controllers/retirement_what_if_controller.js
Normal 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)
|
||||
}
|
||||
}
|
||||
32
app/views/retirement/_kpis.html.erb
Normal file
32
app/views/retirement/_kpis.html.erb
Normal 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>
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user