@retiregolden/planner-ui 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (298) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +181 -0
  3. package/package.json +77 -0
  4. package/src/App.tsx +246 -0
  5. package/src/RouteErrorBoundary.tsx +45 -0
  6. package/src/assets/hero.png +0 -0
  7. package/src/assets/react.svg +1 -0
  8. package/src/assets/vite.svg +1 -0
  9. package/src/data/fedInvestClient.ts +113 -0
  10. package/src/data/localStore.ts +42 -0
  11. package/src/data/planOrigin.ts +24 -0
  12. package/src/data/planStore.ts +165 -0
  13. package/src/data/v2Backup.ts +101 -0
  14. package/src/import/ImportPage.tsx +347 -0
  15. package/src/import/ReviewChecklistView.tsx +38 -0
  16. package/src/import/brokerCsv.ts +395 -0
  17. package/src/import/csv.ts +133 -0
  18. package/src/import/genericCsv.ts +224 -0
  19. package/src/import/projectionLab.ts +350 -0
  20. package/src/import/reviewChecklist.ts +33 -0
  21. package/src/import/tenForty.ts +275 -0
  22. package/src/index.css +630 -0
  23. package/src/index.ts +16 -0
  24. package/src/learn/ArticleBody.tsx +78 -0
  25. package/src/learn/ArticlePage.tsx +57 -0
  26. package/src/learn/GlossaryPage.tsx +33 -0
  27. package/src/learn/LearnAboutScreen.tsx +41 -0
  28. package/src/learn/LearnCards.tsx +41 -0
  29. package/src/learn/LearnLink.tsx +91 -0
  30. package/src/learn/LearningCenterPage.tsx +114 -0
  31. package/src/learn/SourcesPage.tsx +98 -0
  32. package/src/learn/components/ArticleFigure.tsx +34 -0
  33. package/src/learn/components/ArticleShell.tsx +86 -0
  34. package/src/learn/components/ComparisonTable.tsx +42 -0
  35. package/src/learn/components/FormulaBlock.tsx +34 -0
  36. package/src/learn/components/PurchasingPowerChart.tsx +41 -0
  37. package/src/learn/components/RelatedArticles.tsx +27 -0
  38. package/src/learn/components/ScenarioCard.tsx +24 -0
  39. package/src/learn/components/SourceList.tsx +23 -0
  40. package/src/learn/components/charts.tsx +21 -0
  41. package/src/learn/content/about-retiregolden.ts +100 -0
  42. package/src/learn/content/aca-premium-tax-credits-and-magi.ts +103 -0
  43. package/src/learn/content/account-types-overview.ts +106 -0
  44. package/src/learn/content/after-tax-estate.ts +111 -0
  45. package/src/learn/content/agi-magi-and-taxable-income.ts +112 -0
  46. package/src/learn/content/appealing-irmaa-ssa-44.ts +95 -0
  47. package/src/learn/content/assumption-general-inflation.ts +82 -0
  48. package/src/learn/content/assumption-healthcare-inflation.ts +85 -0
  49. package/src/learn/content/assumption-heir-tax-rate.ts +79 -0
  50. package/src/learn/content/assumption-investment-returns.ts +90 -0
  51. package/src/learn/content/assumption-longevity-planning-age.ts +78 -0
  52. package/src/learn/content/assumption-recent-magi.ts +83 -0
  53. package/src/learn/content/assumption-social-security-cola.ts +89 -0
  54. package/src/learn/content/assumption-social-security-trust-fund.ts +83 -0
  55. package/src/learn/content/assumption-state-tax-override.ts +79 -0
  56. package/src/learn/content/beneficiaries-and-account-titling.ts +99 -0
  57. package/src/learn/content/break-even-useful-lens.ts +94 -0
  58. package/src/learn/content/building-a-retirement-spending-budget.ts +100 -0
  59. package/src/learn/content/cola-and-inflation-protection.ts +102 -0
  60. package/src/learn/content/divorced-spousal-and-survivor-records.ts +104 -0
  61. package/src/learn/content/dynamic-spending-guardrails.ts +90 -0
  62. package/src/learn/content/earnings-test-before-fra.ts +100 -0
  63. package/src/learn/content/employer-match-and-contribution-order.ts +104 -0
  64. package/src/learn/content/examplePlanArticles.ts +525 -0
  65. package/src/learn/content/fees-expense-ratios-and-compounding-drag.ts +98 -0
  66. package/src/learn/content/fi-number-and-four-percent-rule.ts +64 -0
  67. package/src/learn/content/filling-a-tax-bracket-with-roth-conversions.ts +98 -0
  68. package/src/learn/content/funded-ratio.ts +70 -0
  69. package/src/learn/content/healthcare-after-65.ts +103 -0
  70. package/src/learn/content/healthcare-before-65.ts +104 -0
  71. package/src/learn/content/historical-vs-random-return-models.ts +101 -0
  72. package/src/learn/content/how-assumptions-change-the-answer.ts +105 -0
  73. package/src/learn/content/how-much-can-i-spend.ts +105 -0
  74. package/src/learn/content/how-social-security-is-taxed.ts +95 -0
  75. package/src/learn/content/how-the-optimizer-thinks.ts +102 -0
  76. package/src/learn/content/how-the-optimizer-values-after-tax-estate.ts +97 -0
  77. package/src/learn/content/how-to-model-accumulation.ts +67 -0
  78. package/src/learn/content/how-to-read-a-retirement-projection.ts +115 -0
  79. package/src/learn/content/hsas-and-qualified-medical-expenses.ts +108 -0
  80. package/src/learn/content/hsas-as-retirement-accounts.ts +101 -0
  81. package/src/learn/content/inflation-risk.ts +98 -0
  82. package/src/learn/content/inherited-ira-10-year-rule.ts +105 -0
  83. package/src/learn/content/insurance-in-your-retirement-plan.ts +103 -0
  84. package/src/learn/content/irmaa-two-year-lookback.ts +99 -0
  85. package/src/learn/content/long-term-care-costs-and-insurance.ts +103 -0
  86. package/src/learn/content/long-term-care-insurance-as-risk-transfer.ts +98 -0
  87. package/src/learn/content/longevity-risk.ts +99 -0
  88. package/src/learn/content/marginal-vs-effective-tax-rate.ts +98 -0
  89. package/src/learn/content/medicare-part-b-vs-part-d-irmaa.ts +102 -0
  90. package/src/learn/content/mortality-weighted-social-security.ts +113 -0
  91. package/src/learn/content/moving-to-retiregolden.ts +86 -0
  92. package/src/learn/content/niit-high-income-investment-tax.ts +98 -0
  93. package/src/learn/content/ordinary-income-vs-capital-gains.ts +103 -0
  94. package/src/learn/content/paying-conversion-taxes-taxable-vs-ira.ts +102 -0
  95. package/src/learn/content/pensions-and-annuities.ts +101 -0
  96. package/src/learn/content/permanent-life-insurance-in-a-plan.ts +106 -0
  97. package/src/learn/content/pia-aime-and-bend-points.ts +103 -0
  98. package/src/learn/content/planner-overview.ts +106 -0
  99. package/src/learn/content/planning-for-couples-and-survivor-years.ts +108 -0
  100. package/src/learn/content/privacy-what-stays-in-your-browser.ts +99 -0
  101. package/src/learn/content/qcds-qualified-charitable-distributions.ts +101 -0
  102. package/src/learn/content/reading-the-results-page.ts +96 -0
  103. package/src/learn/content/reading-the-social-security-analysis-page.ts +106 -0
  104. package/src/learn/content/real-estate-home-equity-and-debt.ts +100 -0
  105. package/src/learn/content/reports-csv-exports-and-sharing.ts +101 -0
  106. package/src/learn/content/risk-based-guardrails.ts +100 -0
  107. package/src/learn/content/rmds-required-minimum-distributions.ts +100 -0
  108. package/src/learn/content/roth-conversion-basics.ts +104 -0
  109. package/src/learn/content/rsus-and-espp.ts +101 -0
  110. package/src/learn/content/rule-of-55-and-72t.ts +107 -0
  111. package/src/learn/content/savings-rate-biggest-lever.ts +66 -0
  112. package/src/learn/content/seed-your-plan-from-your-tax-return.ts +93 -0
  113. package/src/learn/content/sensitivity-testing-what-changes-the-answer.ts +104 -0
  114. package/src/learn/content/sequence-of-returns-risk.ts +98 -0
  115. package/src/learn/content/social-security-bridge.ts +67 -0
  116. package/src/learn/content/social-security-claiming-age-basics.ts +113 -0
  117. package/src/learn/content/social-security-taxes-vs-benefits.ts +76 -0
  118. package/src/learn/content/spending-profiles-and-the-retirement-smile.ts +92 -0
  119. package/src/learn/content/spousal-and-survivor-benefits.ts +120 -0
  120. package/src/learn/content/ssdi-and-retirement-planning.ts +72 -0
  121. package/src/learn/content/standard-deduction-senior-deduction-and-itemizing.ts +97 -0
  122. package/src/learn/content/state-income-taxes-in-retirement.ts +97 -0
  123. package/src/learn/content/step-up-in-basis.ts +102 -0
  124. package/src/learn/content/survivor-planning-for-couples.ts +110 -0
  125. package/src/learn/content/survivor-spending-in-couple-plans.ts +98 -0
  126. package/src/learn/content/tax-cliffs-and-bracket-edges.ts +105 -0
  127. package/src/learn/content/tax-loss-and-gain-harvesting.ts +99 -0
  128. package/src/learn/content/taxable-brokerage-basis-and-capital-gains.ts +99 -0
  129. package/src/learn/content/three-big-questions-spending-time-risk.ts +103 -0
  130. package/src/learn/content/tips-ladders.ts +92 -0
  131. package/src/learn/content/todays-dollars-vs-future-dollars.ts +107 -0
  132. package/src/learn/content/traditional-vs-roth-contributions.ts +113 -0
  133. package/src/learn/content/troubleshooting-surprising-results.ts +105 -0
  134. package/src/learn/content/trust-fund-haircut-scenarios.ts +101 -0
  135. package/src/learn/content/understanding-monte-carlo-success-rate.ts +118 -0
  136. package/src/learn/content/understanding-your-plan-assumptions.ts +134 -0
  137. package/src/learn/content/using-assumptions-and-provenance.ts +98 -0
  138. package/src/learn/content/using-scenarios-to-compare-choices.ts +99 -0
  139. package/src/learn/content/what-changes-when-you-move-states.ts +141 -0
  140. package/src/learn/content/what-is-fire.ts +65 -0
  141. package/src/learn/content/what-monte-carlo-proves.ts +98 -0
  142. package/src/learn/content/what-retiregolden-models.ts +103 -0
  143. package/src/learn/content/what-retirement-healthcare-really-costs.ts +117 -0
  144. package/src/learn/content/why-95-percent-is-not-a-guarantee.ts +98 -0
  145. package/src/learn/content/why-roth-conversions-raise-other-costs.ts +106 -0
  146. package/src/learn/content/why-small-tax-cliffs-can-matter.ts +109 -0
  147. package/src/learn/content/widows-penalty-and-survivor-brackets.ts +106 -0
  148. package/src/learn/content/withdrawal-order-basics.ts +105 -0
  149. package/src/learn/glossary.ts +191 -0
  150. package/src/learn/inlineMarkdown.tsx +54 -0
  151. package/src/learn/learn.css +537 -0
  152. package/src/learn/learningRegistry.ts +502 -0
  153. package/src/longevity/LongevityResults.tsx +85 -0
  154. package/src/longevity/LongevityWizard.tsx +305 -0
  155. package/src/longevity/constants.ts +15 -0
  156. package/src/longevity/factors.ts +125 -0
  157. package/src/longevity/model.ts +31 -0
  158. package/src/longevity/persistedGuard.ts +129 -0
  159. package/src/longevity/storage.ts +40 -0
  160. package/src/mc/messages.ts +118 -0
  161. package/src/mc/monteCarlo.worker.ts +44 -0
  162. package/src/mc/pool.ts +267 -0
  163. package/src/mc/runRequest.ts +125 -0
  164. package/src/optimize/messages.ts +84 -0
  165. package/src/optimize/optimize.worker.ts +29 -0
  166. package/src/optimize/runOptimize.ts +92 -0
  167. package/src/optimize/runSpendingSolve.ts +47 -0
  168. package/src/optimize/runner.ts +21 -0
  169. package/src/optimize/spendingMessages.ts +44 -0
  170. package/src/optimize/spendingRunner.ts +21 -0
  171. package/src/optimize/spendingSolve.worker.ts +18 -0
  172. package/src/planner/AssumptionsCardPage.tsx +136 -0
  173. package/src/planner/BucketLensCard.tsx +114 -0
  174. package/src/planner/ComparePlansPage.tsx +219 -0
  175. package/src/planner/DisclaimerPage.tsx +88 -0
  176. package/src/planner/HowTestedPage.tsx +159 -0
  177. package/src/planner/LiveStatus.tsx +15 -0
  178. package/src/planner/LongevityModal.tsx +55 -0
  179. package/src/planner/Modal.tsx +97 -0
  180. package/src/planner/MonteCarloPage.tsx +907 -0
  181. package/src/planner/OptimizePage.tsx +611 -0
  182. package/src/planner/PlanContext.tsx +198 -0
  183. package/src/planner/PlanPickerPage.tsx +124 -0
  184. package/src/planner/PlanWorkspace.tsx +290 -0
  185. package/src/planner/ProvenancePanel.tsx +45 -0
  186. package/src/planner/RelocationComparePage.tsx +485 -0
  187. package/src/planner/ReportPage.tsx +375 -0
  188. package/src/planner/ResultsPage.tsx +817 -0
  189. package/src/planner/ScenariosPage.tsx +285 -0
  190. package/src/planner/SocialSecuritySection.tsx +556 -0
  191. package/src/planner/SpendingSolverPage.tsx +512 -0
  192. package/src/planner/SsAnalysisPage.tsx +1134 -0
  193. package/src/planner/SurvivalPercentileModal.tsx +161 -0
  194. package/src/planner/SurvivorTransitionPage.tsx +286 -0
  195. package/src/planner/assumptionsExport.ts +371 -0
  196. package/src/planner/bucketLens.ts +89 -0
  197. package/src/planner/chartFrame.ts +8 -0
  198. package/src/planner/chartStyle.ts +11 -0
  199. package/src/planner/dialogViews.tsx +184 -0
  200. package/src/planner/dialogs.tsx +133 -0
  201. package/src/planner/examples/ExampleLibrary.tsx +189 -0
  202. package/src/planner/examples/ExamplePreviewBanner.tsx +55 -0
  203. package/src/planner/examples/ExamplesPage.tsx +25 -0
  204. package/src/planner/examples/OpenExampleButton.tsx +61 -0
  205. package/src/planner/examples/buildAggressiveSaver.ts +102 -0
  206. package/src/planner/examples/buildAnnuityEstate.ts +137 -0
  207. package/src/planner/examples/buildBaristaFire.ts +115 -0
  208. package/src/planner/examples/buildBracketFillRoth.ts +65 -0
  209. package/src/planner/examples/buildBridgeEarlyRetirement.ts +94 -0
  210. package/src/planner/examples/buildBrokerageNoHsa.ts +109 -0
  211. package/src/planner/examples/buildCoastFire.ts +88 -0
  212. package/src/planner/examples/buildContext.ts +20 -0
  213. package/src/planner/examples/buildEarlyCareerMatch.ts +93 -0
  214. package/src/planner/examples/buildEarlyRetireeAca.ts +61 -0
  215. package/src/planner/examples/buildExampleCouple.ts +103 -0
  216. package/src/planner/examples/buildFixedTargetSpending.ts +74 -0
  217. package/src/planner/examples/buildGlidepathAllocation.ts +131 -0
  218. package/src/planner/examples/buildGuardrailsFlex.ts +120 -0
  219. package/src/planner/examples/buildHsaPropertyDepth.ts +109 -0
  220. package/src/planner/examples/buildHsaStealthRetirement.ts +97 -0
  221. package/src/planner/examples/buildLeanFatFire.ts +109 -0
  222. package/src/planner/examples/buildLtcShock.ts +62 -0
  223. package/src/planner/examples/buildMovingStateTax.ts +53 -0
  224. package/src/planner/examples/buildNoAnnuityBrokerage.ts +92 -0
  225. package/src/planner/examples/buildRmdIrmaa.ts +55 -0
  226. package/src/planner/examples/buildSalaryGrowthEscalation.ts +96 -0
  227. package/src/planner/examples/buildStaticAllocationControl.ts +96 -0
  228. package/src/planner/examples/buildSurvivorYears.ts +62 -0
  229. package/src/planner/examples/buildUnderSavedSingle.ts +51 -0
  230. package/src/planner/examples/exampleCopy.ts +23 -0
  231. package/src/planner/examples/loadExample.ts +90 -0
  232. package/src/planner/examples/registry.ts +313 -0
  233. package/src/planner/explainPanels.tsx +233 -0
  234. package/src/planner/fields.tsx +381 -0
  235. package/src/planner/format.ts +33 -0
  236. package/src/planner/home/DataAndPrivacyCard.tsx +56 -0
  237. package/src/planner/home/GettingStartedPaths.tsx +46 -0
  238. package/src/planner/home/GettingStartedReopener.tsx +32 -0
  239. package/src/planner/home/StartHereLinks.tsx +22 -0
  240. package/src/planner/home/WelcomeHero.tsx +39 -0
  241. package/src/planner/home/YourPlans.tsx +72 -0
  242. package/src/planner/home/importErrorMessage.ts +22 -0
  243. package/src/planner/home/startHereSlugs.ts +7 -0
  244. package/src/planner/home/useHomeData.ts +190 -0
  245. package/src/planner/home/useHomeMode.ts +47 -0
  246. package/src/planner/householdActions.ts +22 -0
  247. package/src/planner/insights/InsightCardView.tsx +340 -0
  248. package/src/planner/insights/InsightsPage.tsx +204 -0
  249. package/src/planner/insights/categoryLabels.ts +11 -0
  250. package/src/planner/learnLinks.ts +85 -0
  251. package/src/planner/marketModelPicker.ts +172 -0
  252. package/src/planner/optimizePageChart.ts +40 -0
  253. package/src/planner/optimizePageClaim.ts +64 -0
  254. package/src/planner/planCompleteness.ts +27 -0
  255. package/src/planner/planContextCore.ts +26 -0
  256. package/src/planner/planner.css +2304 -0
  257. package/src/planner/provenanceLinks.ts +25 -0
  258. package/src/planner/sections/AccountFields.tsx +872 -0
  259. package/src/planner/sections/AccountsSection.tsx +89 -0
  260. package/src/planner/sections/AllocationPanel.tsx +261 -0
  261. package/src/planner/sections/AssumptionsSection.tsx +256 -0
  262. package/src/planner/sections/HouseholdSection.tsx +243 -0
  263. package/src/planner/sections/IncomeFloorSection.tsx +418 -0
  264. package/src/planner/sections/IncomeSection.tsx +170 -0
  265. package/src/planner/sections/InsuranceSection.tsx +362 -0
  266. package/src/planner/sections/SpendingSection.tsx +904 -0
  267. package/src/planner/sections/StrategySection.tsx +349 -0
  268. package/src/planner/sections/UpdateBalancesPanel.tsx +182 -0
  269. package/src/planner/sections/sectionHelpers.ts +48 -0
  270. package/src/planner/sections/shared.tsx +15 -0
  271. package/src/planner/sections.tsx +15 -0
  272. package/src/planner/ssAnalysis.ts +325 -0
  273. package/src/planner/successBand.ts +20 -0
  274. package/src/planner/survivorAnalysis.ts +277 -0
  275. package/src/planner/usStates.ts +19 -0
  276. package/src/planner/useMcSuccessRate.ts +77 -0
  277. package/src/planner/useProjection.ts +63 -0
  278. package/src/relocation/messages.ts +21 -0
  279. package/src/relocation/relocation.worker.ts +18 -0
  280. package/src/relocation/runRelocation.ts +17 -0
  281. package/src/relocation/runner.ts +22 -0
  282. package/src/report/brandingContext.ts +15 -0
  283. package/src/report/downloadReport.ts +34 -0
  284. package/src/report/reportHtml.ts +547 -0
  285. package/src/routes/LearnRoutes.tsx +46 -0
  286. package/src/routes/PlanRoutes.tsx +55 -0
  287. package/src/routes/RouteFallback.tsx +9 -0
  288. package/src/socialSecurity/breakEven.ts +107 -0
  289. package/src/socialSecurity/expectedPv.ts +164 -0
  290. package/src/socialSecurity/explain.ts +92 -0
  291. package/src/socialSecurity/ficaReturn.ts +81 -0
  292. package/src/socialSecurity/persistedSsGuard.ts +138 -0
  293. package/src/socialSecurity/ssFormUtils.ts +48 -0
  294. package/src/socialSecurity/ssaStatementXml.ts +156 -0
  295. package/src/socialSecurity/storage.ts +69 -0
  296. package/src/socialSecurity/survivorSwitching.ts +153 -0
  297. package/src/testSupport/samplePlan.ts +2 -0
  298. package/src/workers/run.ts +45 -0
