Files
sure/app/controllers/retirement_controller.rb
Guillem Arias ee9f5d8b63 feat(retirement): PR4a glide-path chart (D3)
The dashboard centerpiece. Goal::Retirement#glide_payload derives, from
the forecast, the active-plan series + a zero-savings (Walletburst)
shadow + a ±1pp real-return band + the per-age income breakdown for the
hover tooltip + lump markers + the retire/Coast crossover points (three
extra deterministic Forecast runs; cheap).

retirement_glide_chart_controller (D3, mirrors goal_projection_chart's
import / ResizeObserver / theme-observer idiom): portfolio-by-age line +
area, accumulation/drawdown phase shading, the ±1pp band cone, the
dashed Walletburst shadow, a "Retire · age N / $X" chip on the retire
line, a blue Coast crossover ring, purple lump bars, and a hover tooltip
(PR #2029 bg-container/rounded-xl/shadow style) showing the monthly
State / Workplace / Drawdown breakdown + Total-vs-target with a Covered
badge. Wired into the show page above the what-if; container is
privacy-sensitive.

Browser-verified: renders the band, shading, retire chip ($571K), Coast
dot, and shadow against the demo plan. glide_payload + lump_markers
unit-tested. Rubocop + erb_lint + biome clean.

Remaining for PR4: DS::SelectableCard bucket, "Why this target?" anchor
card, skinned DS::Dialog deletes, DE locale, demo seed, system test.
2026-05-29 11:58:56 +02:00

42 lines
1.4 KiB
Ruby

class RetirementController < ApplicationController
include RetirementScoped
def show
@glide = @plan.glide_payload
@pension_sources = @plan.pension_sources.order(:start_age)
@adjustments = @plan.adjustments.ordered
@statements = @plan.statements.chronological.reverse
@bucket_account_ids = @plan.retirement_bucket_entries.pluck(:account_id).to_set
@bucket_candidates = Current.family.accounts.visible.alphabetically
@breadcrumbs = [
[ t("breadcrumbs.home"), root_path ],
[ 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