@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,325 @@
1
+ /**
2
+ * Social Security claiming analysis for the planner.
3
+ *
4
+ * Two layers, mirroring the V5 plan:
5
+ * - whole-plan sweep: for every combination of claim ages, run the full
6
+ * deterministic projection and rank by ending after-tax estate (the choice
7
+ * interacts with taxes, Roth conversions, IRMAA, ACA, RMDs);
8
+ * - benefits-only: mortality-weighted expected present value of the benefits
9
+ * alone (socialSecurity/expectedPv), the actuarial lens.
10
+ *
11
+ * @see DOCS/features/social-security.md
12
+ */
13
+
14
+ import type { IncomeStream, Person, Plan } from '@retiregolden/engine/model/plan'
15
+ import { summarizeProjection, type ProjectionSummary } from '@retiregolden/engine/projection/compare'
16
+ import { simulatePlan } from '@retiregolden/engine/projection/simulate'
17
+ import {
18
+ createDecisionContext,
19
+ evaluateCandidate,
20
+ objectivePolicyForPlan,
21
+ rankEvaluations,
22
+ socialSecurityClaimGridGenerator,
23
+ type ObjectivePolicyId,
24
+ } from '@retiregolden/engine/decisions'
25
+ import {
26
+ expectedPvCouple,
27
+ expectedPvSingle,
28
+ type ClaimantInput,
29
+ } from '../socialSecurity/expectedPv'
30
+ import { spousalBenefitFactor } from '@retiregolden/engine/socialSecurity/claimFactor'
31
+ import { DIVORCED_MIN_MARRIAGE_YEARS } from '@retiregolden/engine/socialSecurity/maritalBenefits'
32
+ import {
33
+ computePiaFromEarnings,
34
+ isPiaFromEarningsError,
35
+ piaInputFromEarnings,
36
+ resolveEarningsProjection,
37
+ type PiaFromEarningsResult,
38
+ } from '@retiregolden/engine/socialSecurity/piaFromEarnings'
39
+ import { currentStartYear, taxCalculatorFor } from './useProjection'
40
+
41
+ type SsStream = Extract<IncomeStream, { type: 'socialSecurity' }>
42
+
43
+ export const CLAIM_AGES = [62, 63, 64, 65, 66, 67, 68, 69, 70] as const
44
+
45
+ export function dobParts(person: Person): { y: number; m: number; d: number } {
46
+ return { y: Number(person.dob.slice(0, 4)), m: Number(person.dob.slice(5, 7)), d: Number(person.dob.slice(8, 10)) }
47
+ }
48
+
49
+ /**
50
+ * Claim ages worth considering for a person: 62–70, but never earlier than the
51
+ * age they have already reached (you can't claim in the past). Someone already
52
+ * past 70 is left with 70.
53
+ */
54
+ export function candidateClaimAges(person: Person, startYear: number): number[] {
55
+ const currentAge = startYear - dobParts(person).y
56
+ const ages = CLAIM_AGES.filter((a) => a >= currentAge)
57
+ return ages.length > 0 ? ages : [70]
58
+ }
59
+
60
+ export function ssStreamFor(plan: Plan, personId: string): SsStream | undefined {
61
+ return plan.incomes.find((s): s is SsStream => s.type === 'socialSecurity' && s.personId === personId)
62
+ }
63
+
64
+ export interface ResolvedPia {
65
+ /** Monthly PIA at FRA in today's dollars, or null if it can't be resolved. */
66
+ piaMonthly: number | null
67
+ warning: string | null
68
+ /** Full earnings-mode computation detail (indexed years, projection, AIME), when derived from earnings. */
69
+ detail: PiaFromEarningsResult | null
70
+ }
71
+
72
+ /** Resolve a stream's PIA the same way the engine does: entered, or derived from earnings. */
73
+ export function resolvePia(person: Person, stream: SsStream): ResolvedPia {
74
+ if (stream.piaMonthly !== null) return { piaMonthly: stream.piaMonthly, warning: null, detail: null }
75
+ if (!stream.earnings || stream.earnings.length === 0) {
76
+ return { piaMonthly: null, warning: 'No PIA entered and no earnings history.', detail: null }
77
+ }
78
+ const { y, m, d } = dobParts(person)
79
+ const projection = resolveEarningsProjection(stream.earningsProjection, person.retirementAge)
80
+ const result = computePiaFromEarnings(piaInputFromEarnings(y, m, d, stream.earnings, projection))
81
+ if (isPiaFromEarningsError(result)) {
82
+ return { piaMonthly: null, warning: `Earnings history could not be used (${result.code}).`, detail: null }
83
+ }
84
+ return {
85
+ piaMonthly: result.piaMonthly,
86
+ warning: result.usesStandInForFutureTables ? 'PIA uses stand-in SSA tables for years beyond published data.' : null,
87
+ detail: result,
88
+ }
89
+ }
90
+
91
+ /** People who have a Social Security stream with a resolvable benefit. */
92
+ export function claimingPeople(plan: Plan): { person: Person; stream: SsStream; pia: number }[] {
93
+ const out: { person: Person; stream: SsStream; pia: number }[] = []
94
+ for (const person of plan.household.people) {
95
+ const stream = ssStreamFor(plan, person.id)
96
+ if (!stream) continue
97
+ const { piaMonthly } = resolvePia(person, stream)
98
+ if (piaMonthly !== null && piaMonthly > 0) out.push({ person, stream, pia: piaMonthly })
99
+ }
100
+ return out
101
+ }
102
+
103
+ // ---------------------------------------------------------------------------
104
+ // Whole-plan sweep
105
+ // ---------------------------------------------------------------------------
106
+
107
+ export interface SweepRow {
108
+ /** Claim age (years) per claiming person, keyed by personId. */
109
+ claimByPersonId: Record<string, number>
110
+ summary: ProjectionSummary
111
+ /** Objective-policy metric delta versus the current plan; higher is better. */
112
+ primaryValue: number
113
+ eligible: boolean
114
+ lossReason: string | null
115
+ }
116
+
117
+ export interface SweepResult {
118
+ /** personId order the grid axes follow (1 entry = single, 2 = couple). */
119
+ personIds: string[]
120
+ objectivePolicyId: ObjectivePolicyId
121
+ primaryMetricLabel: string
122
+ rows: SweepRow[]
123
+ /** Rows sorted by the selected objective policy, descending. */
124
+ ranked: SweepRow[]
125
+ }
126
+
127
+ export function planWithClaimAges(plan: Plan, claimByPersonId: Record<string, number>): Plan {
128
+ const next = structuredClone(plan)
129
+ for (const stream of next.incomes) {
130
+ if (stream.type === 'socialSecurity' && claimByPersonId[stream.personId] !== undefined) {
131
+ stream.claimAge = { years: claimByPersonId[stream.personId]!, months: 0 }
132
+ }
133
+ }
134
+ return next
135
+ }
136
+
137
+ /**
138
+ * Run the full projection for every claim-age combination (62–70 per claiming
139
+ * person) and rank by ending after-tax estate. 9 runs single, 81 couple —
140
+ * each ~a few ms, so this stays well under a frame budget on the main thread.
141
+ */
142
+ export function sweepClaimingStrategies(
143
+ plan: Plan,
144
+ startYear = currentStartYear(),
145
+ objectivePolicyId: ObjectivePolicyId = 'max-after-tax-estate',
146
+ ): SweepResult {
147
+ const people = claimingPeople(plan)
148
+ const personIds = people.map((p) => p.person.id)
149
+ const taxCalculator = taxCalculatorFor(plan)
150
+ if (personIds.length === 0) {
151
+ const policy = objectivePolicyForPlan(objectivePolicyId, plan)
152
+ return { personIds, objectivePolicyId, primaryMetricLabel: policy.primaryMetricLabel, rows: [], ranked: [] }
153
+ }
154
+
155
+ const ctx = createDecisionContext(plan, { startYear, taxCalculator })
156
+ const policy = objectivePolicyForPlan(objectivePolicyId, plan)
157
+ const candidates = socialSecurityClaimGridGenerator.generate(ctx)
158
+ const evaluations = candidates.map((candidate) => evaluateCandidate(ctx, candidate))
159
+ const { ranked: rankedDecisions } = rankEvaluations(evaluations, ctx, policy, 0)
160
+
161
+ const rowByCandidateId = new Map<string, SweepRow>()
162
+ for (const row of rankedDecisions) {
163
+ const claimByPersonId = row.evaluation.candidate.metadata?.['claimByPersonId']
164
+ if (!claimByPersonId || typeof claimByPersonId !== 'object') continue
165
+ const claim = claimByPersonId as Record<string, number>
166
+ if (!personIds.every((id) => typeof claim[id] === 'number')) continue
167
+ rowByCandidateId.set(row.evaluation.candidate.id, {
168
+ claimByPersonId: Object.fromEntries(personIds.map((id) => [id, claim[id]!])),
169
+ summary: row.evaluation.candidateSummary,
170
+ primaryValue: row.primaryValue,
171
+ eligible: row.eligible,
172
+ lossReason: row.lossReason,
173
+ })
174
+ }
175
+
176
+ const rows = candidates
177
+ .map((candidate) => rowByCandidateId.get(candidate.id))
178
+ .filter((row): row is SweepRow => row !== undefined)
179
+ const ranked = rankedDecisions
180
+ .map((row) => rowByCandidateId.get(row.evaluation.candidate.id))
181
+ .filter((row): row is SweepRow => row !== undefined)
182
+ return { personIds, objectivePolicyId, primaryMetricLabel: policy.primaryMetricLabel, rows, ranked }
183
+ }
184
+
185
+ // ---------------------------------------------------------------------------
186
+ // Monthly refinement around the best whole-year strategy
187
+ // ---------------------------------------------------------------------------
188
+
189
+ export interface MonthlyClaim {
190
+ years: number
191
+ months: number
192
+ }
193
+
194
+ export interface MonthlyRefinement {
195
+ claimByPersonId: Record<string, MonthlyClaim>
196
+ summary: ProjectionSummary
197
+ }
198
+
199
+ export function planWithClaimAgesMonthly(plan: Plan, claimByPersonId: Record<string, MonthlyClaim>): Plan {
200
+ const next = structuredClone(plan)
201
+ for (const stream of next.incomes) {
202
+ if (stream.type === 'socialSecurity' && claimByPersonId[stream.personId] !== undefined) {
203
+ stream.claimAge = { ...claimByPersonId[stream.personId]! }
204
+ }
205
+ }
206
+ return next
207
+ }
208
+
209
+ /**
210
+ * Second-pass monthly refinement: starting from the best whole-year strategy,
211
+ * sweep each claiming person's claim age to the month within ±1 year (clamped to
212
+ * 62y0m–70y0m and the current age) by coordinate ascent, keeping the best
213
+ * after-tax estate. ~25 runs per person, so it stays on the main thread.
214
+ */
215
+ export function refineClaimingMonthly(
216
+ plan: Plan,
217
+ baseClaimYears: Record<string, number>,
218
+ startYear = currentStartYear(),
219
+ ): MonthlyRefinement {
220
+ const people = claimingPeople(plan)
221
+ const taxCalculator = taxCalculatorFor(plan)
222
+ const evaluate = (claim: Record<string, MonthlyClaim>): ProjectionSummary => {
223
+ const candidate = planWithClaimAgesMonthly(plan, claim)
224
+ return summarizeProjection(candidate, simulatePlan(candidate, { startYear, taxCalculator }))
225
+ }
226
+
227
+ let best: Record<string, MonthlyClaim> = {}
228
+ for (const id of Object.keys(baseClaimYears)) best[id] = { years: baseClaimYears[id]!, months: 0 }
229
+ let bestSummary = evaluate(best)
230
+
231
+ for (const { person } of people) {
232
+ const baseYear = best[person.id]!.years
233
+ const currentAge = startYear - dobParts(person).y
234
+ let localBest = best[person.id]!
235
+ for (let yy = baseYear - 1; yy <= baseYear + 1; yy++) {
236
+ if (yy < 62 || yy > 70 || yy < currentAge) continue
237
+ const maxMonth = yy === 70 ? 0 : 11 // engine caps claim at 70y0m
238
+ for (let mm = 0; mm <= maxMonth; mm++) {
239
+ const summary = evaluate({ ...best, [person.id]: { years: yy, months: mm } })
240
+ if (summary.endingAfterTaxEstate > bestSummary.endingAfterTaxEstate) {
241
+ bestSummary = summary
242
+ localBest = { years: yy, months: mm }
243
+ }
244
+ }
245
+ }
246
+ best = { ...best, [person.id]: localBest }
247
+ }
248
+ return { claimByPersonId: best, summary: bestSummary }
249
+ }
250
+
251
+ // ---------------------------------------------------------------------------
252
+ // Benefits-only (actuarial) ranking
253
+ // ---------------------------------------------------------------------------
254
+
255
+ export interface BenefitsPvRow {
256
+ claimByPersonId: Record<string, number>
257
+ expectedPv: number
258
+ }
259
+
260
+ function claimantInput(person: Person, pia: number, claimYears: number, startYear: number): ClaimantInput {
261
+ const { y, m, d } = dobParts(person)
262
+ return {
263
+ currentAge: Math.max(0, startYear - y),
264
+ dob: { year: y, month: m, day: d },
265
+ sex: person.sex,
266
+ piaMonthly: pia,
267
+ claimAge: { years: claimYears, months: 0 },
268
+ }
269
+ }
270
+
271
+ /**
272
+ * Best divorced-spousal monthly benefit (0.5 × ex PIA, reduced for the claim age)
273
+ * across eligible ex-spouses, for a currently-unmarried claimant. The actuarial
274
+ * view assumes the ex is eligible; survivor benefits are handled separately by
275
+ * the survivor-switching analysis.
276
+ */
277
+ function divorcedSpousalFloorMonthly(person: Person, stream: SsStream, claimYears: number, householdSingle: boolean): number {
278
+ if (!householdSingle) return 0
279
+ const { y, m, d } = dobParts(person)
280
+ let best = 0
281
+ for (const r of stream.formerSpouses ?? []) {
282
+ if (r.relationship !== 'divorced' || r.marriageYears < DIVORCED_MIN_MARRIAGE_YEARS) continue
283
+ best = Math.max(best, 0.5 * r.piaMonthly * spousalBenefitFactor(y, m, d, { years: claimYears, months: 0 }))
284
+ }
285
+ return best
286
+ }
287
+
288
+ /**
289
+ * Mortality-weighted expected PV of benefits for every claim-age combination,
290
+ * ranked descending. Ignores the portfolio and taxes — the pure insurance view.
291
+ */
292
+ export function benefitsOnlyRanking(plan: Plan, discountRate: number, startYear = currentStartYear()): {
293
+ personIds: string[]
294
+ rows: BenefitsPvRow[]
295
+ ranked: BenefitsPvRow[]
296
+ } {
297
+ const people = claimingPeople(plan)
298
+ const personIds = people.map((p) => p.person.id)
299
+ const householdSingle = plan.household.people.length === 1
300
+ const rows: BenefitsPvRow[] = []
301
+
302
+ if (people.length === 1) {
303
+ const { person, pia, stream } = people[0]!
304
+ for (const age of candidateClaimAges(person, startYear)) {
305
+ const benefitFloorMonthly = divorcedSpousalFloorMonthly(person, stream, age, householdSingle)
306
+ const pv = expectedPvSingle({ ...claimantInput(person, pia, age, startYear), benefitFloorMonthly }, { discountRate })
307
+ rows.push({ claimByPersonId: { [person.id]: age }, expectedPv: pv })
308
+ }
309
+ } else if (people.length === 2) {
310
+ const [a, b] = people
311
+ for (const ageA of candidateClaimAges(a!.person, startYear)) {
312
+ for (const ageB of candidateClaimAges(b!.person, startYear)) {
313
+ const pv = expectedPvCouple(
314
+ claimantInput(a!.person, a!.pia, ageA, startYear),
315
+ claimantInput(b!.person, b!.pia, ageB, startYear),
316
+ { discountRate },
317
+ )
318
+ rows.push({ claimByPersonId: { [a!.person.id]: ageA, [b!.person.id]: ageB }, expectedPv: pv })
319
+ }
320
+ }
321
+ }
322
+
323
+ const ranked = [...rows].sort((x, y) => y.expectedPv - x.expectedPv)
324
+ return { personIds, rows, ranked }
325
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Display-only tone banding for the Monte Carlo success rate (UI/UX round 2,
3
+ * Step 3). Red is reserved for genuinely failing plans so a high-60s/70s plan no
4
+ * longer reads as failure; the number and every computed metric are untouched.
5
+ * Bands agree with the "Understanding Monte Carlo success rate" Learning Center
6
+ * article.
7
+ */
8
+ export function successBand(rate: number): { color: string; severity: string; verdict: string } {
9
+ const pct = rate * 100
10
+ if (pct >= 90) return { color: 'var(--good)', severity: 'on track', verdict: 'On track in nearly all markets.' }
11
+ if (pct >= 75)
12
+ return {
13
+ color: 'var(--good)',
14
+ severity: 'on track in most markets',
15
+ verdict: 'On track in most markets — some sequence risk remains.',
16
+ }
17
+ if (pct >= 60)
18
+ return { color: 'var(--warn)', severity: 'needs attention', verdict: 'Workable, but sequence risk is real here.' }
19
+ return { color: 'var(--bad)', severity: 'at risk', verdict: 'This plan depletes in many markets.' }
20
+ }
@@ -0,0 +1,277 @@
1
+ /**
2
+ * Survivor-transition analysis for two-adult plans (survivor-widowhood-and-
3
+ * irmaa-relief, step 2).
4
+ *
5
+ * For each death-timing scenario (either spouse first, at a bounded set of
6
+ * ages), run the SAME deterministic ledger the Results page uses with a
7
+ * `deathAgeByPersonId` override and read the transition facts straight off the
8
+ * projection years: the filing-status timeline, the tax/MAGI shift around the
9
+ * death, the survivor's Social Security outcome, survivor spending coverage,
10
+ * the IRMAA difference with and without SSA-44 relief, and the pre-death
11
+ * Roth-conversion lever priced as an ordinary scenario patch. Nothing here is
12
+ * a separate model — every number must agree exactly with running the
13
+ * equivalent scenario by hand (guarded by tests).
14
+ */
15
+
16
+ import type { Plan } from '@retiregolden/engine/model/plan'
17
+ import { applyScenarioPatch } from '@retiregolden/engine/scenarios/scenarios'
18
+ import { summarizeProjection } from '@retiregolden/engine/projection/compare'
19
+ import { simulatePlan } from '@retiregolden/engine/projection/simulate'
20
+ import type { ProjectedFilingStatus, ProjectionResult, TaxCalculator } from '@retiregolden/engine/projection/types'
21
+
22
+ export const SURVIVOR_DEATH_AGES = [70, 75, 80, 85, 90] as const
23
+
24
+ export interface SurvivorAnalysisOptions {
25
+ startYear: number
26
+ taxCalculator: TaxCalculator
27
+ /** Death-age grid per first-to-die person; defaults to {@link SURVIVOR_DEATH_AGES}, clamped to plausible ages. */
28
+ deathAges?: number[]
29
+ }
30
+
31
+ /** One contiguous run of a filing status in the projection. */
32
+ export interface FilingSegment {
33
+ fromYear: number
34
+ toYear: number
35
+ status: ProjectedFilingStatus
36
+ }
37
+
38
+ export interface SurvivorYearFacts {
39
+ year: number
40
+ magi: number
41
+ tax: number
42
+ filingStatus: ProjectedFilingStatus
43
+ }
44
+
45
+ export interface SurvivorIrmaaYear {
46
+ year: number
47
+ /** IRMAA tier / annual Medicare premiums under the plain two-year lookback. */
48
+ tierWithoutSsa44: number
49
+ premiumsWithoutSsa44: number
50
+ /** … and with SSA-44 survivor-year redetermination modeled. */
51
+ tierWithSsa44: number
52
+ premiumsWithSsa44: number
53
+ }
54
+
55
+ export interface SurvivorScenarioRow {
56
+ /** The first-to-die person this row models. */
57
+ deceasedPersonId: string
58
+ deathAge: number
59
+ deathYear: number
60
+ /** Compressed filing-status timeline of the whole projection. */
61
+ filingTimeline: FilingSegment[]
62
+ /** The last joint-filing year (the death year) vs the first survivor-filed year. */
63
+ lastJointYear: SurvivorYearFacts
64
+ firstSurvivorYear: SurvivorYearFacts
65
+ /** Household Social Security in the death year vs the first survivor year (nominal). */
66
+ ssBeforeDeath: number
67
+ ssAfterDeath: number
68
+ /** SSA-44 window years (death year + 1, + 2), premiums from two otherwise-identical runs. */
69
+ irmaaYears: SurvivorIrmaaYear[]
70
+ /** Total nominal Medicare premium saved by SSA-44 across all survivor years. */
71
+ ssa44PremiumSavings: number
72
+ /** Survivor years (after the death year) with any required-spending shortfall. */
73
+ survivorShortfallYears: number
74
+ /** Lowest end-of-year investable balance across survivor years (nominal). */
75
+ minSurvivorInvestable: number
76
+ /** Ending after-tax estate / lifetime tax for this death timing, at the plan's own settings (nominal). */
77
+ baseEndingAfterTaxEstate: number
78
+ baseLifetimeTax: number
79
+ /**
80
+ * The pre-death conversion lever: the same death timing with conversions
81
+ * filling the 12% bracket through the last joint year (the widows-penalty
82
+ * detector's preview patch), priced on the same ledger. Null when the patch
83
+ * fails validation (never expected).
84
+ */
85
+ conversionLever: {
86
+ endingAfterTaxEstate: number
87
+ lifetimeTax: number
88
+ estateDelta: number
89
+ lifetimeTaxDelta: number
90
+ } | null
91
+ }
92
+
93
+ export interface SurvivorAnalysis {
94
+ /** False for plans that are not a two-adult married-filing-jointly household. */
95
+ eligible: boolean
96
+ /** Whether the plan itself already models SSA-44 survivor relief. */
97
+ planUsesSsa44: boolean
98
+ rows: SurvivorScenarioRow[]
99
+ /** Death timings whose ledger runs threw and were skipped (0 = clean sweep). */
100
+ failedTimings: number
101
+ /** Set by the page when the whole sweep threw — render an error state, not tables. */
102
+ error?: boolean
103
+ }
104
+
105
+ function dobYearOf(plan: Plan, personId: string): number {
106
+ const p = plan.household.people.find((x) => x.id === personId)!
107
+ return Number(p.dob.slice(0, 4))
108
+ }
109
+
110
+ /** The plan with SSA-44 survivor relief forced on/off (other settings untouched). */
111
+ function withSurvivorSsa44(plan: Plan, on: boolean): Plan {
112
+ return {
113
+ ...plan,
114
+ expenses: {
115
+ ...plan.expenses,
116
+ healthcare: {
117
+ ...plan.expenses.healthcare,
118
+ ssa44: { survivorYears: on, retirementYears: plan.expenses.healthcare.ssa44?.retirementYears ?? false },
119
+ },
120
+ },
121
+ }
122
+ }
123
+
124
+ function filingTimeline(result: ProjectionResult): FilingSegment[] {
125
+ const segments: FilingSegment[] = []
126
+ for (const y of result.years) {
127
+ const last = segments[segments.length - 1]
128
+ if (last && last.status === y.filingStatus) last.toYear = y.year
129
+ else segments.push({ fromYear: y.year, toYear: y.year, status: y.filingStatus })
130
+ }
131
+ return segments
132
+ }
133
+
134
+ /** The widows-penalty detector's conversion-acceleration patch for a given last joint year. */
135
+ export function conversionLeverPatch(startYear: number, lastJointYear: number): Record<string, unknown> {
136
+ return {
137
+ strategies: {
138
+ rothConversion: {
139
+ mode: 'fillToTarget',
140
+ target: 'topOfBracket',
141
+ targetValue: 12,
142
+ startYear,
143
+ endYear: lastJointYear,
144
+ },
145
+ },
146
+ }
147
+ }
148
+
149
+ /**
150
+ * Death ages worth sweeping for a person: the default grid clamped to
151
+ * [current age, planning age), so every row is a genuine "dies earlier than
152
+ * planned" scenario. The current attained age is included — dying at the end
153
+ * of this year is a valid earlier-than-planned timing (`lastAliveYear =
154
+ * dobYear + deathAge ≥ startYear`). Empty when no grid age fits.
155
+ */
156
+ export function candidateDeathAges(plan: Plan, personId: string, startYear: number, grid: number[]): number[] {
157
+ const person = plan.household.people.find((p) => p.id === personId)!
158
+ const currentAge = startYear - dobYearOf(plan, personId)
159
+ return grid.filter((a) => a >= currentAge && a < person.longevity.planningAge)
160
+ }
161
+
162
+ /**
163
+ * One death timing = three full ledger runs: the SSA-44 off/on pair (whichever
164
+ * matches the plan's own setting doubles as the base run — the field is
165
+ * engine-identical to absent when off, and summarize ignores it) plus the
166
+ * conversion-lever run. Returns null when the timing yields no survivor phase.
167
+ */
168
+ function buildTimingRow(
169
+ plan: Plan,
170
+ opts: SurvivorAnalysisOptions,
171
+ personId: string,
172
+ deathAge: number,
173
+ planUsesSsa44: boolean,
174
+ ): SurvivorScenarioRow | null {
175
+ const deathYear = dobYearOf(plan, personId) + deathAge
176
+ const simOpts = { startYear: opts.startYear, taxCalculator: opts.taxCalculator, deathAgeByPersonId: { [personId]: deathAge } }
177
+
178
+ const without = simulatePlan(withSurvivorSsa44(plan, false), simOpts)
179
+ const withRelief = simulatePlan(withSurvivorSsa44(plan, true), simOpts)
180
+ const base = planUsesSsa44 ? withRelief : without
181
+ const baseSummary = summarizeProjection(plan, base)
182
+
183
+ const lastJoint = base.years.find((y) => y.year === deathYear)
184
+ const firstSurvivor = base.years.find((y) => y.year === deathYear + 1)
185
+ if (!lastJoint || !firstSurvivor) return null
186
+
187
+ const irmaaYears: SurvivorIrmaaYear[] = [deathYear + 1, deathYear + 2]
188
+ .map((year) => {
189
+ const off = without.years.find((y) => y.year === year)
190
+ const on = withRelief.years.find((y) => y.year === year)
191
+ if (!off || !on) return null
192
+ return {
193
+ year,
194
+ tierWithoutSsa44: off.irmaaTier,
195
+ premiumsWithoutSsa44: off.medicarePremiums,
196
+ tierWithSsa44: on.irmaaTier,
197
+ premiumsWithSsa44: on.medicarePremiums,
198
+ }
199
+ })
200
+ .filter((x): x is SurvivorIrmaaYear => x !== null)
201
+ let ssa44PremiumSavings = 0
202
+ for (const y of without.years) {
203
+ const on = withRelief.years.find((x) => x.year === y.year)
204
+ if (on) ssa44PremiumSavings += y.medicarePremiums - on.medicarePremiums
205
+ }
206
+
207
+ const survivorYears = base.years.filter((y) => y.year > deathYear && y.people.some((p) => p.alive))
208
+ const survivorShortfallYears = survivorYears.filter((y) => y.requiredShortfall > 0.005).length
209
+ const minSurvivorInvestable = survivorYears.length > 0 ? Math.min(...survivorYears.map((y) => y.investableTotal)) : 0
210
+
211
+ // The conversion lever, priced as an ordinary scenario patch on the same
212
+ // death timing (agrees exactly with adding the scenario by hand).
213
+ let conversionLever: SurvivorScenarioRow['conversionLever'] = null
214
+ const patched = applyScenarioPatch(plan, conversionLeverPatch(opts.startYear, deathYear))
215
+ if (patched.ok) {
216
+ const leverSummary = summarizeProjection(patched.plan, simulatePlan(patched.plan, simOpts))
217
+ conversionLever = {
218
+ endingAfterTaxEstate: leverSummary.endingAfterTaxEstate,
219
+ lifetimeTax: leverSummary.lifetimeTaxesAndPenalties,
220
+ estateDelta: leverSummary.endingAfterTaxEstate - baseSummary.endingAfterTaxEstate,
221
+ lifetimeTaxDelta: leverSummary.lifetimeTaxesAndPenalties - baseSummary.lifetimeTaxesAndPenalties,
222
+ }
223
+ }
224
+
225
+ return {
226
+ deceasedPersonId: personId,
227
+ deathAge,
228
+ deathYear,
229
+ filingTimeline: filingTimeline(base),
230
+ lastJointYear: { year: lastJoint.year, magi: lastJoint.magi, tax: lastJoint.tax, filingStatus: lastJoint.filingStatus },
231
+ firstSurvivorYear: {
232
+ year: firstSurvivor.year,
233
+ magi: firstSurvivor.magi,
234
+ tax: firstSurvivor.tax,
235
+ filingStatus: firstSurvivor.filingStatus,
236
+ },
237
+ ssBeforeDeath: lastJoint.incomes.socialSecurity,
238
+ ssAfterDeath: firstSurvivor.incomes.socialSecurity,
239
+ irmaaYears,
240
+ ssa44PremiumSavings,
241
+ survivorShortfallYears,
242
+ minSurvivorInvestable,
243
+ baseEndingAfterTaxEstate: baseSummary.endingAfterTaxEstate,
244
+ baseLifetimeTax: baseSummary.lifetimeTaxesAndPenalties,
245
+ conversionLever,
246
+ }
247
+ }
248
+
249
+ export function buildSurvivorAnalysis(plan: Plan, opts: SurvivorAnalysisOptions): SurvivorAnalysis {
250
+ const eligible = plan.household.filingStatus === 'marriedFilingJointly' && plan.household.people.length === 2
251
+ const planUsesSsa44 = plan.expenses.healthcare.ssa44?.survivorYears === true
252
+ if (!eligible) return { eligible, planUsesSsa44, rows: [], failedTimings: 0 }
253
+
254
+ const grid = opts.deathAges ?? [...SURVIVOR_DEATH_AGES]
255
+ const rows: SurvivorScenarioRow[] = []
256
+ let failedTimings = 0
257
+
258
+ for (const person of plan.household.people) {
259
+ const survivor = plan.household.people.find((p) => p.id !== person.id)!
260
+ const survivorLastYear = dobYearOf(plan, survivor.id) + survivor.longevity.planningAge
261
+ for (const deathAge of candidateDeathAges(plan, person.id, opts.startYear, grid)) {
262
+ const deathYear = dobYearOf(plan, person.id) + deathAge
263
+ // Only genuine first deaths: the survivor must outlive this timing.
264
+ if (deathYear >= survivorLastYear) continue
265
+ // A throwing timing is skipped (and counted) rather than sinking the
266
+ // whole sweep — the page reports partial results honestly.
267
+ try {
268
+ const row = buildTimingRow(plan, opts, person.id, deathAge, planUsesSsa44)
269
+ if (row) rows.push(row)
270
+ } catch {
271
+ failedTimings += 1
272
+ }
273
+ }
274
+ }
275
+
276
+ return { eligible, planUsesSsa44, rows, failedTimings }
277
+ }
@@ -0,0 +1,19 @@
1
+ export const US_STATES: ReadonlyArray<{ value: string; label: string }> = [
2
+ { value: 'AL', label: 'Alabama' }, { value: 'AK', label: 'Alaska' }, { value: 'AZ', label: 'Arizona' },
3
+ { value: 'AR', label: 'Arkansas' }, { value: 'CA', label: 'California' }, { value: 'CO', label: 'Colorado' },
4
+ { value: 'CT', label: 'Connecticut' }, { value: 'DE', label: 'Delaware' }, { value: 'DC', label: 'District of Columbia' },
5
+ { value: 'FL', label: 'Florida' }, { value: 'GA', label: 'Georgia' }, { value: 'HI', label: 'Hawaii' },
6
+ { value: 'ID', label: 'Idaho' }, { value: 'IL', label: 'Illinois' }, { value: 'IN', label: 'Indiana' },
7
+ { value: 'IA', label: 'Iowa' }, { value: 'KS', label: 'Kansas' }, { value: 'KY', label: 'Kentucky' },
8
+ { value: 'LA', label: 'Louisiana' }, { value: 'ME', label: 'Maine' }, { value: 'MD', label: 'Maryland' },
9
+ { value: 'MA', label: 'Massachusetts' }, { value: 'MI', label: 'Michigan' }, { value: 'MN', label: 'Minnesota' },
10
+ { value: 'MS', label: 'Mississippi' }, { value: 'MO', label: 'Missouri' }, { value: 'MT', label: 'Montana' },
11
+ { value: 'NE', label: 'Nebraska' }, { value: 'NV', label: 'Nevada' }, { value: 'NH', label: 'New Hampshire' },
12
+ { value: 'NJ', label: 'New Jersey' }, { value: 'NM', label: 'New Mexico' }, { value: 'NY', label: 'New York' },
13
+ { value: 'NC', label: 'North Carolina' }, { value: 'ND', label: 'North Dakota' }, { value: 'OH', label: 'Ohio' },
14
+ { value: 'OK', label: 'Oklahoma' }, { value: 'OR', label: 'Oregon' }, { value: 'PA', label: 'Pennsylvania' },
15
+ { value: 'RI', label: 'Rhode Island' }, { value: 'SC', label: 'South Carolina' }, { value: 'SD', label: 'South Dakota' },
16
+ { value: 'TN', label: 'Tennessee' }, { value: 'TX', label: 'Texas' }, { value: 'UT', label: 'Utah' },
17
+ { value: 'VT', label: 'Vermont' }, { value: 'VA', label: 'Virginia' }, { value: 'WA', label: 'Washington' },
18
+ { value: 'WV', label: 'West Virginia' }, { value: 'WI', label: 'Wisconsin' }, { value: 'WY', label: 'Wyoming' },
19
+ ]