@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,611 @@
1
+ /**
2
+ * Optimize tab (roadmap V8): runs the multi-year withdrawal/Roth optimizer in a
3
+ * Web Worker, then shows the proposed conversion schedule and its effect on the
4
+ * after-tax estate versus the plan's current strategy — measured on the EXACT
5
+ * ledger (the worker's MILP is a linearisation; these headline numbers come from
6
+ * re-running `simulate` with the proposed schedule, per V8 §3.1). The result is
7
+ * advisory: "Apply" installs it as an optimized strategy, "Accept as manual"
8
+ * rewrites it as an editable manual schedule.
9
+ */
10
+
11
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
12
+ import { Link } from 'react-router-dom'
13
+ import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'
14
+
15
+ import type { OptimizedSchedule } from '@retiregolden/engine/strategies/optimizer'
16
+ import { objectivePolicies, type ObjectivePolicyId } from '@retiregolden/engine/decisions'
17
+ import {
18
+ type ExactLedgerRecommendationState,
19
+ type ExactLedgerValidation,
20
+ withOptimizedConversions,
21
+ } from '@retiregolden/engine/projection/optimizePlan'
22
+ import { DEFAULT_PATH_COUNT, runMonteCarlo } from '../mc/pool'
23
+ import type { OptimizeResult } from '../optimize/messages'
24
+ import { runOptimize } from '../optimize/runner'
25
+ import { downloadStandaloneReport } from '../report/downloadReport'
26
+ import { useReportBranding } from '../report/brandingContext'
27
+ import { reportEvidenceFromOptimizeResult } from '../report/reportHtml'
28
+ import { usePlan } from './planContextCore'
29
+ import { WhyRecommendationPanel } from './explainPanels'
30
+ import { CheckboxField, HelpTip, SelectField } from './fields'
31
+ import { LearnAboutScreen } from '../learn/LearnAboutScreen'
32
+ import { fmtMoney, fmtMoneyCompact } from './format'
33
+ import { LEARN } from './learnLinks'
34
+ import { buildOptimizeChartRows, shouldShowRecommendedScheduleBars } from './optimizePageChart'
35
+ import { applyOptimizeRecommendation, claimEstateGain, planWithWinningClaim } from './optimizePageClaim'
36
+ import { currentStartYear, projectPlan, seedFromPlanId } from './useProjection'
37
+ import { chartTooltipStyle } from './chartStyle'
38
+
39
+ function DeltaStat({
40
+ label,
41
+ value,
42
+ tone,
43
+ help,
44
+ }: {
45
+ label: string
46
+ value: string
47
+ tone: 'good' | 'bad' | 'neutral'
48
+ help: string
49
+ }) {
50
+ return (
51
+ <div className="card">
52
+ <span className="field-label-row">
53
+ <span className="field-label">{label}</span>
54
+ <HelpTip text={help} />
55
+ </span>
56
+ <div className={`stat-value stat-value--${tone}`}>{value}</div>
57
+ </div>
58
+ )
59
+ }
60
+
61
+ function recommendationHeading(validation: ExactLedgerValidation): string {
62
+ switch (validation.recommendationState) {
63
+ case 'beneficial':
64
+ return `Up to ${fmtMoney(validation.afterTaxEstateDelta)} more for your heirs.`
65
+ case 'neutral':
66
+ return 'The optimizer matches your current strategy.'
67
+ case 'rejected':
68
+ return 'This lower-tax schedule is not recommended.'
69
+ case 'unexecutable':
70
+ return 'This conversion schedule is mostly theoretical.'
71
+ }
72
+ }
73
+
74
+ function recommendationBody(validation: ExactLedgerValidation): string {
75
+ const requested = fmtMoney(validation.requestedConversionTotal)
76
+ const executed = fmtMoney(validation.executedConversionTotal)
77
+ const from = fmtMoneyCompact(validation.baseline.endingAfterTaxEstate)
78
+ const to = fmtMoneyCompact(validation.candidate.endingAfterTaxEstate)
79
+ const taxPhrase =
80
+ validation.lifetimeTaxDelta < 0
81
+ ? `lowers lifetime tax by ${fmtMoney(Math.abs(validation.lifetimeTaxDelta))}`
82
+ : validation.lifetimeTaxDelta > 0
83
+ ? `raises lifetime tax by ${fmtMoney(validation.lifetimeTaxDelta)}`
84
+ : 'leaves lifetime tax unchanged'
85
+
86
+ switch (validation.recommendationState) {
87
+ case 'beneficial':
88
+ return `Converting ${requested} raises your projected after-tax estate from ${from} to ${to}.`
89
+ case 'neutral':
90
+ return `Converting ${requested} leaves your projected after-tax estate essentially unchanged at ${to}.`
91
+ case 'rejected':
92
+ return `Converting ${requested} ${taxPhrase}, but your projected after-tax estate moves from ${from} to ${to}.`
93
+ case 'unexecutable':
94
+ return `The optimizer proposed converting ${requested}, but only ${executed} could actually be converted — the traditional balance it counted on is not available in the plan years shown.`
95
+ }
96
+ }
97
+
98
+ function stateColor(state: ExactLedgerRecommendationState): string {
99
+ if (state === 'beneficial') return 'var(--good)'
100
+ if (state === 'rejected' || state === 'unexecutable') return 'var(--bad)'
101
+ return 'var(--fg)'
102
+ }
103
+
104
+ function formatPct(value: number): string {
105
+ return `${Math.round(value * 100)}%`
106
+ }
107
+
108
+ function totalScheduleConversions(schedule: OptimizedSchedule | null): number {
109
+ return schedule?.conversions.reduce((a, c) => a + c.amount, 0) ?? 0
110
+ }
111
+
112
+ /**
113
+ * Objectives offered on this surface. `max-sustainable-spending` is excluded:
114
+ * conversion candidates never change base spending, so it cannot separate them
115
+ * — that objective lives on the "How much can I spend?" page instead.
116
+ */
117
+ const OBJECTIVE_CHOICES: ReadonlyArray<{ value: ObjectivePolicyId; label: string }> = [
118
+ 'max-after-tax-estate',
119
+ 'max-spending-durability',
120
+ 'min-lifetime-tax-estate-floor',
121
+ 'protect-survivor-liquidity',
122
+ 'bridge-durability',
123
+ 'max-downside-resilience',
124
+ ].map((id) => ({ value: id as ObjectivePolicyId, label: objectivePolicies[id as ObjectivePolicyId].label }))
125
+
126
+ export function OptimizePage() {
127
+ const { plan, update } = usePlan()
128
+ const reportBranding = useReportBranding()
129
+ const startYear = currentStartYear()
130
+
131
+ const [optimizeResult, setOptimizeResult] = useState<OptimizeResult | null>(null)
132
+ const [running, setRunning] = useState(false)
133
+ const [error, setError] = useState<string | null>(null)
134
+ const [mcRate, setMcRate] = useState<number | null>(null)
135
+ const [objectiveId, setObjectiveId] = useState<ObjectivePolicyId>('max-after-tax-estate')
136
+ // Default-off: the claim grid re-runs the full optimizer for every claim
137
+ // combination (up to ~7×), so the user opts into the extra runtime per session.
138
+ const [coOptimizeClaim, setCoOptimizeClaim] = useState(false)
139
+ const runToken = useRef(0)
140
+
141
+ const hasSocialSecurityIncome = plan.incomes.some((s) => s.type === 'socialSecurity')
142
+ const coOptimizeRequested = coOptimizeClaim && hasSocialSecurityIncome
143
+
144
+ // Derived-state-during-render (same pattern as fields.tsx useLocalText): if
145
+ // every SS stream is removed the checkbox unmounts but its state would
146
+ // survive, so re-adding a stream would silently resurrect the expensive
147
+ // joint-grid run. Clear it — the toggle stays a deliberate opt-in.
148
+ const [prevHasSsIncome, setPrevHasSsIncome] = useState(hasSocialSecurityIncome)
149
+ if (prevHasSsIncome !== hasSocialSecurityIncome) {
150
+ setPrevHasSsIncome(hasSocialSecurityIncome)
151
+ if (!hasSocialSecurityIncome) setCoOptimizeClaim(false)
152
+ }
153
+
154
+ const schedule = optimizeResult?.schedule ?? null
155
+ const postProcessed = optimizeResult?.postProcessed ?? null
156
+ const tournament = optimizeResult?.tournament ?? null
157
+ // The exact-ledger tournament arbitrates the recommendation: when a simple
158
+ // candidate strategy beats the post-processed MILP schedule on the exact
159
+ // after-tax estate, its schedule is what the page shows and applies.
160
+ const candidateWins = tournament?.winnerSource === 'candidate'
161
+ // Nothing evaluated beat the plan's already-installed conversion strategy —
162
+ // the usual state right after applying a winning schedule and re-running.
163
+ // Rendered as a calm "no change recommended" card, not a rejected-schedule
164
+ // diagnostic (the deltas on this page are always vs the current plan, so a
165
+ // fresh solver proposal that loses to the incumbent shows scary negatives).
166
+ const incumbentHolds = tournament?.winnerSource === 'incumbent'
167
+ const candidateReplacedMilp = candidateWins && postProcessed?.recommendationSchedule === 'cleaned'
168
+ const displaySchedule = postProcessed?.cleanedSchedule ?? schedule
169
+ const recommendedConversions = useMemo(
170
+ () =>
171
+ incumbentHolds
172
+ ? [] // the plan already holds the best schedule — nothing to apply or Monte-Carlo
173
+ : candidateWins
174
+ ? tournament!.winnerConversions
175
+ : (displaySchedule?.conversions ?? []),
176
+ [incumbentHolds, candidateWins, tournament, displaySchedule],
177
+ )
178
+ // Step 5 claim-age co-optimization: when a claim change won, the schedule and
179
+ // every validation delta on this page were computed against the claim-patched
180
+ // plan, so Monte Carlo, the report, and Apply must all start from it.
181
+ const claimAge = optimizeResult?.claimAge ?? null
182
+ const claimChangeRecommended = claimAge?.winningClaimPatch != null
183
+ const planForRecommendation = useMemo(() => planWithWinningClaim(plan, claimAge), [plan, claimAge])
184
+ const optimizedPlan = useMemo(() => {
185
+ if (recommendedConversions.length > 0) return withOptimizedConversions(planForRecommendation, recommendedConversions)
186
+ // A claim change with no conversion change (the incumbent schedule holds
187
+ // under the new claim) is still a recommendation worth pricing and reporting.
188
+ return claimChangeRecommended ? planForRecommendation : null
189
+ }, [planForRecommendation, recommendedConversions, claimChangeRecommended])
190
+ const validation = candidateWins ? (tournament?.winnerValidation ?? null) : (postProcessed?.cleanedValidation ?? null)
191
+
192
+ const run = useCallback(() => {
193
+ const token = ++runToken.current
194
+ setRunning(true)
195
+ setError(null)
196
+ setMcRate(null)
197
+ runOptimize({ plan, startYear, objectivePolicyId: objectiveId, coOptimizeClaimAge: coOptimizeRequested })
198
+ .then((s) => {
199
+ if (token === runToken.current) setOptimizeResult(s)
200
+ })
201
+ .catch((e: unknown) => {
202
+ if (token === runToken.current) {
203
+ setError(e instanceof Error ? e.message : String(e))
204
+ // Drop any prior result so a stale chart/Apply can't render against
205
+ // inputs the optimizer just failed on.
206
+ setOptimizeResult(null)
207
+ }
208
+ })
209
+ .finally(() => {
210
+ if (token === runToken.current) setRunning(false)
211
+ })
212
+ }, [plan, startYear, objectiveId, coOptimizeRequested])
213
+
214
+ // Auto-run on plan / rate change (debounced).
215
+ useEffect(() => {
216
+ const t = window.setTimeout(run, 300)
217
+ return () => window.clearTimeout(t)
218
+ }, [run])
219
+
220
+ // Auto Monte Carlo success-% for the proposed schedule (V8 §1.6).
221
+ useEffect(() => {
222
+ if (!optimizedPlan) return
223
+ let cancelled = false
224
+ void runMonteCarlo(optimizedPlan, {
225
+ startYear,
226
+ pathCount: DEFAULT_PATH_COUNT,
227
+ seed: seedFromPlanId(plan.id),
228
+ model: { type: 'lognormal', inflationMeanPct: plan.assumptions.inflationPct, returnVolPct: 12 },
229
+ }).then((s) => {
230
+ if (!cancelled) setMcRate(s.successRate)
231
+ })
232
+ return () => {
233
+ cancelled = true
234
+ }
235
+ }, [optimizedPlan, startYear, plan.id, plan.assumptions.inflationPct])
236
+
237
+ const estateDelta = validation?.afterTaxEstateDelta ?? 0
238
+ const taxDelta = validation?.lifetimeTaxDelta ?? 0
239
+ const totalConversions = recommendedConversions.reduce((sum, c) => sum + c.amount, 0)
240
+ const rawConversions = totalScheduleConversions(schedule)
241
+ const executedConversions = validation?.executedConversionTotal ?? 0
242
+ const hasPostProcessingAdjustments = (postProcessed?.adjustments.length ?? 0) > 0
243
+ const hasExecutionMismatch =
244
+ !candidateWins &&
245
+ (hasPostProcessingAdjustments ||
246
+ (validation?.firstMateriallyUnexecutedYear !== null && validation?.firstMateriallyUnexecutedYear !== undefined))
247
+ const showRecommendedBars = shouldShowRecommendedScheduleBars(candidateWins, hasExecutionMismatch)
248
+ const recommendationState = validation?.recommendationState ?? 'neutral'
249
+ const blocksApply = candidateWins
250
+ ? false
251
+ : recommendationState === 'rejected' ||
252
+ recommendationState === 'unexecutable' ||
253
+ postProcessed?.recommendationSchedule === 'none'
254
+ // A claim change can win on estate alone while the conversion side is
255
+ // unappliable (incumbent holds, empty/infeasible schedule, or diagnostic-only
256
+ // result). The claim card then carries its own apply control so the winning
257
+ // claim change is never advertised without a way to install it.
258
+ const scheduleApplyAvailable = recommendedConversions.length > 0 && !blocksApply
259
+
260
+ const chartRows = useMemo(
261
+ () => buildOptimizeChartRows({ schedule, recommendedConversions, postProcessed, candidateWins }),
262
+ [schedule, recommendedConversions, postProcessed, candidateWins],
263
+ )
264
+
265
+ const apply = (mode: 'optimized' | 'manual') => {
266
+ if (recommendedConversions.length === 0 || blocksApply) return
267
+ // Claim change and schedule install together: the schedule was computed
268
+ // against the claim-patched plan, so conversions alone would be wrong.
269
+ update((d) => applyOptimizeRecommendation(d, { claimAge, conversions: recommendedConversions, mode }))
270
+ }
271
+
272
+ // The incumbent-holds path has no schedule to install, but a winning claim
273
+ // change is still an actionable recommendation on its own (the current
274
+ // conversion strategy already holds under the new claim ages).
275
+ const applyClaimChangeOnly = () => {
276
+ if (!claimChangeRecommended) return
277
+ update((d) => applyOptimizeRecommendation(d, { claimAge, conversions: [], mode: 'optimized' }))
278
+ }
279
+
280
+ const rerunButton = (label = 'Re-run optimizer') => (
281
+ <button type="button" className="btn btn-secondary btn-small" disabled={running} onClick={run}>
282
+ {running ? 'Optimizing...' : label}
283
+ </button>
284
+ )
285
+
286
+ const downloadRecommendationReport = () => {
287
+ if (!optimizeResult) return
288
+ // Report the plan the evidence section describes: when the optimizer recommends
289
+ // a schedule, project that recommended plan so the headline results and ledger
290
+ // appendix match the recommendation. When nothing beats the incumbent (no
291
+ // optimizedPlan), the recommendation is "no change", so the current plan is correct.
292
+ const reportPlan = optimizedPlan ?? plan
293
+ const view = projectPlan(reportPlan, startYear)
294
+ downloadStandaloneReport({
295
+ plan: reportPlan,
296
+ result: view.result,
297
+ summary: view.summary,
298
+ startYear,
299
+ recommendationEvidence: reportEvidenceFromOptimizeResult(optimizeResult),
300
+ branding: reportBranding,
301
+ })
302
+ }
303
+
304
+ return (
305
+ <section>
306
+ <div className="card">
307
+ <h2>Roth & Tax Optimizer</h2>
308
+ <p className="card-hint">
309
+ Search for a Roth-conversion & withdrawal schedule, then rank the candidates by the objective you choose.
310
+ The default leaves the most after-tax wealth to your heirs; other modes can favor spending durability,
311
+ lifetime tax with an estate floor, survivor liquidity, or bridge-year resilience. RetireGolden generates
312
+ candidate schedules (a multi-year math program over federal brackets, IRMAA thresholds, and RMDs, plus
313
+ simple bracket-fill and cliff-cap strategies), compares every candidate on your full year-by-year
314
+ projection, and recommends the best result it found within the search limits.
315
+ </p>
316
+ <p className="field-hint">
317
+ Using the {formatPct(plan.assumptions.heirTaxRatePct / 100)} heir tax rate from{' '}
318
+ <Link to={`/plan/${plan.id}/assumptions`}>Assumptions</Link>.
319
+ </p>
320
+ <div className="form-grid" style={{ marginTop: '0.5rem' }}>
321
+ <SelectField
322
+ label="Optimize for"
323
+ help="What 'better' means when candidate schedules are ranked on your full year-by-year projection. The default maximizes the after-tax estate; other objectives re-rank the same evaluations — money lasting longer, lowest lifetime tax without breaking the estate floor, the worst-case balance in survivor years, or the worst-case balance across pre-Social-Security bridge years. Every objective still hard-rejects candidates that shorten how long the money lasts."
324
+ hint={objectivePolicies[objectiveId].description}
325
+ learn={LEARN.optimizerObjectives}
326
+ value={objectiveId}
327
+ options={OBJECTIVE_CHOICES}
328
+ onCommit={setObjectiveId}
329
+ />
330
+ {hasSocialSecurityIncome ? (
331
+ <CheckboxField
332
+ label="Also optimize Social Security claim age"
333
+ help="Re-runs the full conversion optimizer at each canonical claim age (62 / full retirement age / 70, for up to two Social Security streams) and recommends the claim-age + conversion pair with the best projected after-tax estate. A claim change must beat the current-claim optimum by a clear margin to be recommended."
334
+ hint="Re-runs the full optimizer once per claim combination — expect several times longer than a standard run."
335
+ value={coOptimizeClaim}
336
+ onCommit={setCoOptimizeClaim}
337
+ />
338
+ ) : null}
339
+ </div>
340
+ {running ? (
341
+ <>
342
+ <div className="skeleton" style={{ height: '2rem', marginTop: '0.75rem' }} aria-label="Optimizing" />
343
+ {coOptimizeRequested ? (
344
+ <p className="field-hint" style={{ margin: '0.5rem 0 0' }} role="status">
345
+ Also optimizing Social Security claim age: the optimizer is re-run in full for every claim
346
+ combination, so this takes several times longer than a standard optimize.
347
+ </p>
348
+ ) : null}
349
+ </>
350
+ ) : null}
351
+ {error ? <p style={{ color: 'var(--bad)' }}>Optimizer error: {error}</p> : null}
352
+ <div style={{ marginTop: '0.75rem', display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }}>
353
+ {!schedule && !running ? rerunButton(error ? 'Try again' : 'Run optimizer') : null}
354
+ {/* Also disabled while re-running: the held result (and any claim
355
+ patch in it) describes the pre-edit plan, so a report downloaded
356
+ mid-run would mix live plan fields with stale recommendations. */}
357
+ <button
358
+ type="button"
359
+ className="btn btn-secondary btn-small"
360
+ disabled={!optimizeResult || running}
361
+ onClick={downloadRecommendationReport}
362
+ >
363
+ Download recommendation report
364
+ </button>
365
+ </div>
366
+ </div>
367
+
368
+ {schedule && !running && claimAge?.enabled ? (
369
+ claimChangeRecommended ? (
370
+ <div className="card" style={{ borderColor: 'var(--good)' }}>
371
+ <h2 style={{ margin: '0 0 0.35rem', color: 'var(--good)' }}>
372
+ Recommended claim change: {claimAge.winningClaimLabel}
373
+ </h2>
374
+ <p className="muted" style={{ margin: 0 }}>
375
+ Changing the claim age and re-optimizing conversions is worth{' '}
376
+ <strong>{fmtMoney(claimEstateGain(claimAge))}</strong> more projected after-tax estate than the best
377
+ result at your current claim ages ({fmtMoneyCompact(claimAge.currentClaimExactEstate)} →{' '}
378
+ {fmtMoneyCompact(claimAge.jointExactEstate)}), across {claimAge.combinationsEvaluated} claim combinations
379
+ each fully re-optimized.
380
+ </p>
381
+ <p className="field-hint" style={{ margin: '0.6rem 0 0' }}>
382
+ {incumbentHolds
383
+ ? 'Your current conversion strategy already holds under the new claim age, so applying changes only the Social Security claim age.'
384
+ : scheduleApplyAvailable
385
+ ? 'Everything below — the schedule, the estate and tax deltas, and the success rate — was computed assuming this claim change. Apply installs the new claim age and the conversion schedule together; the schedule alone would not be correct for your current claim ages.'
386
+ : recommendedConversions.length === 0
387
+ ? 'No conversion change comes with this recommendation, so the button here changes just the Social Security claim age — the estate gain above comes from the claim change itself.'
388
+ : 'The conversion schedule from this run is diagnostic-only and cannot be applied, so the button here changes just the Social Security claim age. The estate gain above was measured with that schedule included, so the claim change alone may capture only part of it.'}
389
+ </p>
390
+ {/* Claim-only recommendations show their success rate here (the
391
+ stats row below never renders for them); when a schedule comes
392
+ along, the normal stats row already shows the rate computed
393
+ against the same claim-patched plan via optimizedPlan. */}
394
+ {recommendedConversions.length === 0 && mcRate !== null ? (
395
+ <p className="field-hint" style={{ margin: '0.45rem 0 0' }}>
396
+ Monte Carlo success rate with this claim change: {Math.round(mcRate * 100)}%.
397
+ </p>
398
+ ) : null}
399
+ {!scheduleApplyAvailable ? (
400
+ <div style={{ marginTop: '0.75rem' }}>
401
+ <button type="button" className="btn btn-primary btn-small" onClick={applyClaimChangeOnly}>
402
+ Apply claim change
403
+ </button>
404
+ </div>
405
+ ) : null}
406
+ </div>
407
+ ) : (
408
+ <div className="card">
409
+ <p className="field-hint" style={{ margin: 0 }}>
410
+ Social Security claim age co-optimized: {claimAge.combinationsEvaluated} claim combinations were each
411
+ fully re-optimized; none beat your current claim ages by a meaningful margin, so the recommendation
412
+ below keeps them.
413
+ </p>
414
+ </div>
415
+ )
416
+ ) : null}
417
+
418
+ {schedule && !running ? (
419
+ incumbentHolds && tournament ? (
420
+ <div className="card">
421
+ <h2 style={{ margin: '0 0 0.35rem', color: 'var(--good)' }}>
422
+ No change recommended — {tournament.winnerLabel} is still the best result found.
423
+ </h2>
424
+ <p className="muted" style={{ margin: 0 }}>
425
+ RetireGolden compared {tournament.candidates.length} simple candidate strategies and a fresh solver
426
+ schedule against your current plan on your full year-by-year projection; none improved it. Your current schedule (
427
+ {fmtMoney(tournament.winnerConversions.reduce((sum, c) => sum + c.amount, 0))} of conversions across{' '}
428
+ {tournament.winnerConversions.length} year{tournament.winnerConversions.length === 1 ? '' : 's'}) stays
429
+ in place{claimChangeRecommended ? ' — only the claim change above is left to apply.' : ' — nothing to apply.'}
430
+ </p>
431
+ {postProcessed?.cleanedValidation ? (
432
+ <p className="field-hint" style={{ margin: '0.6rem 0 0' }}>
433
+ Diagnostic: the solver's latest cleaned schedule would move the projected after-tax estate by{' '}
434
+ {fmtMoney(postProcessed.cleanedValidation.afterTaxEstateDelta)} versus your current plan, so it is not
435
+ offered as a recommendation. To explore alternatives from scratch, remove or edit the conversion
436
+ schedule under Strategy and re-run.
437
+ </p>
438
+ ) : (
439
+ <p className="field-hint" style={{ margin: '0.6rem 0 0' }}>
440
+ To explore alternatives from scratch, remove or edit the conversion schedule under Strategy and re-run.
441
+ </p>
442
+ )}
443
+ <div style={{ marginTop: '0.75rem' }}>{rerunButton()}</div>
444
+ </div>
445
+ ) : schedule.status === 'infeasible' && !candidateWins ? (
446
+ <div className="card">
447
+ <h2>Couldn't optimize this plan</h2>
448
+ <p className="muted">
449
+ The optimizer couldn't find a feasible schedule — usually because the plan runs out of money before the end
450
+ (spending exceeds what the portfolio can cover), so there's no conversion strategy to weigh. Resolve the
451
+ shortfall in Results or Monte Carlo, then try again.
452
+ </p>
453
+ <div style={{ marginTop: '0.75rem' }}>{rerunButton()}</div>
454
+ </div>
455
+ ) : rawConversions < 1 && !candidateWins ? (
456
+ <div className="card">
457
+ <h2>No beneficial conversions found</h2>
458
+ <p className="muted">
459
+ For this plan the optimizer didn't find conversions that improve the after-tax estate (often because there
460
+ is little pre-tax balance to convert, or the current strategy already captures the opportunity).
461
+ </p>
462
+ <div style={{ marginTop: '0.75rem' }}>{rerunButton()}</div>
463
+ </div>
464
+ ) : (
465
+ <>
466
+ <div className="mc-hero">
467
+ <div>
468
+ <h2 style={{ margin: '0 0 0.35rem', color: stateColor(recommendationState) }}>
469
+ {validation ? recommendationHeading(validation) : 'The optimizer matches your current strategy.'}
470
+ </h2>
471
+ <p className="muted" style={{ margin: 0 }}>
472
+ {validation
473
+ ? recommendationBody(validation)
474
+ : `${fmtMoney(totalConversions)} of conversions across ${recommendedConversions.length} year(s).`}
475
+ </p>
476
+ {candidateWins && tournament ? (
477
+ <p className="field-hint" style={{ margin: '0.45rem 0 0' }}>
478
+ {candidateReplacedMilp ? (
479
+ <>
480
+ A simple strategy wins on your projection: <strong>{tournament.winnerLabel}</strong> beats the
481
+ solver's schedule by {fmtMoney(tournament.marginOverMilpDollars)} of after-tax estate. The
482
+ schedule shown and applied below follows it; the "Requested" bars show what the solver had
483
+ proposed.
484
+ </>
485
+ ) : (
486
+ <>
487
+ A simple strategy wins on your projection: <strong>{tournament.winnerLabel}</strong>. The
488
+ schedule shown and applied below follows it.
489
+ </>
490
+ )}
491
+ {tournament.searchRefined
492
+ ? ' A bounded search on your full projection then fine-tuned the winning schedule’s per-year amounts.'
493
+ : ''}
494
+ </p>
495
+ ) : null}
496
+ {tournament && tournament.policyId !== 'max-after-tax-estate' ? (
497
+ <p className="field-hint" style={{ margin: '0.45rem 0 0' }}>
498
+ Candidates ranked by <strong>{objectivePolicies[tournament.policyId].label}</strong> — the estate
499
+ and tax deltas below are context, not the ranking metric.
500
+ </p>
501
+ ) : null}
502
+ {hasExecutionMismatch && validation ? (
503
+ <p className="field-hint" style={{ margin: '0.45rem 0 0' }}>
504
+ Raw optimizer request: {fmtMoney(rawConversions)}. Cleaned executable schedule:{' '}
505
+ {fmtMoney(totalConversions)}. Executed after cleaning: {fmtMoney(executedConversions)} (
506
+ {formatPct(validation.executedConversionRatio)}).
507
+ {postProcessed?.rawValidation.firstMateriallyUnexecutedYear
508
+ ? ` First raw shortfall: ${postProcessed.rawValidation.firstMateriallyUnexecutedYear}.`
509
+ : ''}
510
+ {postProcessed?.adjustments.some((a) => a.reason === 'estate-pruned')
511
+ ? ' Later conversion years that lowered the projected after-tax estate were pruned.'
512
+ : ''}
513
+ </p>
514
+ ) : null}
515
+ </div>
516
+ </div>
517
+
518
+ <div className="stat-grid">
519
+ <DeltaStat
520
+ label="After-tax estate"
521
+ value={`${estateDelta >= 0 ? '+' : ''}${fmtMoney(estateDelta)}`}
522
+ tone={estateDelta > 0 ? 'good' : estateDelta < 0 ? 'bad' : 'neutral'}
523
+ help="Change in ending net worth, net of the heir tax on inherited pre-tax balances, versus your current strategy. Measured on your full year-by-year projection, not the optimizer's simplified model."
524
+ />
525
+ <DeltaStat
526
+ label="Lifetime tax"
527
+ value={`${taxDelta <= 0 ? '' : '+'}${fmtMoney(taxDelta)}`}
528
+ tone={taxDelta < 0 ? 'good' : taxDelta > 0 ? 'bad' : 'neutral'}
529
+ help="Change in total taxes and penalties paid over the whole plan. Conversions usually raise lifetime tax (you pay sooner) even as they raise the after-tax estate — the estate figure is the goal, this is the cost."
530
+ />
531
+ <DeltaStat
532
+ label="Success rate"
533
+ value={mcRate === null ? '…' : `${Math.round(mcRate * 100)}%`}
534
+ tone={mcRate !== null && mcRate >= 0.9 ? 'good' : 'neutral'}
535
+ help="Monte Carlo success probability for the proposed schedule (1,000 paths, lognormal markets), so you can confirm the conversions don't materially raise the risk of running out."
536
+ />
537
+ </div>
538
+
539
+ <div className="chart-card">
540
+ <h2>Proposed conversions by year</h2>
541
+ <p className="card-hint">
542
+ {candidateWins
543
+ ? candidateReplacedMilp
544
+ ? 'Raw optimizer request, winning candidate schedule, and what your projection actually executed (nominal dollars).'
545
+ : 'Winning candidate schedule and what your projection actually executed (nominal dollars).'
546
+ : 'Raw optimizer requests, cleaned schedule, and what your projection actually executed (nominal dollars).'}
547
+ </p>
548
+ <div style={{ width: '100%', height: 280 }}>
549
+ <ResponsiveContainer width="100%" height="100%">
550
+ <BarChart data={chartRows} margin={{ left: 12, right: 8, top: 8 }}>
551
+ <CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
552
+ <XAxis dataKey="year" tick={{ fill: 'var(--muted)', fontSize: 12 }} />
553
+ <YAxis tickFormatter={fmtMoneyCompact} tick={{ fill: 'var(--muted)', fontSize: 12 }} width={70} />
554
+ <Tooltip formatter={(v: unknown) => fmtMoney(Number(v))} contentStyle={chartTooltipStyle} />
555
+ <Bar dataKey="requested" name="Requested" fill="var(--chart-1)" />
556
+ {showRecommendedBars ? (
557
+ <Bar
558
+ dataKey="cleaned"
559
+ name={candidateWins ? 'Recommended schedule' : 'Cleaned schedule'}
560
+ fill="var(--chart-2)"
561
+ />
562
+ ) : null}
563
+ {showRecommendedBars ? (
564
+ <Bar
565
+ dataKey="executed"
566
+ name={candidateWins ? 'Executed recommendation' : 'Executed after cleaning'}
567
+ fill="var(--chart-3)"
568
+ />
569
+ ) : null}
570
+ </BarChart>
571
+ </ResponsiveContainer>
572
+ </div>
573
+ <p className="field-hint">
574
+ Optimizer status: {schedule.status} · solved in {schedule.solveMs.toFixed(0)} ms. The optimizer reasons
575
+ over a simplified plan; the headline figures above come from re-running your full projection with the cleaned schedule.
576
+ </p>
577
+ </div>
578
+
579
+ <div className="card">
580
+ <h2>Use this schedule</h2>
581
+ <p className="card-hint">
582
+ {blocksApply
583
+ ? 'This result is shown as a diagnostic; it cannot be applied to your plan as a recommended schedule.'
584
+ : `Apply keeps it labeled as optimizer output; Accept as manual copies the same amounts into an editable manual conversion schedule you can adjust under Strategy.${
585
+ claimChangeRecommended
586
+ ? ' Both buttons also install the recommended Social Security claim change — the schedule was computed assuming it.'
587
+ : ''
588
+ }`}
589
+ </p>
590
+ <div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }}>
591
+ <button type="button" className="btn btn-primary btn-small" disabled={blocksApply} onClick={() => apply('optimized')}>
592
+ Apply optimized schedule
593
+ </button>
594
+ <button type="button" className="btn btn-secondary btn-small" disabled={blocksApply} onClick={() => apply('manual')}>
595
+ Accept as manual
596
+ </button>
597
+ {rerunButton()}
598
+ </div>
599
+ </div>
600
+ </>
601
+ )
602
+ ) : null}
603
+
604
+ {tournament && schedule && !running ? (
605
+ <WhyRecommendationPanel tournament={tournament} objectiveLabel={objectivePolicies[tournament.policyId].label} />
606
+ ) : null}
607
+
608
+ <LearnAboutScreen route="/plan/:planId/optimize" />
609
+ </section>
610
+ )
611
+ }