@@ -0,0 +1,243 @@
1
+ /** Household section: people, filing status, state, longevity. */
2
+
3
+ import { useState } from 'react'
4
+
5
+ import { removePartner } from '../householdActions'
6
+ import { usePlan } from '../planContextCore'
7
+ import { CheckboxField, DateField, NumberField, SelectField, TextField } from '../fields'
8
+ import { LEARN } from '../learnLinks'
9
+ import { LongevityModal } from '../LongevityModal'
10
+ import { SurvivalPercentileModal } from '../SurvivalPercentileModal'
11
+ import { US_STATES } from '../usStates'
12
+ import { Issues } from './shared'
13
+ import { MONTH_OPTIONS, newId } from './sectionHelpers'
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Household
17
+ // ---------------------------------------------------------------------------
18
+
19
+ export function HouseholdSection() {
20
+ const { plan, update } = usePlan()
21
+ const couple = plan.household.people.length === 2
22
+ const [longevityFor, setLongevityFor] = useState<number | null>(null)
23
+ const [percentileFor, setPercentileFor] = useState<number | null>(null)
24
+ return (
25
+ <section>
26
+ <div className="card">
27
+ <h2>Household</h2>
28
+ <p className="card-hint">
29
+ Who the plan is for. The projection runs from this year until the later planning age, pays wages until each
30
+ person's retirement age, and models survivor rules (filing status, Social Security step-up) after the first
31
+ death. Hover any ⓘ for details.
32
+ </p>
33
+ <div className="form-grid">
34
+ <SelectField
35
+ label="Filing status"
36
+ help="Sets the federal tax brackets, deductions, and IRMAA tiers. Married filing jointly requires two people; the death year remains joint, then the survivor normally files single."
37
+ value={plan.household.filingStatus}
38
+ options={[
39
+ { value: 'single', label: 'Single' },
40
+ { value: 'marriedFilingJointly', label: 'Married filing jointly' },
41
+ ]}
42
+ onCommit={(v) => update((d) => void (d.household.filingStatus = v))}
43
+ />
44
+ {couple && plan.household.filingStatus === 'marriedFilingJointly' ? (
45
+ <CheckboxField
46
+ label="Qualifying dependent for survivor"
47
+ help="Opt in when the surviving spouse would qualify for qualifying surviving spouse filing status. RetireGolden does not model dependents, so this uses joint brackets and deductions for the two years after the spouse's death."
48
+ value={plan.household.hasQualifyingDependent}
49
+ onCommit={(v) => update((d) => void (d.household.hasQualifyingDependent = v))}
50
+ />
51
+ ) : null}
52
+ <SelectField
53
+ label="State (starting residence)"
54
+ help="Drives state income tax. Several states are modeled with real brackets and retirement-income rules; others fall back to the flat effective-rate override under Assumptions. Add a move below to change states mid-retirement."
55
+ value={plan.household.state}
56
+ options={US_STATES}
57
+ onCommit={(v) => update((d) => void (d.household.state = v))}
58
+ />
59
+ </div>
60
+ {plan.household.people.map((person, i) => (
61
+ <div className="item-row" key={person.id} style={{ marginTop: '1rem' }}>
62
+ <div className="item-row-head">
63
+ <span className="item-row-title">
64
+ <span className="type-chip">{i === 0 ? 'Primary' : 'Partner'}</span>
65
+ {person.name}
66
+ </span>
67
+ {i === 1 ? (
68
+ <button
69
+ type="button"
70
+ className="btn-ghost btn-ghost-danger"
71
+ onClick={() => update((d) => removePartner(d, person.id))}
72
+ >
73
+ Remove
74
+ </button>
75
+ ) : null}
76
+ </div>
77
+ <div className="form-grid">
78
+ <TextField label="Name" value={person.name} onCommit={(v) => update((d) => void (d.household.people[i]!.name = v || 'Person'))} />
79
+ <DateField label="Date of birth" value={person.dob} onCommit={(v) => update((d) => void (d.household.people[i]!.dob = v))} />
80
+ <SelectField
81
+ label="Sex"
82
+ help="Only used as the baseline for the life-expectancy estimate (SSA period life tables differ by sex). Pick 'Average' to use a blended table."
83
+ value={person.sex}
84
+ options={[
85
+ { value: 'female', label: 'Female' },
86
+ { value: 'male', label: 'Male' },
87
+ { value: 'average', label: 'Average' },
88
+ ]}
89
+ onCommit={(v) => update((d) => void (d.household.people[i]!.sex = v))}
90
+ />
91
+ <NumberField
92
+ label="Retirement age"
93
+ help="Wages and payroll contributions stop in the year this age is reached. Leave blank if this person never has wages in the plan."
94
+ value={person.retirementAge}
95
+ allowNull
96
+ min={30}
97
+ max={80}
98
+ onCommit={(v) => update((d) => void (d.household.people[i]!.retirementAge = v))}
99
+ />
100
+ <div className="field-with-action">
101
+ <NumberField
102
+ label="Planning age"
103
+ help="How long the plan runs for this person — the age the money must last to, not a prediction of death. Planning beyond average life expectancy is prudent; 'Calculate' estimates an age from a short health questionnaire, and 'Percentile' anchors it to a survival probability (e.g. the age you have a 25% chance of reaching). Typing a number always overrides."
104
+ learn={LEARN.longevity}
105
+ value={person.longevity.planningAge}
106
+ min={60}
107
+ max={120}
108
+ onCommit={(v) => update((d) => void (d.household.people[i]!.longevity = { planningAge: Math.round(v ?? 95), source: 'manual' }))}
109
+ />
110
+ <button type="button" className="btn btn-secondary btn-small" onClick={() => setLongevityFor(i)}>
111
+ Calculate
112
+ </button>
113
+ <button type="button" className="btn btn-secondary btn-small" onClick={() => setPercentileFor(i)}>
114
+ Percentile
115
+ </button>
116
+ </div>
117
+ {person.longevity.source === 'percentile' && person.longevity.percentile ? (
118
+ <p className="field-hint" style={{ gridColumn: '1 / -1', margin: 0 }}>
119
+ Planning age {person.longevity.planningAge} = the age{' '}
120
+ {person.longevity.percentile.joint ? 'at least one of you' : person.name} had a{' '}
121
+ {person.longevity.percentile.pct}% chance of reaching when picked
122
+ {person.longevity.percentile.healthMultiplier !== undefined ||
123
+ person.longevity.percentile.partnerHealthMultiplier !== undefined
124
+ ? ', health-adjusted'
125
+ : ''}{' '}
126
+ (SSA 2022 table). Re-open Percentile to refresh it as ages change.
127
+ </p>
128
+ ) : null}
129
+ </div>
130
+ </div>
131
+ ))}
132
+ {longevityFor !== null && plan.household.people[longevityFor] ? (
133
+ <LongevityModal
134
+ person={plan.household.people[longevityFor]!}
135
+ personIndex={longevityFor}
136
+ onApply={(age) =>
137
+ update((d) => {
138
+ d.household.people[longevityFor]!.longevity = { planningAge: age, source: 'model' }
139
+ })
140
+ }
141
+ onClose={() => setLongevityFor(null)}
142
+ />
143
+ ) : null}
144
+ {percentileFor !== null && plan.household.people[percentileFor] ? (
145
+ <SurvivalPercentileModal
146
+ person={plan.household.people[percentileFor]!}
147
+ personIndex={percentileFor}
148
+ partner={plan.household.people[1 - percentileFor] ?? null}
149
+ onApply={(longevity) =>
150
+ update((d) => {
151
+ d.household.people[percentileFor]!.longevity = longevity
152
+ })
153
+ }
154
+ onClose={() => setPercentileFor(null)}
155
+ />
156
+ ) : null}
157
+ {!couple ? (
158
+ <div className="add-row">
159
+ <button
160
+ type="button"
161
+ className="btn btn-secondary btn-small"
162
+ onClick={() =>
163
+ update((d) => {
164
+ d.household.people.push({
165
+ id: newId(),
166
+ name: 'Partner',
167
+ dob: '1965-01-01',
168
+ sex: 'average',
169
+ retirementAge: 65,
170
+ longevity: { planningAge: 95, source: 'manual' },
171
+ })
172
+ d.household.filingStatus = 'marriedFilingJointly'
173
+ })
174
+ }
175
+ >
176
+ + Add partner
177
+ </button>
178
+ </div>
179
+ ) : null}
180
+
181
+ <h3>Moving in retirement</h3>
182
+ <p className="card-hint">
183
+ Optional. Add a relocation and the projection splits the move year by month, then uses the new state's rules afterward.
184
+ </p>
185
+ {plan.household.stateMoves.map((move, i) => (
186
+ <div className="item-row" key={i}>
187
+ <div className="item-row-head">
188
+ <span className="item-row-title">
189
+ <span className="type-chip">Move</span>to {US_STATES.find((s) => s.value === move.state)?.label ?? move.state} in {MONTH_OPTIONS.find((m) => m.value === String(move.fromMonth))?.label ?? 'July'} {move.fromYear}
190
+ </span>
191
+ <button type="button" className="btn-ghost btn-ghost-danger" onClick={() => update((d) => void d.household.stateMoves.splice(i, 1))}>
192
+ Remove
193
+ </button>
194
+ </div>
195
+ <div className="form-grid">
196
+ <NumberField
197
+ label="Move year"
198
+ help="The calendar year of residence in the new state. The projection allocates this year by move month."
199
+ value={move.fromYear}
200
+ min={1900}
201
+ max={2200}
202
+ onCommit={(v) => update((d) => void (d.household.stateMoves[i]!.fromYear = Math.round(v ?? move.fromYear)))}
203
+ />
204
+ <SelectField
205
+ label="Move month"
206
+ value={String(move.fromMonth)}
207
+ options={MONTH_OPTIONS}
208
+ onCommit={(v) => update((d) => void (d.household.stateMoves[i]!.fromMonth = Number(v)))}
209
+ />
210
+ <SelectField
211
+ label="New state"
212
+ value={move.state}
213
+ options={US_STATES}
214
+ onCommit={(v) => update((d) => void (d.household.stateMoves[i]!.state = v))}
215
+ />
216
+ </div>
217
+ </div>
218
+ ))}
219
+ <div className="add-row">
220
+ <button
221
+ type="button"
222
+ className="btn btn-secondary btn-small"
223
+ onClick={() =>
224
+ update((d) => {
225
+ const lastYear = d.household.stateMoves[d.household.stateMoves.length - 1]?.fromYear
226
+ d.household.stateMoves.push({ fromYear: (lastYear ?? new Date().getFullYear()) + 1, fromMonth: 7, state: d.household.state })
227
+ })
228
+ }
229
+ >
230
+ + Add a move
231
+ </button>
232
+ </div>
233
+
234
+ <Issues />
235
+ </div>
236
+ </section>
237
+ )
238
+ }
239
+
240
+ // ---------------------------------------------------------------------------
241
+ // Accounts
242
+ // ---------------------------------------------------------------------------
243
+
@@ -0,0 +1,418 @@
1
+ /**
2
+ * Income floor section (social-security-bridge-and-tips-ladder): TIPS ladders
3
+ * as plan artifacts — build a level real income floor or a Social Security
4
+ * bridge, see its buy-list and quoted cost on the embedded real-yield curve,
5
+ * read the funded ratio, and (opt-in only) sanity-check against live Treasury
6
+ * FedInvest prices.
7
+ */
8
+
9
+ import { useMemo, useState } from 'react'
10
+
11
+ import type { TipsLadder } from '@retiregolden/engine/model/plan'
12
+ import { EMBEDDED_REAL_YIELD_CURVE } from '@retiregolden/engine/params'
13
+ import { buildLadder, type LadderBuild } from '@retiregolden/engine/ladder/ladderMath'
14
+ import { computeFundedRatio } from '@retiregolden/engine/ladder/fundedRatio'
15
+ import {
16
+ FEDINVEST_PAGE_URL,
17
+ latestPriceDateIso,
18
+ nearestTipsForYear,
19
+ type FedInvestSnapshot,
20
+ } from '@retiregolden/engine/ladder/fedInvest'
21
+ import { fetchFedInvestTips, importFedInvestCsv, readFedInvestCache } from '../../data/fedInvestClient'
22
+ import { LearnAboutScreen } from '../../learn/LearnAboutScreen'
23
+ import { LearnLink } from '../../learn/LearnLink'
24
+ import { LEARN } from '../learnLinks'
25
+ import { usePlan } from '../planContextCore'
26
+ import { provenanceSource } from '../provenanceLinks'
27
+ import { CheckboxField, MoneyField, NumberField, SelectField, TextField } from '../fields'
28
+ import { fmtMoney, fmtMoneyCompact } from '../format'
29
+ import { currentStartYear, useProjection } from '../useProjection'
30
+ import { Issues } from './shared'
31
+
32
+ const CURVE = EMBEDDED_REAL_YIELD_CURVE
33
+
34
+ /** Quote a ladder exactly the way the ledger prices it (same anchor rules). */
35
+ function quoteLadder(ladder: TipsLadder, startYear: number): LadderBuild | null {
36
+ const anchorYear = ladder.purchase ? ladder.purchase.year : startYear - 1
37
+ const effectiveStartYear = Math.max(ladder.startYear, anchorYear + 1)
38
+ if (ladder.endYear < effectiveStartYear || ladder.annualRealAmount <= 0) return null
39
+ return buildLadder({
40
+ annualRealIncome: ladder.annualRealAmount,
41
+ firstPayoutOffset: effectiveStartYear - anchorYear,
42
+ payoutYears: ladder.endYear - effectiveStartYear + 1,
43
+ curve: CURVE,
44
+ })
45
+ }
46
+
47
+ function LadderRow({ ladder, index, startYear }: { ladder: TipsLadder; index: number; startYear: number }) {
48
+ const { plan, update } = usePlan()
49
+ const quote = useMemo(() => quoteLadder(ladder, startYear), [ladder, startYear])
50
+ const fundingOptions = plan.accounts
51
+ .filter((a) => a.type === 'cash' || a.type === 'taxable' || a.type === 'equityComp')
52
+ .map((a) => ({ value: a.id, label: a.name }))
53
+ const edit = (fn: (l: TipsLadder) => void) =>
54
+ update((d) => {
55
+ const l = d.incomeFloor?.ladders[index]
56
+ if (l) fn(l)
57
+ })
58
+
59
+ return (
60
+ <div className="item-row">
61
+ <div className="item-row-head">
62
+ <span className="item-row-title">
63
+ <span className="type-chip">{ladder.purpose === 'bridge' ? 'Bridge' : 'Floor'}</span>
64
+ {ladder.name}
65
+ </span>
66
+ <button
67
+ type="button"
68
+ className="btn-ghost btn-ghost-danger"
69
+ onClick={() => update((d) => void d.incomeFloor?.ladders.splice(index, 1))}
70
+ >
71
+ Remove
72
+ </button>
73
+ </div>
74
+ <div className="form-grid">
75
+ <TextField label="Name" value={ladder.name} onCommit={(v) => edit((l) => void (l.name = v || 'TIPS ladder'))} />
76
+ <SelectField
77
+ label="Purpose"
78
+ help="Labeling only — a bridge covers the years until a delayed Social Security claim; a floor covers essential spending. The math is the same."
79
+ value={ladder.purpose}
80
+ options={[
81
+ { value: 'floor', label: 'Essential-spending floor' },
82
+ { value: 'bridge', label: 'Social Security bridge' },
83
+ ]}
84
+ onCommit={(v) => edit((l) => void (l.purpose = v))}
85
+ />
86
+ <MoneyField
87
+ label="Annual real income (today's $)"
88
+ help="The level inflation-adjusted income the ladder pays in each payout year. TIPS index to CPI, so this stays constant in today's dollars. Quotes price each rung on the embedded Treasury real-yield curve."
89
+ source={provenanceSource('real-yield-curve')}
90
+ value={ladder.annualRealAmount}
91
+ onCommit={(v) => edit((l) => void (l.annualRealAmount = Math.max(0, v ?? 0)))}
92
+ />
93
+ <NumberField
94
+ label="First payout year"
95
+ value={ladder.startYear}
96
+ min={1900}
97
+ max={2200}
98
+ onCommit={(v) => edit((l) => void (l.startYear = Math.round(v ?? l.startYear)))}
99
+ />
100
+ <NumberField
101
+ label="Last payout year"
102
+ value={ladder.endYear}
103
+ min={1900}
104
+ max={2200}
105
+ onCommit={(v) => edit((l) => void (l.endYear = Math.round(v ?? l.endYear)))}
106
+ />
107
+ <CheckboxField
108
+ label="Purchase in the plan"
109
+ help="When on, the quoted cost is withdrawn from the funding account in the purchase year (realizing capital gains pro-rata if it sells appreciated holdings). When off, the ladder is treated as already owned."
110
+ value={ladder.purchase !== undefined}
111
+ onCommit={(v) =>
112
+ edit((l) => {
113
+ l.purchase = v
114
+ ? { year: Math.min(startYear, l.startYear - 1), fundingAccountId: fundingOptions[0]?.value ?? '' }
115
+ : undefined
116
+ })
117
+ }
118
+ />
119
+ {ladder.purchase ? (
120
+ <>
121
+ <SelectField
122
+ label="Funded from"
123
+ value={ladder.purchase.fundingAccountId}
124
+ options={fundingOptions.length > 0 ? fundingOptions : [{ value: '', label: 'No cash/taxable account' }]}
125
+ onCommit={(v) => edit((l) => void (l.purchase && (l.purchase.fundingAccountId = v)))}
126
+ />
127
+ <NumberField
128
+ label="Purchase year"
129
+ hint="Must be before the first payout year."
130
+ value={ladder.purchase.year}
131
+ min={1900}
132
+ max={2200}
133
+ onCommit={(v) => edit((l) => void (l.purchase && (l.purchase.year = Math.round(v ?? l.purchase.year))))}
134
+ />
135
+ </>
136
+ ) : null}
137
+ </div>
138
+ {quote ? (
139
+ <>
140
+ <p className="card-hint">
141
+ Quoted cost <strong>{fmtMoney(quote.totalCost)}</strong> (today's $) for {quote.rungs.length} rung
142
+ {quote.rungs.length === 1 ? '' : 's'} — real yields as of {CURVE.asOfIso}. That's{' '}
143
+ {((ladder.annualRealAmount / quote.totalCost) * 100).toFixed(2)}% of cost per year, inflation-protected.
144
+ </p>
145
+ <details>
146
+ <summary>Buy-list (planning-grade)</summary>
147
+ <div className="year-table-wrap">
148
+ <table>
149
+ <thead>
150
+ <tr>
151
+ <th>Matures</th>
152
+ <th>Face (today's $)</th>
153
+ <th>Coupon</th>
154
+ <th>Est. cost</th>
155
+ </tr>
156
+ </thead>
157
+ <tbody>
158
+ {quote.rungs.map((rung) => (
159
+ <tr key={rung.maturityOffset}>
160
+ <td>{(ladder.purchase ? ladder.purchase.year : startYear - 1) + rung.maturityOffset}</td>
161
+ <td>{fmtMoney(rung.face)}</td>
162
+ <td>{rung.couponRatePct.toFixed(3)}%</td>
163
+ <td>{fmtMoney(rung.cost)}</td>
164
+ </tr>
165
+ ))}
166
+ </tbody>
167
+ </table>
168
+ </div>
169
+ <p className="card-hint">
170
+ Execute at your brokerage or TreasuryDirect — RetireGolden never places orders. Annual coupons and par-rung
171
+ pricing are planning simplifications; real quotes differ slightly.
172
+ </p>
173
+ </details>
174
+ </>
175
+ ) : (
176
+ <p className="card-hint">Set an amount and a payout window (after the purchase year) to see a quote.</p>
177
+ )}
178
+ </div>
179
+ )
180
+ }
181
+
182
+ /** Funded-ratio card — shared with the Results page (step 4 of the plan). */
183
+ export function FundedRatioCard() {
184
+ const { plan } = usePlan()
185
+ const { result, deflate } = useProjection(plan)
186
+ const startYear = result.startYear
187
+ const fr = useMemo(() => {
188
+ const primary = plan.household.people[0]
189
+ const retirementYear =
190
+ primary && primary.retirementAge !== null ? Number(primary.dob.slice(0, 4)) + primary.retirementAge : startYear
191
+ return computeFundedRatio({
192
+ years: result.years,
193
+ startYear,
194
+ deflate,
195
+ curve: CURVE,
196
+ fromYear: Math.max(retirementYear, startYear),
197
+ })
198
+ }, [plan, result, deflate, startYear])
199
+
200
+ if (!fr) return null
201
+ return (
202
+ <div className="card">
203
+ <h2>Funded ratio</h2>
204
+ <p className="card-hint">
205
+ Pension accounting for your household: essential spending valued on the TIPS curve vs. the guaranteed income
206
+ dedicated to it. <LearnLink {...LEARN.fundedRatio} />
207
+ </p>
208
+ <div className="stat-grid">
209
+ <div>
210
+ <div className={`stat-value ${fr.fundedRatioPct >= 100 ? 'stat-value--good' : 'stat-value--neutral'}`}>
211
+ {Math.round(fr.fundedRatioPct)}%
212
+ </div>
213
+ <div className="muted">of the essential floor is funded by guaranteed income</div>
214
+ </div>
215
+ <div>
216
+ <div className="stat-value stat-value--sm">{fmtMoneyCompact(fr.essentialSpendingPv)}</div>
217
+ <div className="muted">essential spending, valued today</div>
218
+ </div>
219
+ <div>
220
+ <div className="stat-value stat-value--sm">{fmtMoneyCompact(fr.guaranteedIncomePv)}</div>
221
+ <div className="muted">guaranteed income, valued today</div>
222
+ </div>
223
+ <div>
224
+ <div className="stat-value stat-value--sm">{fmtMoneyCompact(fr.unfundedPv)}</div>
225
+ <div className="muted">gap riding on the portfolio</div>
226
+ </div>
227
+ </div>
228
+ <p className="card-hint">
229
+ {plan.expenses.requiredAnnual === undefined
230
+ ? 'Tip: you have not separated required spending from lifestyle on the Spending page, so the "floor" here is your whole budget and the ratio reads low.'
231
+ : `Counted from ${fr.fromYear} through ${fr.toYear}, discounted at Treasury real yields as of ${CURVE.asOfIso}.`}
232
+ </p>
233
+ </div>
234
+ )
235
+ }
236
+
237
+ function LivePricesCard() {
238
+ const { plan } = usePlan()
239
+ const startYear = currentStartYear()
240
+ // Cache-first with zero network: a previously fetched/imported day shows
241
+ // immediately; the fetch button only appears for a fresh look.
242
+ const [snapshot, setSnapshot] = useState<FedInvestSnapshot | null>(() => readFedInvestCache())
243
+ const [loading, setLoading] = useState(false)
244
+ const [error, setError] = useState<string | null>(null)
245
+ const ladders = plan.incomeFloor?.ladders
246
+ const rungYears = useMemo(() => {
247
+ const years = new Set<number>()
248
+ for (const ladder of ladders ?? []) {
249
+ for (let y = Math.max(ladder.startYear, startYear + 1); y <= ladder.endYear; y++) years.add(y)
250
+ }
251
+ return [...years].sort((a, b) => a - b)
252
+ }, [ladders, startYear])
253
+
254
+ if (!ladders || ladders.length === 0) return null
255
+
256
+ const load = async () => {
257
+ setLoading(true)
258
+ setError(null)
259
+ try {
260
+ setSnapshot(await fetchFedInvestTips())
261
+ } catch (e) {
262
+ setError(e instanceof Error ? e.message : 'The price fetch failed.')
263
+ } finally {
264
+ setLoading(false)
265
+ }
266
+ }
267
+
268
+ return (
269
+ <div className="card">
270
+ <h2>Live TIPS prices (optional)</h2>
271
+ <p className="card-hint">
272
+ Your plan always works offline on the embedded yield curve. If you want to sanity-check the quote against real
273
+ securities, this button asks the U.S. Treasury's FedInvest service for the latest end-of-day TIPS prices. It is
274
+ the app's only network request, sends nothing but a date, and is cached on this device for the day.
275
+ </p>
276
+ {snapshot === null ? (
277
+ <button type="button" className="btn btn-secondary" onClick={() => void load()} disabled={loading}>
278
+ {loading ? 'Fetching…' : 'Fetch live prices from Treasury FedInvest'}
279
+ </button>
280
+ ) : (
281
+ <>
282
+ <p className="card-hint">
283
+ {snapshot.priceDateIso === null
284
+ ? `${snapshot.tips.length} TIPS from an imported file — dated by your download (the FedInvest CSV carries no date)`
285
+ : `${snapshot.tips.length} TIPS priced ${snapshot.priceDateIso}`}{' '}
286
+ — per $100 face, before inflation index ratio.
287
+ </p>
288
+ {/* An imported or previous-day snapshot is not fresh: keep the fetch available. */}
289
+ {snapshot.source === 'import' || snapshot.priceDateIso !== latestPriceDateIso() ? (
290
+ <button type="button" className="btn btn-secondary btn-small" onClick={() => void load()} disabled={loading}>
291
+ {loading ? 'Fetching…' : 'Fetch the latest prices from Treasury FedInvest'}
292
+ </button>
293
+ ) : null}
294
+ <div className="table-scroll">
295
+ <table>
296
+ <thead>
297
+ <tr>
298
+ <th>Rung year</th>
299
+ <th>Nearest real TIPS (CUSIP)</th>
300
+ <th>Coupon</th>
301
+ <th>Matures</th>
302
+ <th>End-of-day price</th>
303
+ </tr>
304
+ </thead>
305
+ <tbody>
306
+ {rungYears.map((year) => {
307
+ const match = nearestTipsForYear(snapshot.tips, year)
308
+ return (
309
+ <tr key={year}>
310
+ <td>{year}</td>
311
+ <td>{match ? match.cusip : '— none matures nearby —'}</td>
312
+ <td>{match ? `${match.ratePct.toFixed(3)}%` : ''}</td>
313
+ <td>{match ? match.maturityIso : ''}</td>
314
+ <td>{match ? match.endOfDayPrice.toFixed(2) : ''}</td>
315
+ </tr>
316
+ )
317
+ })}
318
+ </tbody>
319
+ </table>
320
+ </div>
321
+ </>
322
+ )}
323
+ {error ? (
324
+ <>
325
+ <p className="card-hint" role="alert">
326
+ {error} Your plan still works on the embedded curve.
327
+ </p>
328
+ <p className="card-hint">
329
+ Treasury's service does not allow direct browser requests from other sites (CORS), so the fetch can be
330
+ blocked even when you are online. Zero-network alternative: download <code>securityprice.csv</code>{' '}
331
+ yourself from{' '}
332
+ <a href={FEDINVEST_PAGE_URL} target="_blank" rel="noreferrer">
333
+ FedInvest
334
+ </a>{' '}
335
+ (pick the latest date, CSV format) and import it here.
336
+ </p>
337
+ <label className="btn btn-secondary btn-small">
338
+ Import securityprice.csv
339
+ <input
340
+ type="file"
341
+ accept=".csv,text/csv"
342
+ style={{ display: 'none' }}
343
+ onChange={(e) => {
344
+ const file = e.target.files?.[0]
345
+ if (!file) return
346
+ void file.text().then(
347
+ (text) => {
348
+ try {
349
+ setSnapshot(importFedInvestCsv(text))
350
+ setError(null)
351
+ } catch (err) {
352
+ setError(err instanceof Error ? err.message : 'That file could not be read.')
353
+ }
354
+ },
355
+ () => setError('That file could not be read.'),
356
+ )
357
+ }}
358
+ />
359
+ </label>
360
+ </>
361
+ ) : null}
362
+ </div>
363
+ )
364
+ }
365
+
366
+ export function IncomeFloorSection() {
367
+ const { plan, update } = usePlan()
368
+ const startYear = currentStartYear()
369
+ const ladders = plan.incomeFloor?.ladders ?? []
370
+
371
+ return (
372
+ <section>
373
+ <div className="card">
374
+ <h2>TIPS ladders</h2>
375
+ <p className="card-hint">
376
+ A TIPS ladder turns a lump sum into guaranteed, inflation-adjusted income — a DIY real pension for a Social
377
+ Security bridge or an essential-spending floor. Cash flows run through your full ledger with real TIPS
378
+ taxation (federally ordinary, state-exempt, accretion taxed as it accrues).{' '}
379
+ <LearnLink {...LEARN.tipsLadders} />
380
+ </p>
381
+ {ladders.map((ladder, i) => (
382
+ <LadderRow key={ladder.id} ladder={ladder} index={i} startYear={startYear} />
383
+ ))}
384
+ <div className="add-row">
385
+ <button
386
+ type="button"
387
+ className="btn btn-secondary btn-small"
388
+ onClick={() =>
389
+ update((d) => {
390
+ const ladder: TipsLadder = {
391
+ id: crypto.randomUUID(),
392
+ name: 'Income floor ladder',
393
+ purpose: 'floor',
394
+ startYear: startYear + 1,
395
+ endYear: startYear + 20,
396
+ annualRealAmount: 12_000,
397
+ }
398
+ if (d.incomeFloor) d.incomeFloor.ladders.push(ladder)
399
+ else d.incomeFloor = { ladders: [ladder] }
400
+ })
401
+ }
402
+ >
403
+ + TIPS ladder
404
+ </button>
405
+ </div>
406
+ <p className="card-hint">
407
+ Curve provenance: {CURVE.source}, as of {CURVE.asOfIso}. Looking for a bridge sized from your own benefit?
408
+ The Social Security Optimizer has a one-click bridge panel. <LearnLink {...LEARN.socialSecurityBridge} />
409
+ </p>
410
+ <Issues />
411
+ </div>
412
+
413
+ <FundedRatioCard />
414
+ <LivePricesCard />
415
+ <LearnAboutScreen route="/plan/:planId/income-floor" />
416
+ </section>
417
+ )
418
+ }