@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,904 @@
1
+ /** Spending section: baseline, phases, goals, debts, property. */
2
+
3
+ import { useEffect, useRef, useState } from 'react'
4
+
5
+ import type { GoalFlexibility, SpendingClassification, SpendingPolicy } from '@retiregolden/engine/model/plan'
6
+ import { annualDeltaPhases, spendingShapePhases, type SpendingShapeId } from '@retiregolden/engine/spending/shapePresets'
7
+ import { startingInvestableOf, type RiskBasedGuardrailSolution } from '@retiregolden/engine/montecarlo/riskBasedGuardrails'
8
+ import { runRiskBasedGuardrailSolve } from '../../mc/pool'
9
+ import { usePlan } from '../planContextCore'
10
+ import { CheckboxField, MoneyField, NumberField, PercentField, SelectField, TextField } from '../fields'
11
+ import { fmtMoney } from '../format'
12
+ import { buildModel } from '../marketModelPicker'
13
+ import { currentStartYear, seedFromPlanId } from '../useProjection'
14
+ import { LearnAboutScreen } from '../../learn/LearnAboutScreen'
15
+ import { LearnLink } from '../../learn/LearnLink'
16
+ import { LEARN } from '../learnLinks'
17
+ import { Issues } from './shared'
18
+ import { newId } from './sectionHelpers'
19
+
20
+ // Named spending shapes compile to ordinary `expenses.phases` rows (visible
21
+ // and editable afterwards) — never a parallel model. Calibrations and sources
22
+ // live with the compiler in engine/spending/shapePresets.ts.
23
+ type SpendingProfileId = SpendingShapeId
24
+
25
+ /** Solver budget for the on-demand threshold solve (worker; ~40 probes). */
26
+ const THRESHOLD_SOLVE_PATH_COUNT = 200
27
+
28
+ export function SpendingSection() {
29
+ const { plan, update } = usePlan()
30
+ const e = plan.expenses
31
+ const [solvingThresholds, setSolvingThresholds] = useState(false)
32
+ const [thresholdSolveError, setThresholdSolveError] = useState<string | null>(null)
33
+ const [thresholdSolution, setThresholdSolution] = useState<RiskBasedGuardrailSolution | null>(null)
34
+ // Latest committed band, readable from async solve callbacks: a solve result
35
+ // computed against an older band (edited while the worker was busy) is
36
+ // discarded instead of persisted.
37
+ const committedBandRef = useRef({ mode: e.spendingPolicy?.mode, lower: 70, upper: 95 })
38
+ useEffect(() => {
39
+ committedBandRef.current = {
40
+ mode: e.spendingPolicy?.mode,
41
+ lower: e.spendingPolicy?.targetSuccessLowerPct ?? 70,
42
+ upper: e.spendingPolicy?.targetSuccessUpperPct ?? 95,
43
+ }
44
+ })
45
+ const solveThresholds = () => {
46
+ setSolvingThresholds(true)
47
+ setThresholdSolveError(null)
48
+ const solvedBand = { ...committedBandRef.current }
49
+ void runRiskBasedGuardrailSolve(plan, {
50
+ startYear: currentStartYear(),
51
+ pathCount: THRESHOLD_SOLVE_PATH_COUNT,
52
+ seed: seedFromPlanId(plan.id),
53
+ model: buildModel('lognormal', plan.assumptions.inflationPct, 12, 60, plan),
54
+ })
55
+ .then((solution) => {
56
+ const current = committedBandRef.current
57
+ if (current.mode !== 'riskBasedGuardrails' || current.lower !== solvedBand.lower || current.upper !== solvedBand.upper) {
58
+ return // the band (or mode) changed mid-solve; the result no longer applies
59
+ }
60
+ setThresholdSolution(solution)
61
+ update((d) => {
62
+ const policy = d.expenses.spendingPolicy
63
+ if (!policy || policy.mode !== 'riskBasedGuardrails') return
64
+ if (solution.lower) policy.lowerBalanceThresholdPct = Math.round(solution.lower.balanceFrac * 10_000) / 100
65
+ else delete policy.lowerBalanceThresholdPct
66
+ if (solution.upper) policy.upperBalanceThresholdPct = Math.round(solution.upper.balanceFrac * 10_000) / 100
67
+ else delete policy.upperBalanceThresholdPct
68
+ })
69
+ })
70
+ .catch((err: unknown) => setThresholdSolveError(err instanceof Error ? err.message : String(err)))
71
+ .finally(() => setSolvingThresholds(false))
72
+ }
73
+ const hasEarlyPullFlexibleGoals = e.oneTimeGoals.some((g) => {
74
+ const flexibility = g.flexibility ?? 'fixed'
75
+ const earliestYear = Math.min(g.earliestYear ?? g.year, g.year)
76
+ return flexibility !== 'fixed' && earliestYear < g.year
77
+ })
78
+ const applyProfile = (profile: SpendingProfileId) =>
79
+ update((d) => {
80
+ d.expenses.phases = spendingShapePhases(profile, plan.household.people[0]?.retirementAge ?? 65)
81
+ })
82
+ const [customDeltaPct, setCustomDeltaPct] = useState(-1.5)
83
+ const applyCustomDelta = () =>
84
+ update((d) => {
85
+ d.expenses.phases = annualDeltaPhases(customDeltaPct, plan.household.people[0]?.retirementAge ?? 65)
86
+ })
87
+ // Mutate the ABW parameter object, creating it on first edit.
88
+ const setAbw = (mutate: (abw: NonNullable<SpendingPolicy['abw']>) => void) =>
89
+ update((d) => {
90
+ const policy = d.expenses.spendingPolicy
91
+ if (!policy || policy.mode !== 'abw') return
92
+ policy.abw ??= { returnSource: 'fixed' }
93
+ mutate(policy.abw)
94
+ })
95
+ return (
96
+ <section>
97
+ <div className="card">
98
+ <h2>Spending</h2>
99
+ <p className="card-hint">
100
+ Today's dollars; the engine inflates everything. Healthcare is separate: enter full (unsubsidized) premiums pre-65
101
+ and optionally apply the modeled ACA premium credit; Medicare + IRMAA from 65. <LearnLink {...LEARN.retirementHealthcareCosts} />
102
+ </p>
103
+ <div className="form-grid">
104
+ <MoneyField
105
+ label="Baseline annual spending"
106
+ help="Everyday living costs in today's dollars: food, utilities, transportation, clothing, entertainment, routine travel, auto insurance, and out-of-pocket medical (copays, deductibles, dental, vision) — the costs with no separate input. Leave OUT anything modeled elsewhere: mortgage/loan payments (debt accounts); property tax & homeowner's insurance (enter those on the home/property account, where they correctly continue after the mortgage is paid off); health-insurance premiums (Healthcare below); and long-term-care or life-insurance premiums (Insurance). The Results page breaks all of these out in a Spending-by-category chart."
107
+ learn={LEARN.spendingBudget}
108
+ hint="Living costs incl. auto insurance & out-of-pocket medical; exclude mortgage, property tax, premiums."
109
+ value={e.baseAnnual}
110
+ onCommit={(v) =>
111
+ update((d) => {
112
+ d.expenses.baseAnnual = v ?? 0
113
+ if (d.expenses.requiredAnnual !== undefined) {
114
+ d.expenses.requiredAnnual = Math.min(d.expenses.requiredAnnual, d.expenses.baseAnnual)
115
+ }
116
+ })
117
+ }
118
+ />
119
+ <MoneyField
120
+ label="Required floor (today's $)"
121
+ help="The must-fund slice of baseline spending — the least you could live on in a bad market, before any discretionary lifestyle. Only matters when Spending guardrails (below) are on: the guardrail rations the gap between baseline and this floor but never cuts below it. Leave 0 (or equal to baseline) to treat all spending as required, which is today's behavior."
122
+ learn={LEARN.spendingBudget}
123
+ hint="Guardrails never cut below this. 0 = all spending is required."
124
+ value={e.requiredAnnual ?? 0}
125
+ onCommit={(v) =>
126
+ update((d) => {
127
+ if (!v || v <= 0) delete d.expenses.requiredAnnual
128
+ else d.expenses.requiredAnnual = Math.min(v, d.expenses.baseAnnual)
129
+ })
130
+ }
131
+ />
132
+ <MoneyField
133
+ label="Ideal annual upside"
134
+ help="Flexible annual spending above your target lifestyle: extra travel, upgrades, gifts, or other spending you would fund only when the plan is running ahead. Fixed target mode funds it every year; guardrails fund it only after target spending has been restored."
135
+ learn={LEARN.spendingBudget}
136
+ hint="Above baseline target; funded after required and target."
137
+ value={e.idealAnnual ?? 0}
138
+ onCommit={(v) =>
139
+ update((d) => {
140
+ if (!v || v <= 0) delete d.expenses.idealAnnual
141
+ else d.expenses.idealAnnual = v
142
+ })
143
+ }
144
+ />
145
+ <MoneyField
146
+ label="Excess annual upside"
147
+ help="Opportunistic annual spending funded last in strong paths. Use this for lifestyle upside you are comfortable skipping before touching required or target spending."
148
+ learn={LEARN.spendingBudget}
149
+ hint="Funded after ideal; 0 = none."
150
+ value={e.excessAnnual ?? 0}
151
+ onCommit={(v) =>
152
+ update((d) => {
153
+ if (!v || v <= 0) delete d.expenses.excessAnnual
154
+ else d.expenses.excessAnnual = v
155
+ })
156
+ }
157
+ />
158
+ {plan.household.people.length > 1 ? (
159
+ <PercentField
160
+ label="Survivor spending"
161
+ help="Household spending in years when only one of you is alive, as a percent of the couple's spending. Studies of retired couples typically land between 60% and 80% — housing and utilities barely drop, while food, travel, and healthcare for the second person do. Scales baseline + phase spending only; one-time goals, healthcare premiums, debt payments, and property costs keep their own schedules."
162
+ learn={LEARN.survivorSpending}
163
+ hint="100% = no change in survivor years."
164
+ step={5}
165
+ min={0}
166
+ max={100}
167
+ value={e.survivorSpendingPct ?? 100}
168
+ onCommit={(v) =>
169
+ update((d) => {
170
+ if (v === null || v >= 100) delete d.expenses.survivorSpendingPct
171
+ else d.expenses.survivorSpendingPct = Math.max(0, v)
172
+ })
173
+ }
174
+ />
175
+ ) : null}
176
+ <MoneyField
177
+ label="Bequest target (today's $)"
178
+ help="The after-tax estate you want the plan to still leave at the end, in today's dollars. Used as the estate floor by the sustainable-spending solver ('How much can I spend?') and by the estate-floor optimizer objective — it does not change the projection itself. Leave 0 for no target."
179
+ learn={LEARN.sustainableSpending}
180
+ hint="Estate floor for the spending solver and optimizer objectives; 0 = none."
181
+ value={e.bequestTargetDollars ?? 0}
182
+ onCommit={(v) =>
183
+ update((d) => {
184
+ if (!v || v <= 0) delete d.expenses.bequestTargetDollars
185
+ else d.expenses.bequestTargetDollars = v
186
+ })
187
+ }
188
+ />
189
+ </div>
190
+
191
+ <h3>Dynamic spending policy</h3>
192
+ <p className="card-hint">
193
+ Let spending flex with the market instead of holding a fixed budget. Guardrails trim and restore the
194
+ discretionary layer (baseline minus the required floor) — the floor is never cut. Amortized spending
195
+ (ABW, the rule behind VPW and TPAW) goes further: it replaces the baseline entirely, re-computing each
196
+ year&apos;s spending from the actual portfolio and remaining horizon. Applies in Results and Monte Carlo.{' '}
197
+ <LearnLink {...LEARN.spendingBudget} />
198
+ </p>
199
+ <div className="form-grid">
200
+ <SelectField
201
+ label="Spending policy"
202
+ help="Fixed target funds the whole budget every year (today's behavior). Withdrawal-rate guardrails ration the discretionary layer path by path based on how the current withdrawal rate compares to the starting rate. Risk-based guardrails trigger on dollar portfolio thresholds solved from your target probability-of-success band — cut only when the plan's odds actually leave the band, not on the withdrawal rate alone. Amortized spending (ABW) ignores the baseline and phases and spends each year's amortized payment: the actual start-of-year portfolio spread over the remaining horizon at an expected real return, so spending self-corrects after good or bad markets and the portfolio is designed to be spent down by the horizon."
203
+ learn={LEARN.dynamicSpendingGuardrails}
204
+ value={e.spendingPolicy?.mode ?? 'fixedTarget'}
205
+ options={[
206
+ { value: 'fixedTarget', label: 'Fixed target (no guardrails)' },
207
+ { value: 'withdrawalRateGuardrails', label: 'Withdrawal-rate guardrails' },
208
+ { value: 'riskBasedGuardrails', label: 'Risk-based guardrails (success band)' },
209
+ { value: 'abw', label: 'Amortized spending (ABW / VPW)' },
210
+ ]}
211
+ onCommit={(mode) =>
212
+ update((d) => {
213
+ if (mode === 'fixedTarget') delete d.expenses.spendingPolicy
214
+ else d.expenses.spendingPolicy = { ...d.expenses.spendingPolicy, mode }
215
+ })
216
+ }
217
+ />
218
+ {e.spendingPolicy?.mode === 'withdrawalRateGuardrails' ? (
219
+ <>
220
+ <PercentField
221
+ label="Upper guardrail"
222
+ help="Cut discretionary spending when the current withdrawal rate exceeds this percent of the starting rate. A common setting is 120%."
223
+ hint="% of the starting withdrawal rate that triggers a cut."
224
+ learn={LEARN.spendingBudget}
225
+ step={5}
226
+ min={100}
227
+ max={200}
228
+ value={e.spendingPolicy.upperGuardrailPct ?? 120}
229
+ onCommit={(v) => update((d) => void (d.expenses.spendingPolicy!.upperGuardrailPct = v ?? 120))}
230
+ />
231
+ <PercentField
232
+ label="Lower guardrail"
233
+ help="Restore discretionary spending when the current withdrawal rate falls below this percent of the starting rate. A common setting is 80%."
234
+ hint="% of the starting withdrawal rate that allows a raise."
235
+ learn={LEARN.spendingBudget}
236
+ step={5}
237
+ min={0}
238
+ max={100}
239
+ value={e.spendingPolicy.lowerGuardrailPct ?? 80}
240
+ onCommit={(v) => update((d) => void (d.expenses.spendingPolicy!.lowerGuardrailPct = v ?? 80))}
241
+ />
242
+ <PercentField
243
+ label="Adjustment size"
244
+ help="How much of the full discretionary layer each cut or raise moves. A common setting is 10%."
245
+ hint="Cut/raise step, as a % of the discretionary layer."
246
+ learn={LEARN.spendingBudget}
247
+ step={5}
248
+ min={0}
249
+ max={100}
250
+ value={e.spendingPolicy.adjustmentPct ?? 10}
251
+ onCommit={(v) => update((d) => void (d.expenses.spendingPolicy!.adjustmentPct = v ?? 10))}
252
+ />
253
+ <CheckboxField
254
+ label="Allow upside raises"
255
+ help="When enabled, strong paths can restore target spending and then fund ideal/excess annual layers or pull flexible goals earlier within their window. The required floor still stays protected in down markets."
256
+ learn={LEARN.spendingBudget}
257
+ value={e.spendingPolicy.allowRaisesAboveTarget ?? ((e.idealAnnual ?? 0) + (e.excessAnnual ?? 0) > 0 || hasEarlyPullFlexibleGoals)}
258
+ onCommit={(v) => update((d) => void (d.expenses.spendingPolicy!.allowRaisesAboveTarget = v))}
259
+ />
260
+ </>
261
+ ) : null}
262
+ {e.spendingPolicy?.mode === 'riskBasedGuardrails' ? (
263
+ <>
264
+ <PercentField
265
+ label="Cut when success falls below"
266
+ help="The lower edge of your target probability-of-success band. The solver finds the portfolio balance where the plan's Monte Carlo success probability would drop to this level; spending cuts trigger below that dollar threshold."
267
+ hint="Lower edge of the target success band."
268
+ learn={LEARN.riskBasedGuardrails}
269
+ step={5}
270
+ min={1}
271
+ max={99}
272
+ value={e.spendingPolicy.targetSuccessLowerPct ?? 70}
273
+ onCommit={(v) => {
274
+ // The solved thresholds (and any displayed suggestions) belonged to the old band.
275
+ setThresholdSolution(null)
276
+ update((d) => {
277
+ const policy = d.expenses.spendingPolicy!
278
+ // Keep the band ordered: the cut edge must stay below the raise edge.
279
+ policy.targetSuccessLowerPct = Math.min(v ?? 70, (policy.targetSuccessUpperPct ?? 95) - 1)
280
+ delete policy.lowerBalanceThresholdPct
281
+ delete policy.upperBalanceThresholdPct
282
+ })
283
+ }}
284
+ />
285
+ <PercentField
286
+ label="Raise when success rises above"
287
+ help="The upper edge of your target probability-of-success band. Above the balance where success clears this level, discretionary spending can be restored or raised."
288
+ hint="Upper edge of the target success band."
289
+ learn={LEARN.riskBasedGuardrails}
290
+ step={5}
291
+ min={1}
292
+ max={100}
293
+ value={e.spendingPolicy.targetSuccessUpperPct ?? 95}
294
+ onCommit={(v) => {
295
+ setThresholdSolution(null)
296
+ update((d) => {
297
+ const policy = d.expenses.spendingPolicy!
298
+ policy.targetSuccessUpperPct = Math.max(v ?? 95, (policy.targetSuccessLowerPct ?? 70) + 1)
299
+ delete policy.lowerBalanceThresholdPct
300
+ delete policy.upperBalanceThresholdPct
301
+ })
302
+ }}
303
+ />
304
+ <PercentField
305
+ label="Adjustment size"
306
+ help="How much of the full discretionary layer each cut or raise moves. A common setting is 10%."
307
+ hint="Cut/raise step, as a % of the discretionary layer."
308
+ learn={LEARN.riskBasedGuardrails}
309
+ step={5}
310
+ min={0}
311
+ max={100}
312
+ value={e.spendingPolicy.adjustmentPct ?? 10}
313
+ onCommit={(v) => update((d) => void (d.expenses.spendingPolicy!.adjustmentPct = v ?? 10))}
314
+ />
315
+ <CheckboxField
316
+ label="Allow upside raises"
317
+ help="When enabled, strong paths can restore target spending and then fund ideal/excess annual layers or pull flexible goals earlier within their window. The required floor still stays protected in down markets."
318
+ learn={LEARN.riskBasedGuardrails}
319
+ value={e.spendingPolicy.allowRaisesAboveTarget ?? ((e.idealAnnual ?? 0) + (e.excessAnnual ?? 0) > 0 || hasEarlyPullFlexibleGoals)}
320
+ onCommit={(v) => update((d) => void (d.expenses.spendingPolicy!.allowRaisesAboveTarget = v))}
321
+ />
322
+ </>
323
+ ) : null}
324
+ {e.spendingPolicy?.mode === 'abw' ? (
325
+ <>
326
+ <SelectField
327
+ label="Expected return source"
328
+ help="Where the amortization's expected real return comes from. Fixed uses the rate you enter (the VPW preset's approach — its published global returns, 60/40 weighted, are about 3.8%/yr real). CAPE conditions on valuations: expected stock return = 100 ÷ CAPE (the cyclically-adjusted earnings yield), blended with the bond yield at your stock share — richer valuations mean lower planned spending. TIPS yield prices the whole portfolio at a real bond yield — the most conservative reading."
329
+ learn={LEARN.spendingBudget}
330
+ value={e.spendingPolicy.abw?.returnSource ?? 'fixed'}
331
+ options={[
332
+ { value: 'fixed', label: 'Fixed real return (VPW-style)' },
333
+ { value: 'cape', label: 'CAPE earnings yield (valuation-aware)' },
334
+ { value: 'tips', label: 'TIPS real yield (most conservative)' },
335
+ ]}
336
+ onCommit={(v) => setAbw((abw) => void (abw.returnSource = v))}
337
+ />
338
+ {(e.spendingPolicy.abw?.returnSource ?? 'fixed') === 'fixed' ? (
339
+ <PercentField
340
+ label="Expected real return"
341
+ help="Expected portfolio return per year above inflation, used to amortize the balance over the remaining horizon. The Bogleheads VPW table's global internal rates of return (stocks 5.0%, bonds 1.9% real) weighted 60/40 give about 3.8%. Higher values front-load spending and risk deeper later cuts if markets disappoint."
342
+ hint="%/yr above inflation. VPW 60/40 ≈ 3.8%."
343
+ learn={LEARN.spendingBudget}
344
+ step={0.1}
345
+ min={-5}
346
+ max={12}
347
+ value={e.spendingPolicy.abw?.fixedRealReturnPct ?? 3.8}
348
+ onCommit={(v) => setAbw((abw) => void (abw.fixedRealReturnPct = v ?? 3.8))}
349
+ />
350
+ ) : null}
351
+ {e.spendingPolicy.abw?.returnSource === 'cape' ? (
352
+ <>
353
+ <NumberField
354
+ label="Starting CAPE"
355
+ help="The cyclically-adjusted price/earnings ratio used for the expected stock return (100 ÷ CAPE). Around 25 matches this app's CAPE-conditioned market model default; check a current published CAPE for today's value."
356
+ hint="Expected stock return = 100 ÷ CAPE."
357
+ learn={LEARN.spendingBudget}
358
+ step={1}
359
+ min={5}
360
+ max={60}
361
+ value={e.spendingPolicy.abw?.startingCape ?? 25}
362
+ onCommit={(v) => setAbw((abw) => void (abw.startingCape = v ?? 25))}
363
+ />
364
+ <PercentField
365
+ label="Stock share"
366
+ help="How much of the portfolio is priced at the CAPE earnings yield; the rest is priced at the real bond yield below."
367
+ hint="Blends the CAPE yield with the bond yield."
368
+ learn={LEARN.spendingBudget}
369
+ step={5}
370
+ min={0}
371
+ max={100}
372
+ value={e.spendingPolicy.abw?.equitySharePct ?? 60}
373
+ onCommit={(v) => setAbw((abw) => void (abw.equitySharePct = v ?? 60))}
374
+ />
375
+ </>
376
+ ) : null}
377
+ {e.spendingPolicy.abw?.returnSource === 'cape' || e.spendingPolicy.abw?.returnSource === 'tips' ? (
378
+ <PercentField
379
+ label="Real bond/TIPS yield"
380
+ help="The real (above-inflation) bond yield: the whole portfolio under the TIPS source, or the non-stock share under CAPE. Long TIPS real yields were near 2% in mid-2026; check the current curve."
381
+ hint="%/yr above inflation; ~2% in mid-2026."
382
+ learn={LEARN.spendingBudget}
383
+ step={0.1}
384
+ min={-2}
385
+ max={8}
386
+ value={e.spendingPolicy.abw?.bondRealYieldPct ?? 2}
387
+ onCommit={(v) => setAbw((abw) => void (abw.bondRealYieldPct = v ?? 2))}
388
+ />
389
+ ) : null}
390
+ <SelectField
391
+ label="Amortize to"
392
+ help="The horizon the balance is spread over. Planning age uses the household's plan horizon. The survival percentiles amortize to the age you (for couples: either of you) have a 25% or 10% chance of reaching — the unadjusted SSA life table, with no health-questionnaire adjustment even if your planning age used one — a shorter, spendier horizon than a conservative planning age."
393
+ learn={LEARN.longevity}
394
+ value={e.spendingPolicy.abw?.horizon ?? 'planningAge'}
395
+ options={[
396
+ { value: 'planningAge', label: 'Planning age (plan horizon)' },
397
+ { value: 'survival25', label: 'Age with 25% chance of reaching' },
398
+ { value: 'survival10', label: 'Age with 10% chance of reaching' },
399
+ ]}
400
+ onCommit={(v) => setAbw((abw) => void (abw.horizon = v))}
401
+ />
402
+ <PercentField
403
+ label="Spending tilt"
404
+ help="Planned real change in spending per year. Negative front-loads spending into early retirement (consistent with the observed decline in real retiree spending); positive defers it. 0 plans level real spending."
405
+ hint="−1 to −1.5%/yr matches observed spending declines."
406
+ learn={LEARN.spendingProfiles}
407
+ step={0.5}
408
+ min={-5}
409
+ max={5}
410
+ value={e.spendingPolicy.abw?.tiltPct ?? 0}
411
+ onCommit={(v) => setAbw((abw) => void (abw.tiltPct = v ?? 0))}
412
+ />
413
+ </>
414
+ ) : null}
415
+ </div>
416
+ {e.spendingPolicy?.mode === 'abw' ? (
417
+ <div className="callout callout--info">
418
+ <p className="card-hint">
419
+ Amortized spending replaces the baseline, retirement phases, and required/ideal/excess layers: each
420
+ year&apos;s recurring lifestyle spending is the amortized payment from the actual start-of-year
421
+ portfolio. Healthcare, debt payments, property costs, insurance premiums, and one-time goals stay
422
+ separately modeled on top. Because the payment is recomputed every year, spending self-corrects after
423
+ market surprises instead of failing — the trade-off is a variable budget.{' '}
424
+ <button
425
+ type="button"
426
+ className="btn btn-secondary btn-small"
427
+ onClick={() =>
428
+ update((d) => {
429
+ d.expenses.spendingPolicy = {
430
+ mode: 'abw',
431
+ abw: { returnSource: 'fixed', fixedRealReturnPct: 3.8, horizon: 'planningAge', tiltPct: 0 },
432
+ }
433
+ })
434
+ }
435
+ >
436
+ Apply the VPW preset
437
+ </button>
438
+ </p>
439
+ </div>
440
+ ) : null}
441
+ {e.spendingPolicy?.mode === 'riskBasedGuardrails' ? (
442
+ <div className="callout callout--info">
443
+ {e.spendingPolicy.lowerBalanceThresholdPct !== undefined ||
444
+ e.spendingPolicy.upperBalanceThresholdPct !== undefined ? (
445
+ <p className="card-hint">
446
+ Solved dollar guardrails for the {e.spendingPolicy.targetSuccessLowerPct ?? 70}–
447
+ {e.spendingPolicy.targetSuccessUpperPct ?? 95}% success band:{' '}
448
+ {e.spendingPolicy.lowerBalanceThresholdPct !== undefined ? (
449
+ <>
450
+ cut spending if the portfolio falls below{' '}
451
+ <strong>{fmtMoney((e.spendingPolicy.lowerBalanceThresholdPct / 100) * startingInvestableOf(plan))}</strong>
452
+ </>
453
+ ) : (
454
+ <>no cut threshold was solved for this band</>
455
+ )}
456
+ {'; '}
457
+ {e.spendingPolicy.upperBalanceThresholdPct !== undefined ? (
458
+ <>
459
+ raise if it rises above{' '}
460
+ <strong>{fmtMoney((e.spendingPolicy.upperBalanceThresholdPct / 100) * startingInvestableOf(plan))}</strong>
461
+ </>
462
+ ) : (
463
+ <>no raise threshold was solved for this band</>
464
+ )}
465
+ . Thresholds are in today's dollars, solved under the standard smooth-randomness market model
466
+ (12% return volatility, 60/40 weighting) with your plan's inflation — custom Monte Carlo page
467
+ model settings are not reflected here. Re-solve after meaningful plan changes.
468
+ </p>
469
+ ) : (
470
+ <p className="card-hint">
471
+ No dollar thresholds solved yet — until they are computed, this policy holds spending steady (it
472
+ behaves like fixed target). Solving runs a bounded Monte Carlo search in the background under the
473
+ standard smooth-randomness market model.
474
+ </p>
475
+ )}
476
+ {thresholdSolution?.lowerOutcome === 'never-reaches-band' ? (
477
+ <p className="card-hint">
478
+ <strong>Heads up:</strong> the plan's success probability stays below your{' '}
479
+ {thresholdSolution.lowerBandPct}% cut edge even with several times the current portfolio, so no
480
+ cut threshold exists — the plan is underfunded for this band, not safe. Consider lower target
481
+ spending or a lower band.
482
+ </p>
483
+ ) : null}
484
+ {thresholdSolution?.lowerOutcome === 'always-above-band' ? (
485
+ <p className="card-hint">
486
+ The plan stays above the {thresholdSolution.lowerBandPct}% cut edge even at very low balances
487
+ (guaranteed income carries it), so no cut trigger is needed.
488
+ </p>
489
+ ) : null}
490
+ {thresholdSolution?.suggestedCut || thresholdSolution?.suggestedRaise ? (
491
+ <p className="card-hint">
492
+ {thresholdSolution.suggestedCut ? (
493
+ <>
494
+ At the cut threshold, trimming about{' '}
495
+ <strong>{fmtMoney(thresholdSolution.suggestedCut.monthlyDollars)}/mo</strong> restores the middle of
496
+ the band.{' '}
497
+ </>
498
+ ) : null}
499
+ {thresholdSolution.suggestedRaise ? (
500
+ <>
501
+ At the raise threshold, roughly{' '}
502
+ <strong>{fmtMoney(thresholdSolution.suggestedRaise.monthlyDollars)}/mo</strong> of extra spending
503
+ still keeps the plan above the middle of the band.
504
+ </>
505
+ ) : null}
506
+ </p>
507
+ ) : null}
508
+ <button
509
+ type="button"
510
+ className="btn btn-secondary btn-small"
511
+ disabled={solvingThresholds}
512
+ onClick={solveThresholds}
513
+ >
514
+ {solvingThresholds
515
+ ? 'Solving thresholds…'
516
+ : e.spendingPolicy.lowerBalanceThresholdPct !== undefined ||
517
+ e.spendingPolicy.upperBalanceThresholdPct !== undefined
518
+ ? 'Re-solve dollar thresholds'
519
+ : 'Solve dollar thresholds'}
520
+ </button>
521
+ {thresholdSolveError ? <p className="card-hint error-text">{thresholdSolveError}</p> : null}
522
+ </div>
523
+ ) : null}
524
+
525
+ <h3>Retirement phases</h3>
526
+ <p className="card-hint">
527
+ Spending multipliers by the primary person's age (go-go / slow-go / no-go).{' '}
528
+ <LearnLink {...LEARN.spendingProfiles} />
529
+ </p>
530
+ {e.phases.map((p, i) => (
531
+ <div className="item-row" key={i}>
532
+ <div className="item-row-head">
533
+ <span className="item-row-title"><span className="type-chip">Phase</span>from age {p.fromAge}</span>
534
+ <button type="button" className="btn-ghost btn-ghost-danger" onClick={() => update((d) => void d.expenses.phases.splice(i, 1))}>Remove</button>
535
+ </div>
536
+ <div className="form-grid">
537
+ <NumberField
538
+ label="From age"
539
+ help="The first age when this phase applies, using the primary person's age as the clock."
540
+ learn={LEARN.spendingProfiles}
541
+ value={p.fromAge}
542
+ min={40}
543
+ max={110}
544
+ onCommit={(v) => update((d) => void (d.expenses.phases[i]!.fromAge = Math.round(v ?? 65)))}
545
+ />
546
+ <NumberField
547
+ label="Multiplier"
548
+ help="Multiplies baseline spending from this age forward. For example, 0.90 means recurring lifestyle spending is 10% lower before inflation."
549
+ hint="1.00 = no change."
550
+ learn={LEARN.spendingProfiles}
551
+ step={0.05}
552
+ value={p.multiplier}
553
+ min={0}
554
+ max={3}
555
+ onCommit={(v) => update((d) => void (d.expenses.phases[i]!.multiplier = v ?? 1))}
556
+ />
557
+ </div>
558
+ </div>
559
+ ))}
560
+ <div className="add-row">
561
+ <button type="button" className="btn btn-secondary btn-small" onClick={() => update((d) => void d.expenses.phases.push({ fromAge: 75, multiplier: 0.9 }))}>+ Phase</button>
562
+ </div>
563
+ <p className="field-hint" style={{ margin: '0.6rem 0 0.25rem' }}>
564
+ Profiles write ordinary phase rows you can edit afterwards{e.phases.length > 0 ? ' (replacing the phases above)' : ''}:{' '}<LearnLink {...LEARN.spendingProfiles} />
565
+ </p>
566
+ <div className="add-row">
567
+ <button type="button" className="btn btn-secondary btn-small" onClick={() => applyProfile('flat')}>
568
+ Constant-real (no phases)
569
+ </button>
570
+ <button type="button" className="btn btn-secondary btn-small" onClick={() => applyProfile('smile')}>
571
+ Retirement smile (−10% at 75, −20% at 85)
572
+ </button>
573
+ <button type="button" className="btn btn-secondary btn-small" onClick={() => applyProfile('smirk')}>
574
+ Retirement smirk (−1%/yr real)
575
+ </button>
576
+ <button type="button" className="btn btn-secondary btn-small" onClick={() => applyProfile('frontLoaded')}>
577
+ Front-loaded travel (+10% until 75)
578
+ </button>
579
+ </div>
580
+ <p className="field-hint" style={{ margin: '0.6rem 0 0.25rem' }}>
581
+ The smile is the shape of <em>average</em> retiree spending — a decline that late healthcare partly
582
+ reverses (the preset approximates it as two downward steps); the smirk is the <em>median</em> — a steady
583
+ real decline with no late rise, per Blanchett&apos;s spending research. Or pick your own annual real
584
+ drift, compiled to 5-year phase steps:
585
+ </p>
586
+ <div className="add-row" style={{ alignItems: 'end' }}>
587
+ <PercentField
588
+ label="Custom real change per year"
589
+ help="Your own steady real spending drift, applied from retirement and compiled into 5-year phase steps (the compounded multiplier at each step age, to age 100). Research on actual retirees clusters around −1% to −2%/yr; a positive value plans rising real spending."
590
+ hint="Negative = declining real spending."
591
+ learn={LEARN.spendingProfiles}
592
+ step={0.5}
593
+ min={-5}
594
+ max={5}
595
+ value={customDeltaPct}
596
+ onCommit={(v) => setCustomDeltaPct(v ?? -1.5)}
597
+ />
598
+ <button type="button" className="btn btn-secondary btn-small" onClick={applyCustomDelta}>
599
+ Apply custom shape
600
+ </button>
601
+ </div>
602
+
603
+ <h3>One-time goals</h3>
604
+ <p className="card-hint">
605
+ Big, named purchases or gifts that should happen once instead of becoming part of everyday spending.{' '}
606
+ <LearnLink {...LEARN.spendingBudget} />
607
+ </p>
608
+ <div className="add-row">
609
+ <button
610
+ type="button"
611
+ className="btn btn-secondary btn-small"
612
+ onClick={() =>
613
+ update((d) => {
614
+ const targetYear = new Date().getFullYear() + 3
615
+ d.expenses.oneTimeGoals.push({
616
+ id: newId(),
617
+ label: 'Car replacement',
618
+ year: targetYear,
619
+ amount: 35_000,
620
+ classification: 'target',
621
+ flexibility: 'movable',
622
+ earliestYear: targetYear - 2,
623
+ latestYear: targetYear,
624
+ })
625
+ })
626
+ }
627
+ >
628
+ Car replacement
629
+ </button>
630
+ <button
631
+ type="button"
632
+ className="btn btn-secondary btn-small"
633
+ onClick={() =>
634
+ update((d) => {
635
+ const targetYear = new Date().getFullYear() + 2
636
+ d.expenses.oneTimeGoals.push({
637
+ id: newId(),
638
+ label: 'Home improvement',
639
+ year: targetYear,
640
+ amount: 50_000,
641
+ classification: 'ideal',
642
+ flexibility: 'movable',
643
+ earliestYear: targetYear,
644
+ latestYear: targetYear + 2,
645
+ allowPartialFunding: true,
646
+ minFundingPct: 50,
647
+ })
648
+ })
649
+ }
650
+ >
651
+ Home improvement
652
+ </button>
653
+ <button
654
+ type="button"
655
+ className="btn btn-secondary btn-small"
656
+ onClick={() =>
657
+ update((d) => {
658
+ const targetYear = new Date().getFullYear() + 1
659
+ d.expenses.oneTimeGoals.push({
660
+ id: newId(),
661
+ label: 'Big trip',
662
+ year: targetYear,
663
+ amount: 12_000,
664
+ classification: 'excess',
665
+ flexibility: 'skippable',
666
+ earliestYear: targetYear,
667
+ latestYear: targetYear + 1,
668
+ })
669
+ })
670
+ }
671
+ >
672
+ Big trip
673
+ </button>
674
+ </div>
675
+ {e.oneTimeGoals.map((g, i) => (
676
+ <div className="item-row" key={g.id}>
677
+ <div className="item-row-head">
678
+ <span className="item-row-title"><span className="type-chip">Goal</span>{g.label}</span>
679
+ <button type="button" className="btn-ghost btn-ghost-danger" onClick={() => update((d) => void d.expenses.oneTimeGoals.splice(i, 1))}>Remove</button>
680
+ </div>
681
+ <div className="form-grid">
682
+ <TextField
683
+ label="Label"
684
+ help="Name the goal so it is recognizable in Results, such as travel, car replacement, wedding gift, or home project."
685
+ learn={LEARN.spendingBudget}
686
+ value={g.label}
687
+ onCommit={(v) => update((d) => void (d.expenses.oneTimeGoals[i]!.label = v || 'Goal'))}
688
+ />
689
+ <NumberField
690
+ label="Year"
691
+ help="The calendar year the goal is funded. The amount is inflated from today's dollars to that year."
692
+ learn={LEARN.spendingBudget}
693
+ value={g.year}
694
+ min={1900}
695
+ max={2200}
696
+ onCommit={(v) =>
697
+ update((d) => {
698
+ const goal = d.expenses.oneTimeGoals[i]!
699
+ goal.year = Math.round(v ?? g.year)
700
+ if (goal.earliestYear !== undefined) goal.earliestYear = Math.min(goal.earliestYear, goal.year)
701
+ if (goal.latestYear !== undefined) goal.latestYear = Math.max(goal.latestYear, goal.year)
702
+ })
703
+ }
704
+ />
705
+ <MoneyField
706
+ label="Amount (today's $)"
707
+ help="The one-time cost in today's dollars. Keep recurring lifestyle costs in baseline spending instead."
708
+ learn={LEARN.spendingBudget}
709
+ value={g.amount}
710
+ onCommit={(v) => update((d) => void (d.expenses.oneTimeGoals[i]!.amount = v ?? 0))}
711
+ />
712
+ <SelectField<SpendingClassification>
713
+ label="Layer"
714
+ help="Required goals are part of the must-fund floor. Target goals are intended lifestyle. Ideal and excess goals are flexible upside funded after target spending."
715
+ learn={LEARN.spendingBudget}
716
+ value={g.classification ?? 'target'}
717
+ options={[
718
+ { value: 'required', label: 'Required' },
719
+ { value: 'target', label: 'Target' },
720
+ { value: 'ideal', label: 'Ideal' },
721
+ { value: 'excess', label: 'Excess' },
722
+ ]}
723
+ onCommit={(classification) =>
724
+ update((d) => {
725
+ const goal = d.expenses.oneTimeGoals[i]!
726
+ if (classification === 'target') delete goal.classification
727
+ else goal.classification = classification
728
+ })
729
+ }
730
+ />
731
+ <SelectField
732
+ label="Flexibility"
733
+ help="Fixed happens in its year no matter what. Movable funds in its year normally, but is delayed up to the latest year while guardrails are cutting discretionary spending. Skippable is the same but dropped entirely if it is still unaffordable at the latest year. Only matters when Spending guardrails are on."
734
+ learn={LEARN.spendingBudget}
735
+ value={g.flexibility ?? 'fixed'}
736
+ options={[
737
+ { value: 'fixed', label: 'Fixed (happens in its year)' },
738
+ { value: 'movable', label: 'Movable (delay under a cut)' },
739
+ { value: 'skippable', label: 'Skippable (drop if unaffordable)' },
740
+ ]}
741
+ onCommit={(flex: GoalFlexibility) =>
742
+ update((d) => {
743
+ const goal = d.expenses.oneTimeGoals[i]!
744
+ if (flex === 'fixed') {
745
+ delete goal.flexibility
746
+ delete goal.earliestYear
747
+ delete goal.latestYear
748
+ delete goal.priority
749
+ delete goal.minFundingPct
750
+ delete goal.allowPartialFunding
751
+ } else {
752
+ goal.flexibility = flex
753
+ goal.earliestYear ??= goal.year
754
+ goal.latestYear ??= goal.year
755
+ }
756
+ })
757
+ }
758
+ />
759
+ {g.flexibility && g.flexibility !== 'fixed' ? (
760
+ <>
761
+ <NumberField
762
+ label="Earliest year"
763
+ help="The earliest year a strong guardrail path may pull this goal forward. Leave equal to the target year to prevent acceleration."
764
+ learn={LEARN.spendingBudget}
765
+ value={g.earliestYear ?? g.year}
766
+ min={1900}
767
+ max={2200}
768
+ onCommit={(v) =>
769
+ update((d) => void (d.expenses.oneTimeGoals[i]!.earliestYear = Math.min(Math.round(v ?? g.year), g.year)))
770
+ }
771
+ />
772
+ <NumberField
773
+ label="Latest year"
774
+ help="The latest year this goal may be delayed to. If it cannot be funded by then, the unfunded amount is reported as a layer shortfall."
775
+ learn={LEARN.spendingBudget}
776
+ value={g.latestYear ?? g.year}
777
+ min={1900}
778
+ max={2200}
779
+ onCommit={(v) =>
780
+ update((d) => void (d.expenses.oneTimeGoals[i]!.latestYear = Math.max(Math.round(v ?? g.year), g.year)))
781
+ }
782
+ />
783
+ <NumberField
784
+ label="Priority"
785
+ help="Lower numbers fund first within the same spending layer. Required goals still outrank target, ideal, and excess goals."
786
+ learn={LEARN.spendingBudget}
787
+ value={g.priority ?? i}
788
+ min={0}
789
+ max={999}
790
+ onCommit={(v) => update((d) => void (d.expenses.oneTimeGoals[i]!.priority = Math.round(v ?? i)))}
791
+ />
792
+ <CheckboxField
793
+ label="Allow partial funding"
794
+ help="When enabled, a flexible goal can resolve with less than the full amount if the hard flexible-goal budget clears the minimum funding percent."
795
+ learn={LEARN.spendingBudget}
796
+ value={g.allowPartialFunding ?? false}
797
+ onCommit={(v) =>
798
+ update((d) => {
799
+ const goal = d.expenses.oneTimeGoals[i]!
800
+ if (!v) {
801
+ delete goal.allowPartialFunding
802
+ delete goal.minFundingPct
803
+ } else {
804
+ goal.allowPartialFunding = true
805
+ goal.minFundingPct ??= 50
806
+ }
807
+ })
808
+ }
809
+ />
810
+ {g.allowPartialFunding ? (
811
+ <PercentField
812
+ label="Minimum funding"
813
+ help="The smallest percent of the goal that must be available before RetireGolden records it as partially funded instead of deferred or skipped."
814
+ learn={LEARN.spendingBudget}
815
+ step={5}
816
+ min={0}
817
+ max={95}
818
+ value={g.minFundingPct ?? 50}
819
+ onCommit={(v) =>
820
+ update((d) => void (d.expenses.oneTimeGoals[i]!.minFundingPct = Math.min(95, Math.max(0, v ?? 50))))
821
+ }
822
+ />
823
+ ) : null}
824
+ </>
825
+ ) : null}
826
+ </div>
827
+ </div>
828
+ ))}
829
+ <div className="add-row">
830
+ <button
831
+ type="button"
832
+ className="btn btn-secondary btn-small"
833
+ onClick={() => update((d) => void d.expenses.oneTimeGoals.push({ id: newId(), label: 'New goal', year: new Date().getFullYear() + 2, amount: 10_000 }))}
834
+ >
835
+ + Goal
836
+ </button>
837
+ </div>
838
+
839
+ <h3>Healthcare</h3>
840
+ <div className="form-grid">
841
+ <MoneyField
842
+ label="Pre-65 premium / person / month"
843
+ help="Enter the full unsubsidized monthly premium before any ACA credit. As a rough 2026 check, KFF's national benchmark Silver premium for a 40-year-old is $625/month, but older retirees and local quotes can differ a lot."
844
+ learn={LEARN.healthcareBefore65}
845
+ hint="Full (unsubsidized) marketplace premium."
846
+ value={e.healthcare.pre65MonthlyPremiumPerPerson}
847
+ onCommit={(v) => update((d) => void (d.expenses.healthcare.pre65MonthlyPremiumPerPerson = v ?? 0))}
848
+ />
849
+ <MoneyField
850
+ label="Medicare extras / person / month"
851
+ help="Enter recurring post-65 coverage costs beyond standard Part B: Part D, Medigap, Medicare Advantage, dental, vision, or similar premiums. RetireGolden adds the 2026 Part B base premium ($202.90/month) and IRMAA separately."
852
+ learn={LEARN.healthcareAfter65}
853
+ hint="Part D, Medigap/Advantage; Part B + IRMAA added automatically."
854
+ value={e.healthcare.medicareExtrasMonthlyPerPerson}
855
+ onCommit={(v) => update((d) => void (d.expenses.healthcare.medicareExtrasMonthlyPerPerson = v ?? 0))}
856
+ />
857
+ <CheckboxField
858
+ label="Apply ACA premium credit"
859
+ help="Applies a simplified ACA premium tax credit before Medicare eligibility, based on household size and MAGI. Use only for Marketplace coverage that can receive the credit."
860
+ learn={LEARN.acaCredit}
861
+ hint="Models the 400% FPL cliff against your MAGI."
862
+ value={e.healthcare.applyAcaCredit}
863
+ onCommit={(v) => update((d) => void (d.expenses.healthcare.applyAcaCredit = v))}
864
+ />
865
+ {plan.household.filingStatus === 'marriedFilingJointly' && plan.household.people.length === 2 ? (
866
+ <CheckboxField
867
+ label="Model SSA-44 IRMAA relief in survivor years"
868
+ help="Form SSA-44 lets a surviving spouse ask Social Security to base Medicare's income surcharge (IRMAA) on the current year's lower income instead of the usual two-year lookback. This models the effect of a granted redetermination in the two years after the first death; filing the form itself is up to you."
869
+ learn={LEARN.ssa44}
870
+ hint="Death of spouse is a qualifying life-changing event."
871
+ value={e.healthcare.ssa44?.survivorYears ?? false}
872
+ onCommit={(v) =>
873
+ update((d) => {
874
+ const prior = d.expenses.healthcare.ssa44
875
+ d.expenses.healthcare.ssa44 = { survivorYears: v, retirementYears: prior?.retirementYears ?? false }
876
+ })
877
+ }
878
+ />
879
+ ) : null}
880
+ <CheckboxField
881
+ label="Model SSA-44 IRMAA relief in retirement years"
882
+ help="Stopping work is also an SSA-44 qualifying event. This applies the same redetermination in the two years after each person's retirement year, when the lookback still reflects working income."
883
+ learn={LEARN.ssa44}
884
+ hint="Work stoppage is a qualifying life-changing event."
885
+ value={e.healthcare.ssa44?.retirementYears ?? false}
886
+ onCommit={(v) =>
887
+ update((d) => {
888
+ const prior = d.expenses.healthcare.ssa44
889
+ d.expenses.healthcare.ssa44 = { survivorYears: prior?.survivorYears ?? false, retirementYears: v }
890
+ })
891
+ }
892
+ />
893
+ </div>
894
+ <Issues />
895
+ </div>
896
+ <LearnAboutScreen route="/plan/:planId/spending" />
897
+ </section>
898
+ )
899
+ }
900
+
901
+ // ---------------------------------------------------------------------------
902
+ // Strategy
903
+ // ---------------------------------------------------------------------------
904
+