Skip to content

Path to Freedom — capital pools, retirement model, depletion chart#180

Merged
fleveque merged 1 commit into
mainfrom
feat/path-to-freedom-followups
May 27, 2026
Merged

Path to Freedom — capital pools, retirement model, depletion chart#180
fleveque merged 1 commit into
mainfrom
feat/path-to-freedom-followups

Conversation

@fleveque

Copy link
Copy Markdown
Owner

Summary

Builds on the v1 /freedom page (PR #179) with five follow-ups:

  • Optional capital pools beyond dividend stocks: an interest-bearing pool (bonds / real estate / savings) and a growth pool (index funds / growth stocks). Reinvest-interest checkbox controls whether interest compounds during accumulation or counts toward the goal.
  • Capped adaptive distribution: each post-goal year sells up to the inflation-adjusted shortfall, never more than 4% of capital-at-distribution (SWR-style ceiling, inflated yearly). Healthy retirements hit the goal exactly; lean ones get passive + 4% with the gap visible as acquisitive-power loss. Sales come from the growth pool first.
  • Retirement-age trigger: optional motivation_retirement_age + motivation_birth_year. Forces distribution to start at that age even if the dividend goal hasn't been met — distribution proceeds with whatever passive income exists.
  • Total-capital chart: dual-axis (capital left, monthly income right), continues past acquisitive-power loss until capital hits zero, with a rose-dashed vertical marker at the year real income is first lost. Two new insight stats: Real income lasts and Capital lasts.
  • Bugfix: /freedom rendered blank on hard refresh because two useMemos sat below an early-return guard. Moved them above the guard so the hook count is stable between loading and loaded renders.

Other UX touches

  • DEFAULT_MAX_YEARS 60 → 80 so realistic FIRE projections have room to deplete on the chart.
  • X-axis tick selector switched from year-based to pixel-based collision detection (LABEL_MIN_PX) — anchors (today/goal/start/end) win over step ticks. Fixes overlapping +37y +38y and -2y 0 labels regardless of span.
  • Optional motivation_birth_year renders an "age at this year" secondary row on the X axis of all three charts.
  • Y-axis labels: trimDecimalZeros keeps $1.25M and $1.88M distinct (they both used to collapse to $1M).
  • Reordered insight stat cards so the two post-goal numbers (Real income lasts, Capital lasts) sit in the top row instead of wrapping below.

Backend

  • 5 migrations adding motivation_interest_capital, motivation_interest_rate_pct, motivation_growth_capital, motivation_growth_rate_pct, motivation_reinvest_interest, motivation_birth_year, motivation_retirement_age to users. (One short-lived motivation_birth_date was replaced by motivation_birth_year in the same series — followup migration, not a rollback.)
  • MotivationProjectionService.simulate extended with all the new inputs; distribution_outcome implements the capped-adaptive rule. Cache invalidation triggers include every new field.
  • ProfilesController permits + serializes the new fields; the demo bundle showcases all of them.
  • TS twin in app/frontend/lib/motivation.ts stays in sync.

Test plan

  • bundle exec rspec — full suite still green (716 examples).
  • npx tsc --noEmit and npx vite build clean.
  • bin/rubocop and bin/brakeman clean.
  • Manual: visit /freedom, fill capital pools + retirement age, observe the three charts update + the new stat cards.
  • Manual: hard-refresh /freedom (the bug from this PR) — page must render, not blank.
  • Manual: set retirement age below the year the goal hits — distribution starts at retirement age, income line shows the SWR-capped shortfall, vertical "acquisitive power lost" bar lands at year 0 of distribution.
  • Manual: visit /demo — same flows work from the prefilled demo profile.

🤖 Generated with Claude Code

Builds on the v1 /freedom page with five things the original lacked:

Capital pools
- Two optional pools next to the dividend portfolio: interest-bearing
  (bonds/real estate/savings) and growth (index funds/growth stocks).
  Reinvest-interest checkbox controls whether interest compounds during
  accumulation or counts toward the goal as paid-out income.
- Five new persisted columns on users + validations + serializer +
  ProfilesController params.

Distribution model (after goal/retirement)
- Capped adaptive withdrawal: each year sell up to the inflation-
  adjusted shortfall, never more than 4% of capital-at-distribution
  (inflated each year — SWR ceiling). Healthy retirements get exactly
  the goal income; lean retirements get passive + 4% with the gap
  visible as acquisitive-power loss.
- Sales come from the growth pool first, then dividend/interest pools
  proportionally — dividend income stays nominally constant for as
  long as possible.

Retirement-age trigger
- Optional motivation_retirement_age input. Combined with the new
  motivation_birth_year, forces distribution to start at that age
  even if the dividend goal hasn't been met. Empty distribution-year
  interpolation when retirement triggers (clean birthday boundary,
  not sub-year).

Total-capital chart with monthly income
- New TotalCapitalChart component with dual Y axis: total capital
  on the left (declines through distribution), monthly income on
  the right. Vertical rose-dashed marker at the year acquisitive
  power is first lost. Distribution loop continues past that point
  until capital hits zero, so the chart shows the full depletion
  curve. Two new insight stat cards: "Real income lasts" and
  "Capital lasts".

Misc UX
- Bumped DEFAULT_MAX_YEARS 60 → 80 so realistic FIRE scenarios have
  room to deplete on the chart.
- X-axis tick selector now uses pixel-based collision detection
  (LABEL_MIN_PX), prioritising anchors (today/goal/start/end) over
  step ticks. Fixes overlapping "+37y +38y" / "-2y 0" labels.
- Optional motivation_birth_year input renders an "age at this year"
  secondary row on the X axis of all three charts.
- Y-axis labels use trimDecimalZeros so $1.25M and $1.88M don't both
  render as $1M.

Bugfix
- PathToFreedomPage had two useMemo calls below an early-return guard,
  violating rules of hooks. On hard refresh (when React Query cache
  wasn't pre-warmed) the page rendered blank because the hook count
  changed between the loading and loaded renders.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@fleveque fleveque merged commit 256005a into main May 27, 2026
5 checks passed
@fleveque fleveque deleted the feat/path-to-freedom-followups branch May 27, 2026 17:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant