@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,512 @@
1
+ /**
2
+ * "How much can I spend?" (sustainable-spending plan, Step 4): runs the
3
+ * exact-ledger max-spending solver (`solveMaxSustainableSpending`) in a Web
4
+ * Worker and presents the answer with its constraints — the no-depletion
5
+ * horizon and the optional bequest target from the Spending screen — plus the
6
+ * exact-ledger evidence at the solved level. Deterministic: identical inputs
7
+ * re-solve to identical answers under the fixed ~25-simulation budget.
8
+ */
9
+
10
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
11
+ import { Link, useNavigate } from 'react-router-dom'
12
+
13
+ import { compareSwrRules } from '@retiregolden/engine/decisions/swrComparator'
14
+ import { startingInvestableOf } from '@retiregolden/engine/montecarlo/riskBasedGuardrails'
15
+ import { spendingShapePhases, type SpendingShapeId } from '@retiregolden/engine/spending/shapePresets'
16
+ import type { Plan } from '@retiregolden/engine/model/plan'
17
+ import type { SpendingSolveResult } from '../optimize/spendingMessages'
18
+ import { runSpendingSolve } from '../optimize/spendingRunner'
19
+ import { usePlan } from './planContextCore'
20
+ import { HelpTip } from './fields'
21
+ import { LearnAboutScreen } from '../learn/LearnAboutScreen'
22
+ import { LearnLink } from '../learn/LearnLink'
23
+ import { fmtMoney } from './format'
24
+ import { LEARN } from './learnLinks'
25
+ import { currentStartYear, taxCalculatorFor } from './useProjection'
26
+
27
+ function makeScenarioId(): string {
28
+ return typeof crypto !== 'undefined' && 'randomUUID' in crypto
29
+ ? `scenario-${crypto.randomUUID()}`
30
+ : `scenario-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
31
+ }
32
+
33
+ function Stat({
34
+ label,
35
+ value,
36
+ tone,
37
+ help,
38
+ small = false,
39
+ }: {
40
+ label: string
41
+ value: string
42
+ tone: 'good' | 'bad' | 'neutral'
43
+ help: string
44
+ small?: boolean
45
+ }) {
46
+ return (
47
+ <div className="card">
48
+ <span className="field-label-row">
49
+ <span className="field-label">{label}</span>
50
+ <HelpTip text={help} />
51
+ </span>
52
+ <div className={`stat-value${small ? ' stat-value--sm' : ''} stat-value--${tone}`}>{value}</div>
53
+ </div>
54
+ )
55
+ }
56
+
57
+ interface ShapeRow {
58
+ id: SpendingShapeId
59
+ label: string
60
+ maxBaseAnnual: number | null
61
+ }
62
+
63
+ const SHAPE_DEFS: { id: SpendingShapeId; label: string }[] = [
64
+ { id: 'flat', label: 'Constant-real (no decline)' },
65
+ { id: 'smile', label: 'Smile — average retiree (−10% at 75, −20% at 85)' },
66
+ { id: 'smirk', label: 'Smirk — median retiree (−1%/yr real)' },
67
+ ]
68
+
69
+ export function SpendingSolverPage() {
70
+ const { plan, update } = usePlan()
71
+ const navigate = useNavigate()
72
+ const startYear = currentStartYear()
73
+
74
+ const [result, setResult] = useState<SpendingSolveResult | null>(null)
75
+ const [running, setRunning] = useState(false)
76
+ const [error, setError] = useState<string | null>(null)
77
+ const runToken = useRef(0)
78
+
79
+ // Amortized spending recomputes annual spending from the portfolio, so the
80
+ // fixed-baseline bisection has nothing to move (the engine solver refuses
81
+ // ABW plans with a diagnostic). Skip solving and explain instead; the
82
+ // per-shape and SWR cards below solve fixed-target variants and stay useful.
83
+ const abwActive = plan.expenses.spendingPolicy?.mode === 'abw'
84
+
85
+ // --- solver output per spending shape (on demand; ~25 sims per shape) ----
86
+ // Results are keyed to the plan object they were solved on, so an edit
87
+ // anywhere simply makes them disappear instead of showing stale answers.
88
+ const [shapeState, setShapeState] = useState<{
89
+ forPlan: Plan
90
+ rows: ShapeRow[] | null
91
+ error: string | null
92
+ } | null>(null)
93
+ const [shapesRunning, setShapesRunning] = useState(false)
94
+ const shapeToken = useRef(0)
95
+ const shapeRows = shapeState !== null && shapeState.forPlan === plan ? shapeState.rows : null
96
+ const shapesError = shapeState !== null && shapeState.forPlan === plan ? shapeState.error : null
97
+ const compareShapes = () => {
98
+ const token = ++shapeToken.current
99
+ const forPlan = plan
100
+ setShapesRunning(true)
101
+ setShapeState(null)
102
+ const retirementAge = plan.household.people[0]?.retirementAge ?? 65
103
+ void (async () => {
104
+ try {
105
+ const rows: ShapeRow[] = []
106
+ for (const def of SHAPE_DEFS) {
107
+ const variant: Plan = {
108
+ ...forPlan,
109
+ expenses: {
110
+ ...forPlan.expenses,
111
+ phases: spendingShapePhases(def.id, retirementAge),
112
+ // Under ABW the ledger ignores baseAnnual/phases entirely, so a
113
+ // per-shape solve must price fixed-target variants; guardrail
114
+ // policies stay (the solver handles them).
115
+ ...(forPlan.expenses.spendingPolicy?.mode === 'abw' ? { spendingPolicy: undefined } : {}),
116
+ },
117
+ }
118
+ const solved = await runSpendingSolve({ plan: variant, startYear })
119
+ rows.push({ ...def, maxBaseAnnual: solved.maxBaseAnnual })
120
+ }
121
+ if (token === shapeToken.current) setShapeState({ forPlan, rows, error: null })
122
+ } catch (e: unknown) {
123
+ if (token === shapeToken.current) {
124
+ setShapeState({ forPlan, rows: null, error: e instanceof Error ? e.message : String(e) })
125
+ }
126
+ } finally {
127
+ if (token === shapeToken.current) setShapesRunning(false)
128
+ }
129
+ })()
130
+ }
131
+
132
+ // --- published SWR rules on this plan (three deterministic ledger runs) --
133
+ const swrRows = useMemo(
134
+ () => compareSwrRules(plan, { startYear, taxCalculator: taxCalculatorFor(plan) }),
135
+ [plan, startYear],
136
+ )
137
+ const startingInvestable = useMemo(() => startingInvestableOf(plan), [plan])
138
+
139
+ const run = useCallback(() => {
140
+ const token = ++runToken.current
141
+ setRunning(true)
142
+ setError(null)
143
+ runSpendingSolve({ plan, startYear })
144
+ .then((r) => {
145
+ if (token === runToken.current) setResult(r)
146
+ })
147
+ .catch((e: unknown) => {
148
+ if (token === runToken.current) {
149
+ setError(e instanceof Error ? e.message : String(e))
150
+ setResult(null)
151
+ }
152
+ })
153
+ .finally(() => {
154
+ if (token === runToken.current) setRunning(false)
155
+ })
156
+ }, [plan, startYear])
157
+
158
+ // Auto-run on plan change (debounced), like the Roth & Tax Optimizer.
159
+ useEffect(() => {
160
+ if (abwActive) return // nothing to solve; the ABW callout explains
161
+ const t = window.setTimeout(run, 300)
162
+ return () => window.clearTimeout(t)
163
+ }, [run, abwActive])
164
+
165
+ // The solver bisects to ~$500 resolution; a to-the-dollar headline claims
166
+ // precision the answer doesn't have. Floor to $100 (still feasible — it's
167
+ // below the level that passed) and use the same number everywhere: display,
168
+ // Apply, and scenarios.
169
+ const solvedRounded = result?.maxBaseAnnual != null ? Math.floor(result.maxBaseAnnual / 100) * 100 : null
170
+
171
+ const applyToSpending = () => {
172
+ if (solvedRounded === null) return
173
+ const solved = solvedRounded
174
+ update((d) => {
175
+ d.expenses.baseAnnual = solved
176
+ })
177
+ // Land on Spending so the applied baseline is visible where it's edited.
178
+ navigate(`/plan/${plan.id}/spending`)
179
+ }
180
+
181
+ const addScenario = () => {
182
+ if (solvedRounded === null) return
183
+ const solved = solvedRounded
184
+ const baseName = `Spend ${fmtMoney(solved)}/yr (max sustainable)`
185
+ const names = new Set(plan.scenarios.map((s) => s.name))
186
+ let name = baseName
187
+ let suffix = 2
188
+ while (names.has(name)) name = `${baseName} (${suffix++})`
189
+ update((d) => {
190
+ d.scenarios.push({ id: makeScenarioId(), name, patch: { expenses: { baseAnnual: solved } } })
191
+ })
192
+ navigate(`/plan/${plan.id}/scenarios`)
193
+ }
194
+
195
+ // Slack measured against the rounded display value so the two tiles agree.
196
+ const slack = result && solvedRounded !== null ? solvedRounded - result.currentBaseAnnual : null
197
+ // Deflate nominal end-of-plan evidence back to today's dollars so it reads
198
+ // on the same scale as the today's-dollars spending answer.
199
+ const deflator =
200
+ result?.evidence != null
201
+ ? Math.pow(1 + plan.assumptions.inflationPct / 100, -(result.evidence.endYear - startYear))
202
+ : 1
203
+
204
+ return (
205
+ <section>
206
+ <div className="card">
207
+ <h2>How much can I spend?</h2>
208
+ <p className="card-hint">
209
+ Finds the highest annual base spending (today's dollars) your exact projection ledger can sustain: the plan
210
+ must never run out of investable money through the full horizon, and the ending after-tax estate must stay at
211
+ or above your bequest target. Every probed level re-runs the whole ledger — taxes, ACA and IRMAA cliffs,
212
+ withdrawal order, healthcare, debts, and survivor years all price in. Phases and one-time goals stay as
213
+ entered; only the baseline level moves. <LearnLink {...LEARN.sustainableSpending} />
214
+ </p>
215
+ {abwActive ? (
216
+ <div className="callout callout--info">
217
+ <p className="card-hint">
218
+ This plan uses <strong>amortized spending (ABW)</strong>: annual spending is recomputed from the actual
219
+ portfolio every year, so there is no fixed base-spending level to solve for — the amortization rule{' '}
220
+ <em>is</em> the answer to &quot;how much can I spend?&quot;, and it adjusts itself as markets move. See
221
+ each year&apos;s amount on Results, or switch the spending policy back to fixed target under{' '}
222
+ <Link to={`/plan/${plan.id}/spending`}>Spending</Link> to use this solver. The shape and published-rule
223
+ comparisons below solve fixed-target variants of your plan and remain useful.
224
+ </p>
225
+ </div>
226
+ ) : (
227
+ <p className="field-hint">
228
+ {result && result.estateFloorTodayDollars > 0 ? (
229
+ <>
230
+ Enforcing your {fmtMoney(result.estateFloorTodayDollars)} bequest target (today's dollars) from{' '}
231
+ <Link to={`/plan/${plan.id}/spending`}>Spending</Link>.
232
+ </>
233
+ ) : (
234
+ <>
235
+ No bequest target set — the only constraint is not running out. Set one under{' '}
236
+ <Link to={`/plan/${plan.id}/spending`}>Spending</Link> to protect an estate floor.
237
+ </>
238
+ )}
239
+ </p>
240
+ )}
241
+ {running ? <div className="skeleton" style={{ height: '2rem', marginTop: '0.75rem' }} aria-label="Solving" /> : null}
242
+ {error ? <p style={{ color: 'var(--bad)' }}>Solver error: {error}</p> : null}
243
+ {error && !running ? (
244
+ <div style={{ marginTop: '0.75rem' }}>
245
+ <button type="button" className="btn btn-secondary btn-small" onClick={run}>
246
+ Try again
247
+ </button>
248
+ </div>
249
+ ) : null}
250
+ </div>
251
+
252
+ {result && !running ? (
253
+ result.maxBaseAnnual === null ? (
254
+ <div className="card">
255
+ <h2 style={{ color: 'var(--bad)' }}>No sustainable spending level found</h2>
256
+ <p className="muted">
257
+ {result.diagnostics.length > 0
258
+ ? result.diagnostics.join(' ')
259
+ : 'Even minimal base spending depletes the portfolio or breaks the bequest target within the plan horizon.'}{' '}
260
+ Fixed costs modeled outside baseline spending (healthcare, debt service, property carrying costs, one-time
261
+ goals) may already exceed what the plan can fund.
262
+ </p>
263
+ </div>
264
+ ) : (
265
+ <>
266
+ <div className="mc-hero">
267
+ <div>
268
+ <h2 style={{ margin: '0 0 0.35rem', color: slack !== null && slack >= 0 ? 'var(--good)' : 'var(--bad)' }}>
269
+ Your plan can sustain about {fmtMoney(solvedRounded ?? 0)} of baseline spending per year.
270
+ </h2>
271
+ <p className="muted" style={{ margin: 0 }}>
272
+ {slack !== null && slack >= 0
273
+ ? `That is ${fmtMoney(slack)} per year of headroom above your current ${fmtMoney(result.currentBaseAnnual)} baseline (today's dollars).`
274
+ : `That is ${fmtMoney(Math.abs(slack ?? 0))} per year BELOW your current ${fmtMoney(result.currentBaseAnnual)} baseline — your projection cannot sustain today's spending through the horizon.`}
275
+ {!result.converged
276
+ ? ' The simulation budget ran out before the answer fully converged, so this is a feasible lower bound.'
277
+ : ''}
278
+ </p>
279
+ </div>
280
+ </div>
281
+
282
+ <div className="stat-grid">
283
+ <Stat
284
+ label="Max sustainable spending"
285
+ value={`${fmtMoney(solvedRounded ?? 0)}/yr`}
286
+ tone="neutral"
287
+ help="Highest annual baseline spending (today's dollars) whose full year-by-year projection never depletes investable assets and keeps the ending after-tax estate at or above your bequest target. Solved by bisection to ~$500 resolution, then shown rounded down to the nearest $100 — the same rounded figure is what Apply and scenarios use."
288
+ />
289
+ <Stat
290
+ label="Spending slack"
291
+ value={`${slack !== null && slack >= 0 ? '+' : ''}${fmtMoney(slack ?? 0)}/yr`}
292
+ tone={slack !== null && slack > 0 ? 'good' : slack !== null && slack < 0 ? 'bad' : 'neutral'}
293
+ help="Max sustainable spending minus your current baseline. Positive = headroom you are not using; negative = the current baseline overspends what your plan can sustain."
294
+ />
295
+ <Stat
296
+ label="What limits it"
297
+ value={
298
+ result.limitingConstraint === 'estate-floor'
299
+ ? 'Bequest target'
300
+ : result.limitingConstraint === 'depletion'
301
+ ? 'Depletion — at higher spending'
302
+ : 'No limit found'
303
+ }
304
+ tone="neutral"
305
+ small
306
+ help="What failed at the next-higher spending level the solver probed — not at the answer above, which passed. 'Depletion' means spending any more than the answer would run the portfolio out of money before the end of the plan; 'Bequest target' means the ending estate would fall below your floor. 'No limit found' means spending appeared unbounded at every probed level (guaranteed income outruns spending)."
307
+ />
308
+ </div>
309
+
310
+ {result.evidence ? (
311
+ <div className="card">
312
+ <h2>Evidence at that level</h2>
313
+ <p className="card-hint">
314
+ From the full projection run at the solver&apos;s exact answer (shown as{' '}
315
+ {fmtMoney(solvedRounded ?? 0)}/yr, rounded down to the nearest $100) — the same year-by-year
316
+ numbers Results shows, not an approximation.
317
+ </p>
318
+ <ul style={{ margin: '0.25rem 0 0.75rem 1.1rem', lineHeight: 1.7 }}>
319
+ <li>
320
+ Money lasts through <strong>{result.evidence.endYear}</strong> (end of plan
321
+ {result.evidence.depletionYear === null ? ', never depleting' : ''}).
322
+ </li>
323
+ <li>
324
+ Ending after-tax estate: <strong>{fmtMoney(result.evidence.endingAfterTaxEstate * deflator)}</strong>{' '}
325
+ today's dollars ({fmtMoney(result.evidence.endingAfterTaxEstate)} nominal)
326
+ {result.estateFloorTodayDollars > 0 ? ` vs. the ${fmtMoney(result.estateFloorTodayDollars)} floor` : ''}.
327
+ </li>
328
+ <li>
329
+ Lifetime taxes and penalties at that spending level:{' '}
330
+ <strong>{fmtMoney(result.evidence.lifetimeTaxesAndPenalties)}</strong> (nominal, summed).
331
+ </li>
332
+ </ul>
333
+ <p className="field-hint">
334
+ Solved in {result.simulationCount} full-plan simulations
335
+ {result.converged ? ', converged to ~$500 resolution.' : ' — budget exhausted, feasible lower bound.'}
336
+ </p>
337
+ <div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }}>
338
+ <button type="button" className="btn btn-primary btn-small" onClick={applyToSpending}>
339
+ Apply to Spending
340
+ </button>
341
+ <button type="button" className="btn btn-secondary btn-small" onClick={addScenario}>
342
+ Add as scenario
343
+ </button>
344
+ <button type="button" className="btn btn-secondary btn-small" disabled={running} onClick={run}>
345
+ Re-solve
346
+ </button>
347
+ </div>
348
+ <p className="field-hint" style={{ marginTop: '0.5rem' }}>
349
+ "Apply to Spending" sets your plan's baseline spending to {fmtMoney(solvedRounded ?? 0)}/yr and opens
350
+ the Spending screen. "Add as scenario" instead creates a side-by-side scenario under Scenarios without
351
+ changing your plan.
352
+ </p>
353
+ <details className="ss-explainer">
354
+ <summary>Why this number?</summary>
355
+ <p>
356
+ The solver bisects on the baseline spending level: each probe re-runs your entire year-by-year
357
+ projection (taxes, ACA/IRMAA cliffs, withdrawal order, healthcare, debts, survivor years) and
358
+ checks two constraints — investable assets must never deplete before {result.evidence.endYear}, and
359
+ the ending after-tax estate must stay at or above{' '}
360
+ {result.estateFloorTodayDollars > 0
361
+ ? `your ${fmtMoney(result.estateFloorTodayDollars)} bequest target`
362
+ : 'zero (no bequest target set)'}
363
+ . The solver&apos;s exact answer is the highest level that passed both — it is shown, applied,
364
+ and added to scenarios rounded down to the nearest $100 ({fmtMoney(solvedRounded ?? 0)}), which
365
+ therefore also passes. The next-higher probe failed on{' '}
366
+ {result.limitingConstraint === 'estate-floor'
367
+ ? 'the bequest target'
368
+ : result.limitingConstraint === 'depletion'
369
+ ? 'running out of money'
370
+ : 'nothing (spending appeared unbounded at every probed level)'}
371
+ . This answer used {result.simulationCount} full-projection runs
372
+ {result.converged ? ' and converged to ~$500 resolution' : ' before the budget ran out (feasible lower bound)'}
373
+ . The assumptions behind every run are on{' '}
374
+ <Link to={`/plan/${plan.id}/assumptions-card`}>your assumptions card</Link>.
375
+ </p>
376
+ </details>
377
+ </div>
378
+ ) : null}
379
+ </>
380
+ )
381
+ ) : null}
382
+
383
+ <div className="card">
384
+ <h2>What shape of spending?</h2>
385
+ <p className="card-hint">
386
+ The answer above keeps your phases as entered. Research on actual retirees says spending rarely stays
387
+ constant-real: the <em>average</em> path is a &quot;smile&quot; (a slow decline that late-life healthcare
388
+ partly reverses — the preset approximates it as two downward steps) and the <em>median</em> path is a
389
+ &quot;smirk&quot; — a steady ~1%/yr real decline with no late rise at all (Blanchett).
390
+ Shape-aware plans support a higher initial spend from the same portfolio. Solve your plan under each shape
391
+ to see the size of that effect here — apply a shape on the{' '}
392
+ <Link to={`/plan/${plan.id}/spending`}>Spending</Link> screen if you want to keep it.{' '}
393
+ <LearnLink {...LEARN.spendingProfiles} />
394
+ </p>
395
+ {shapeRows === null && !shapesRunning ? (
396
+ <button type="button" className="btn btn-secondary btn-small" onClick={compareShapes}>
397
+ Solve per shape (~75 simulations)
398
+ </button>
399
+ ) : null}
400
+ {shapesRunning ? <div className="skeleton" style={{ height: '2rem' }} aria-label="Solving per shape" /> : null}
401
+ {shapesError ? <p style={{ color: 'var(--bad)' }}>Per-shape solve error: {shapesError}</p> : null}
402
+ {shapeRows !== null && !shapesRunning ? (
403
+ <>
404
+ <div className="table-scroll">
405
+ <table>
406
+ <thead>
407
+ <tr>
408
+ <th style={{ textAlign: 'left' }}>Spending shape</th>
409
+ <th style={{ textAlign: 'right' }}>Max sustainable initial spend</th>
410
+ <th style={{ textAlign: 'right' }}>vs constant-real</th>
411
+ </tr>
412
+ </thead>
413
+ <tbody>
414
+ {shapeRows.map((row) => {
415
+ const flat = shapeRows.find((r) => r.id === 'flat')?.maxBaseAnnual ?? null
416
+ const delta = row.maxBaseAnnual !== null && flat !== null ? row.maxBaseAnnual - flat : null
417
+ return (
418
+ <tr key={row.id}>
419
+ <td>{row.label}</td>
420
+ <td style={{ textAlign: 'right' }}>
421
+ {row.maxBaseAnnual !== null ? `${fmtMoney(Math.floor(row.maxBaseAnnual / 100) * 100)}/yr` : '—'}
422
+ </td>
423
+ <td style={{ textAlign: 'right' }}>
424
+ {delta === null || row.id === 'flat' ? '—' : `${delta >= 0 ? '+' : ''}${fmtMoney(delta)}/yr`}
425
+ </td>
426
+ </tr>
427
+ )
428
+ })}
429
+ </tbody>
430
+ </table>
431
+ </div>
432
+ <p className="field-hint" style={{ marginTop: '0.5rem' }}>
433
+ Each row re-solves your full plan with that shape&apos;s phase rows (initial spend in today&apos;s
434
+ dollars; later years follow the shape). No shape is &quot;the answer&quot; — they are framings of how
435
+ your own later-life spending might behave.
436
+ </p>
437
+ </>
438
+ ) : null}
439
+ </div>
440
+
441
+ <div className="card">
442
+ <h2>Whose 4% rule? Published rules vs this plan</h2>
443
+ <p className="card-hint">
444
+ The community argues about the &quot;right&quot; safe withdrawal rate — Bengen&apos;s 2025 book says 4.7%,
445
+ Morningstar&apos;s latest study says 3.9%, Early Retirement Now conditions it on market valuations. Here is
446
+ each published rule priced on <em>your</em> plan through the same year-by-year ledger, next to your
447
+ plan&apos;s own solved answer above. The rules assume constant-real spending of a fixed fraction of the
448
+ starting portfolio; your solver answer prices your actual phases, taxes, and horizon — which is why they
449
+ differ.
450
+ </p>
451
+ <div className="table-scroll">
452
+ <table>
453
+ <thead>
454
+ <tr>
455
+ <th style={{ textAlign: 'left' }}>Rule</th>
456
+ <th style={{ textAlign: 'right' }}>Rate</th>
457
+ <th style={{ textAlign: 'right' }}>Spending/yr</th>
458
+ <th style={{ textAlign: 'left' }}>On your plan</th>
459
+ <th style={{ textAlign: 'right' }}>Ending estate (today&apos;s $)</th>
460
+ </tr>
461
+ </thead>
462
+ <tbody>
463
+ {swrRows.map((row) => {
464
+ const deflate = Math.pow(1 + plan.assumptions.inflationPct / 100, -(row.endYear - startYear))
465
+ return (
466
+ <tr key={row.id}>
467
+ <td>
468
+ {row.label} <HelpTip text={row.citation} />
469
+ </td>
470
+ <td style={{ textAlign: 'right' }}>{row.initialRatePct.toFixed(2)}%</td>
471
+ <td style={{ textAlign: 'right' }}>{fmtMoney(row.initialAnnualSpend)}</td>
472
+ <td>
473
+ {row.depletionYear === null ? (
474
+ <span style={{ color: 'var(--good)' }}>lasts through {row.endYear}</span>
475
+ ) : (
476
+ <span style={{ color: 'var(--bad)' }}>runs out in {row.depletionYear}</span>
477
+ )}
478
+ </td>
479
+ <td style={{ textAlign: 'right' }}>
480
+ {row.depletionYear === null ? fmtMoney(row.endingAfterTaxEstate * deflate) : '—'}
481
+ </td>
482
+ </tr>
483
+ )
484
+ })}
485
+ {solvedRounded !== null && startingInvestable > 0 ? (
486
+ <tr>
487
+ <td>
488
+ <strong>This plan&apos;s solver</strong>{' '}
489
+ <HelpTip text="The exact-ledger answer from the top of this page, expressed as an initial rate on the same starting investable balance so it can sit in the same table. Unlike the published rules it prices your actual phases, taxes, healthcare, and horizon." />
490
+ </td>
491
+ <td style={{ textAlign: 'right' }}>{((solvedRounded / startingInvestable) * 100).toFixed(2)}%</td>
492
+ <td style={{ textAlign: 'right' }}>{fmtMoney(solvedRounded)}</td>
493
+ <td>solved on your exact plan</td>
494
+ <td style={{ textAlign: 'right' }}>—</td>
495
+ </tr>
496
+ ) : null}
497
+ </tbody>
498
+ </table>
499
+ </div>
500
+ <p className="field-hint" style={{ marginTop: '0.5rem' }}>
501
+ Each rule runs with your accounts, taxes, healthcare, goals, and horizon unchanged — only recurring
502
+ lifestyle spending is set to the rule&apos;s level (constant-real, as the rules define it). A rule
503
+ &quot;running out&quot; on your plan usually means your horizon is longer than the 30 years the rule was
504
+ derived for, or your fixed costs differ from a generic retiree&apos;s — evidence for why a plan-specific
505
+ answer beats a debate about whose rule is right.
506
+ </p>
507
+ </div>
508
+
509
+ <LearnAboutScreen route="/plan/:planId/spending-solver" />
510
+ </section>
511
+ )
512
+ }