@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,1134 @@
1
+ /**
2
+ * Explore → Social Security: two views of the claiming decision.
3
+ * - "In your plan": sweep every claim-age combination through the full
4
+ * projection, ranked by ending after-tax estate; apply the winner.
5
+ * - "Benefits only": mortality-weighted expected present value of the
6
+ * benefits alone (the actuarial / insurance lens).
7
+ */
8
+
9
+ import { useMemo, useState } from 'react'
10
+ import {
11
+ CartesianGrid,
12
+ Legend,
13
+ Line,
14
+ LineChart,
15
+ ResponsiveContainer,
16
+ Tooltip,
17
+ XAxis,
18
+ YAxis,
19
+ } from 'recharts'
20
+
21
+ import { runMonteCarlo } from '../mc/pool'
22
+ import { sizeBridge, type BridgeSizing } from '@retiregolden/engine/ladder/bridge'
23
+ import { EMBEDDED_REAL_YIELD_CURVE } from '@retiregolden/engine/params'
24
+ import type { TipsLadder } from '@retiregolden/engine/model/plan'
25
+ import { computeBreakEven } from '../socialSecurity/breakEven'
26
+ import { rankSwitchStrategies } from '../socialSecurity/survivorSwitching'
27
+ import { survivorBenefitMonthly } from '@retiregolden/engine/socialSecurity/survivorBenefit'
28
+ import { ficaOasdiPaidIn } from '../socialSecurity/ficaReturn'
29
+ import { expectedPvSingle } from '../socialSecurity/expectedPv'
30
+ import { claimFactor } from '@retiregolden/engine/socialSecurity/claimFactor'
31
+ import { objectivePolicies, type ObjectivePolicyId } from '@retiregolden/engine/decisions'
32
+ import { effectiveBirthYear, fraForBirthYear, fraTotalMonths, survivorFraForBirthYear } from '@retiregolden/engine/socialSecurity/nra'
33
+ import { packForYear } from '@retiregolden/engine/params'
34
+ import { usePlan } from './planContextCore'
35
+ import { CheckboxField, HelpTip, SelectField } from './fields'
36
+ import { LEARN } from './learnLinks'
37
+ import { LearnLink } from '../learn/LearnLink'
38
+ import { LearnAboutScreen } from '../learn/LearnAboutScreen'
39
+ import { fmtMoney, fmtMoneyCompact } from './format'
40
+ import { currentStartYear, projectPlan, seedFromPlanId } from './useProjection'
41
+ import {
42
+ benefitsOnlyRanking,
43
+ candidateClaimAges,
44
+ claimingPeople,
45
+ dobParts,
46
+ planWithClaimAges,
47
+ refineClaimingMonthly,
48
+ sweepClaimingStrategies,
49
+ type MonthlyClaim,
50
+ type MonthlyRefinement,
51
+ type SweepRow,
52
+ } from './ssAnalysis'
53
+ import { chartTooltipStyle } from './chartStyle'
54
+
55
+ type Tab = 'plan' | 'benefits' | 'breakeven'
56
+
57
+ const BREAKEVEN_COLORS = ['var(--chart-2)', 'var(--chart-1)', 'var(--chart-3)']
58
+
59
+ const OBJECTIVE_CHOICES: ReadonlyArray<{ value: ObjectivePolicyId; label: string }> = [
60
+ 'max-after-tax-estate',
61
+ 'max-spending-durability',
62
+ 'min-lifetime-tax-estate-floor',
63
+ 'protect-survivor-liquidity',
64
+ 'bridge-durability',
65
+ ].map((id) => ({ value: id as ObjectivePolicyId, label: objectivePolicies[id as ObjectivePolicyId].label }))
66
+
67
+ function ageLabel(claim: Record<string, number>, ids: string[]): string {
68
+ return ids.map((id) => claim[id]).join(' / ')
69
+ }
70
+
71
+ /** Background tint for a heatmap cell scaled 0 (worst) … 1 (best). */
72
+ function heatColor(t: number): string {
73
+ return `color-mix(in srgb, var(--good) ${Math.round(t * 70)}%, var(--surface-1))`
74
+ }
75
+
76
+ function EmptyState() {
77
+ return (
78
+ <div className="empty-state">
79
+ <h2>No Social Security to analyze yet</h2>
80
+ <p>Add a benefit for at least one person on the Social Security entry form, then come back here.</p>
81
+ </div>
82
+ )
83
+ }
84
+
85
+ export function SsAnalysisPage() {
86
+ const { plan, update } = usePlan()
87
+ const [tab, setTab] = useState<Tab>('plan')
88
+ const people = useMemo(() => claimingPeople(plan), [plan])
89
+
90
+ if (people.length === 0) {
91
+ return (
92
+ <section>
93
+ <div className="card">
94
+ <h2>Social Security Optimizer</h2>
95
+ <EmptyState />
96
+ </div>
97
+ </section>
98
+ )
99
+ }
100
+
101
+ const personName = (id: string) => plan.household.people.find((p) => p.id === id)?.name ?? id
102
+ const applyStrategy = (claim: Record<string, number>) =>
103
+ update((d) => {
104
+ for (const s of d.incomes) {
105
+ if (s.type === 'socialSecurity' && claim[s.personId] !== undefined) {
106
+ s.claimAge = { years: claim[s.personId]!, months: 0 }
107
+ }
108
+ }
109
+ })
110
+
111
+ return (
112
+ <section>
113
+ <div className="card">
114
+ <h2>Social Security Optimizer</h2>
115
+ <div className="seg" role="tablist" style={{ marginBottom: '1rem' }}>
116
+ <button type="button" role="tab" aria-pressed={tab === 'plan'} onClick={() => setTab('plan')}>
117
+ In your plan
118
+ </button>
119
+ <button type="button" role="tab" aria-pressed={tab === 'benefits'} onClick={() => setTab('benefits')}>
120
+ Benefits only
121
+ </button>
122
+ <button type="button" role="tab" aria-pressed={tab === 'breakeven'} onClick={() => setTab('breakeven')}>
123
+ Break-even
124
+ </button>
125
+ </div>
126
+ {tab === 'plan' ? (
127
+ <InYourPlanTab personIds={people.map((p) => p.person.id)} personName={personName} applyStrategy={applyStrategy} />
128
+ ) : tab === 'benefits' ? (
129
+ <BenefitsOnlyTab personIds={people.map((p) => p.person.id)} personName={personName} applyStrategy={applyStrategy} />
130
+ ) : (
131
+ <BreakEvenTab personIds={people.map((p) => p.person.id)} personName={personName} />
132
+ )}
133
+ </div>
134
+
135
+ <BridgePanel />
136
+
137
+ <LearnAboutScreen route="/plan/:planId/social-security-analysis" />
138
+ </section>
139
+ )
140
+ }
141
+
142
+ // ---------------------------------------------------------------------------
143
+ // Social Security bridge (social-security-bridge-and-tips-ladder, step 3)
144
+ // ---------------------------------------------------------------------------
145
+
146
+ interface BridgeComparisonRow {
147
+ name: string
148
+ endingAfterTaxEstate: number
149
+ depletionYear: number | null
150
+ successRate: number | null
151
+ }
152
+
153
+ /**
154
+ * Packages the delay-and-bridge strategy as a plan artifact: sizes the bridge
155
+ * from each claimant's own numbers (forgone age-62 benefit × gap years),
156
+ * quotes the TIPS ladder on the embedded curve, adds it to the plan in one
157
+ * click, and prices "bridge + delayed claim" against "claim at 62" on the
158
+ * same deterministic ledger and the same seeded Monte Carlo paths.
159
+ */
160
+ function BridgePanel() {
161
+ const { plan, update } = usePlan()
162
+ const startYear = currentStartYear()
163
+ const people = useMemo(() => claimingPeople(plan), [plan])
164
+
165
+ const existingLadders = plan.incomeFloor?.ladders
166
+ const sized = useMemo(() => {
167
+ const out: Array<{ personId: string; name: string; bridge: BridgeSizing; ladder: TipsLadder }> = []
168
+ for (const { person, stream, pia } of people) {
169
+ const { y, m, d } = dobParts(person)
170
+ const bridge = sizeBridge({
171
+ piaMonthly: pia,
172
+ dob: { year: y, month: m, day: d },
173
+ claimAge: stream.claimAge,
174
+ currentYear: startYear,
175
+ retirementYear: person.retirementAge !== null ? y + person.retirementAge : startYear,
176
+ curve: EMBEDDED_REAL_YIELD_CURVE,
177
+ })
178
+ if (!bridge) continue
179
+ // A plan ladder already covering this whole window means the bridge is
180
+ // bought — offering it again would double-count purchases and flows.
181
+ // Same coverage rule as the ss-bridge-gap detector and the generator
182
+ // (full coverage: one year short is a real unfunded gap year).
183
+ const covered = existingLadders?.some((l) => l.startYear <= bridge.startYear && l.endYear >= bridge.endYear)
184
+ if (covered) continue
185
+ out.push({
186
+ personId: person.id,
187
+ name: person.name,
188
+ bridge,
189
+ ladder: {
190
+ id: `bridge-${person.id}-${bridge.startYear}`,
191
+ name: `SS bridge (${person.name})`,
192
+ purpose: 'bridge',
193
+ startYear: bridge.startYear,
194
+ endYear: bridge.endYear,
195
+ annualRealAmount: bridge.annualRealAmount,
196
+ },
197
+ })
198
+ }
199
+ return out
200
+ }, [people, startYear, existingLadders])
201
+
202
+ const fundingOptions = plan.accounts
203
+ .filter((a) => a.type === 'cash' || a.type === 'taxable' || a.type === 'equityComp')
204
+ .map((a) => ({ value: a.id, label: a.name }))
205
+ const [fundingId, setFundingId] = useState<string>('')
206
+ const funding = fundingOptions.find((o) => o.value === fundingId) ?? fundingOptions[0]
207
+ const [rows, setRows] = useState<BridgeComparisonRow[] | null>(null)
208
+ const [comparing, setComparing] = useState(false)
209
+ const [compareError, setCompareError] = useState<string | null>(null)
210
+
211
+ const alreadyBridged = (plan.incomeFloor?.ladders ?? []).some((l) => l.purpose === 'bridge')
212
+ if (sized.length === 0) {
213
+ return alreadyBridged ? (
214
+ <div className="card">
215
+ <h2>Social Security bridge</h2>
216
+ <p className="card-hint">
217
+ Your plan already holds a bridge ladder covering the gap years — see it on the Income floor page.{' '}
218
+ <LearnLink {...LEARN.socialSecurityBridge} />
219
+ </p>
220
+ </div>
221
+ ) : null
222
+ }
223
+
224
+ // Never append a ladder id the plan already holds: the coverage filter above
225
+ // normally empties `sized` once a bridge is bought, but a user-edited window
226
+ // can re-open the offer while the deterministic id still exists — appending
227
+ // it again would collide ids and double-count flows.
228
+ const laddersWithFunding = (existing: TipsLadder[] | undefined): TipsLadder[] =>
229
+ sized
230
+ .filter(({ ladder }) => !existing?.some((l) => l.id === ladder.id))
231
+ .map(({ ladder }) => ({
232
+ ...ladder,
233
+ purchase: funding ? { year: startYear, fundingAccountId: funding.value } : undefined,
234
+ }))
235
+
236
+ const planWithBridge = () => {
237
+ const next = structuredClone(plan)
238
+ const ladders = laddersWithFunding(next.incomeFloor?.ladders)
239
+ if (next.incomeFloor) next.incomeFloor.ladders.push(...ladders)
240
+ else next.incomeFloor = { ladders }
241
+ return next
242
+ }
243
+
244
+ const addToPlan = () =>
245
+ update((d) => {
246
+ const ladders = laddersWithFunding(d.incomeFloor?.ladders)
247
+ if (d.incomeFloor) d.incomeFloor.ladders.push(...ladders)
248
+ else d.incomeFloor = { ladders }
249
+ })
250
+
251
+ const runComparison = async () => {
252
+ setComparing(true)
253
+ setCompareError(null)
254
+ try {
255
+ const claimEarly = planWithClaimAges(
256
+ plan,
257
+ Object.fromEntries(sized.map((s) => [s.personId, 62])),
258
+ )
259
+ const bridged = planWithBridge()
260
+ const variants: Array<{ name: string; plan: typeof plan }> = [
261
+ { name: 'Claim at 62, no bridge', plan: claimEarly },
262
+ { name: 'Current claim ages, no bridge', plan },
263
+ { name: 'Current claim ages + TIPS bridge', plan: bridged },
264
+ ]
265
+ const out: BridgeComparisonRow[] = []
266
+ for (const v of variants) {
267
+ const projection = projectPlan(v.plan, startYear)
268
+ // Same seed ⇒ identical market paths across variants (same-path delta).
269
+ const mc = await runMonteCarlo(v.plan, {
270
+ startYear,
271
+ pathCount: 500,
272
+ seed: seedFromPlanId(plan.id),
273
+ model: { type: 'lognormal', inflationMeanPct: plan.assumptions.inflationPct },
274
+ })
275
+ out.push({
276
+ name: v.name,
277
+ endingAfterTaxEstate: projection.summary.endingAfterTaxEstate,
278
+ depletionYear: projection.summary.depletionYear,
279
+ successRate: mc.successRate,
280
+ })
281
+ }
282
+ setRows(out)
283
+ } catch (e) {
284
+ setCompareError(e instanceof Error ? e.message : 'The comparison failed.')
285
+ } finally {
286
+ setComparing(false)
287
+ }
288
+ }
289
+
290
+ const totalCost = sized.reduce((sum, s) => sum + s.bridge.ladderCost, 0)
291
+ return (
292
+ <div className="card">
293
+ <h2>Social Security bridge</h2>
294
+ <p className="card-hint">
295
+ Delaying is the cheapest inflation-protected annuity you can buy — the bridge pays you the forgone age-62
296
+ benefit until your claim starts, so the delay never cuts lifestyle. Sized from your own plan; quoted on
297
+ Treasury real yields as of {EMBEDDED_REAL_YIELD_CURVE.asOfIso}. <LearnLink {...LEARN.socialSecurityBridge} />
298
+ </p>
299
+ <div className="year-table-wrap">
300
+ <table>
301
+ <thead>
302
+ <tr>
303
+ <th>Person</th>
304
+ <th>Bridge pays</th>
305
+ <th>Years</th>
306
+ <th>TIPS ladder cost (today's $)</th>
307
+ </tr>
308
+ </thead>
309
+ <tbody>
310
+ {sized.map((s) => (
311
+ <tr key={s.personId}>
312
+ <td>{s.name}</td>
313
+ <td>
314
+ {fmtMoney(s.bridge.annualRealAmount)}/yr ({fmtMoney(s.bridge.monthlyAge62Benefit)}/mo, real)
315
+ </td>
316
+ <td>
317
+ {s.bridge.startYear}–{s.bridge.endYear}
318
+ </td>
319
+ <td>{fmtMoney(s.bridge.ladderCost)}</td>
320
+ </tr>
321
+ ))}
322
+ </tbody>
323
+ </table>
324
+ </div>
325
+ <div className="form-grid">
326
+ <SelectField
327
+ label="Fund the bridge from"
328
+ help="The ladder cost is withdrawn from this account in the purchase year — a transfer into the ladder, not spending."
329
+ value={funding?.value ?? ''}
330
+ options={fundingOptions.length > 0 ? fundingOptions : [{ value: '', label: 'No cash/taxable account' }]}
331
+ onCommit={setFundingId}
332
+ />
333
+ </div>
334
+ <div className="add-row">
335
+ <button type="button" className="btn btn-primary btn-small" onClick={addToPlan} disabled={!funding}>
336
+ Add bridge ladder{sized.length > 1 ? 's' : ''} to plan ({fmtMoneyCompact(totalCost)})
337
+ </button>{' '}
338
+ <button
339
+ type="button"
340
+ className="btn btn-secondary btn-small"
341
+ onClick={() => void runComparison()}
342
+ disabled={comparing || !funding}
343
+ >
344
+ {comparing ? 'Comparing…' : 'Compare vs claiming at 62'}
345
+ </button>
346
+ </div>
347
+ {!funding ? (
348
+ <p className="card-hint">
349
+ Add a cash or taxable account to fund the bridge — without one there is nothing to buy the ladder with, so
350
+ the add and compare actions stay off.
351
+ </p>
352
+ ) : null}
353
+ {compareError ? (
354
+ <p className="card-hint" role="alert">
355
+ {compareError}
356
+ </p>
357
+ ) : null}
358
+ {rows ? (
359
+ <div className="year-table-wrap">
360
+ <table>
361
+ <thead>
362
+ <tr>
363
+ <th>Strategy</th>
364
+ <th>Market success</th>
365
+ <th>Money lasts</th>
366
+ <th>Ending after-tax estate</th>
367
+ </tr>
368
+ </thead>
369
+ <tbody>
370
+ {rows.map((row) => (
371
+ <tr key={row.name}>
372
+ <td>{row.name}</td>
373
+ <td>{row.successRate !== null ? `${Math.round(row.successRate * 100)}%` : '—'}</td>
374
+ <td>{row.depletionYear === null ? 'full plan' : `until ${row.depletionYear}`}</td>
375
+ <td>{fmtMoneyCompact(row.endingAfterTaxEstate)}</td>
376
+ </tr>
377
+ ))}
378
+ </tbody>
379
+ </table>
380
+ <p className="card-hint">
381
+ All three run on the same steady-markets ledger and the same 500 seeded market paths, so every difference
382
+ is the strategy — not luck of the draw.
383
+ </p>
384
+ </div>
385
+ ) : null}
386
+ </div>
387
+ )
388
+ }
389
+
390
+ interface TabProps {
391
+ personIds: string[]
392
+ personName: (id: string) => string
393
+ applyStrategy: (claim: Record<string, number>) => void
394
+ }
395
+
396
+ function currentClaim(plan: ReturnType<typeof usePlan>['plan'], ids: string[]): Record<string, number> {
397
+ const out: Record<string, number> = {}
398
+ for (const s of plan.incomes) {
399
+ if (s.type === 'socialSecurity' && ids.includes(s.personId)) out[s.personId] = s.claimAge.years
400
+ }
401
+ return out
402
+ }
403
+
404
+ function fmtClaim(c: MonthlyClaim): string {
405
+ return c.months > 0 ? `${c.years}y ${c.months}m` : `${c.years}`
406
+ }
407
+
408
+ function fmtObjectiveDelta(label: string, value: number): string {
409
+ const sign = value >= 0 ? '+' : ''
410
+ if (label.toLowerCase().includes('years')) return `${sign}${value} yr`
411
+ return `${sign}${fmtMoneyCompact(value)}`
412
+ }
413
+
414
+ function InYourPlanTab({ personIds, personName, applyStrategy }: TabProps) {
415
+ const { plan, update } = usePlan()
416
+ const startYear = currentStartYear()
417
+ const [objectiveId, setObjectiveId] = useState<ObjectivePolicyId>('max-after-tax-estate')
418
+ const sweep = useMemo(() => sweepClaimingStrategies(plan, startYear, objectiveId), [plan, startYear, objectiveId])
419
+ const [mc, setMc] = useState<Record<string, number> | null>(null)
420
+ const [mcRunning, setMcRunning] = useState(false)
421
+ const [mcError, setMcError] = useState<string | null>(null)
422
+ const [refined, setRefined] = useState<MonthlyRefinement | null>(null)
423
+
424
+ const best = sweep.ranked[0]
425
+ const current = currentClaim(plan, personIds)
426
+ const currentRow = sweep.rows.find((r) => personIds.every((id) => r.claimByPersonId[id] === current[id]))
427
+ const keyOf = (r: SweepRow) => personIds.map((id) => r.claimByPersonId[id]).join('-')
428
+
429
+ const applyMonthly = (claim: Record<string, MonthlyClaim>) =>
430
+ update((d) => {
431
+ for (const s of d.incomes) {
432
+ if (s.type === 'socialSecurity' && claim[s.personId] !== undefined) s.claimAge = { ...claim[s.personId]! }
433
+ }
434
+ })
435
+
436
+ const runRobustness = async () => {
437
+ setMcRunning(true)
438
+ setMcError(null)
439
+ try {
440
+ const top = sweep.ranked.slice(0, 5)
441
+ const out: Record<string, number> = {}
442
+ for (const row of top) {
443
+ const candidate = planWithClaimAges(plan, row.claimByPersonId)
444
+ const summary = await runMonteCarlo(candidate, {
445
+ startYear,
446
+ pathCount: 500,
447
+ seed: seedFromPlanId(plan.id),
448
+ model: { type: 'lognormal', inflationMeanPct: plan.assumptions.inflationPct },
449
+ })
450
+ out[keyOf(row)] = summary.successRate
451
+ }
452
+ setMc(out)
453
+ } catch (e: unknown) {
454
+ setMcError(e instanceof Error ? e.message : String(e))
455
+ } finally {
456
+ setMcRunning(false)
457
+ }
458
+ }
459
+
460
+ return (
461
+ <div>
462
+ <p className="card-hint">
463
+ Each claim-age combination is run through your full plan — taxes, Roth conversions, IRMAA, ACA, and RMDs
464
+ included — and ranked by the objective you choose{' '}
465
+ <HelpTip text="Ending net worth minus the income tax heirs owe on inherited pre-tax (traditional) balances, at the heir tax rate in Assumptions. This is the deterministic, single-planning-age view; the Benefits-only tab adds the mortality-weighted insurance angle." />.
466
+ Results assume your expected returns; use the robustness check to see how the ranking holds up across markets.
467
+ </p>
468
+ <div className="form-grid" style={{ marginBottom: '0.75rem', maxWidth: '26rem' }}>
469
+ <SelectField
470
+ label="Rank claim ages by"
471
+ help="Every whole-year Social Security claim-age candidate is evaluated on your full year-by-year projection, then those same evaluations are re-ranked by this objective."
472
+ hint={objectivePolicies[objectiveId].description}
473
+ value={objectiveId}
474
+ options={OBJECTIVE_CHOICES}
475
+ onCommit={setObjectiveId}
476
+ />
477
+ </div>
478
+
479
+ {best ? (
480
+ <div className="callout callout--info">
481
+ <strong>Best by {objectivePolicies[objectiveId].label.toLowerCase()}: claim at {ageLabel(best.claimByPersonId, personIds)}</strong>
482
+ {personIds.length === 2 ? ` (${personIds.map(personName).join(' / ')})` : ''} — after-tax estate{' '}
483
+ {fmtMoneyCompact(best.summary.endingAfterTaxEstate)}
484
+ {currentRow && keyOf(currentRow) !== keyOf(best) ? (
485
+ <>
486
+ {' '}
487
+ vs {fmtMoneyCompact(currentRow.summary.endingAfterTaxEstate)} at your current{' '}
488
+ {ageLabel(current, personIds)} (
489
+ <span className="delta-pos">
490
+ +{fmtMoneyCompact(best.summary.endingAfterTaxEstate - currentRow.summary.endingAfterTaxEstate)}
491
+ </span>
492
+ ).
493
+ </>
494
+ ) : ' — your current choice.'}
495
+ {objectiveId !== 'max-after-tax-estate' ? (
496
+ <>
497
+ {' '}
498
+ Ranked on {sweep.primaryMetricLabel.toLowerCase()} ({fmtObjectiveDelta(sweep.primaryMetricLabel, best.primaryValue)} vs current);
499
+ estate is shown for context.
500
+ </>
501
+ ) : null}
502
+ {best && keyOf(best) !== keyOf(currentRow ?? best) ? (
503
+ <div style={{ marginTop: '0.6rem' }}>
504
+ <button type="button" className="btn btn-primary btn-small" onClick={() => applyStrategy(best.claimByPersonId)}>
505
+ Apply {ageLabel(best.claimByPersonId, personIds)}
506
+ </button>
507
+ </div>
508
+ ) : null}
509
+ </div>
510
+ ) : null}
511
+
512
+ {personIds.length === 2 ? <CoupleStrategyPanel personName={personName} best={best} /> : null}
513
+
514
+ {personIds.length === 1 ? (
515
+ <SingleSweepTable personIds={personIds} sweep={sweep} current={current} applyStrategy={applyStrategy} />
516
+ ) : (
517
+ <CoupleHeatmap personIds={personIds} personName={personName} sweep={sweep} current={current} applyStrategy={applyStrategy} />
518
+ )}
519
+
520
+ <div className="add-row" style={{ marginTop: '1rem' }}>
521
+ {best ? (
522
+ <button type="button" className="btn btn-secondary btn-small" onClick={() => setRefined(refineClaimingMonthly(plan, best.claimByPersonId, startYear))}>
523
+ Refine to the month
524
+ </button>
525
+ ) : null}
526
+ <button type="button" className="btn btn-secondary btn-small" disabled={mcRunning} onClick={() => void runRobustness()}>
527
+ {mcRunning ? 'Running…' : 'Check robustness (Monte Carlo, top 5)'}
528
+ </button>
529
+ </div>
530
+ {mcError ? (
531
+ <div className="error-recovery" role="alert">
532
+ <p className="error-text">Robustness check error: {mcError}</p>
533
+ <button type="button" className="btn btn-secondary btn-small" disabled={mcRunning} onClick={() => void runRobustness()}>
534
+ Run again
535
+ </button>
536
+ </div>
537
+ ) : null}
538
+
539
+ {refined && best ? (
540
+ <div className="callout callout--info" style={{ marginTop: '0.75rem' }}>
541
+ <strong>To the month: claim at {personIds.map((id) => fmtClaim(refined.claimByPersonId[id]!)).join(' / ')}</strong>
542
+ {personIds.length === 2 ? ` (${personIds.map(personName).join(' / ')})` : ''} — after-tax estate{' '}
543
+ {fmtMoneyCompact(refined.summary.endingAfterTaxEstate)}
544
+ {refined.summary.endingAfterTaxEstate > best.summary.endingAfterTaxEstate ? (
545
+ <>
546
+ {' '}
547
+ (<span className="delta-pos">+{fmtMoneyCompact(refined.summary.endingAfterTaxEstate - best.summary.endingAfterTaxEstate)}</span>{' '}
548
+ over the whole-year pick).
549
+ </>
550
+ ) : ' — the whole-year pick is already optimal to the month.'}
551
+ {personIds.some((id) => refined.claimByPersonId[id]!.months > 0) ? (
552
+ <div style={{ marginTop: '0.6rem' }}>
553
+ <button type="button" className="btn btn-primary btn-small" onClick={() => applyMonthly(refined.claimByPersonId)}>
554
+ Apply {personIds.map((id) => fmtClaim(refined.claimByPersonId[id]!)).join(' / ')}
555
+ </button>
556
+ </div>
557
+ ) : null}
558
+ </div>
559
+ ) : null}
560
+ {mc ? (
561
+ <table className="claim-table" style={{ marginTop: '0.75rem' }}>
562
+ <thead>
563
+ <tr>
564
+ <th>Strategy (claim ages)</th>
565
+ <th>After-tax estate</th>
566
+ <th>Success %</th>
567
+ </tr>
568
+ </thead>
569
+ <tbody>
570
+ {sweep.ranked.slice(0, 5).map((r) => (
571
+ <tr key={keyOf(r)}>
572
+ <td>{ageLabel(r.claimByPersonId, personIds)}</td>
573
+ <td>{fmtMoneyCompact(r.summary.endingAfterTaxEstate)}</td>
574
+ <td>{mc[keyOf(r)] !== undefined ? `${Math.round(mc[keyOf(r)]! * 100)}%` : '—'}</td>
575
+ </tr>
576
+ ))}
577
+ </tbody>
578
+ </table>
579
+ ) : null}
580
+ </div>
581
+ )
582
+ }
583
+
584
+ /**
585
+ * Couple primer beside the heatmap: which spouse has the higher benefit, why
586
+ * survivor protection usually argues for delaying that one, and whether the
587
+ * recommended strategy follows the common "lower earlier / higher later" pattern.
588
+ */
589
+ function CoupleStrategyPanel({ personName, best }: { personName: (id: string) => string; best?: SweepRow }) {
590
+ const { plan } = usePlan()
591
+ const people = claimingPeople(plan)
592
+ if (people.length !== 2) return null
593
+ const [higher, lower] = [...people].sort((a, b) => b.pia - a.pia)
594
+ if (!higher || !lower) return null
595
+ const samePia = higher.pia === lower.pia
596
+ const higherName = personName(higher.person.id)
597
+ const lowerName = personName(lower.person.id)
598
+
599
+ let patternNote = null
600
+ if (best && !samePia) {
601
+ const higherClaim = best.claimByPersonId[higher.person.id]
602
+ const lowerClaim = best.claimByPersonId[lower.person.id]
603
+ if (higherClaim !== undefined && lowerClaim !== undefined) {
604
+ patternNote =
605
+ higherClaim >= lowerClaim ? (
606
+ <>
607
+ {' '}
608
+ The best strategy above follows the common pattern: <strong>{lowerName}</strong> claims at {lowerClaim} and{' '}
609
+ <strong>{higherName}</strong> delays to {higherClaim}, locking in the larger survivor check.
610
+ </>
611
+ ) : (
612
+ <>
613
+ {' '}
614
+ Here the best strategy has <strong>{higherName}</strong> ({higherClaim}) claim before{' '}
615
+ <strong>{lowerName}</strong> ({lowerClaim}) — taxes, longevity, or portfolio assumptions outweigh the usual
616
+ survivor-protection delay.
617
+ </>
618
+ )
619
+ }
620
+ }
621
+
622
+ return (
623
+ <div className="callout callout--info">
624
+ {samePia ? (
625
+ <>
626
+ Both benefits are about {fmtMoneyCompact(higher.pia * 12)}/yr. When the first spouse dies the survivor keeps the
627
+ larger of the two checks, so delaying either claim raises the survivor floor.
628
+ </>
629
+ ) : (
630
+ <>
631
+ <strong>{higherName}</strong> has the higher benefit ({fmtMoneyCompact(higher.pia * 12)}/yr vs{' '}
632
+ {fmtMoneyCompact(lower.pia * 12)}/yr for {lowerName}). After the first death the survivor keeps only the larger
633
+ check, so delaying <strong>{higherName}</strong>’s claim protects whoever lives longest.
634
+ </>
635
+ )}
636
+ {patternNote}
637
+ </div>
638
+ )
639
+ }
640
+
641
+ function SingleSweepTable({
642
+ personIds,
643
+ sweep,
644
+ current,
645
+ applyStrategy,
646
+ }: {
647
+ personIds: string[]
648
+ sweep: ReturnType<typeof sweepClaimingStrategies>
649
+ current: Record<string, number>
650
+ applyStrategy: (claim: Record<string, number>) => void
651
+ }) {
652
+ const id = personIds[0]!
653
+ const byAge = [...sweep.rows].sort((a, b) => a.claimByPersonId[id]! - b.claimByPersonId[id]!)
654
+ const bestKey = sweep.ranked[0]!.claimByPersonId[id]
655
+ return (
656
+ <div className="year-table-wrap" style={{ border: 'none' }}>
657
+ <table className="claim-table">
658
+ <thead>
659
+ <tr>
660
+ <th>Claim at</th>
661
+ <th>After-tax estate</th>
662
+ <th>Lifetime tax</th>
663
+ <th>Depletes</th>
664
+ <th aria-label="apply" />
665
+ </tr>
666
+ </thead>
667
+ <tbody>
668
+ {byAge.map((r) => {
669
+ const age = r.claimByPersonId[id]!
670
+ return (
671
+ <tr key={age} className={(age === bestKey ? 'claim-row--best ' : '') + (age === current[id] ? 'claim-row--current' : '')}>
672
+ <td>{age}</td>
673
+ <td>{fmtMoneyCompact(r.summary.endingAfterTaxEstate)}</td>
674
+ <td>{fmtMoneyCompact(r.summary.lifetimeTaxesAndPenalties)}</td>
675
+ <td>{r.summary.depletionYear ?? 'never'}</td>
676
+ <td>
677
+ <button
678
+ type="button"
679
+ className="btn btn-secondary btn-small"
680
+ disabled={age === current[id]}
681
+ onClick={() => applyStrategy(r.claimByPersonId)}
682
+ >
683
+ Use
684
+ </button>
685
+ </td>
686
+ </tr>
687
+ )
688
+ })}
689
+ </tbody>
690
+ </table>
691
+ </div>
692
+ )
693
+ }
694
+
695
+ function CoupleHeatmap({
696
+ personIds,
697
+ personName,
698
+ sweep,
699
+ current,
700
+ applyStrategy,
701
+ }: {
702
+ personIds: string[]
703
+ personName: (id: string) => string
704
+ sweep: ReturnType<typeof sweepClaimingStrategies>
705
+ current: Record<string, number>
706
+ applyStrategy: (claim: Record<string, number>) => void
707
+ }) {
708
+ const { plan } = usePlan()
709
+ const [rowId, colId] = personIds
710
+ const rowAges = candidateClaimAges(plan.household.people.find((p) => p.id === rowId)!, currentStartYear())
711
+ const colAges = candidateClaimAges(plan.household.people.find((p) => p.id === colId)!, currentStartYear())
712
+ const estate = (ra: number, ca: number) =>
713
+ sweep.rows.find((r) => r.claimByPersonId[rowId!] === ra && r.claimByPersonId[colId!] === ca)?.summary.endingAfterTaxEstate ?? 0
714
+ const values = sweep.rows.map((r) => r.summary.endingAfterTaxEstate)
715
+ const min = Math.min(...values)
716
+ const max = Math.max(...values)
717
+ const norm = (v: number) => (max > min ? (v - min) / (max - min) : 1)
718
+ const bestKey = `${sweep.ranked[0]!.claimByPersonId[rowId!]}-${sweep.ranked[0]!.claimByPersonId[colId!]}`
719
+
720
+ return (
721
+ <>
722
+ <p className="card-hint" style={{ marginTop: '0.5rem' }}>
723
+ After-tax estate by claim age — rows: {personName(rowId!)}, columns: {personName(colId!)}. Greener is better;
724
+ click a cell to apply it.
725
+ </p>
726
+ <div className="year-table-wrap" style={{ border: 'none' }}>
727
+ <table className="claim-table heatmap">
728
+ <thead>
729
+ <tr>
730
+ <th>{personName(rowId!)} ↓ / {personName(colId!)} →</th>
731
+ {colAges.map((ca) => (
732
+ <th key={ca}>{ca}</th>
733
+ ))}
734
+ </tr>
735
+ </thead>
736
+ <tbody>
737
+ {rowAges.map((ra) => (
738
+ <tr key={ra}>
739
+ <th>{ra}</th>
740
+ {colAges.map((ca) => {
741
+ const v = estate(ra, ca)
742
+ const isBest = `${ra}-${ca}` === bestKey
743
+ const isCurrent = current[rowId!] === ra && current[colId!] === ca
744
+ return (
745
+ <td
746
+ key={ca}
747
+ style={{ background: heatColor(norm(v)), cursor: 'pointer', outline: isCurrent ? '2px solid var(--accent)' : undefined, fontWeight: isBest ? 700 : undefined }}
748
+ title={`${personName(rowId!)} ${ra} / ${personName(colId!)} ${ca}: ${fmtMoneyCompact(v)}${isBest ? ' (best)' : ''}`}
749
+ onClick={() => applyStrategy({ [rowId!]: ra, [colId!]: ca })}
750
+ >
751
+ {fmtMoneyCompact(v)}
752
+ </td>
753
+ )
754
+ })}
755
+ </tr>
756
+ ))}
757
+ </tbody>
758
+ </table>
759
+ </div>
760
+ </>
761
+ )
762
+ }
763
+
764
+ /**
765
+ * Break-even education (V7 phase 2). The simple cumulative-benefit lens for one
766
+ * person's own retirement benefit, with COLA and an optional investment return.
767
+ * Deliberately lean copy — the conceptual narrative is the V9 Learning Center's
768
+ * job; the In-your-plan sweep is the complete answer.
769
+ */
770
+ function BreakEvenTab({ personIds, personName }: { personIds: string[]; personName: (id: string) => string }) {
771
+ const { plan } = usePlan()
772
+ const people = claimingPeople(plan)
773
+ const [selectedId, setSelectedId] = useState(personIds[0]!)
774
+ const [growthPct, setGrowthPct] = useState(0)
775
+
776
+ const entry = people.find((p) => p.person.id === selectedId) ?? people[0]!
777
+ const { person, pia } = entry
778
+ const { y, m, d } = dobParts(person)
779
+ const fra = fraForBirthYear(effectiveBirthYear(y, m, d))
780
+ const currentAge = currentStartYear() - y
781
+ const throughAge = person.longevity.planningAge
782
+ const cola = plan.assumptions.ssCola.mode === 'fixed' ? plan.assumptions.ssCola.annualPct : plan.assumptions.inflationPct
783
+
784
+ const claimAges = Array.from(new Set([62, fra.years, 70]))
785
+ .sort((a, b) => a - b)
786
+ .filter((a) => a >= currentAge)
787
+ const result =
788
+ claimAges.length >= 2
789
+ ? computeBreakEven({ dob: { year: y, month: m, day: d }, piaMonthly: pia, claimAges, colaPct: cola, growthPct, throughAge })
790
+ : null
791
+
792
+ const personSelect =
793
+ people.length === 2 ? (
794
+ <div className="seg" role="group" aria-label="Person" style={{ marginBottom: '0.75rem' }}>
795
+ {people.map((p) => (
796
+ <button key={p.person.id} type="button" aria-pressed={selectedId === p.person.id} onClick={() => setSelectedId(p.person.id)}>
797
+ {personName(p.person.id)}
798
+ </button>
799
+ ))}
800
+ </div>
801
+ ) : null
802
+
803
+ if (!result) {
804
+ return (
805
+ <div>
806
+ {personSelect}
807
+ <p className="card-hint">
808
+ {personName(selectedId)} is already past most claim ages, so there's nothing left to compare here.
809
+ </p>
810
+ </div>
811
+ )
812
+ }
813
+
814
+ const rows = result.series.map((pt) => {
815
+ const row: Record<string, number> = { age: pt.age }
816
+ for (const a of claimAges) row[`a${a}`] = pt.cumulative[a]!
817
+ return row
818
+ })
819
+
820
+ return (
821
+ <div>
822
+ <p className="card-hint">
823
+ Claim early and collect sooner, or wait for a bigger check? This compares the cumulative lifetime benefit from{' '}
824
+ {personName(selectedId)}'s own retirement benefit at each claim age — COLA {cola}%, checks invested at the chosen
825
+ return{' '}
826
+ <HelpTip text="Pedagogical view. It ignores spousal/survivor benefits, taxes, and the rest of your portfolio; the In-your-plan sweep is the complete answer. A higher assumed return rewards claiming early, pushing break-even later." />.
827
+ It's the simple lens — the In-your-plan tab is the complete one.
828
+ </p>
829
+
830
+ {personSelect}
831
+
832
+ <div className="seg" role="group" aria-label="Investment return on benefits" style={{ marginBottom: '1rem' }}>
833
+ {[0, 3, 5, 7].map((g) => (
834
+ <button key={g} type="button" aria-pressed={growthPct === g} onClick={() => setGrowthPct(g)}>
835
+ {g}% return
836
+ </button>
837
+ ))}
838
+ </div>
839
+
840
+ <div className="callout callout--info">
841
+ {result.crossings.map((c) => (
842
+ <div key={`${c.early}v${c.late}`}>
843
+ <strong>
844
+ {c.early} vs {c.late}:
845
+ </strong>{' '}
846
+ {c.age === null ? (
847
+ <>claiming at {c.late} never catches up by age {throughAge}{growthPct > 0 ? ` at a ${growthPct}% return` : ''}.</>
848
+ ) : (
849
+ <>
850
+ waiting until {c.late} pulls ahead around age <strong>{c.age}</strong>.
851
+ </>
852
+ )}
853
+ </div>
854
+ ))}
855
+ </div>
856
+
857
+ <div className="chart-card">
858
+ <div style={{ width: '100%', height: 320 }}>
859
+ <ResponsiveContainer width="100%" height="100%">
860
+ <LineChart data={rows} margin={{ left: 12, right: 8, top: 8 }}>
861
+ <CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
862
+ <XAxis dataKey="age" tick={{ fill: 'var(--muted)', fontSize: 12 }} />
863
+ <YAxis tickFormatter={fmtMoneyCompact} tick={{ fill: 'var(--muted)', fontSize: 12 }} width={70} />
864
+ <Tooltip formatter={(v: unknown) => fmtMoney(Number(v))} labelFormatter={(l) => `Age ${l}`} contentStyle={chartTooltipStyle} />
865
+ <Legend />
866
+ {claimAges.map((a, i) => (
867
+ <Line key={a} dataKey={`a${a}`} name={`Claim ${a}`} stroke={BREAKEVEN_COLORS[i % BREAKEVEN_COLORS.length]} dot={false} strokeWidth={2} />
868
+ ))}
869
+ </LineChart>
870
+ </ResponsiveContainer>
871
+ </div>
872
+ <p className="muted small">
873
+ Cumulative benefits received through each age{growthPct > 0 ? `, with each check invested at ${growthPct}%` : ''}.
874
+ </p>
875
+ </div>
876
+ </div>
877
+ )
878
+ }
879
+
880
+ function BenefitsOnlyTab({ personIds, personName, applyStrategy }: TabProps) {
881
+ const { plan } = usePlan()
882
+ const [discountPct, setDiscountPct] = useState(2)
883
+ const ranking = useMemo(
884
+ () => benefitsOnlyRanking(plan, discountPct / 100, currentStartYear()),
885
+ [plan, discountPct],
886
+ )
887
+ const best = ranking.ranked[0]
888
+ const current = currentClaim(plan, personIds)
889
+ const keyOf = (claim: Record<string, number>) => personIds.map((id) => claim[id]).join('-')
890
+
891
+ return (
892
+ <div>
893
+ <p className="card-hint">
894
+ The actuarial view: expected lifetime benefits weighted by the chance of being alive to receive them (SSA
895
+ mortality), ignoring your portfolio and taxes{' '}
896
+ <HelpTip text="The standard actuarial method: each future year's benefit is multiplied by the probability of survival and discounted to today. This isolates Social Security's longevity-insurance value — useful alongside the In-your-plan tab, which adds taxes and portfolio growth." />. When this disagrees with the In-your-plan tab, the gap is how much taxes and growth are pulling your answer.
897
+ </p>
898
+ <div className="form-grid" style={{ maxWidth: '22rem' }}>
899
+ <div className="field">
900
+ <span className="field-label-row">
901
+ <span className="field-label">Real discount rate: {discountPct}%</span>
902
+ <HelpTip text="The real (after-inflation) rate used to value future benefits — conventionally near the long-term TIPS yield (~2%). Higher rates favor claiming earlier; a very high personal rate (impatience or poor health) can make 62 optimal." />
903
+ </span>
904
+ <input type="range" min={0} max={8} step={0.5} value={discountPct} onChange={(e) => setDiscountPct(Number(e.target.value))} />
905
+ </div>
906
+ </div>
907
+
908
+ {best ? (
909
+ <div className="callout callout--info">
910
+ <strong>Highest expected value: claim at {ageLabel(best.claimByPersonId, personIds)}</strong>
911
+ {personIds.length === 2 ? ` (${personIds.map(personName).join(' / ')})` : ''} — expected PV{' '}
912
+ {fmtMoneyCompact(best.expectedPv)}.
913
+ {keyOf(best.claimByPersonId) !== keyOf(current) ? (
914
+ <div style={{ marginTop: '0.6rem' }}>
915
+ <button type="button" className="btn btn-primary btn-small" onClick={() => applyStrategy(best.claimByPersonId)}>
916
+ Apply {ageLabel(best.claimByPersonId, personIds)}
917
+ </button>
918
+ </div>
919
+ ) : null}
920
+ </div>
921
+ ) : null}
922
+
923
+ <div className="year-table-wrap" style={{ border: 'none' }}>
924
+ <table className="claim-table">
925
+ <thead>
926
+ <tr>
927
+ <th>Claim age{personIds.length === 2 ? 's' : ''}</th>
928
+ <th>Expected PV</th>
929
+ <th aria-label="apply" />
930
+ </tr>
931
+ </thead>
932
+ <tbody>
933
+ {ranking.ranked.slice(0, 10).map((r) => {
934
+ const isCurrent = keyOf(r.claimByPersonId) === keyOf(current)
935
+ return (
936
+ <tr key={keyOf(r.claimByPersonId)} className={isCurrent ? 'claim-row--current' : undefined}>
937
+ <td>{ageLabel(r.claimByPersonId, personIds)}</td>
938
+ <td>{fmtMoneyCompact(r.expectedPv)}</td>
939
+ <td>
940
+ <button type="button" className="btn btn-secondary btn-small" disabled={isCurrent} onClick={() => applyStrategy(r.claimByPersonId)}>
941
+ Use
942
+ </button>
943
+ </td>
944
+ </tr>
945
+ )
946
+ })}
947
+ </tbody>
948
+ </table>
949
+ </div>
950
+ {ranking.ranked.length > 10 ? <p className="muted small">Showing the top 10 of {ranking.ranked.length} combinations.</p> : null}
951
+
952
+ <SurvivorSwitchingPanel discountPct={discountPct} />
953
+ <FicaReturnPanel discountPct={discountPct} />
954
+ </div>
955
+ )
956
+ }
957
+
958
+ /**
959
+ * "What you paid in vs. what you get back" — an education/context readout (no
960
+ * engine-tax change). Paid-in = OASDI payroll tax over the entered earnings
961
+ * history (employee 6.2% / self-employed 12.4%, capped at the wage base); get-back
962
+ * = the survival-weighted expected PV of lifetime benefits at the chosen claim age
963
+ * (reusing the tested `expectedPvSingle` path). Only renders when an earnings
964
+ * history is present; Quick-PIA plans get a note.
965
+ */
966
+ function FicaReturnPanel({ discountPct }: { discountPct: number }) {
967
+ const { plan } = usePlan()
968
+ const [selfEmployed, setSelfEmployed] = useState(false)
969
+ const people = claimingPeople(plan)
970
+ const startYear = currentStartYear()
971
+ const { pack } = packForYear(startYear)
972
+ const discountRate = discountPct / 100
973
+
974
+ const withEarnings = people.filter((p) => p.stream.earnings && p.stream.earnings.length > 0)
975
+
976
+ return (
977
+ <details className="ss-explainer" style={{ marginTop: '1.5rem' }}>
978
+ <summary>What you paid in vs. what you get back</summary>
979
+ <p className="card-hint">
980
+ An illustrative "return on your Social Security taxes": the OASDI payroll tax you paid over your earnings
981
+ history, beside the survival-weighted expected value of your lifetime benefits at your current claim age. This
982
+ is an individual-level illustration, not the program's actuarial return, and it excludes the insurance value of
983
+ disability/survivor benefits, spousal benefits, and Medicare.{' '}
984
+ <LearnLink {...LEARN.ssTaxesVsBenefits} variant="inline" />
985
+ </p>
986
+ <div className="form-grid" style={{ maxWidth: '24rem' }}>
987
+ <CheckboxField
988
+ label="Self-employed (12.4% OASDI)"
989
+ help="Toggles the OASDI rate from the employee share (6.2%) to the self-employed total (12.4%). A simplification applied uniformly to your earnings history."
990
+ value={selfEmployed}
991
+ onCommit={setSelfEmployed}
992
+ />
993
+ </div>
994
+ {withEarnings.length === 0 ? (
995
+ <p className="muted small">Enter an earnings history on the Social Security step to see what you paid in.</p>
996
+ ) : (
997
+ withEarnings.map(({ person, stream, pia }) => {
998
+ const { y, m, d } = dobParts(person)
999
+ const paidIn = ficaOasdiPaidIn(stream.earnings ?? [], {
1000
+ oasdiEmployeeRatePct: pack.socialSecurity.oasdiEmployeeRatePct,
1001
+ selfEmployed,
1002
+ })
1003
+ const getBack = expectedPvSingle(
1004
+ {
1005
+ currentAge: Math.max(0, startYear - y),
1006
+ dob: { year: y, month: m, day: d },
1007
+ sex: person.sex,
1008
+ piaMonthly: pia,
1009
+ claimAge: stream.claimAge,
1010
+ },
1011
+ { discountRate },
1012
+ )
1013
+ const ratio = paidIn.paidIn > 0 ? getBack / paidIn.paidIn : 0
1014
+ return (
1015
+ <div key={person.id} className="callout callout--info" style={{ marginTop: '0.6rem' }}>
1016
+ <strong>{person.name}</strong>
1017
+ <div className="year-table-wrap" style={{ border: 'none' }}>
1018
+ <table className="claim-table">
1019
+ <tbody>
1020
+ <tr><td>Paid in (OASDI)</td><td>{fmtMoney(paidIn.paidIn)}</td></tr>
1021
+ {paidIn.employerPaid > 0 ? (
1022
+ <tr><td className="muted small">Employer paid (context)</td><td className="muted small">{fmtMoney(paidIn.employerPaid)}</td></tr>
1023
+ ) : null}
1024
+ <tr><td>Expected lifetime benefits (PV)</td><td>{fmtMoneyCompact(getBack)}</td></tr>
1025
+ <tr><td>Ratio (get back ÷ paid in)</td><td>{ratio > 0 ? `${ratio.toFixed(2)}×` : '—'}</td></tr>
1026
+ </tbody>
1027
+ </table>
1028
+ </div>
1029
+ <p className="muted small" style={{ marginTop: '0.3rem' }}>
1030
+ At a {discountPct}% real discount rate. Excludes Medicare tax, disability/survivor insurance value, and
1031
+ spousal benefits; the OASDI rate is applied uniformly over your career.
1032
+ </p>
1033
+ </div>
1034
+ )
1035
+ })
1036
+ )}
1037
+ </details>
1038
+ )
1039
+ }
1040
+
1041
+ /**
1042
+ * Survivor ↔ personal switching for a widowed single user: rank strategies that
1043
+ * sequence the survivor benefit and the person's own benefit. Shown only when the
1044
+ * single person has a deceased former spouse whose survivor benefit is preserved.
1045
+ */
1046
+ function SurvivorSwitchingPanel({ discountPct }: { discountPct: number }) {
1047
+ const { plan } = usePlan()
1048
+ const people = claimingPeople(plan)
1049
+ if (plan.household.people.length !== 1 || people.length !== 1) return null
1050
+ const { person, pia, stream } = people[0]!
1051
+ const eligible = (stream.formerSpouses ?? []).filter(
1052
+ (r) => r.relationship === 'deceased' && r.marriageYears >= 0.75 && (r.remarriedAtAge === null || r.remarriedAtAge >= 60),
1053
+ )
1054
+ if (eligible.length === 0) return null
1055
+ // Pick the deceased ex whose **payable** survivor benefit is highest (not raw
1056
+ // PIA): after RIB-LIM + the deceased's claim-age factor, a lower-PIA ex who
1057
+ // delayed can beat a higher-PIA ex who claimed early. Each ex's payable is
1058
+ // computed at the claimant's survivor FRA (the maximum survivor benefit each
1059
+ // record can provide), mirroring how the ledger's `bestMaritalBenefit` picks
1060
+ // the largest candidate.
1061
+ const { y, m, d } = dobParts(person)
1062
+ const claimantEffYear = effectiveBirthYear(y, m, d)
1063
+ const survivorFraMonths = fraTotalMonths(survivorFraForBirthYear(claimantEffYear))
1064
+ const survivorFraClaimAge = { years: Math.floor(survivorFraMonths / 12), months: survivorFraMonths % 12 }
1065
+ let bestEx = eligible[0]!
1066
+ let bestPayable = 0
1067
+ for (const r of eligible) {
1068
+ const exDobYear = Number(r.dob.slice(0, 4))
1069
+ const exDobMonth = Number(r.dob.slice(5, 7))
1070
+ const exDobDay = Number(r.dob.slice(8, 10))
1071
+ const exFra = fraForBirthYear(effectiveBirthYear(exDobYear, exDobMonth, exDobDay))
1072
+ const exClaimAge = r.deceasedClaimAge ?? { years: exFra.years, months: exFra.extraMonths }
1073
+ const actual = r.piaMonthly * claimFactor(exDobYear, exDobMonth, exDobDay, exClaimAge)
1074
+ const payable = survivorBenefitMonthly({
1075
+ deceasedPiaMonthly: r.piaMonthly,
1076
+ deceasedActualMonthly: actual,
1077
+ survivorClaimAge: survivorFraClaimAge,
1078
+ survivorFraMonths,
1079
+ })
1080
+ if (payable > bestPayable) {
1081
+ bestPayable = payable
1082
+ bestEx = r
1083
+ }
1084
+ }
1085
+ const exDobYear = Number(bestEx.dob.slice(0, 4))
1086
+ const exDobMonth = Number(bestEx.dob.slice(5, 7))
1087
+ const exDobDay = Number(bestEx.dob.slice(8, 10))
1088
+ const exFra = fraForBirthYear(effectiveBirthYear(exDobYear, exDobMonth, exDobDay))
1089
+ const exClaimAge = bestEx.deceasedClaimAge ?? { years: exFra.years, months: exFra.extraMonths }
1090
+ const survivorMonthly = bestEx.piaMonthly * claimFactor(exDobYear, exDobMonth, exDobDay, exClaimAge)
1091
+ if (survivorMonthly <= 0) return null
1092
+
1093
+ const ranked = rankSwitchStrategies(
1094
+ {
1095
+ dob: { year: y, month: m, day: d },
1096
+ sex: person.sex,
1097
+ currentAge: currentStartYear() - y,
1098
+ ownPiaMonthly: pia,
1099
+ survivorMonthly,
1100
+ deceasedPiaMonthly: bestEx.piaMonthly,
1101
+ },
1102
+ { discountRate: discountPct / 100 },
1103
+ ).slice(0, 5)
1104
+
1105
+ return (
1106
+ <div style={{ marginTop: '1.5rem' }}>
1107
+ <h3>Survivor vs. personal timing</h3>
1108
+ <p className="card-hint">
1109
+ As a widow(er) you can hold both a survivor benefit and your own, and switch between them. Survivor benefits stop
1110
+ growing at your full retirement age while your own grows to 70, so the order matters. Ranked by expected value at{' '}
1111
+ {discountPct}%{' '}
1112
+ <HelpTip text="Illustrative: the survivor base is the deceased's actual (claim-age-adjusted) benefit, the RIB-LIM widow's-limit caps it at 82.5% of the deceased's PIA when they claimed early, and the early-claim widow(er) reduction (up to 28.5% at 60) applies before the survivor's FRA — the same computation the projection ledger uses. Only one benefit is paid at a time — the larger of those claimed." />.
1113
+ </p>
1114
+ <div className="year-table-wrap" style={{ border: 'none' }}>
1115
+ <table className="claim-table">
1116
+ <thead>
1117
+ <tr>
1118
+ <th>Strategy</th>
1119
+ <th>Expected PV</th>
1120
+ </tr>
1121
+ </thead>
1122
+ <tbody>
1123
+ {ranked.map((r, i) => (
1124
+ <tr key={r.label} className={i === 0 ? 'claim-row--best' : undefined}>
1125
+ <td>{r.label}</td>
1126
+ <td>{fmtMoneyCompact(r.expectedPv)}</td>
1127
+ </tr>
1128
+ ))}
1129
+ </tbody>
1130
+ </table>
1131
+ </div>
1132
+ </div>
1133
+ )
1134
+ }