@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,907 @@
1
+ /**
2
+ * Monte Carlo: success gauge, percentile fan, ending-balance and depletion
3
+ * distributions. Runs 1,000 paths automatically on the worker pool (sync
4
+ * fallback in tests) and 10,000 on demand; the seed is stable per plan and
5
+ * re-rollable.
6
+ */
7
+
8
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
9
+ import { Link } from 'react-router-dom'
10
+ import {
11
+ Area,
12
+ Bar,
13
+ BarChart,
14
+ CartesianGrid,
15
+ Line,
16
+ ComposedChart,
17
+ ResponsiveContainer,
18
+ Tooltip,
19
+ XAxis,
20
+ YAxis,
21
+ } from 'recharts'
22
+
23
+ import { planUsesAssetAllocation } from '@retiregolden/engine/allocation/assetClasses'
24
+ import type { Plan } from '@retiregolden/engine/model/plan'
25
+ import { DEFAULT_LTC_SHOCK } from '@retiregolden/engine/montecarlo/ltcShock'
26
+ import { startingInvestableOf } from '@retiregolden/engine/montecarlo/riskBasedGuardrails'
27
+ import type { MarketModelConfig } from '@retiregolden/engine/montecarlo/marketModels'
28
+ import type { AnnuitizationSweep } from '@retiregolden/engine/decisions/annuitization'
29
+ import type { StochasticFrontierPoint } from '@retiregolden/engine/montecarlo/frontiers'
30
+ import type { MonteCarloSummary } from '@retiregolden/engine/montecarlo/run'
31
+ import {
32
+ DEFAULT_PATH_COUNT,
33
+ ON_DEMAND_PATH_COUNT,
34
+ runHistoricalStressSuiteViews,
35
+ runMonteCarlo,
36
+ runStochasticFrontiers,
37
+ type HistoricalStressSuiteViewResult,
38
+ } from '../mc/pool'
39
+ import { usePlan } from './planContextCore'
40
+ import { WhySuccessPanel } from './explainPanels'
41
+ import { LiveStatus } from './LiveStatus'
42
+ import { HelpTip } from './fields'
43
+ import { LearnAboutScreen } from '../learn/LearnAboutScreen'
44
+ import { fmtMoney, fmtMoneyCompact } from './format'
45
+ import {
46
+ buildModel,
47
+ catalogLabelOf,
48
+ MODEL_CATALOG,
49
+ MODEL_PRESETS,
50
+ PRESET_FAMILY_LABELS,
51
+ presetFamilyOf,
52
+ type ModelKind,
53
+ } from './marketModelPicker'
54
+ import { currentStartYear, seedFromPlanId } from './useProjection'
55
+ import { chartTooltipStyle } from './chartStyle'
56
+ import { successBand } from './successBand'
57
+ import { frameH } from './chartFrame'
58
+
59
+ function SuccessGauge({ rate, pathCount }: { rate: number; pathCount: number }) {
60
+ const pct = Math.round(rate * 100)
61
+ const { color: tone, severity } = successBand(rate)
62
+ const r = 64
63
+ const c = 2 * Math.PI * r
64
+ return (
65
+ <div
66
+ className="success-gauge"
67
+ role="img"
68
+ aria-label={`Success probability ${pct} percent — ${severity} — over ${pathCount} paths`}
69
+ >
70
+ <svg width="176" height="176" viewBox="0 0 176 176">
71
+ <circle cx="88" cy="88" r={r} fill="none" stroke="var(--surface-2)" strokeWidth="16" />
72
+ <circle
73
+ cx="88"
74
+ cy="88"
75
+ r={r}
76
+ fill="none"
77
+ stroke={tone}
78
+ strokeWidth="16"
79
+ strokeLinecap="round"
80
+ strokeDasharray={`${c * rate} ${c}`}
81
+ />
82
+ </svg>
83
+ <div className="success-gauge-label">
84
+ <span className="success-gauge-value" style={{ color: tone }}>
85
+ {pct}%
86
+ </span>
87
+ <span className="success-gauge-caption">{pathCount.toLocaleString()} paths</span>
88
+ </div>
89
+ </div>
90
+ )
91
+ }
92
+
93
+ export function MonteCarloPage() {
94
+ const { plan } = usePlan()
95
+ const [modelKind, setModelKind] = useState<ModelKind>('lognormal')
96
+ const [returnVolPct, setReturnVolPct] = useState(12)
97
+ const [equityWeightPct, setEquityWeightPct] = useState(60)
98
+ const [seed, setSeed] = useState(() => seedFromPlanId(plan.id))
99
+ const [stochasticLongevity, setStochasticLongevity] = useState(false)
100
+ const [ltcShock, setLtcShock] = useState(false)
101
+ const [summary, setSummary] = useState<MonteCarloSummary | null>(null)
102
+ const [frontier, setFrontier] = useState<{
103
+ plan: Plan
104
+ model: MarketModelConfig
105
+ seed: number
106
+ stochasticLongevity: boolean
107
+ ltcShock: boolean
108
+ spending: StochasticFrontierPoint[]
109
+ retirement: StochasticFrontierPoint[]
110
+ annuitization: AnnuitizationSweep
111
+ } | null>(null)
112
+ const [frontierRunning, setFrontierRunning] = useState(false)
113
+ const [historical, setHistorical] = useState<{
114
+ plan: Plan
115
+ equityWeightPct: number
116
+ result: HistoricalStressSuiteViewResult
117
+ } | null>(null)
118
+ const [historicalRunning, setHistoricalRunning] = useState(false)
119
+ const [progress, setProgress] = useState(0)
120
+ const [running, setRunning] = useState(false)
121
+ const [statusMessage, setStatusMessage] = useState('')
122
+ const [error, setError] = useState<string | null>(null)
123
+ const [frontierError, setFrontierError] = useState<string | null>(null)
124
+ const [historicalError, setHistoricalError] = useState<string | null>(null)
125
+ const runToken = useRef(0)
126
+
127
+ const model = useMemo(
128
+ () => buildModel(modelKind, plan.assumptions.inflationPct, returnVolPct, equityWeightPct, plan),
129
+ [modelKind, plan, returnVolPct, equityWeightPct],
130
+ )
131
+
132
+ const run = useCallback(
133
+ (paths: number) => {
134
+ const token = ++runToken.current
135
+ setRunning(true)
136
+ setProgress(0)
137
+ setError(null)
138
+ setStatusMessage(`Simulating ${paths.toLocaleString()} market paths…`)
139
+ void runMonteCarlo(plan, {
140
+ startYear: currentStartYear(),
141
+ pathCount: paths,
142
+ seed,
143
+ model,
144
+ stochasticLongevity,
145
+ ltcShock: ltcShock ? DEFAULT_LTC_SHOCK : null,
146
+ onProgress: (done, total) => {
147
+ if (token === runToken.current) setProgress(done / total)
148
+ },
149
+ })
150
+ .then((s) => {
151
+ if (token === runToken.current) {
152
+ setSummary(s)
153
+ setStatusMessage(
154
+ `Simulation complete. ${Math.round(s.successRate * 100)} percent of markets sustain the plan.`,
155
+ )
156
+ }
157
+ })
158
+ .catch((e: unknown) => {
159
+ if (token === runToken.current) {
160
+ setError(e instanceof Error ? e.message : String(e))
161
+ setStatusMessage('')
162
+ }
163
+ })
164
+ .finally(() => {
165
+ if (token === runToken.current) setRunning(false)
166
+ })
167
+ },
168
+ [plan, seed, model, stochasticLongevity, ltcShock],
169
+ )
170
+
171
+ const runFrontiers = useCallback(() => {
172
+ setFrontierRunning(true)
173
+ setFrontierError(null)
174
+ void runStochasticFrontiers(plan, {
175
+ startYear: currentStartYear(),
176
+ pathCount: 200,
177
+ seed,
178
+ model,
179
+ stochasticLongevity,
180
+ ltcShock: ltcShock ? DEFAULT_LTC_SHOCK : null,
181
+ })
182
+ .then((result) => {
183
+ setFrontier({
184
+ plan,
185
+ model,
186
+ seed,
187
+ stochasticLongevity,
188
+ ltcShock,
189
+ spending: result.spending,
190
+ retirement: result.retirement,
191
+ annuitization: result.annuitization,
192
+ })
193
+ })
194
+ .catch((e: unknown) => setFrontierError(e instanceof Error ? e.message : String(e)))
195
+ .finally(() => setFrontierRunning(false))
196
+ }, [plan, model, seed, stochasticLongevity, ltcShock])
197
+
198
+ const runHistoricalSuites = useCallback(() => {
199
+ setHistoricalRunning(true)
200
+ setHistoricalError(null)
201
+ void runHistoricalStressSuiteViews(plan, {
202
+ startYear: currentStartYear(),
203
+ equityWeightPct,
204
+ classShocks: planUsesAssetAllocation(plan),
205
+ worstWindowCount: 5,
206
+ })
207
+ .then((result) => {
208
+ setHistorical({ plan, equityWeightPct, result })
209
+ })
210
+ .catch((e: unknown) => setHistoricalError(e instanceof Error ? e.message : String(e)))
211
+ .finally(() => setHistoricalRunning(false))
212
+ }, [plan, equityWeightPct])
213
+
214
+ // Auto-run 1k paths whenever the plan, seed, or model changes (debounced).
215
+ useEffect(() => {
216
+ const t = window.setTimeout(() => run(DEFAULT_PATH_COUNT), 250)
217
+ return () => window.clearTimeout(t)
218
+ }, [run])
219
+
220
+ const fanRows = useMemo(() => summary?.fan ?? [], [summary])
221
+ const histRows = useMemo(() => {
222
+ if (!summary) return []
223
+ const { min, binWidth, counts } = summary.endingInvestable.histogram
224
+ return counts.map((count, i) => ({ label: fmtMoneyCompact(min + (i + 0.5) * binWidth), count }))
225
+ }, [summary])
226
+ const depletionRows = useMemo(
227
+ () =>
228
+ summary?.depletionProbabilityByYear.map((row) => ({
229
+ year: row.year,
230
+ probability: row.probability * 100,
231
+ cumulativeProbability: row.cumulativeProbability * 100,
232
+ })) ?? [],
233
+ [summary],
234
+ )
235
+ const visibleFrontier =
236
+ frontier &&
237
+ frontier.plan === plan &&
238
+ frontier.model === model &&
239
+ frontier.seed === seed &&
240
+ frontier.stochasticLongevity === stochasticLongevity &&
241
+ frontier.ltcShock === ltcShock
242
+ ? frontier
243
+ : null
244
+ const visibleHistorical =
245
+ historical && historical.plan === plan && historical.equityWeightPct === equityWeightPct ? historical.result : null
246
+
247
+ return (
248
+ <section>
249
+ <LiveStatus message={statusMessage} />
250
+ <div className="card">
251
+ <h2>Market model</h2>
252
+ <p className="card-hint">
253
+ Your deterministic projection assumes the same return and inflation every year. Monte Carlo replays the exact
254
+ same plan a thousand times with markets that vary year to year, then reports how often the money lasts. The
255
+ model below controls <em>how</em> those markets are generated — your expected returns and inflation from
256
+ Assumptions stay the center of the distribution either way.
257
+ </p>
258
+ <div className="form-grid">
259
+ <div className="field field-span-full">
260
+ <span className="field-label-row">
261
+ <span className="field-label">Model</span>
262
+ <HelpTip text="Monte Carlo needs a rule for generating each year's market. Smooth randomness draws each year's return from a bell-shaped curve around your expected return, with inflation statistically linked to it. Replay real history starts your retirement in a random year since 1928 and lets actual market history unfold from there. Stress test forces a 25% market drop in the first year, then returns to normal randomness. The advanced catalog offers more ways to shape the markets — fat tails, volatility clustering, bull/bear regimes, and more. Every model keeps your expected returns and inflation from Assumptions at the center of the distribution, and the same seed always reproduces the same markets." />
263
+ </span>
264
+ <div className="model-preset-row" role="group" aria-label="Market model presets">
265
+ {MODEL_PRESETS.map((preset) => (
266
+ <button
267
+ key={preset.id}
268
+ type="button"
269
+ className="model-preset"
270
+ aria-pressed={modelKind === preset.kind}
271
+ onClick={() => setModelKind(preset.kind)}
272
+ >
273
+ <span className="model-preset-label">{preset.label}</span>
274
+ <span className="model-preset-desc">{preset.description}</span>
275
+ </button>
276
+ ))}
277
+ </div>
278
+ <details className="ss-explainer model-advanced">
279
+ <summary>Advanced models</summary>
280
+ <div className="field model-advanced-field">
281
+ <label className="field-label" htmlFor="mc-advanced-model">
282
+ Full model catalog
283
+ </label>
284
+ <select id="mc-advanced-model" value={modelKind} onChange={(e) => setModelKind(e.target.value as ModelKind)}>
285
+ {MODEL_CATALOG.map((entry) => (
286
+ <option key={entry.kind} value={entry.kind}>
287
+ {entry.label}
288
+ </option>
289
+ ))}
290
+ </select>
291
+ <p className="field-hint">
292
+ {catalogLabelOf(modelKind)} is part of the “{PRESET_FAMILY_LABELS[presetFamilyOf(modelKind)]}” family.
293
+ </p>
294
+ <p className="field-hint">Current market seed: {seed} — “Re-roll markets” draws a new one.</p>
295
+ </div>
296
+ </details>
297
+ </div>
298
+ {modelKind === 'lognormal' ? (
299
+ <div className="field">
300
+ <span className="field-label-row">
301
+ <span className="field-label">Return volatility: {returnVolPct}%</span>
302
+ <HelpTip text="How wildly returns swing year to year (one standard deviation). ~10% resembles a balanced 60/40 portfolio, ~15% a mostly-stock portfolio, ~20% all stocks. Higher volatility means more sequence-of-returns risk: the same average return with bigger swings fails more often." />
303
+ </span>
304
+ <input type="range" min={5} max={25} step={1} value={returnVolPct} onChange={(e) => setReturnVolPct(Number(e.target.value))} />
305
+ </div>
306
+ ) : (
307
+ <div className="field">
308
+ <span className="field-label-row">
309
+ <span className="field-label">Equity weight: {equityWeightPct}/{100 - equityWeightPct}</span>
310
+ <HelpTip text="The stock/bond mix used when sampling historical years (e.g. 60/40 = 60% S&P 500, 40% 10-year Treasuries). It shapes how volatile the resampled history is; your accounts' expected returns still come from the Accounts/Assumptions forms." />
311
+ </span>
312
+ <input type="range" min={0} max={100} step={5} value={equityWeightPct} onChange={(e) => setEquityWeightPct(Number(e.target.value))} />
313
+ </div>
314
+ )}
315
+ <div className="field">
316
+ <span className="field-label-row">
317
+ <span className="field-label">Market draw</span>
318
+ <HelpTip text="The random sequence is reproducible: the same draw always produces the same thousand markets, so results don't jump around as you edit the plan. Re-roll to check the conclusion holds under a different draw — if success swings more than a point or two, run 10,000 paths. The exact seed number is under Advanced models." />
319
+ </span>
320
+ <button type="button" className="btn btn-secondary btn-small" onClick={() => setSeed((Math.random() * 0xffffffff) >>> 0)}>
321
+ Re-roll markets
322
+ </button>
323
+ </div>
324
+ <div className="field">
325
+ <span className="field-label-row">
326
+ <span className="field-label">Precision</span>
327
+ <HelpTip text="1,000 paths run automatically and put the success rate within roughly ±1.5 points. 10,000 paths tighten that to about ±0.5 — useful when comparing strategies that differ by a hair." />
328
+ </span>
329
+ <button type="button" className="btn btn-secondary btn-small" disabled={running} onClick={() => run(ON_DEMAND_PATH_COUNT)}>
330
+ Run {ON_DEMAND_PATH_COUNT.toLocaleString()} paths
331
+ </button>
332
+ </div>
333
+ <div className="field">
334
+ <label className="radio-option">
335
+ <input type="checkbox" checked={stochasticLongevity} onChange={(e) => setStochasticLongevity(e.target.checked)} />
336
+ <span>
337
+ <span className="field-label-row">
338
+ <span className="field-label">Model longevity</span>
339
+ <HelpTip text="Instead of everyone living to their fixed planning age, each path draws a lifespan from SSA mortality tables (by age and sex). Outcomes are then weighted by how long people actually live — dying earlier frees the plan, living longer stresses it. Couples use a joint-life RMD divisor when one spouse is much younger." />
340
+ </span>
341
+ <p className="field-hint">Draw lifespans from mortality tables instead of the fixed planning age.</p>
342
+ </span>
343
+ </label>
344
+ </div>
345
+ <div className="field">
346
+ <label className="radio-option">
347
+ <input type="checkbox" checked={ltcShock} onChange={(e) => setLtcShock(e.target.checked)} />
348
+ <span>
349
+ <span className="field-label-row">
350
+ <span className="field-label">Model an LTC shock</span>
351
+ <HelpTip text="Each path may draw a paid long-term-care episode (incidence, onset age, and duration from published LTC-risk research), adding a late-life cost spike. Any LTC policy you've entered offsets it — so the success rate with vs. without coverage shows what the policy buys you across the whole distribution." />
352
+ </span>
353
+ <p className="field-hint">Add a probabilistic care episode; your LTC policy offsets it.</p>
354
+ </span>
355
+ </label>
356
+ </div>
357
+ </div>
358
+ {running ? (
359
+ <div className="progress-track" role="progressbar" aria-valuenow={Math.round(progress * 100)} aria-valuemin={0} aria-valuemax={100}>
360
+ <div className="progress-fill" style={{ transform: `scaleX(${Math.max(0.04, progress)})` }} />
361
+ </div>
362
+ ) : null}
363
+ {error ? (
364
+ <div className="error-recovery" role="alert">
365
+ <p className="error-text">Simulation error: {error}</p>
366
+ <button type="button" className="btn btn-secondary btn-small" onClick={() => run(DEFAULT_PATH_COUNT)}>
367
+ Run again
368
+ </button>
369
+ </div>
370
+ ) : null}
371
+ </div>
372
+
373
+ {summary === null ? (
374
+ error ? null : <div className="skeleton" style={{ height: '22rem' }} aria-label="Simulating" />
375
+ ) : (
376
+ <>
377
+ <div className="mc-hero">
378
+ <SuccessGauge rate={summary.successRate} pathCount={summary.pathCount} />
379
+ <div>
380
+ <h2>{successBand(summary.successRate).verdict}</h2>
381
+ <p className="muted">
382
+ Median ending estate {fmtMoneyCompact(summary.endingAfterTaxEstate.percentiles.p50)} · worst 10%{' '}
383
+ {fmtMoneyCompact(summary.endingAfterTaxEstate.percentiles.p10)} · best 10%{' '}
384
+ {fmtMoneyCompact(summary.endingAfterTaxEstate.percentiles.p90)}. Success means investable assets never run
385
+ out before the end of the plan.
386
+ </p>
387
+ {summary.successRate < 0.9 ? (
388
+ <p className="mc-handrail">
389
+ A result like this is usually movable — small changes to spending, retirement age, or claiming
390
+ strategy shift it the most. See what would change this:{' '}
391
+ <Link to={`/plan/${plan.id}/insights`}>Insights</Link> ·{' '}
392
+ <Link to={`/plan/${plan.id}/spending-solver`}>How much can I spend?</Link>
393
+ </p>
394
+ ) : null}
395
+ </div>
396
+ </div>
397
+
398
+ <WhySuccessPanel summary={summary} modelLabel={catalogLabelOf(modelKind)} seed={seed} planId={plan.id} />
399
+
400
+ <div className="chart-card">
401
+ <h2>Downside risk</h2>
402
+ <p className="card-hint">
403
+ Success rate answers "did the plan last?" These metrics show how deep the misses are when it does not.
404
+ </p>
405
+ <div className="stat-grid">
406
+ <div>
407
+ <div className="stat-value">{fmtMoneyCompact(summary.endingAfterTaxEstate.percentiles.p10)}</div>
408
+ <div className="muted">
409
+ In the worst 10% of markets, you&apos;d end with{' '}
410
+ <HelpTip
411
+ text="The 10th-percentile (p10) ending estate: 9 in 10 simulated markets leave you with more than this, 1 in 10 with less."
412
+ learn={{ slug: 'understanding-monte-carlo-success-rate', label: 'Understanding success rate' }}
413
+ />
414
+ </div>
415
+ </div>
416
+ <div>
417
+ <div className="stat-value">{fmtMoneyCompact(summary.downsideRisk.expectedShortfallDollars)}</div>
418
+ <div className="muted">
419
+ Typical shortfall when the plan falls short{' '}
420
+ <HelpTip
421
+ text="Expected shortfall: the average unmet spending across only the markets where the money runs out — a sense of how bad a bad outcome tends to be."
422
+ learn={{ slug: 'sequence-of-returns-risk', label: 'Sequence-of-returns risk' }}
423
+ />
424
+ </div>
425
+ </div>
426
+ <div>
427
+ <div className="stat-value">{fmtMoneyCompact(summary.spendingShortfall.p90TotalShortfallDollars)}</div>
428
+ <div className="muted">
429
+ Worst-case total spending miss{' '}
430
+ <HelpTip text="The 90th-percentile (p90) cumulative shortfall: in the worst 10% of markets, total unmet spending across the whole retirement reaches at least this much." />
431
+ </div>
432
+ </div>
433
+ <div>
434
+ <div className="stat-value">{Math.round(summary.downsideRisk.failureRate * 100)}%</div>
435
+ <div className="muted">
436
+ Chance the money runs out{' '}
437
+ <HelpTip text="The share of simulated markets in which investable assets are depleted before the end of the plan — the complement of the success rate." />
438
+ </div>
439
+ </div>
440
+ </div>
441
+ {depletionRows.length > 0 ? (
442
+ <div
443
+ className="chart-frame chart-frame--spaced"
444
+ style={frameH(220)}
445
+ role="img"
446
+ aria-label={`Depletion probability by year: by ${depletionRows[depletionRows.length - 1]!.year}, ${depletionRows[depletionRows.length - 1]!.cumulativeProbability.toFixed(1)} percent of paths have depleted.`}
447
+ >
448
+ <ResponsiveContainer width="100%" height="100%">
449
+ <ComposedChart data={depletionRows} margin={{ left: 12, right: 8, top: 8 }}>
450
+ <CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
451
+ <XAxis dataKey="year" tick={{ fill: 'var(--muted)', fontSize: 12 }} />
452
+ <YAxis tickFormatter={(v) => `${Math.round(Number(v))}%`} tick={{ fill: 'var(--muted)', fontSize: 12 }} width={56} />
453
+ <Tooltip formatter={(v: unknown) => `${Number(v).toFixed(1)}%`} contentStyle={chartTooltipStyle} />
454
+ <Bar dataKey="probability" name="First depletion" fill="var(--chart-3)" />
455
+ {/* Rising depletion odds must not wear the brand/good green. */}
456
+ <Line dataKey="cumulativeProbability" name="Depleted by year" stroke="var(--bad)" strokeWidth={2.5} dot={false} />
457
+ </ComposedChart>
458
+ </ResponsiveContainer>
459
+ </div>
460
+ ) : (
461
+ <p className="muted">No path depleted, so there is no depletion-by-year curve to show.</p>
462
+ )}
463
+ </div>
464
+
465
+ {summary.requiredFloorSuccessRate !== summary.successRate ||
466
+ summary.targetLifestyleSuccessRate !== summary.successRate ||
467
+ summary.idealFundingRate < 1 ||
468
+ summary.excessFundingRate < 1 ||
469
+ summary.flexibleGoals.funded + summary.flexibleGoals.partiallyFunded + summary.flexibleGoals.deferred + summary.flexibleGoals.skipped > 0 ||
470
+ summary.guardrailActionCounts.cut + summary.guardrailActionCounts.raise > 0 ? (
471
+ <div className="chart-card">
472
+ <h2>Spending resilience</h2>
473
+ <p className="card-hint">
474
+ With a required floor or spending guardrails, a single success number is too blunt. These separate
475
+ funding the essential floor every year from funding the full target lifestyle.
476
+ </p>
477
+ {/* Two headline numbers; the qualifying metrics live one click away
478
+ so they stop taxing the pair that matters (round-4 critique). */}
479
+ <div className="stat-grid">
480
+ <div>
481
+ <div className="stat-value">{Math.round(summary.requiredFloorSuccessRate * 100)}%</div>
482
+ <div className="muted">funded the required floor every year</div>
483
+ </div>
484
+ <div>
485
+ <div className="stat-value">{Math.round(summary.targetLifestyleSuccessRate * 100)}%</div>
486
+ <div className="muted">funded the full target lifestyle every year</div>
487
+ </div>
488
+ </div>
489
+ <details className="ss-explainer">
490
+ <summary>All resilience metrics</summary>
491
+ <div className="stat-grid">
492
+ <div>
493
+ <div className="stat-value">{Math.round(summary.successRate * 100)}%</div>
494
+ <div className="muted">never depleted investable assets</div>
495
+ </div>
496
+ <div>
497
+ <div className="stat-value">{Math.round(summary.targetAttainmentPct.p50 * 100)}%</div>
498
+ <div className="muted">median target dollars funded</div>
499
+ </div>
500
+ <div>
501
+ <div className="stat-value">{fmtMoneyCompact(summary.p90AverageAnnualTargetShortfall)}</div>
502
+ <div className="muted">
503
+ Worst-case average annual target miss{' '}
504
+ <HelpTip text="The 90th-percentile (p90) average annual shortfall against the target lifestyle: only 1 in 10 markets miss by more." />
505
+ </div>
506
+ </div>
507
+ <div>
508
+ <div className="stat-value">{summary.averageYearsBelowTarget.toFixed(1)}</div>
509
+ <div className="muted">average years below target</div>
510
+ </div>
511
+ {summary.idealFundingRate < 1 || summary.excessFundingRate < 1 ? (
512
+ <div>
513
+ <div className="stat-value">
514
+ {Math.round(summary.idealFundingRate * 100)}% / {Math.round(summary.excessFundingRate * 100)}%
515
+ </div>
516
+ <div className="muted">ideal / excess upside funded</div>
517
+ </div>
518
+ ) : null}
519
+ {summary.flexibleGoals.funded + summary.flexibleGoals.partiallyFunded + summary.flexibleGoals.deferred + summary.flexibleGoals.skipped > 0 ? (
520
+ <div>
521
+ <div className="stat-value stat-value--sm">
522
+ {summary.flexibleGoals.funded} funded · {summary.flexibleGoals.partiallyFunded} partial ·{' '}
523
+ {summary.flexibleGoals.deferred} deferred · {summary.flexibleGoals.skipped} skipped
524
+ </div>
525
+ <div className="muted">flexible goal outcomes across paths</div>
526
+ </div>
527
+ ) : null}
528
+ {summary.guardrailActionCounts.cut + summary.guardrailActionCounts.raise > 0 ? (
529
+ <div>
530
+ <div className="stat-value stat-value--sm">
531
+ {summary.guardrailActionCounts.cut} cuts · {summary.guardrailActionCounts.raise} raises
532
+ </div>
533
+ <div className="muted">guardrail actions across path-years</div>
534
+ </div>
535
+ ) : null}
536
+ </div>
537
+ </details>
538
+ </div>
539
+ ) : null}
540
+
541
+ {/* Guardrail modes only: ABW re-amortizes instead of cutting, so the
542
+ cut/raise adjustment framing would just read as zeros there. */}
543
+ {plan.expenses.spendingPolicy &&
544
+ (plan.expenses.spendingPolicy.mode === 'withdrawalRateGuardrails' ||
545
+ plan.expenses.spendingPolicy.mode === 'riskBasedGuardrails') ? (
546
+ <div className="chart-card">
547
+ <h2>Adjustment outlook</h2>
548
+ <p className="card-hint">
549
+ A more useful headline than one success number: how likely a mid-course spending adjustment is, how
550
+ deep it tends to go, how long it lasts — and how likely the plan ends with money left over.
551
+ </p>
552
+ {/* Headline pair: how likely a cut is, how likely it ends well. */}
553
+ <div className="stat-grid">
554
+ <div>
555
+ <div className="stat-value">{Math.round(summary.adjustments.pathsWithCut * 100)}%</div>
556
+ <div className="muted">of paths needed at least one spending cut</div>
557
+ </div>
558
+ <div>
559
+ <div className="stat-value">{Math.round(summary.adjustments.probEndingSurplus * 100)}%</div>
560
+ <div className="muted">chance the plan ends with a surplus</div>
561
+ </div>
562
+ </div>
563
+ <details className="ss-explainer">
564
+ <summary>Cut depth, duration, and raises</summary>
565
+ <div className="stat-grid">
566
+ <div>
567
+ <div className="stat-value">
568
+ {Math.round(summary.adjustments.medianMaxCutDepth * 100)}% / {Math.round(summary.adjustments.p90MaxCutDepth * 100)}%
569
+ </div>
570
+ <div className="muted">
571
+ median / worst-case deepest cut, as a share of flexible spending{' '}
572
+ <HelpTip text="Worst-case here is the 90th percentile (p90): only 1 in 10 cut paths dig deeper than this." />
573
+ </div>
574
+ </div>
575
+ <div>
576
+ <div className="stat-value">
577
+ {summary.adjustments.averageCutYears.toFixed(1)} / {summary.adjustments.p90CutYears.toFixed(0)}
578
+ </div>
579
+ <div className="muted">
580
+ average / worst-case years spent below target (cut paths){' '}
581
+ <HelpTip text="Worst-case here is the 90th percentile (p90) across cut paths." />
582
+ </div>
583
+ </div>
584
+ <div>
585
+ <div className="stat-value">{summary.adjustments.averageLongestCutSpellYears.toFixed(1)}</div>
586
+ <div className="muted">average longest consecutive cut spell, years</div>
587
+ </div>
588
+ <div>
589
+ <div className="stat-value">{Math.round(summary.adjustments.pathsWithRaise * 100)}%</div>
590
+ <div className="muted">of paths earned at least one raise</div>
591
+ </div>
592
+ {summary.adjustments.probEndingAboveBequestTarget !== null ? (
593
+ <div>
594
+ <div className="stat-value">{Math.round(summary.adjustments.probEndingAboveBequestTarget * 100)}%</div>
595
+ <div className="muted">chance the estate clears the bequest target</div>
596
+ </div>
597
+ ) : null}
598
+ </div>
599
+ </details>
600
+ {plan.expenses.spendingPolicy.mode === 'riskBasedGuardrails' ? (
601
+ plan.expenses.spendingPolicy.lowerBalanceThresholdPct !== undefined ||
602
+ plan.expenses.spendingPolicy.upperBalanceThresholdPct !== undefined ? (
603
+ <p className="card-hint">
604
+ Risk-based dollar guardrails ({plan.expenses.spendingPolicy.targetSuccessLowerPct ?? 70}–
605
+ {plan.expenses.spendingPolicy.targetSuccessUpperPct ?? 95}% success band):{' '}
606
+ {plan.expenses.spendingPolicy.lowerBalanceThresholdPct !== undefined
607
+ ? `cut below ${fmtMoney((plan.expenses.spendingPolicy.lowerBalanceThresholdPct / 100) * startingInvestableOf(plan))}`
608
+ : 'no cut threshold'}
609
+ {' · '}
610
+ {plan.expenses.spendingPolicy.upperBalanceThresholdPct !== undefined
611
+ ? `raise above ${fmtMoney((plan.expenses.spendingPolicy.upperBalanceThresholdPct / 100) * startingInvestableOf(plan))}`
612
+ : 'no raise threshold'}
613
+ {' '}(today's dollars, solved on Spending).
614
+ </p>
615
+ ) : (
616
+ <p className="card-hint">
617
+ Risk-based guardrails are selected but the dollar thresholds have not been solved yet — solve them
618
+ on the Spending screen to activate adjustments.
619
+ </p>
620
+ )
621
+ ) : null}
622
+ </div>
623
+ ) : null}
624
+
625
+ <div className="chart-card">
626
+ <div className="item-row-head">
627
+ <h2 style={{ margin: 0 }}>Frontier views</h2>
628
+ <button type="button" className="btn btn-secondary btn-small" disabled={frontierRunning} onClick={runFrontiers}>
629
+ {frontierRunning ? 'Running...' : 'Run 200-path frontiers'}
630
+ </button>
631
+ </div>
632
+ <p className="card-hint">
633
+ These sweeps reuse the same seeded market paths for every point, so the curve shows the effect of the
634
+ changed spending or retirement age instead of fresh random noise.
635
+ </p>
636
+ {frontierError ? (
637
+ <div className="error-recovery" role="alert">
638
+ <p className="error-text">Frontier run error: {frontierError}</p>
639
+ <button type="button" className="btn btn-secondary btn-small" disabled={frontierRunning} onClick={runFrontiers}>
640
+ Run again
641
+ </button>
642
+ </div>
643
+ ) : null}
644
+ {visibleFrontier ? (
645
+ <div className="chart-grid">
646
+ <div>
647
+ <h3 style={{ marginTop: 0 }}>Spending vs. success</h3>
648
+ <div className="chart-frame" style={frameH(240)}>
649
+ <ResponsiveContainer width="100%" height="100%">
650
+ <ComposedChart data={visibleFrontier.spending} margin={{ left: 12, right: 8, top: 8 }}>
651
+ <CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
652
+ <XAxis dataKey="x" tickFormatter={fmtMoneyCompact} tick={{ fill: 'var(--muted)', fontSize: 12 }} />
653
+ <YAxis tickFormatter={(v) => `${Math.round(Number(v) * 100)}%`} tick={{ fill: 'var(--muted)', fontSize: 12 }} width={56} />
654
+ <Tooltip
655
+ formatter={(v: unknown, name: unknown) =>
656
+ name === 'Success rate' ? `${Math.round(Number(v) * 100)}%` : fmtMoneyCompact(Number(v))
657
+ }
658
+ labelFormatter={(v) => `Spending ${fmtMoneyCompact(Number(v))}`}
659
+ contentStyle={chartTooltipStyle}
660
+ />
661
+ <Line dataKey="successRate" name="Success rate" stroke="var(--chart-1)" strokeWidth={2.5} />
662
+ </ComposedChart>
663
+ </ResponsiveContainer>
664
+ </div>
665
+ </div>
666
+ <div>
667
+ <h3 style={{ marginTop: 0 }}>Retirement age vs. success</h3>
668
+ <div className="chart-frame" style={frameH(240)}>
669
+ <ResponsiveContainer width="100%" height="100%">
670
+ <ComposedChart data={visibleFrontier.retirement} margin={{ left: 12, right: 8, top: 8 }}>
671
+ <CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
672
+ <XAxis dataKey="x" tick={{ fill: 'var(--muted)', fontSize: 12 }} />
673
+ <YAxis tickFormatter={(v) => `${Math.round(Number(v) * 100)}%`} tick={{ fill: 'var(--muted)', fontSize: 12 }} width={56} />
674
+ <Tooltip
675
+ formatter={(v: unknown, name: unknown) =>
676
+ name === 'Success rate' ? `${Math.round(Number(v) * 100)}%` : fmtMoneyCompact(Number(v))
677
+ }
678
+ labelFormatter={(v) => `Retirement age ${v}`}
679
+ contentStyle={chartTooltipStyle}
680
+ />
681
+ <Line dataKey="successRate" name="Success rate" stroke="var(--chart-2)" strokeWidth={2.5} />
682
+ </ComposedChart>
683
+ </ResponsiveContainer>
684
+ </div>
685
+ </div>
686
+ </div>
687
+ ) : (
688
+ <p className="muted">Run the frontier sweep to compare bounded variants on the same market paths.</p>
689
+ )}
690
+ {visibleFrontier && visibleFrontier.annuitization.points.length > 1 ? (
691
+ <div style={{ marginTop: '1rem' }}>
692
+ <h3 style={{ marginTop: 0 }}>How much to annuitize?</h3>
693
+ <p className="card-hint">
694
+ Each point trades that share of your investable assets for an immediate life annuity (payout rate{' '}
695
+ {visibleFrontier.annuitization.payoutRatePct.toFixed(1)}% at age {visibleFrontier.annuitization.startAge},{' '}
696
+ {visibleFrontier.annuitization.rateSource === 'default-table'
697
+ ? 'from a published-quote planning table — check a marketplace quote before acting'
698
+ : 'from your entered quote'}
699
+ ), priced on the exact ledger over the same market paths.
700
+ {visibleFrontier.annuitization.attributionAvailable
701
+ ? ' The dashed line holds the same dollars but shifts them from bonds to stocks instead of buying the annuity — the implicit rising-equity glidepath that research attributes much of a SPIA\'s benefit to. The gap between the lines is what annuitization adds beyond it (mortality credits, spending floor), net of lost liquidity.'
702
+ : ''}
703
+ </p>
704
+ <div className="chart-grid">
705
+ <div>
706
+ <div className="chart-frame" style={frameH(240)}>
707
+ <ResponsiveContainer width="100%" height="100%">
708
+ <ComposedChart
709
+ data={visibleFrontier.annuitization.points.map((p) => ({
710
+ pct: Math.round(p.effectiveAllocationPct * 10) / 10,
711
+ success: p.metrics.successRate,
712
+ control: p.glidepathControl?.successRate,
713
+ }))}
714
+ margin={{ left: 12, right: 8, top: 8 }}
715
+ >
716
+ <CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
717
+ <XAxis dataKey="pct" tickFormatter={(v) => `${v}%`} tick={{ fill: 'var(--muted)', fontSize: 12 }} />
718
+ <YAxis tickFormatter={(v) => `${Math.round(Number(v) * 100)}%`} tick={{ fill: 'var(--muted)', fontSize: 12 }} width={56} />
719
+ <Tooltip
720
+ formatter={(v: unknown) =>
721
+ Number.isFinite(Number(v)) ? `${(Number(v) * 100).toFixed(1)}%` : '—'
722
+ }
723
+ labelFormatter={(v) => `${v}% annuitized`}
724
+ contentStyle={chartTooltipStyle}
725
+ />
726
+ <Line dataKey="success" name="Success rate (annuitized)" stroke="var(--chart-1)" strokeWidth={2.5} />
727
+ {visibleFrontier.annuitization.attributionAvailable ? (
728
+ <Line
729
+ dataKey="control"
730
+ name="Glidepath only (no annuity)"
731
+ stroke="var(--chart-3)"
732
+ strokeWidth={2}
733
+ strokeDasharray="6 4"
734
+ />
735
+ ) : null}
736
+ </ComposedChart>
737
+ </ResponsiveContainer>
738
+ </div>
739
+ </div>
740
+ <div>
741
+ <div className="year-table-wrap" style={{ border: 'none' }}>
742
+ <table className="compare-table">
743
+ <thead>
744
+ <tr>
745
+ <th>Annuitized</th>
746
+ <th>Premium</th>
747
+ <th>Income/yr</th>
748
+ <th>Median estate</th>
749
+ </tr>
750
+ </thead>
751
+ <tbody>
752
+ {visibleFrontier.annuitization.points.map((p) => (
753
+ <tr key={p.allocationPct}>
754
+ <td>{Math.round(p.effectiveAllocationPct * 10) / 10}%</td>
755
+ <td>{fmtMoneyCompact(p.premium)}</td>
756
+ <td>{fmtMoneyCompact(p.annualIncome)}</td>
757
+ <td>{fmtMoneyCompact(p.metrics.medianEndingAfterTaxEstate)}</td>
758
+ </tr>
759
+ ))}
760
+ </tbody>
761
+ </table>
762
+ </div>
763
+ </div>
764
+ </div>
765
+ {visibleFrontier.annuitization.notes.map((note) => (
766
+ <p key={note} className="muted" style={{ marginBottom: 0 }}>
767
+ {note}
768
+ </p>
769
+ ))}
770
+ </div>
771
+ ) : null}
772
+ </div>
773
+
774
+ <div className="chart-card">
775
+ <div className="item-row-head">
776
+ <h2 style={{ margin: 0 }}>Historical stress windows</h2>
777
+ <button type="button" className="btn btn-secondary btn-small" disabled={historicalRunning} onClick={runHistoricalSuites}>
778
+ {historicalRunning ? 'Running...' : 'Run rolling/reversed suite'}
779
+ </button>
780
+ </div>
781
+ <p className="card-hint">
782
+ Replay every rolling historical window, then replay those windows in reverse order, to find the named
783
+ sequences that hurt this plan most.
784
+ </p>
785
+ {historicalError ? (
786
+ <div className="error-recovery" role="alert">
787
+ <p className="error-text">Stress suite error: {historicalError}</p>
788
+ <button type="button" className="btn btn-secondary btn-small" disabled={historicalRunning} onClick={runHistoricalSuites}>
789
+ Run again
790
+ </button>
791
+ </div>
792
+ ) : null}
793
+ {visibleHistorical ? (
794
+ <div className="chart-grid">
795
+ {visibleHistorical.suites.map((suite) => (
796
+ <div key={suite.kind}>
797
+ <h3 style={{ marginTop: 0 }}>{suite.name}</h3>
798
+ <div className="year-table-wrap" style={{ border: 'none' }}>
799
+ <table className="compare-table">
800
+ <thead>
801
+ <tr>
802
+ <th>Window</th>
803
+ <th>Estate</th>
804
+ <th>Depletes</th>
805
+ <th>Shortfall</th>
806
+ </tr>
807
+ </thead>
808
+ <tbody>
809
+ {suite.worstByEndingAfterTaxEstate.map((window) => (
810
+ <tr key={window.label}>
811
+ <td>{window.label}</td>
812
+ <td>{fmtMoneyCompact(window.summary.endingAfterTaxEstate)}</td>
813
+ <td>{window.projection.depletionYear ?? 'never'}</td>
814
+ <td>{fmtMoneyCompact(window.totalShortfall)}</td>
815
+ </tr>
816
+ ))}
817
+ </tbody>
818
+ </table>
819
+ </div>
820
+ </div>
821
+ ))}
822
+ </div>
823
+ ) : (
824
+ <p className="muted">Run the suite to list the worst rolling and reversed historical windows.</p>
825
+ )}
826
+ </div>
827
+
828
+ <div className="chart-card">
829
+ <h2>Range of outcomes</h2>
830
+ <p className="card-hint">Investable assets per year (nominal). Bands: 10–90th and 25–75th percentiles; line: median.</p>
831
+ <div
832
+ className="chart-frame"
833
+ style={frameH(340)}
834
+ role="img"
835
+ aria-label={
836
+ fanRows.length > 0
837
+ ? `Fan chart of investable assets by year. In ${fanRows[fanRows.length - 1]!.year}, the median is ${fmtMoneyCompact(fanRows[fanRows.length - 1]!.p50)}, with the 10th to 90th percentile range spanning ${fmtMoneyCompact(fanRows[fanRows.length - 1]!.p10)} to ${fmtMoneyCompact(fanRows[fanRows.length - 1]!.p90)}.`
838
+ : 'Fan chart of investable assets by year.'
839
+ }
840
+ >
841
+ <ResponsiveContainer width="100%" height="100%">
842
+ <ComposedChart data={fanRows} margin={{ left: 12, right: 8, top: 8 }}>
843
+ <CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
844
+ <XAxis dataKey="year" tick={{ fill: 'var(--muted)', fontSize: 12 }} />
845
+ <YAxis tickFormatter={fmtMoneyCompact} tick={{ fill: 'var(--muted)', fontSize: 12 }} width={70} />
846
+ <Tooltip formatter={(v: unknown) => fmtMoney(Number(v))} contentStyle={chartTooltipStyle} />
847
+ <Area dataKey="p10" stackId="outer" stroke="none" fill="transparent" name="p10" />
848
+ <Area dataKey={(d: { p10: number; p90: number }) => d.p90 - d.p10} stackId="outer" stroke="none" fill="var(--chart-1)" fillOpacity={0.18} name="10–90%" />
849
+ <Area dataKey="p25" stackId="inner" stroke="none" fill="transparent" name="p25" legendType="none" />
850
+ <Area dataKey={(d: { p25: number; p75: number }) => d.p75 - d.p25} stackId="inner" stroke="none" fill="var(--chart-1)" fillOpacity={0.3} name="25–75%" />
851
+ <Line dataKey="p50" stroke="var(--chart-1)" strokeWidth={2.5} dot={false} name="Median" />
852
+ </ComposedChart>
853
+ </ResponsiveContainer>
854
+ </div>
855
+ </div>
856
+
857
+ <div className="chart-grid">
858
+ <div className="chart-card">
859
+ <h2>Ending balance distribution</h2>
860
+ <div
861
+ className="chart-frame"
862
+ style={frameH(240)}
863
+ role="img"
864
+ aria-label={`Histogram of ending investable balances across ${summary.pathCount.toLocaleString()} simulated paths. Median ending estate ${fmtMoneyCompact(summary.endingAfterTaxEstate.percentiles.p50)}.`}
865
+ >
866
+ <ResponsiveContainer width="100%" height="100%">
867
+ <BarChart data={histRows} margin={{ left: 4, right: 8, top: 8 }}>
868
+ <XAxis dataKey="label" tick={{ fill: 'var(--muted)', fontSize: 10 }} interval="preserveStartEnd" />
869
+ <YAxis tick={{ fill: 'var(--muted)', fontSize: 11 }} width={40} />
870
+ <Tooltip contentStyle={chartTooltipStyle} />
871
+ <Bar dataKey="count" name="Paths" fill="var(--chart-2)" />
872
+ </BarChart>
873
+ </ResponsiveContainer>
874
+ </div>
875
+ </div>
876
+ <div className="chart-card">
877
+ <h2>When depleting plans run out</h2>
878
+ {summary.depletionYearCounts.length === 0 ? (
879
+ <p className="muted">No path depleted — every simulated market carried the plan to the end.</p>
880
+ ) : (
881
+ <div
882
+ className="chart-frame"
883
+ style={frameH(240)}
884
+ role="img"
885
+ aria-label={`Histogram of first-depletion years for the ${summary.depletionYearCounts.reduce((a, r) => a + r.count, 0)} paths that ran out of money.`}
886
+ >
887
+ <ResponsiveContainer width="100%" height="100%">
888
+ <BarChart data={summary.depletionYearCounts} margin={{ left: 4, right: 8, top: 8 }}>
889
+ <XAxis dataKey="year" tick={{ fill: 'var(--muted)', fontSize: 11 }} />
890
+ <YAxis tick={{ fill: 'var(--muted)', fontSize: 11 }} width={40} />
891
+ <Tooltip contentStyle={chartTooltipStyle} />
892
+ <Bar dataKey="count" name="Paths" fill="var(--chart-3)" />
893
+ </BarChart>
894
+ </ResponsiveContainer>
895
+ </div>
896
+ )}
897
+ </div>
898
+ </div>
899
+
900
+ </>
901
+ )}
902
+
903
+ <LearnAboutScreen route="/plan/:planId/monte-carlo" />
904
+ </section>
905
+ )
906
+ }
907
+