@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,285 @@
1
+ /**
2
+ * Scenarios: curated what-if overrides on top of the base plan, compared
3
+ * side by side (deterministic metrics + Monte Carlo success on a shared
4
+ * seed, so every scenario faces the same markets).
5
+ */
6
+
7
+ import { useEffect, useMemo, useState } from 'react'
8
+
9
+ import type { Plan } from '@retiregolden/engine/model/plan'
10
+ import { TRUSTEES_DEFAULT_SS_HAIRCUT } from '@retiregolden/engine/params'
11
+ import { compareScenarios, type ScenarioComparison } from '@retiregolden/engine/scenarios/scenarios'
12
+ import { usePlan } from './planContextCore'
13
+ import { MoneyField, NumberField, PercentField, SelectField } from './fields'
14
+ import { LearnAboutScreen } from '../learn/LearnAboutScreen'
15
+ import { fmtMoneyCompact } from './format'
16
+ import { currentStartYear, seedFromPlanId, taxCalculatorFor } from './useProjection'
17
+
18
+ const newId = () => crypto.randomUUID()
19
+
20
+ type TemplateKind = 'retireEarlier' | 'spendMore' | 'ssCut' | 'noConversions' | 'lowerReturns' | 'ltcShock' | 'coastCheck'
21
+
22
+ const TEMPLATES: Array<{ kind: TemplateKind; label: string }> = [
23
+ { kind: 'retireEarlier', label: 'Retire earlier/later' },
24
+ { kind: 'spendMore', label: 'Spend more/less' },
25
+ { kind: 'ssCut', label: 'Social Security cut' },
26
+ { kind: 'noConversions', label: 'Skip Roth conversions' },
27
+ { kind: 'lowerReturns', label: 'Different returns' },
28
+ { kind: 'ltcShock', label: 'Long-term-care shock' },
29
+ { kind: 'coastCheck', label: 'Coast check: stop contributing' },
30
+ ]
31
+
32
+ interface TemplateParams {
33
+ retireAgeDelta: number
34
+ spendPct: number
35
+ ssCutPct: number
36
+ returnPct: number | null
37
+ ltcYears: number
38
+ ltcAnnual: number
39
+ ltcStartAge: number
40
+ }
41
+
42
+ const DEFAULT_PARAMS: TemplateParams = {
43
+ retireAgeDelta: -2,
44
+ spendPct: 15,
45
+ ssCutPct: TRUSTEES_DEFAULT_SS_HAIRCUT.cutPct,
46
+ returnPct: 4,
47
+ ltcYears: 3,
48
+ ltcAnnual: 110_000,
49
+ ltcStartAge: 84,
50
+ }
51
+
52
+ function zeroAccountContributions(account: Plan['accounts'][number]): Plan['accounts'][number] {
53
+ if (!('annualContribution' in account)) return account
54
+ const next: Record<string, unknown> = { ...account, annualContribution: 0 }
55
+ delete next.contributionSchedule
56
+ return next as Plan['accounts'][number]
57
+ }
58
+
59
+ /** Builds a schema-safe patch for a template. Arrays are replaced wholesale by design. */
60
+ function buildPatch(kind: TemplateKind, p: TemplateParams, plan: Plan): { name: string; patch: Record<string, unknown> } {
61
+ switch (kind) {
62
+ case 'retireEarlier': {
63
+ const people = plan.household.people.map((person) => ({
64
+ ...person,
65
+ retirementAge:
66
+ person.retirementAge === null ? null : Math.min(80, Math.max(30, person.retirementAge + p.retireAgeDelta)),
67
+ }))
68
+ const verb = p.retireAgeDelta < 0 ? 'earlier' : 'later'
69
+ return { name: `Retire ${Math.abs(p.retireAgeDelta)}y ${verb}`, patch: { household: { people } } }
70
+ }
71
+ case 'spendMore': {
72
+ const factor = 1 + p.spendPct / 100
73
+ return {
74
+ name: `Spend ${p.spendPct >= 0 ? '+' : ''}${p.spendPct}%`,
75
+ patch: { expenses: { baseAnnual: Math.max(0, Math.round(plan.expenses.baseAnnual * factor)) } },
76
+ }
77
+ }
78
+ case 'ssCut':
79
+ return {
80
+ name: `${p.ssCutPct}% SS cut from ${TRUSTEES_DEFAULT_SS_HAIRCUT.fromYear}`,
81
+ patch: { assumptions: { ssHaircut: { fromYear: TRUSTEES_DEFAULT_SS_HAIRCUT.fromYear, cutPct: p.ssCutPct } } },
82
+ }
83
+ case 'noConversions':
84
+ return { name: 'No Roth conversions', patch: { strategies: { rothConversion: { mode: 'none' } } } }
85
+ case 'lowerReturns':
86
+ return {
87
+ name: `${p.returnPct ?? 0}% default return`,
88
+ patch: { assumptions: { defaultReturnPct: p.returnPct ?? 0 } },
89
+ }
90
+ case 'ltcShock': {
91
+ const startYear =
92
+ Number(plan.household.people[0]!.dob.slice(0, 4)) + p.ltcStartAge
93
+ const goals = [
94
+ ...plan.expenses.oneTimeGoals,
95
+ ...Array.from({ length: p.ltcYears }, (_, i) => ({
96
+ id: newId(),
97
+ label: `LTC year ${i + 1}`,
98
+ year: startYear + i,
99
+ amount: p.ltcAnnual,
100
+ })),
101
+ ]
102
+ return {
103
+ name: `LTC: ${p.ltcYears}y × ${fmtMoneyCompact(p.ltcAnnual)} at ${p.ltcStartAge}`,
104
+ patch: { expenses: { oneTimeGoals: goals } },
105
+ }
106
+ }
107
+ case 'coastCheck':
108
+ return {
109
+ name: 'Coast check: stop contributing',
110
+ patch: { accounts: plan.accounts.map(zeroAccountContributions) },
111
+ }
112
+ }
113
+ }
114
+
115
+ function AddScenario() {
116
+ const { update } = usePlan()
117
+ const [kind, setKind] = useState<TemplateKind>('retireEarlier')
118
+ const [params, setParams] = useState<TemplateParams>(DEFAULT_PARAMS)
119
+ const set = <K extends keyof TemplateParams>(k: K, v: TemplateParams[K]) => setParams((p) => ({ ...p, [k]: v }))
120
+ return (
121
+ <div className="card">
122
+ <h2>Add a scenario</h2>
123
+ <p className="card-hint">Each scenario overrides just a few assumptions on top of the base plan; everything else stays in sync as you keep editing.</p>
124
+ <div className="form-grid">
125
+ <SelectField
126
+ label="What if…"
127
+ value={kind}
128
+ options={TEMPLATES.map((t) => ({ value: t.kind, label: t.label }))}
129
+ onCommit={(v) => setKind(v)}
130
+ />
131
+ {kind === 'retireEarlier' ? (
132
+ <NumberField label="Years earlier (−) / later (+)" value={params.retireAgeDelta} min={-15} max={15} onCommit={(v) => set('retireAgeDelta', Math.round(v ?? -2))} />
133
+ ) : null}
134
+ {kind === 'spendMore' ? <PercentField label="Spending change" value={params.spendPct} min={-50} max={100} onCommit={(v) => set('spendPct', v ?? 15)} /> : null}
135
+ {kind === 'ssCut' ? <PercentField label="Benefit cut" value={params.ssCutPct} min={0} max={100} onCommit={(v) => set('ssCutPct', v ?? TRUSTEES_DEFAULT_SS_HAIRCUT.cutPct)} /> : null}
136
+ {kind === 'lowerReturns' ? <PercentField label="Default return" value={params.returnPct} onCommit={(v) => set('returnPct', v)} /> : null}
137
+ {kind === 'ltcShock' ? (
138
+ <>
139
+ <NumberField label="Years of care" value={params.ltcYears} min={1} max={10} onCommit={(v) => set('ltcYears', Math.round(v ?? 3))} />
140
+ <MoneyField label="Annual cost (today's $)" value={params.ltcAnnual} onCommit={(v) => set('ltcAnnual', v ?? 110_000)} />
141
+ <NumberField label="Starting age" value={params.ltcStartAge} min={60} max={105} onCommit={(v) => set('ltcStartAge', Math.round(v ?? 84))} />
142
+ </>
143
+ ) : null}
144
+ </div>
145
+ <div className="add-row">
146
+ <button
147
+ type="button"
148
+ className="btn btn-primary btn-small"
149
+ onClick={() =>
150
+ update((d) => {
151
+ const built = buildPatch(kind, params, d)
152
+ d.scenarios.push({ id: newId(), name: built.name, patch: built.patch })
153
+ })
154
+ }
155
+ >
156
+ + Add scenario
157
+ </button>
158
+ </div>
159
+ </div>
160
+ )
161
+ }
162
+
163
+ export function ScenariosPage() {
164
+ const { plan, update } = usePlan()
165
+ const [withMc, setWithMc] = useState(true)
166
+ const [comparison, setComparison] = useState<ScenarioComparison | null>(null)
167
+ const seed = useMemo(() => seedFromPlanId(plan.id), [plan.id])
168
+
169
+ // compareScenarios with MC is CPU-bound but fast (≈100 ms/scenario at 200
170
+ // paths); run it debounced off the main interaction.
171
+ useEffect(() => {
172
+ const t = window.setTimeout(() => {
173
+ setComparison(
174
+ compareScenarios(plan, {
175
+ startYear: currentStartYear(),
176
+ taxCalculator: taxCalculatorFor(plan),
177
+ // Per-row stacks so patches that change tax assumptions (e.g. a
178
+ // relocation scenario clearing the flat override) price correctly.
179
+ taxCalculatorForPlan: taxCalculatorFor,
180
+ monteCarlo: withMc ? { model: { type: 'lognormal', inflationMeanPct: plan.assumptions.inflationPct }, pathCount: 200, seed } : undefined,
181
+ }),
182
+ )
183
+ }, 200)
184
+ return () => window.clearTimeout(t)
185
+ }, [plan, withMc, seed])
186
+
187
+ const base = comparison?.rows[0]
188
+
189
+ return (
190
+ <section>
191
+ <AddScenario />
192
+
193
+ <div className="card">
194
+ <div className="item-row-head">
195
+ <h2 style={{ margin: 0 }}>Side-by-side</h2>
196
+ <label className="radio-option" style={{ padding: 0 }}>
197
+ <input type="checkbox" checked={withMc} onChange={(e) => setWithMc(e.target.checked)} />
198
+ <span className="small">Monte Carlo success % (200 paths, shared markets)</span>
199
+ </label>
200
+ </div>
201
+ {plan.scenarios.length === 0 ? (
202
+ <div className="empty-state">
203
+ <p>No scenarios yet. Add one above — “{TRUSTEES_DEFAULT_SS_HAIRCUT.cutPct}% SS cut” and “retire 2 years earlier” are classics.</p>
204
+ </div>
205
+ ) : comparison === null ? (
206
+ <div className="skeleton" style={{ height: '10rem' }} aria-label="Comparing scenarios" />
207
+ ) : (
208
+ <div className="year-table-wrap" style={{ border: 'none' }}>
209
+ <table className="compare-table">
210
+ <thead>
211
+ <tr>
212
+ <th>Scenario</th>
213
+ {withMc ? <th>Success</th> : null}
214
+ <th>Ending estate</th>
215
+ <th>Lifetime tax</th>
216
+ <th>Depletes</th>
217
+ <th>Changed</th>
218
+ <th aria-label="actions" />
219
+ </tr>
220
+ </thead>
221
+ <tbody>
222
+ {comparison.rows.map((row) => {
223
+ const estateDelta = base && row.scenarioId !== null ? row.summary.endingNetWorth - base.summary.endingNetWorth : null
224
+ return (
225
+ <tr key={row.scenarioId ?? 'base'}>
226
+ <td>
227
+ <strong>{row.name}</strong>
228
+ {row.error ? <div className="small" style={{ color: 'var(--bad)' }}>{row.error}</div> : null}
229
+ </td>
230
+ {withMc ? (
231
+ <td>
232
+ {row.successRate === null ? '—' : (
233
+ <span className={row.successRate >= 0.9 ? 'delta-pos' : row.successRate < 0.75 ? 'delta-neg' : undefined}>
234
+ {Math.round(row.successRate * 100)}%
235
+ </span>
236
+ )}
237
+ </td>
238
+ ) : null}
239
+ <td>
240
+ {row.error ? '—' : fmtMoneyCompact(row.summary.endingNetWorth)}
241
+ {estateDelta !== null && Math.abs(estateDelta) > 500 ? (
242
+ <div className={estateDelta > 0 ? 'small delta-pos' : 'small delta-neg'}>
243
+ {estateDelta > 0 ? '+' : ''}
244
+ {fmtMoneyCompact(estateDelta)}
245
+ </div>
246
+ ) : null}
247
+ </td>
248
+ <td>{row.error ? '—' : fmtMoneyCompact(row.summary.lifetimeTaxesAndPenalties)}</td>
249
+ <td>{row.error ? '—' : (row.summary.depletionYear ?? 'never')}</td>
250
+ <td style={{ maxWidth: '16rem', textAlign: 'left' }}>
251
+ {row.diff.slice(0, 4).map((d) => (
252
+ <span key={d.path} className="diff-chip" title={`${d.path}: ${JSON.stringify(d.baseValue)} → ${JSON.stringify(d.scenarioValue)}`}>
253
+ {d.path.split('.').slice(-2).join('.')}
254
+ </span>
255
+ ))}
256
+ {row.diff.length > 4 ? <span className="diff-chip">+{row.diff.length - 4} more</span> : null}
257
+ </td>
258
+ <td>
259
+ {row.scenarioId !== null ? (
260
+ <button
261
+ type="button"
262
+ className="btn-ghost btn-ghost-danger"
263
+ onClick={() =>
264
+ update((d) => {
265
+ d.scenarios = d.scenarios.filter((s) => s.id !== row.scenarioId)
266
+ })
267
+ }
268
+ >
269
+ Remove
270
+ </button>
271
+ ) : null}
272
+ </td>
273
+ </tr>
274
+ )
275
+ })}
276
+ </tbody>
277
+ </table>
278
+ </div>
279
+ )}
280
+ </div>
281
+
282
+ <LearnAboutScreen route="/plan/:planId/scenarios" />
283
+ </section>
284
+ )
285
+ }