@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,817 @@
1
+ /**
2
+ * Deterministic results: net worth by account category, income vs spending,
3
+ * tax detail, modeling warnings, and the full year-by-year drill-down table
4
+ * with a nominal / today's-dollars toggle and CSV export.
5
+ */
6
+
7
+ import { useMemo, useState } from 'react'
8
+ import { Link } from 'react-router-dom'
9
+ import {
10
+ Area,
11
+ AreaChart,
12
+ Bar,
13
+ BarChart,
14
+ CartesianGrid,
15
+ Legend,
16
+ Line,
17
+ LineChart,
18
+ ReferenceLine,
19
+ ResponsiveContainer,
20
+ Tooltip,
21
+ XAxis,
22
+ YAxis,
23
+ } from 'recharts'
24
+
25
+ import type { Plan } from '@retiregolden/engine/model/plan'
26
+ import { startingInvestableOf } from '@retiregolden/engine/montecarlo/riskBasedGuardrails'
27
+ import { DEFAULT_PATH_COUNT } from '../mc/pool'
28
+ import type { YearResult } from '@retiregolden/engine/projection/types'
29
+ import { usePlan } from './planContextCore'
30
+ import { isPlanIncomplete } from './planCompleteness'
31
+ import { LearnAboutScreen } from '../learn/LearnAboutScreen'
32
+ import { downloadStandaloneReport } from '../report/downloadReport'
33
+ import { useReportBranding } from '../report/brandingContext'
34
+ import { fmtMoney, fmtMoneyCompact } from './format'
35
+ import { useProjection } from './useProjection'
36
+ import { BucketLensCard } from './BucketLensCard'
37
+ import { FundedRatioCard } from './sections/IncomeFloorSection'
38
+ import { chartTooltipStyle } from './chartStyle'
39
+ import { frameH } from './chartFrame'
40
+ import { useMcSuccessRate } from './useMcSuccessRate'
41
+
42
+ type Dollars = 'nominal' | 'today'
43
+
44
+ const CATEGORIES = ['cash', 'taxable', 'equityComp', 'traditional', 'roth', 'hsa'] as const
45
+ const CAT_LABEL: Record<(typeof CATEGORIES)[number], string> = {
46
+ cash: 'Cash',
47
+ taxable: 'Taxable',
48
+ equityComp: 'Equity comp',
49
+ traditional: 'Traditional',
50
+ roth: 'Roth',
51
+ hsa: 'HSA',
52
+ }
53
+ const CAT_COLOR: Record<(typeof CATEGORIES)[number], string> = {
54
+ cash: 'var(--chart-5)',
55
+ taxable: 'var(--chart-2)',
56
+ equityComp: 'var(--chart-6)',
57
+ traditional: 'var(--chart-3)',
58
+ roth: 'var(--chart-1)',
59
+ hsa: 'var(--chart-4)',
60
+ }
61
+
62
+ /** Income streams, stacked bottom-to-top in the income breakdown. */
63
+ const INCOME_SOURCES = [
64
+ { key: 'wages', label: 'Wages', color: 'var(--chart-2)' },
65
+ { key: 'socialSecurity', label: 'Social Security', color: 'var(--chart-1)' },
66
+ { key: 'pension', label: 'Pension', color: 'var(--chart-3)' },
67
+ { key: 'annuity', label: 'Annuity', color: 'var(--chart-4)' },
68
+ { key: 'tipsLadder', label: 'TIPS ladder', color: 'var(--chart-8)' },
69
+ { key: 'recurring', label: 'Other recurring', color: 'var(--chart-5)' },
70
+ { key: 'oneTime', label: 'One-time', color: 'var(--chart-6)' },
71
+ { key: 'taxableYield', label: 'Brokerage yield', color: 'var(--chart-7)' },
72
+ ] as const
73
+
74
+ /** Spending categories, stacked bottom-to-top; sums to expenses + tax + penalties. */
75
+ const EXPENSE_CATEGORIES = [
76
+ { key: 'base', label: 'Baseline living', color: 'var(--chart-1)' },
77
+ { key: 'healthcare', label: 'Healthcare', color: 'var(--chart-2)' },
78
+ { key: 'property', label: 'Property tax + insurance', color: 'var(--chart-3)' },
79
+ { key: 'debt', label: 'Debt payments', color: 'var(--chart-4)' },
80
+ { key: 'insurance', label: 'Insurance premiums', color: 'var(--chart-5)' },
81
+ { key: 'care', label: 'Long-term care (net)', color: 'var(--chart-6)' },
82
+ { key: 'goals', label: 'One-time goals', color: 'var(--chart-8)' },
83
+ { key: 'taxes', label: 'Tax + penalties', color: 'var(--muted)' },
84
+ ] as const
85
+
86
+ function categoryBalances(plan: Plan, y: YearResult): Record<(typeof CATEGORIES)[number], number> {
87
+ const out = { cash: 0, taxable: 0, equityComp: 0, traditional: 0, roth: 0, hsa: 0 }
88
+ for (const a of plan.accounts) {
89
+ if ((CATEGORIES as readonly string[]).includes(a.type)) {
90
+ out[a.type as (typeof CATEGORIES)[number]] += y.balances[a.id] ?? 0
91
+ }
92
+ }
93
+ return out
94
+ }
95
+
96
+ function moneyTick(v: number): string {
97
+ return fmtMoneyCompact(v)
98
+ }
99
+
100
+ function DollarsToggle({ value, onChange }: { value: Dollars; onChange: (v: Dollars) => void }) {
101
+ return (
102
+ <div className="seg" role="group" aria-label="Dollar display">
103
+ <button type="button" aria-pressed={value === 'today'} onClick={() => onChange('today')}>
104
+ Today's $
105
+ </button>
106
+ <button type="button" aria-pressed={value === 'nominal'} onClick={() => onChange('nominal')}>
107
+ Nominal $
108
+ </button>
109
+ </div>
110
+ )
111
+ }
112
+
113
+ const tooltipProps = {
114
+ formatter: (v: unknown) => fmtMoney(Number(v)),
115
+ contentStyle: chartTooltipStyle,
116
+ wrapperStyle: { zIndex: 2 },
117
+ } as const
118
+
119
+ /** "1,000" — keeps verdict copy in sync if the default path count changes. */
120
+ const PATH_COUNT_LABEL = DEFAULT_PATH_COUNT.toLocaleString()
121
+
122
+ /**
123
+ * The FIRE metrics + FI-target chart. Rendered as the leading card only for
124
+ * households still accumulating with retirement 5+ years out; for everyone
125
+ * else it lives behind a disclosure below the balances chart. The FI-date tile
126
+ * is omitted (not "—") when FI is never reached, so a successful near-retiree
127
+ * plan never wears a failure-flavored verdict on a question it didn't ask.
128
+ */
129
+ function FireLens({
130
+ view,
131
+ plan,
132
+ rows,
133
+ dollarLabel,
134
+ }: {
135
+ view: ReturnType<typeof useProjection>
136
+ plan: Plan
137
+ rows: ReadonlyArray<{ year: number; investable: number; fiTarget: number }>
138
+ dollarLabel: string
139
+ }) {
140
+ return (
141
+ <>
142
+ <p className="card-hint">
143
+ {view.summary.fiYear !== null
144
+ ? `Based on your safe withdrawal rate assumption (${plan.assumptions.safeWithdrawalRatePct ?? 4}%), you reach FI in ${view.summary.fiYear} (age ${view.summary.fiAge}).`
145
+ : 'Your investable balance stays below the FI target through the plan horizon.'}{' '}
146
+ Chart shown in {dollarLabel}.
147
+ </p>
148
+ <div className="metric-panel stat-grid">
149
+ <div>
150
+ <div className="stat-value stat-value--sm">{fmtMoney(view.summary.fiNumber)}</div>
151
+ <div className="muted">FI target portfolio</div>
152
+ </div>
153
+ <div>
154
+ <div className="stat-value stat-value--sm">{fmtMoney(view.summary.coastFireNumber)}</div>
155
+ <div className="muted">Coast-FIRE target (now)</div>
156
+ </div>
157
+ <div>
158
+ <div className="stat-value stat-value--sm">{view.summary.averagePreRetirementSavingsRatePct.toFixed(1)}%</div>
159
+ <div className="muted">average savings rate</div>
160
+ </div>
161
+ {view.summary.fiYear !== null ? (
162
+ <div>
163
+ <div className="stat-value stat-value--sm">
164
+ {view.summary.fiYear} (Age {view.summary.fiAge})
165
+ </div>
166
+ <div className="muted">FI date / age</div>
167
+ </div>
168
+ ) : null}
169
+ </div>
170
+
171
+ <div className="chart-frame" style={frameH(320)}>
172
+ <ResponsiveContainer width="100%" height="100%">
173
+ <LineChart
174
+ data={[...rows]}
175
+ margin={{ left: 12, right: 8, top: 8 }}
176
+ aria-label="Path to financial independence: investable portfolio vs. FI target, year by year"
177
+ >
178
+ <CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
179
+ <XAxis dataKey="year" interval="equidistantPreserveStart" tick={{ fill: 'var(--muted)', fontSize: 12 }} />
180
+ <YAxis tickFormatter={moneyTick} tick={{ fill: 'var(--muted)', fontSize: 12 }} width={70} />
181
+ <Legend />
182
+ <Line dataKey="investable" name="Investable Portfolio" stroke="var(--chart-1)" dot={false} strokeWidth={3} />
183
+ <Line dataKey="fiTarget" name="FI Target Line" stroke="var(--bad)" strokeDasharray="4 4" dot={false} strokeWidth={2} />
184
+ <Tooltip {...tooltipProps} />
185
+ </LineChart>
186
+ </ResponsiveContainer>
187
+ </div>
188
+ </>
189
+ )
190
+ }
191
+
192
+ export function ResultsPage() {
193
+ const { plan } = usePlan()
194
+ const reportBranding = useReportBranding()
195
+ const view = useProjection(plan)
196
+ const [dollars, setDollars] = useState<Dollars>('today')
197
+ const dollarLabel = dollars === 'today' ? 'today\'s $' : 'nominal $'
198
+ const adj = useMemo(
199
+ () => (year: number, v: number) => (dollars === 'today' ? view.deflate(year, v) : v),
200
+ [dollars, view],
201
+ )
202
+
203
+ const hasCarryforward = plan.household.capitalLossCarryforward > 0
204
+ const hasAmt = view.result.years.some((y) => y.amt > 0.5)
205
+ const hasFlexibleGoalControls = plan.expenses.oneTimeGoals.some(
206
+ (g) =>
207
+ g.classification !== undefined ||
208
+ g.flexibility !== undefined ||
209
+ g.earliestYear !== undefined ||
210
+ g.latestYear !== undefined ||
211
+ g.priority !== undefined ||
212
+ g.allowPartialFunding === true ||
213
+ g.minFundingPct !== undefined,
214
+ )
215
+ const hasLayeredSpending =
216
+ plan.expenses.requiredAnnual !== undefined ||
217
+ (plan.expenses.idealAnnual ?? 0) > 0 ||
218
+ (plan.expenses.excessAnnual ?? 0) > 0 ||
219
+ (plan.expenses.spendingPolicy !== undefined && plan.expenses.spendingPolicy.mode !== 'fixedTarget') ||
220
+ hasFlexibleGoalControls
221
+ // Highlight the first year the carryforward shelters realized gains (the early
222
+ // brokerage-drawdown years it's meant for), else the first projection year.
223
+ const carryforwardHighlight = hasCarryforward
224
+ ? (view.result.years.find((y) => y.capitalLossUsedAgainstGains > 0.5) ?? view.result.years[0])
225
+ : undefined
226
+
227
+ const rows = useMemo(
228
+ () =>
229
+ view.result.years.map((y) => {
230
+ const cats = categoryBalances(plan, y)
231
+ const nominalFiTarget = view.summary.fiNumber * Math.pow(1 + plan.assumptions.inflationPct / 100, y.year - view.startYear)
232
+ return {
233
+ year: y.year,
234
+ // Zero balances render as null so empty categories stay out of the
235
+ // tooltip (an "HSA: $0" row is noise in a six-series stack).
236
+ ...Object.fromEntries(
237
+ CATEGORIES.map((c) => {
238
+ const v = adj(y.year, cats[c])
239
+ return [c, v > 0.5 ? v : null]
240
+ }),
241
+ ),
242
+ income: adj(y.year, y.incomes.total),
243
+ spending: adj(y.year, y.expenses.total + y.tax + y.penalties),
244
+ tax: adj(y.year, y.tax),
245
+ magi: adj(y.year, y.magi),
246
+ shortfall: adj(y.year, y.shortfall),
247
+ investable: adj(y.year, y.investableTotal),
248
+ fiTarget: adj(y.year, nominalFiTarget),
249
+ }
250
+ }),
251
+ [view, plan, adj],
252
+ )
253
+
254
+ // Zero series values render as null so the tooltip (filterNull) skips them —
255
+ // a stack of 6–8 series otherwise lists every "$0" row as noise.
256
+ const orNull = (v: number) => (v > 0.5 ? v : null)
257
+
258
+ const incomeRows = useMemo(
259
+ () =>
260
+ view.result.years.map((y) => ({
261
+ year: y.year,
262
+ wages: orNull(adj(y.year, y.incomes.wages)),
263
+ socialSecurity: orNull(adj(y.year, y.incomes.socialSecurity)),
264
+ pension: orNull(adj(y.year, y.incomes.pension)),
265
+ annuity: orNull(adj(y.year, y.incomes.annuity)),
266
+ tipsLadder: orNull(adj(y.year, y.incomes.tipsLadder)),
267
+ recurring: orNull(adj(y.year, y.incomes.recurring)),
268
+ oneTime: orNull(adj(y.year, y.incomes.oneTime)),
269
+ taxableYield: orNull(adj(y.year, y.incomes.taxableYield)),
270
+ })),
271
+ [view, adj],
272
+ )
273
+
274
+ const expenseRows = useMemo(
275
+ () =>
276
+ view.result.years.map((y) => ({
277
+ year: y.year,
278
+ base: orNull(adj(y.year, y.expenses.baseSpending)),
279
+ healthcare: orNull(adj(y.year, y.expenses.healthcare)),
280
+ property: orNull(adj(y.year, y.expenses.propertyCosts)),
281
+ debt: orNull(adj(y.year, y.expenses.debtService)),
282
+ insurance: orNull(adj(y.year, y.expenses.insurancePremiums)),
283
+ care: orNull(adj(y.year, Math.max(0, y.expenses.careCost - y.expenses.ltcBenefit))),
284
+ goals: orNull(adj(y.year, y.expenses.oneTimeGoals)),
285
+ taxes: orNull(adj(y.year, y.tax + y.penalties)),
286
+ })),
287
+ [view, adj],
288
+ )
289
+
290
+ const handleCsv = () => {
291
+ const cols = [
292
+ 'year', 'filingStatus', 'wages', 'socialSecurity', 'pension', 'annuity', 'tipsLadder', 'recurring', 'oneTimeIncome', 'taxableInterest', 'ordinaryDividends', 'qualifiedDividends', 'taxableYield', 'totalIncome',
293
+ 'baseSpending', 'goals', 'debtService', 'propertyCosts', 'healthcare', 'insurancePremiums', 'careCost', 'ltcBenefit', 'requiredSpending', 'targetSpending', 'idealSpending', 'excessSpending', 'intendedSpending', 'totalExpenses', 'contributions', 'employerMatch', 'rmd', 'qcd',
294
+ 'rothConversion', 'tax', 'amt', 'penalties', 'magi', 'withdrawals', 'realizedGains', 'lossCarryforwardUsed', 'lossCarryforwardRemaining', 'shortfall', 'investable',
295
+ 'requiredShortfall', 'targetShortfall', 'idealShortfall', 'excessShortfall', 'guardrailAction', 'guardrailFactor', 'flexibleGoalsFunded', 'flexibleGoalsPartiallyFunded', 'flexibleGoalsDeferred', 'flexibleGoalsSkipped', 'flexibleGoalFundedAmount', 'flexibleGoalUnfundedAmount', 'insuranceCashValue', 'ladderValue', 'deathBenefit', 'netWorth',
296
+ ]
297
+ const lines = [cols.join(',')]
298
+ for (const y of view.result.years) {
299
+ lines.push(
300
+ [
301
+ y.year, y.filingStatus, y.incomes.wages, y.incomes.socialSecurity, y.incomes.pension, y.incomes.annuity, y.incomes.tipsLadder, y.incomes.recurring,
302
+ y.incomes.oneTime, y.incomes.taxableInterest, y.incomes.ordinaryDividends, y.incomes.qualifiedDividends, y.incomes.taxableYield, y.incomes.total, y.expenses.baseSpending, y.expenses.oneTimeGoals, y.expenses.debtService,
303
+ y.expenses.propertyCosts, y.expenses.healthcare, y.expenses.insurancePremiums, y.expenses.careCost, y.expenses.ltcBenefit, y.expenses.requiredSpending, y.expenses.targetSpending, y.expenses.idealSpending, y.expenses.excessSpending, y.expenses.intendedSpending, y.expenses.total, y.contributions, y.employerMatch, y.rmd, y.qcd, y.rothConversion, y.tax, y.amt, y.penalties,
304
+ y.magi, y.withdrawals.total, y.realizedGains, y.capitalLossUsedAgainstGains + y.capitalLossUsedAgainstOrdinary, y.capitalLossCarryforwardRemaining, y.shortfall, y.investableTotal,
305
+ y.requiredShortfall, y.targetShortfall, y.idealShortfall, y.excessShortfall, y.guardrailAction, y.expenses.guardrailFactor.toFixed(2), y.flexibleGoals.funded, y.flexibleGoals.partiallyFunded, y.flexibleGoals.deferred, y.flexibleGoals.skipped, y.flexibleGoals.fundedAmount, y.flexibleGoals.unfundedAmount, y.insuranceCashValue, y.ladderValue, y.deathBenefit, y.netWorth,
306
+ ]
307
+ .map((v) => (typeof v === 'number' ? Math.round(v) : v))
308
+ .join(','),
309
+ )
310
+ }
311
+ const blob = new Blob([lines.join('\n')], { type: 'text/csv' })
312
+ const a = document.createElement('a')
313
+ a.href = URL.createObjectURL(blob)
314
+ a.download = `${plan.name.replace(/\W+/g, '-').toLowerCase()}-ledger.csv`
315
+ a.click()
316
+ URL.revokeObjectURL(a.href)
317
+ }
318
+
319
+ const handleHtmlReport = () => {
320
+ downloadStandaloneReport({
321
+ plan,
322
+ result: view.result,
323
+ summary: view.summary,
324
+ startYear: view.startYear,
325
+ branding: reportBranding,
326
+ })
327
+ }
328
+
329
+ const depletionYear = view.summary.depletionYear
330
+ const endYear = view.result.endYear
331
+ const endingToday = view.deflate(endYear, view.result.endingNetWorth)
332
+ // Same debounced, plan-keyed run the KPI bar uses (shared in-flight, so this
333
+ // never adds a second simulation) — the verdict must speak with both of the
334
+ // engine's voices, not just the steady-markets ledger.
335
+ const mcRate = useMcSuccessRate(plan, !isPlanIncomplete(plan))
336
+ // The first full year after depletion shows what the ledger already knows:
337
+ // guaranteed income keeps flowing, and the uncovered gap is the engine's own
338
+ // shortfall figure — no recomputation here. When depletion lands in the
339
+ // final plan year there is no later year, so that year carries the floor.
340
+ const floorYear =
341
+ depletionYear !== null
342
+ ? (view.result.years.find((y) => y.year > depletionYear) ??
343
+ view.result.years.find((y) => y.year === depletionYear))
344
+ : undefined
345
+ // The FIRE lens leads only for households genuinely accumulating: wages in
346
+ // the projection AND retirement 5+ years out. For everyone else (retirees,
347
+ // near-retirees whose plan may succeed while "FI date: —" reads as failure)
348
+ // it stays available behind a disclosure below the balances chart, so it
349
+ // never takes the top spot or contradicts the verdict.
350
+ const isAccumulating = view.result.years.some((y) => y.incomes.wages > 0)
351
+ const yearsToLastRetirement = Math.max(
352
+ 0,
353
+ ...plan.household.people.map((p) =>
354
+ // No set retirement age = working indefinitely = still accumulating.
355
+ p.retirementAge === null ? Number.POSITIVE_INFINITY : Number(p.dob.slice(0, 4)) + p.retirementAge - view.startYear,
356
+ ),
357
+ )
358
+ const fireLeads = isAccumulating && yearsToLastRetirement >= 5
359
+
360
+ return (
361
+ <section>
362
+ {!isPlanIncomplete(plan) ? (
363
+ <div className="results-verdict">
364
+ <h2>
365
+ {depletionYear !== null
366
+ ? `This plan runs out of money in ${depletionYear}.`
367
+ : `Your money lasts the full plan — through ${endYear}.`}
368
+ </h2>
369
+ <p className="muted">
370
+ {depletionYear !== null ? (
371
+ <>
372
+ The portfolio depletes {endYear - depletionYear} year{endYear - depletionYear === 1 ? '' : 's'} before
373
+ the end of the plan.
374
+ {floorYear !== undefined && floorYear.incomes.total > 0.5 ? (
375
+ <>
376
+ {' '}
377
+ Income doesn't stop: about {fmtMoneyCompact(view.deflate(floorYear.year, floorYear.incomes.total))}
378
+ /yr (today's dollars) of Social Security, pensions, and other income keeps arriving
379
+ {floorYear.shortfall > 0.5 ? (
380
+ <>
381
+ , leaving an uncovered spending gap of about{' '}
382
+ {fmtMoneyCompact(view.deflate(floorYear.year, floorYear.shortfall))}/yr
383
+ </>
384
+ ) : null}
385
+ .
386
+ </>
387
+ ) : null}
388
+ {mcRate !== null ? (
389
+ <>
390
+ {' '}
391
+ Across {PATH_COUNT_LABEL} varied markets, this plan succeeds {Math.round(mcRate * 100)}% of the
392
+ time — <Link to={`/plan/${plan.id}/monte-carlo`}>see Monte Carlo</Link>.
393
+ </>
394
+ ) : null}{' '}
395
+ <Link to={`/plan/${plan.id}/insights`}>See what would change this →</Link>
396
+ </>
397
+ ) : (
398
+ <>
399
+ In steady markets, ending net worth is {fmtMoneyCompact(view.result.endingNetWorth)} (
400
+ {fmtMoneyCompact(endingToday)} in today's dollars).
401
+ {mcRate !== null ? (
402
+ <>
403
+ {' '}
404
+ Across {PATH_COUNT_LABEL} varied markets, this plan succeeds {Math.round(mcRate * 100)}% of the
405
+ time — <Link to={`/plan/${plan.id}/monte-carlo`}>see Monte Carlo</Link>.
406
+ </>
407
+ ) : null}{' '}
408
+ The charts below are the evidence behind this verdict.
409
+ </>
410
+ )}
411
+ </p>
412
+ </div>
413
+ ) : null}
414
+
415
+ <div className="results-toolbar">
416
+ <DollarsToggle value={dollars} onChange={setDollars} />
417
+ <button type="button" className="btn btn-secondary btn-small" onClick={handleCsv}>
418
+ Download CSV
419
+ </button>
420
+ <button type="button" className="btn btn-secondary btn-small" onClick={handleHtmlReport}>
421
+ Download HTML report
422
+ </button>
423
+ <Link to={`/plan/${plan.id}/report`} className="btn btn-secondary btn-small">
424
+ View printable report
425
+ </Link>
426
+ <Link to={`/plan/${plan.id}/assumptions-card`} className="btn btn-secondary btn-small">
427
+ View assumptions card
428
+ </Link>
429
+ </div>
430
+
431
+ <p className="results-jump">
432
+ <a href="#year-table">Jump to the year-by-year table ↓</a>
433
+ </p>
434
+
435
+ {view.result.warnings.length > 0 ? (
436
+ <div className="callout callout--warn">
437
+ <strong>Modeling notes</strong>
438
+ <ul>
439
+ {view.result.warnings.map((w) => (
440
+ <li key={w}>{w}</li>
441
+ ))}
442
+ </ul>
443
+ </div>
444
+ ) : null}
445
+
446
+ {plan.expenses.spendingPolicy?.mode === 'riskBasedGuardrails' ? (
447
+ <div className="callout callout--info">
448
+ <strong>Risk-based spending guardrails</strong>
449
+ {plan.expenses.spendingPolicy.lowerBalanceThresholdPct !== undefined ||
450
+ plan.expenses.spendingPolicy.upperBalanceThresholdPct !== undefined ? (
451
+ <p>
452
+ Solved for the {plan.expenses.spendingPolicy.targetSuccessLowerPct ?? 70}–
453
+ {plan.expenses.spendingPolicy.targetSuccessUpperPct ?? 95}% success band (today's dollars):{' '}
454
+ {plan.expenses.spendingPolicy.lowerBalanceThresholdPct !== undefined ? (
455
+ <>
456
+ if the portfolio falls below{' '}
457
+ <strong>
458
+ {fmtMoney((plan.expenses.spendingPolicy.lowerBalanceThresholdPct / 100) * startingInvestableOf(plan))}
459
+ </strong>
460
+ , flexible spending is trimmed in {plan.expenses.spendingPolicy.adjustmentPct ?? 10}% steps
461
+ </>
462
+ ) : (
463
+ <>no cut threshold was solved for this band (see Spending for why)</>
464
+ )}
465
+ {'; '}
466
+ {plan.expenses.spendingPolicy.upperBalanceThresholdPct !== undefined ? (
467
+ <>
468
+ above{' '}
469
+ <strong>
470
+ {fmtMoney((plan.expenses.spendingPolicy.upperBalanceThresholdPct / 100) * startingInvestableOf(plan))}
471
+ </strong>
472
+ , spending can be restored or raised
473
+ </>
474
+ ) : (
475
+ <>no raise threshold was solved for this band</>
476
+ )}
477
+ . The required floor is never cut. Watch the “Guardrails” column below for the years the rule acted.
478
+ </p>
479
+ ) : (
480
+ <p>
481
+ The dollar thresholds for your success band have not been solved yet, so this policy currently holds
482
+ spending steady. Solve them on the Spending screen to activate adjustments.
483
+ </p>
484
+ )}
485
+ </div>
486
+ ) : null}
487
+
488
+ {carryforwardHighlight ? (
489
+ <div className="callout callout--info">
490
+ <strong>Capital loss carryforward</strong>
491
+ <p>
492
+ Starting balance {fmtMoney(plan.household.capitalLossCarryforward)}. In {carryforwardHighlight.year} it offset{' '}
493
+ {fmtMoney(adj(carryforwardHighlight.year, carryforwardHighlight.capitalLossUsedAgainstGains))} of realized
494
+ gains and {fmtMoney(adj(carryforwardHighlight.year, carryforwardHighlight.capitalLossUsedAgainstOrdinary))} of
495
+ ordinary income;{' '}
496
+ {fmtMoney(adj(carryforwardHighlight.year, carryforwardHighlight.capitalLossCarryforwardRemaining))} carries
497
+ forward. It nets against realized gains first, then up to $3,000/yr against ordinary income — watch it deplete
498
+ in the “Loss carryf'd” column below.
499
+ </p>
500
+ </div>
501
+ ) : null}
502
+
503
+ {fireLeads ? (
504
+ <div className="chart-card">
505
+ <h2>Path to Financial Independence (FIRE)</h2>
506
+ <FireLens view={view} plan={plan} rows={rows} dollarLabel={dollarLabel} />
507
+ </div>
508
+ ) : null}
509
+
510
+ <div className="chart-card">
511
+ <h2>Investable balances by account type</h2>
512
+ <p className="card-hint">
513
+ End-of-year balances, shown in {dollarLabel}.
514
+ {view.summary.depletionYear !== null ? (
515
+ <>
516
+ {' '}
517
+ Portfolio depletes in {view.summary.depletionYear}. <Link to={`/plan/${plan.id}/insights`}>See what would change this →</Link>
518
+ </>
519
+ ) : null}
520
+ </p>
521
+ <div className="chart-frame" style={frameH(320)}>
522
+ <ResponsiveContainer width="100%" height="100%">
523
+ <AreaChart
524
+ data={rows}
525
+ margin={{ left: 12, right: 8, top: 8 }}
526
+ aria-label="Investable balances by account type, year by year"
527
+ >
528
+ <CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
529
+ <XAxis dataKey="year" interval="equidistantPreserveStart" tick={{ fill: 'var(--muted)', fontSize: 12 }} />
530
+ <YAxis tickFormatter={moneyTick} tick={{ fill: 'var(--muted)', fontSize: 12 }} width={70} />
531
+ <Legend />
532
+ {CATEGORIES.map((c) => (
533
+ <Area key={c} dataKey={c} stackId="bal" name={CAT_LABEL[c]} stroke={CAT_COLOR[c]} fill={CAT_COLOR[c]} fillOpacity={0.55} />
534
+ ))}
535
+ {view.summary.depletionYear !== null ? (
536
+ <ReferenceLine x={view.summary.depletionYear} stroke="var(--bad)" strokeDasharray="4 4" label={{ value: 'depleted', fill: 'var(--bad)', fontSize: 12 }} />
537
+ ) : null}
538
+ <Tooltip {...tooltipProps} />
539
+ </AreaChart>
540
+ </ResponsiveContainer>
541
+ </div>
542
+ </div>
543
+
544
+ <BucketLensCard result={view.result} adj={adj} />
545
+
546
+ {!fireLeads ? (
547
+ <details className="ss-explainer">
548
+ <summary>Path to Financial Independence (FIRE) — an optional lens</summary>
549
+ <p className="field-hint">
550
+ FI metrics matter most while accumulating; for plans at or near retirement they are shown here for
551
+ reference, not as a verdict.
552
+ </p>
553
+ <FireLens view={view} plan={plan} rows={rows} dollarLabel={dollarLabel} />
554
+ </details>
555
+ ) : null}
556
+
557
+ <div className="chart-card">
558
+ <h2>Income vs. spending</h2>
559
+ <p className="card-hint">
560
+ Spending includes taxes and penalties; the gap is funded by withdrawals. Shown in {dollarLabel}.
561
+ </p>
562
+ <div className="chart-frame" style={frameH(280)}>
563
+ <ResponsiveContainer width="100%" height="100%">
564
+ <BarChart data={rows} margin={{ left: 12, right: 8, top: 8 }} aria-label="Income vs. spending, year by year">
565
+ <CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
566
+ <XAxis dataKey="year" interval="equidistantPreserveStart" tick={{ fill: 'var(--muted)', fontSize: 12 }} />
567
+ <YAxis tickFormatter={moneyTick} tick={{ fill: 'var(--muted)', fontSize: 12 }} width={70} />
568
+ <Legend />
569
+ {/* Income is green (chart-3), spending gold: money in reads as green. */}
570
+ <Bar dataKey="income" name="Income" fill="var(--chart-3)" />
571
+ <Bar dataKey="spending" name="Spending + tax" fill="var(--chart-1)" />
572
+ <Tooltip {...tooltipProps} />
573
+ </BarChart>
574
+ </ResponsiveContainer>
575
+ </div>
576
+ </div>
577
+
578
+ <div className="chart-card">
579
+ <h2>Income by source</h2>
580
+ <p className="card-hint">
581
+ Gross income streams each year, shown in {dollarLabel}. Any shortfall below spending is funded by portfolio
582
+ withdrawals.
583
+ </p>
584
+ <div className="chart-frame" style={frameH(280)}>
585
+ <ResponsiveContainer width="100%" height="100%">
586
+ <BarChart data={incomeRows} margin={{ left: 12, right: 8, top: 8 }} aria-label="Income by source, year by year">
587
+ <CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
588
+ <XAxis dataKey="year" interval="equidistantPreserveStart" tick={{ fill: 'var(--muted)', fontSize: 12 }} />
589
+ <YAxis tickFormatter={moneyTick} tick={{ fill: 'var(--muted)', fontSize: 12 }} width={70} />
590
+ <Legend />
591
+ {INCOME_SOURCES.map((s) => (
592
+ <Bar key={s.key} dataKey={s.key} stackId="inc" name={s.label} fill={s.color} />
593
+ ))}
594
+ <Tooltip {...tooltipProps} />
595
+ </BarChart>
596
+ </ResponsiveContainer>
597
+ </div>
598
+ </div>
599
+
600
+ <FundedRatioCard />
601
+
602
+ <div className="chart-card">
603
+ <h2>Spending by category</h2>
604
+ <p className="card-hint">
605
+ The big line items behind the Expenses column (taxes and penalties included), shown in {dollarLabel}. Mortgage
606
+ principal &amp; interest is "Debt payments"; property tax &amp; insurance are their own band; everything else
607
+ lives in "Baseline living."
608
+ </p>
609
+ <div className="chart-frame" style={frameH(280)}>
610
+ <ResponsiveContainer width="100%" height="100%">
611
+ <BarChart
612
+ data={expenseRows}
613
+ margin={{ left: 12, right: 8, top: 8 }}
614
+ aria-label="Spending by category, year by year"
615
+ >
616
+ <CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
617
+ <XAxis dataKey="year" interval="equidistantPreserveStart" tick={{ fill: 'var(--muted)', fontSize: 12 }} />
618
+ <YAxis tickFormatter={moneyTick} tick={{ fill: 'var(--muted)', fontSize: 12 }} width={70} />
619
+ <Legend />
620
+ {EXPENSE_CATEGORIES.map((c) => (
621
+ <Bar key={c.key} dataKey={c.key} stackId="exp" name={c.label} fill={c.color} />
622
+ ))}
623
+ <Tooltip {...tooltipProps} />
624
+ </BarChart>
625
+ </ResponsiveContainer>
626
+ </div>
627
+ </div>
628
+
629
+ <div className="chart-card">
630
+ <h2>Tax and MAGI ({dollarLabel})</h2>
631
+ <p className="card-hint">
632
+ Modified adjusted gross income (MAGI) sets Medicare's income surcharge (IRMAA, which looks back two years)
633
+ and the marketplace health-insurance credit (ACA) before 65. Threshold checks always use each year's nominal
634
+ dollars.
635
+ </p>
636
+ <div className="chart-frame" style={frameH(280)}>
637
+ <ResponsiveContainer width="100%" height="100%">
638
+ <LineChart data={rows} margin={{ left: 12, right: 8, top: 8 }} aria-label="Tax and MAGI, year by year">
639
+ <CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
640
+ <XAxis dataKey="year" interval="equidistantPreserveStart" tick={{ fill: 'var(--muted)', fontSize: 12 }} />
641
+ <YAxis tickFormatter={moneyTick} tick={{ fill: 'var(--muted)', fontSize: 12 }} width={70} />
642
+ <Legend />
643
+ <Line dataKey="tax" name="Tax" stroke="var(--chart-4)" dot={false} strokeWidth={2} />
644
+ <Line dataKey="magi" name="MAGI" stroke="var(--chart-2)" dot={false} strokeWidth={2} />
645
+ <Tooltip {...tooltipProps} />
646
+ </LineChart>
647
+ </ResponsiveContainer>
648
+ </div>
649
+ </div>
650
+
651
+ <details open id="year-table">
652
+ {/* The h2 inside the summary keeps this reachable by heading navigation
653
+ (screen readers, heading-jump extensions) — a bare summary is not. */}
654
+ <summary className="year-table-summary">
655
+ <h2>Year-by-year detail</h2>
656
+ </summary>
657
+ <div className="year-table-wrap">
658
+ <table className="year-table">
659
+ <thead>
660
+ <tr>
661
+ <th>Year</th>
662
+ <th>Age</th>
663
+ <th>Income</th>
664
+ <th>Expenses</th>
665
+ {hasLayeredSpending ? <th title="Must-fund floor spending, including required lifestyle and system costs.">Required</th> : null}
666
+ {hasLayeredSpending ? <th title="Required plus target lifestyle spending before ideal/excess upside.">Target</th> : null}
667
+ {hasLayeredSpending ? <th title="Ideal and excess spending intended above target.">Upside</th> : null}
668
+ <th>Contrib.</th>
669
+ <th>Match</th>
670
+ <th>RMD</th>
671
+ <th>Conversion</th>
672
+ <th>Withdrawals</th>
673
+ <th>Tax</th>
674
+ {hasAmt ? <th title="Federal alternative minimum tax included in Tax.">AMT</th> : null}
675
+ <th title="Displayed in the active dollar mode. IRMAA and ACA threshold checks use the nominal dollars for each rule.">
676
+ MAGI ({dollarLabel})
677
+ </th>
678
+ <th title="Additional long-term gains you could realize this year at $0 federal tax: your remaining loss carryforward absorbs gains dollar-for-dollar, then the 0% long-term bracket covers more on top.">Tax-free gains room</th>
679
+ {hasCarryforward ? <th title="Capital-loss carryforward remaining at year end.">Loss carryf'd</th> : null}
680
+ <th>Shortfall</th>
681
+ {hasLayeredSpending ? <th title="Required-floor shortfall / target-lifestyle shortfall / upside miss.">Layer miss</th> : null}
682
+ {hasLayeredSpending ? <th title="Guardrail action and flexible goal outcomes.">Guardrails</th> : null}
683
+ <th>Investable</th>
684
+ <th>Net worth</th>
685
+ </tr>
686
+ </thead>
687
+ <tbody>
688
+ {view.result.years.map((y) => (
689
+ <tr key={y.year} className={y.shortfall > 0.005 ? 'row-depleted' : undefined}>
690
+ <td>{y.year}</td>
691
+ <td>{y.people.map((p) => (p.alive ? p.ageAttained : '—')).join(' / ')}</td>
692
+ <td>{fmtMoney(adj(y.year, y.incomes.total))}</td>
693
+ <td>{fmtMoney(adj(y.year, y.expenses.total))}</td>
694
+ {hasLayeredSpending ? <td>{fmtMoney(adj(y.year, y.expenses.requiredSpending))}</td> : null}
695
+ {hasLayeredSpending ? <td>{fmtMoney(adj(y.year, y.expenses.targetSpending))}</td> : null}
696
+ {hasLayeredSpending ? (
697
+ <td>
698
+ {y.expenses.idealSpending + y.expenses.excessSpending > 0.5
699
+ ? fmtMoney(adj(y.year, y.expenses.idealSpending + y.expenses.excessSpending))
700
+ : ''}
701
+ </td>
702
+ ) : null}
703
+ <td>{y.contributions > 0.005 ? fmtMoney(adj(y.year, y.contributions)) : ''}</td>
704
+ <td>{y.employerMatch > 0.005 ? fmtMoney(adj(y.year, y.employerMatch)) : ''}</td>
705
+ <td>{fmtMoney(adj(y.year, y.rmd))}</td>
706
+ <td>{fmtMoney(adj(y.year, y.rothConversion))}</td>
707
+ <td>{fmtMoney(adj(y.year, y.withdrawals.total))}</td>
708
+ <td>{fmtMoney(adj(y.year, y.tax + y.penalties))}</td>
709
+ {hasAmt ? <td>{y.amt > 0.5 ? fmtMoney(adj(y.year, y.amt)) : ''}</td> : null}
710
+ <td>{fmtMoney(adj(y.year, y.magi))}</td>
711
+ <td>{y.ltcgZeroHeadroom + y.capitalLossCarryforwardRemaining > 0.5 ? fmtMoney(adj(y.year, y.ltcgZeroHeadroom + y.capitalLossCarryforwardRemaining)) : ''}</td>
712
+ {hasCarryforward ? <td>{y.capitalLossCarryforwardRemaining > 0.5 ? fmtMoney(adj(y.year, y.capitalLossCarryforwardRemaining)) : '—'}</td> : null}
713
+ <td>{y.shortfall > 0.005 ? fmtMoney(adj(y.year, y.shortfall)) : ''}</td>
714
+ {hasLayeredSpending ? (
715
+ <td>
716
+ {y.requiredShortfall + y.targetShortfall + y.idealShortfall + y.excessShortfall > 0.5 ? (
717
+ <>
718
+ {y.requiredShortfall > 0.5 ? `Req ${fmtMoney(adj(y.year, y.requiredShortfall))} ` : ''}
719
+ {y.targetShortfall > 0.5 ? `Target ${fmtMoney(adj(y.year, y.targetShortfall))} ` : ''}
720
+ {y.idealShortfall + y.excessShortfall > 0.5
721
+ ? `Upside ${fmtMoney(adj(y.year, y.idealShortfall + y.excessShortfall))}`
722
+ : ''}
723
+ </>
724
+ ) : (
725
+ ''
726
+ )}
727
+ </td>
728
+ ) : null}
729
+ {hasLayeredSpending ? (
730
+ <td>
731
+ {y.guardrailAction !== 'hold' ? y.guardrailAction : ''}
732
+ {y.flexibleGoals.funded + y.flexibleGoals.partiallyFunded + y.flexibleGoals.deferred + y.flexibleGoals.skipped > 0 ? (
733
+ <span
734
+ title={`Goals: ${y.flexibleGoals.funded} funded, ${y.flexibleGoals.partiallyFunded} partially funded, ${y.flexibleGoals.deferred} deferred, ${y.flexibleGoals.skipped} skipped`}
735
+ aria-label={`Goals: ${y.flexibleGoals.funded} funded, ${y.flexibleGoals.partiallyFunded} partially funded, ${y.flexibleGoals.deferred} deferred, ${y.flexibleGoals.skipped} skipped`}
736
+ >
737
+ {y.guardrailAction !== 'hold' ? ' · ' : ''}
738
+ {y.flexibleGoals.funded}F/{y.flexibleGoals.partiallyFunded}P/{y.flexibleGoals.deferred}D/{y.flexibleGoals.skipped}S
739
+ </span>
740
+ ) : null}
741
+ </td>
742
+ ) : null}
743
+ <td>{fmtMoney(adj(y.year, y.investableTotal))}</td>
744
+ <td>{fmtMoney(adj(y.year, y.netWorth))}</td>
745
+ </tr>
746
+ ))}
747
+ </tbody>
748
+ </table>
749
+ </div>
750
+ {/* Column semantics used to live only in title= tooltips — invisible on
751
+ touch and unreliable for screen readers. This legend is the
752
+ keyboard/touch-reachable copy of the same explanations. */}
753
+ <details className="ss-explainer">
754
+ <summary>What the columns mean</summary>
755
+ <ul>
756
+ <li>
757
+ <strong>Age</strong> — one entry per person (e.g. "67 / 64"). A person shows "—" after their modeled
758
+ death; income and spending reflect the survivor from that year on.
759
+ </li>
760
+ {hasLayeredSpending ? (
761
+ <>
762
+ <li>
763
+ <strong>Required</strong> — must-fund floor spending, including required lifestyle and system costs.
764
+ </li>
765
+ <li>
766
+ <strong>Target</strong> — required plus target lifestyle spending before ideal/excess upside.
767
+ </li>
768
+ <li>
769
+ <strong>Upside</strong> — ideal and excess spending intended above target.
770
+ </li>
771
+ </>
772
+ ) : null}
773
+ {hasAmt ? (
774
+ <li>
775
+ <strong>AMT</strong> — federal alternative minimum tax, included in Tax.
776
+ </li>
777
+ ) : null}
778
+ <li>
779
+ <strong>MAGI</strong> — modified adjusted gross income, displayed in the active dollar mode; IRMAA and
780
+ ACA threshold checks always use each year's nominal dollars.
781
+ </li>
782
+ <li>
783
+ <strong>Tax-free gains room</strong> — additional long-term gains you could realize this year at $0
784
+ federal tax: remaining loss carryforward absorbs gains first, then the 0% long-term bracket covers more.
785
+ </li>
786
+ {hasCarryforward ? (
787
+ <li>
788
+ <strong>Loss carryf&apos;d</strong> — capital-loss carryforward remaining at year end.
789
+ </li>
790
+ ) : null}
791
+ {hasLayeredSpending ? (
792
+ <>
793
+ <li>
794
+ <strong>Layer miss</strong> — required-floor shortfall / target-lifestyle shortfall / upside miss.
795
+ </li>
796
+ <li>
797
+ <strong>Guardrails</strong> — the guardrail action taken that year (cut / raise), and flexible goal
798
+ outcomes as counts: <strong>F</strong>unded / <strong>P</strong>artial / <strong>D</strong>eferred /{' '}
799
+ <strong>S</strong>kipped (e.g. “1F/0P/2D/1S”).
800
+ </li>
801
+ </>
802
+ ) : null}
803
+ </ul>
804
+ </details>
805
+ </details>
806
+
807
+ <p className="field-hint">
808
+ Every figure above comes from the single year-by-year ledger in this table — the same ledger Monte Carlo and
809
+ the optimizer price against. <Link to={`/plan/${plan.id}/assumptions-card`}>See the assumptions behind it</Link>{' '}
810
+ · <Link to="/how-tested">How RetireGolden is tested</Link>
811
+ </p>
812
+
813
+ <LearnAboutScreen route="/plan/:planId/results" />
814
+ </section>
815
+ )
816
+ }
817
+