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

@@ -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)
}
}