@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,556 @@
1
+ /**
2
+ * Social Security entry. Reuses the household profile (date of birth, planning
3
+ * age) so the user only adds what's new per person: the benefit (a quick PIA
4
+ * or an imported earnings record) and a claiming age. The claim-age trade-off
5
+ * analysis lives on Explore → Social Security.
6
+ */
7
+
8
+ import { useState, type ChangeEvent } from 'react'
9
+ import { Link } from 'react-router-dom'
10
+
11
+ import type { FormerSpouse, IncomeStream, Person } from '@retiregolden/engine/model/plan'
12
+ import {
13
+ bendTierForAime,
14
+ CREDITS_FOR_ELIGIBILITY,
15
+ estimateCredits,
16
+ replaceZeroYearGain,
17
+ summarizeComputation,
18
+ } from '../socialSecurity/explain'
19
+ import { effectiveBirthYear, fraForBirthYear } from '@retiregolden/engine/socialSecurity/nra'
20
+ import type { PiaFromEarningsResult } from '@retiregolden/engine/socialSecurity/piaFromEarnings'
21
+ import { parseSsaStatementXml } from '../socialSecurity/ssaStatementXml'
22
+ import { usePlan } from './planContextCore'
23
+ import { CheckboxField, DateField, NumberField, MoneyField, SelectField } from './fields'
24
+ import { LearnAboutScreen } from '../learn/LearnAboutScreen'
25
+ import { fmtMoney } from './format'
26
+ import { dobParts, resolvePia } from './ssAnalysis'
27
+
28
+ const newId = () => crypto.randomUUID()
29
+
30
+ type SsStream = Extract<IncomeStream, { type: 'socialSecurity' }>
31
+
32
+ /** Teaching panel: how the earnings history becomes a PIA (AIME, zero years, bend tier). */
33
+ function AimeExplainer({ detail, sampleEarnings }: { detail: PiaFromEarningsResult; sampleEarnings: number | null }) {
34
+ const s = summarizeComputation(detail)
35
+ const tier = bendTierForAime(detail.aime, detail.eligibilityYear)
36
+ const gain = sampleEarnings && sampleEarnings > 0 ? replaceZeroYearGain(detail, sampleEarnings) : null
37
+ const tierAside =
38
+ tier.label === '90%' ? ' — the most valuable tier' : tier.label === '15%' ? ' — the least valuable tier, so extra earnings add little' : ''
39
+ return (
40
+ <details className="ss-explainer">
41
+ <summary>How this benefit is built — AIME &amp; bend points</summary>
42
+ <ul>
43
+ <li>
44
+ Averages your top <strong>{s.computationYearCount}</strong> earning years (wage-indexed) into an AIME of{' '}
45
+ <strong>{fmtMoney(detail.aime)}/mo</strong>.
46
+ </li>
47
+ {s.zeroYearsInAime > 0 ? (
48
+ <li>
49
+ <strong>{s.zeroYearsInAime}</strong> of those {s.computationYearCount} years are $0 — each one pulls the
50
+ average down.
51
+ </li>
52
+ ) : (
53
+ <li>Every averaged year has earnings — no zero years dragging the average down.</li>
54
+ )}
55
+ <li>
56
+ Your next dollar of AIME is credited at the <strong>{tier.label}</strong> bend-point rate{tierAside}.
57
+ </li>
58
+ {gain !== null && s.zeroYearsInAime > 0 ? (
59
+ <li>
60
+ Replacing one $0 year with about {fmtMoney(sampleEarnings!)} of earnings would add roughly{' '}
61
+ <strong>{fmtMoney(gain)}/mo</strong> — a rough estimate at the current bend rate.
62
+ </li>
63
+ ) : null}
64
+ </ul>
65
+ </details>
66
+ )
67
+ }
68
+
69
+ /** 40-credit (10-year) covered-work eligibility gate with an optional manual override. */
70
+ function EligibilityNote({ stream, onCommitCredits }: { stream: SsStream; onCommitCredits: (v: number | null) => void }) {
71
+ const est = estimateCredits(stream.earnings ?? [], stream.coveredQuarters)
72
+ return (
73
+ <div style={{ marginTop: '0.6rem' }}>
74
+ <div className="form-grid">
75
+ <NumberField
76
+ label="Covered-work credits"
77
+ help="SSA 'credits' (formerly quarters of coverage). You generally need 40 (about 10 years of covered work) to qualify for your own retirement benefit. Leave blank to estimate from the earnings above; set it if you know the exact number."
78
+ hint={est.estimated ? `Blank = estimated ${est.credits} from your earnings (40 needed).` : '40 needed to qualify.'}
79
+ value={stream.coveredQuarters ?? null}
80
+ allowNull
81
+ min={0}
82
+ max={40}
83
+ onCommit={(v) => onCommitCredits(v === null ? null : Math.round(v))}
84
+ />
85
+ </div>
86
+ {!est.eligible ? (
87
+ <div className="callout callout--warn">
88
+ {est.estimated ? 'Estimated' : 'Entered'} <strong>{est.credits}</strong> of the {CREDITS_FOR_ELIGIBILITY}{' '}
89
+ credits needed — not yet eligible for a personal retirement benefit. Add covered-work years above or set the
90
+ credit count if the estimate is off (it assumes 4 credits per substantial year).
91
+ </div>
92
+ ) : null}
93
+ </div>
94
+ )
95
+ }
96
+
97
+ /** Editor for divorced-spousal / survivor benefit records on a former spouse's PIA. */
98
+ function FormerSpousesEditor({
99
+ stream,
100
+ setStream,
101
+ householdIsSingle,
102
+ }: {
103
+ stream: SsStream
104
+ setStream: (mut: (s: SsStream) => void) => void
105
+ householdIsSingle: boolean
106
+ }) {
107
+ const records = stream.formerSpouses ?? []
108
+ const add = (relationship: 'divorced' | 'deceased') =>
109
+ setStream((s) => {
110
+ const list = s.formerSpouses ?? (s.formerSpouses = [])
111
+ list.push({
112
+ id: newId(),
113
+ relationship,
114
+ dob: '1960-01-01',
115
+ piaMonthly: 0,
116
+ marriageYears: relationship === 'divorced' ? 10 : 1,
117
+ remarriedAtAge: null,
118
+ })
119
+ })
120
+ const updateRecord = (id: string, mut: (r: FormerSpouse) => void) =>
121
+ setStream((s) => {
122
+ const r = s.formerSpouses?.find((x) => x.id === id)
123
+ if (r) mut(r)
124
+ })
125
+ const remove = (id: string) =>
126
+ setStream((s) => {
127
+ if (s.formerSpouses) s.formerSpouses = s.formerSpouses.filter((x) => x.id !== id)
128
+ })
129
+
130
+ return (
131
+ <div style={{ marginTop: '0.8rem' }}>
132
+ <h4 style={{ margin: '0 0 0.3rem' }}>Former spouses</h4>
133
+ <p className="card-hint">
134
+ A 10+ year marriage to a living ex (while you're currently unmarried) can pay a divorced-spousal benefit of up to
135
+ half their benefit; a deceased former spouse can pay a survivor benefit. You receive whichever is largest — your
136
+ own, spousal, or survivor — so add any that might apply.
137
+ </p>
138
+ {records.map((r) => (
139
+ <div key={r.id} className="item-row">
140
+ <div className="item-row-head">
141
+ <span className="item-row-title">
142
+ <span className="type-chip">{r.relationship === 'divorced' ? 'Divorced ex' : 'Survivor'}</span>
143
+ </span>
144
+ <button type="button" className="btn-ghost btn-ghost-danger" onClick={() => remove(r.id)}>
145
+ Remove
146
+ </button>
147
+ </div>
148
+ <div className="form-grid">
149
+ <SelectField
150
+ label="Type"
151
+ value={r.relationship}
152
+ options={[
153
+ { value: 'divorced', label: 'Living ex (divorced-spousal)' },
154
+ { value: 'deceased', label: 'Deceased (survivor)' },
155
+ ]}
156
+ onCommit={(v) => updateRecord(r.id, (x) => (x.relationship = v as FormerSpouse['relationship']))}
157
+ />
158
+ <DateField label="Their date of birth" value={r.dob} onCommit={(v) => updateRecord(r.id, (x) => (x.dob = v))} />
159
+ <MoneyField
160
+ label="Their PIA (monthly at FRA)"
161
+ help="Your estimate of the ex/deceased spouse's monthly benefit at their full retirement age, today's dollars."
162
+ value={r.piaMonthly}
163
+ onCommit={(v) => updateRecord(r.id, (x) => (x.piaMonthly = v ?? 0))}
164
+ />
165
+ <NumberField
166
+ label="Years married"
167
+ hint={r.relationship === 'divorced' ? '10+ for divorced-spousal.' : '9 months (0.75) minimum.'}
168
+ value={r.marriageYears}
169
+ min={0}
170
+ max={75}
171
+ step={r.relationship === 'divorced' ? 1 : 0.25}
172
+ onCommit={(v) => updateRecord(r.id, (x) => (x.marriageYears = v ?? 0))}
173
+ />
174
+ {r.relationship === 'deceased' ? (
175
+ <NumberField
176
+ label="Age you remarried"
177
+ help="Remarrying before 60 forfeits this survivor benefit; at or after 60 preserves it. Leave blank if you didn't remarry after this spouse died."
178
+ value={r.remarriedAtAge}
179
+ allowNull
180
+ min={0}
181
+ max={120}
182
+ onCommit={(v) => updateRecord(r.id, (x) => (x.remarriedAtAge = v === null ? null : Math.round(v)))}
183
+ />
184
+ ) : null}
185
+ {r.relationship === 'deceased' ? (
186
+ <NumberField
187
+ label="When they claimed (age)"
188
+ hint="Leave blank if they claimed at/after FRA."
189
+ help="The age the deceased claimed their own benefit. If they claimed early (before FRA), the widow's-limit (RIB-LIM) caps your survivor benefit at the larger of their reduced benefit or 82.5% of their PIA — usually higher than their reduced amount. Leave blank if they claimed at or after FRA (the safe default)."
190
+ value={r.deceasedClaimAge?.years ?? null}
191
+ allowNull
192
+ min={62}
193
+ max={70}
194
+ onCommit={(v) =>
195
+ updateRecord(r.id, (x) => {
196
+ if (v === null) x.deceasedClaimAge = null
197
+ else x.deceasedClaimAge = { years: Math.round(v), months: x.deceasedClaimAge?.months ?? 0 }
198
+ })
199
+ }
200
+ />
201
+ ) : null}
202
+ {r.relationship === 'deceased' && r.deceasedClaimAge ? (
203
+ <NumberField
204
+ label="When they claimed (+ months)"
205
+ value={r.deceasedClaimAge.months}
206
+ min={0}
207
+ max={11}
208
+ onCommit={(v) =>
209
+ updateRecord(r.id, (x) => {
210
+ if (x.deceasedClaimAge) x.deceasedClaimAge = { ...x.deceasedClaimAge, months: Math.round(v ?? 0) }
211
+ })
212
+ }
213
+ />
214
+ ) : null}
215
+ </div>
216
+ {r.relationship === 'divorced' && !householdIsSingle ? (
217
+ <p className="field-hint" style={{ color: 'var(--warn)' }}>
218
+ Divorced-spousal needs you to be currently unmarried — with a partner on this plan it won't apply (you'd get
219
+ the current-spouse top-up instead).
220
+ </p>
221
+ ) : null}
222
+ </div>
223
+ ))}
224
+ <div className="add-row">
225
+ <button type="button" className="btn btn-secondary btn-small" onClick={() => add('divorced')}>
226
+ + Divorced ex-spouse
227
+ </button>
228
+ <button type="button" className="btn btn-secondary btn-small" onClick={() => add('deceased')}>
229
+ + Deceased former spouse
230
+ </button>
231
+ </div>
232
+ </div>
233
+ )
234
+ }
235
+
236
+ function PersonSsCard({ person, personIndex }: { person: Person; personIndex: number }) {
237
+ const { plan, update } = usePlan()
238
+ const stream = plan.incomes.find((s): s is SsStream => s.type === 'socialSecurity' && s.personId === person.id)
239
+ const { y, m, d } = dobParts(person)
240
+ const fra = fraForBirthYear(effectiveBirthYear(y, m, d))
241
+ const [xmlNote, setXmlNote] = useState<{ kind: 'ok' | 'error'; text: string } | null>(null)
242
+
243
+ if (!stream) {
244
+ return (
245
+ <div className="item-row">
246
+ <div className="item-row-head">
247
+ <span className="item-row-title">
248
+ <span className="type-chip">{personIndex === 0 ? 'Primary' : 'Partner'}</span>
249
+ {person.name}
250
+ </span>
251
+ </div>
252
+ <p className="card-hint" style={{ marginBottom: '0.6rem' }}>
253
+ No Social Security benefit is modeled for {person.name} yet.
254
+ </p>
255
+ <button
256
+ type="button"
257
+ className="btn btn-secondary btn-small"
258
+ onClick={() =>
259
+ update((d2) => {
260
+ d2.incomes.push({ type: 'socialSecurity', id: newId(), personId: person.id, piaMonthly: 0, earnings: null, claimAge: { years: 67, months: 0 } })
261
+ })
262
+ }
263
+ >
264
+ + Add Social Security
265
+ </button>
266
+ </div>
267
+ )
268
+ }
269
+
270
+ const streamIndex = plan.incomes.findIndex((s) => s.id === stream.id)
271
+ const mode: 'quick' | 'earnings' = stream.piaMonthly === null ? 'earnings' : 'quick'
272
+ const resolved = resolvePia(person, stream)
273
+
274
+ const earnings = stream.earnings ?? []
275
+ const mostRecentEarnings =
276
+ earnings.length > 0 ? earnings.reduce((a, b) => (b.year >= a.year ? b : a)).amount : null
277
+ const projYears = (resolved.detail?.indexedYears ?? []).filter((y2) => y2.projected)
278
+ const projectedYears = projYears.length
279
+ const projectedRange = projectedYears > 0 ? `${projYears[0]!.year}–${projYears[projectedYears - 1]!.year}` : ''
280
+ const projectedAmount = projectedYears > 0 ? projYears[0]!.rawEarnings : null
281
+
282
+ const setStream = (mut: (s: SsStream) => void) =>
283
+ update((d2) => {
284
+ const s = d2.incomes[streamIndex]
285
+ if (s?.type === 'socialSecurity') mut(s)
286
+ })
287
+
288
+ const handleXml = (e: ChangeEvent<HTMLInputElement>) => {
289
+ const file = e.target.files?.[0]
290
+ e.target.value = ''
291
+ if (!file) return
292
+ void file.text().then((text) => {
293
+ const result = parseSsaStatementXml(text)
294
+ if (!result.ok) {
295
+ setXmlNote({ kind: 'error', text: result.message })
296
+ return
297
+ }
298
+ setStream((s) => {
299
+ s.piaMonthly = null
300
+ s.earnings = result.rows.map((r) => ({ year: r.year, amount: r.amount }))
301
+ })
302
+ setXmlNote({ kind: 'ok', text: `Imported ${result.rows.length} years of earnings from your statement.` })
303
+ })
304
+ }
305
+
306
+ return (
307
+ <div className="item-row">
308
+ <div className="item-row-head">
309
+ <span className="item-row-title">
310
+ <span className="type-chip">{personIndex === 0 ? 'Primary' : 'Partner'}</span>
311
+ {person.name} — full retirement age {fra.years}
312
+ {fra.extraMonths > 0 ? `y ${fra.extraMonths}m` : ''}
313
+ </span>
314
+ <button type="button" className="btn-ghost btn-ghost-danger" onClick={() => update((d2) => void d2.incomes.splice(streamIndex, 1))}>
315
+ Remove
316
+ </button>
317
+ </div>
318
+
319
+ <div className="form-grid">
320
+ <SelectField
321
+ label="Benefit source"
322
+ help="Quick: type the PIA from your SSA statement. Earnings record: paste or import your full earnings history and we compute the PIA (AIME → bend points), which is more accurate if you'll retire before the statement's assumed work-through age."
323
+ value={mode}
324
+ options={[
325
+ { value: 'quick', label: 'Quick — enter PIA' },
326
+ { value: 'earnings', label: 'Earnings record (advanced)' },
327
+ ]}
328
+ onCommit={(v) =>
329
+ setStream((s) => {
330
+ if (v === 'quick') {
331
+ s.piaMonthly = s.piaMonthly ?? 0
332
+ } else {
333
+ s.piaMonthly = null
334
+ s.earnings = s.earnings ?? []
335
+ }
336
+ })
337
+ }
338
+ />
339
+ <NumberField
340
+ label="Claim age (years)"
341
+ help="The age this person starts benefits. Earlier than full retirement age permanently reduces it; waiting past it adds ~8%/year until 70. Compare the options on Explore → Social Security."
342
+ value={stream.claimAge.years}
343
+ min={62}
344
+ max={70}
345
+ onCommit={(v) => setStream((s) => (s.claimAge = { years: Math.round(v ?? 67), months: s.claimAge.months }))}
346
+ />
347
+ <NumberField
348
+ label="Claim age (+ months)"
349
+ value={stream.claimAge.months}
350
+ min={0}
351
+ max={11}
352
+ onCommit={(v) => setStream((s) => (s.claimAge = { years: s.claimAge.years, months: Math.round(v ?? 0) }))}
353
+ />
354
+ </div>
355
+
356
+ <details className="ss-explainer" style={{ marginTop: '0.6rem' }}>
357
+ <summary>Disability (SSDI)</summary>
358
+ <p className="card-hint">
359
+ If you're receiving Social Security disability, your benefit is your <strong>full PIA</strong> (no
360
+ early-retirement reduction) from the onset age, converting to the retirement benefit at FRA at the same
361
+ dollar amount. Earnings above Substantial Gainful Activity (SGA) suspend it before FRA. Leave this off
362
+ for a normal retirement claim.
363
+ </p>
364
+ <div className="form-grid">
365
+ <CheckboxField
366
+ label="Receiving Social Security disability (SSDI)"
367
+ help="SSDI pays your full PIA (no early-retirement reduction) from the disability onset age, is gated by Substantial Gainful Activity, and converts to the retirement benefit at FRA at the same dollar amount (no delayed-retirement credits). It's taxed like retirement benefits."
368
+ value={stream.disability != null}
369
+ onCommit={(on) =>
370
+ setStream((s) => {
371
+ s.disability = on ? { onsetAge: 62 } : undefined
372
+ })
373
+ }
374
+ />
375
+ {stream.disability ? (
376
+ <NumberField
377
+ label="Disability onset age"
378
+ help="The age your disability began. SSDI starts here (not at your retirement claim age) and pays the full PIA. Must be before your full retirement age — an onset at/after FRA is ignored (SSDI converts to retirement at FRA, so it can't start later)."
379
+ value={stream.disability.onsetAge}
380
+ min={40}
381
+ max={75}
382
+ onCommit={(v) =>
383
+ setStream((s) => {
384
+ if (s.disability) s.disability.onsetAge = Math.round(v ?? 62)
385
+ })
386
+ }
387
+ />
388
+ ) : null}
389
+ </div>
390
+ </details>
391
+
392
+ {mode === 'quick' ? (
393
+ <div className="form-grid">
394
+ <MoneyField
395
+ label="PIA (monthly benefit at FRA)"
396
+ help="Your Primary Insurance Amount — the monthly benefit at full retirement age in today's dollars, from ssa.gov/myaccount."
397
+ value={stream.piaMonthly}
398
+ allowNull
399
+ onCommit={(v) => setStream((s) => (s.piaMonthly = v ?? 0))}
400
+ />
401
+ </div>
402
+ ) : (
403
+ <div className="earnings-entry">
404
+ <label className="field-label" htmlFor={`earn-${stream.id}`}>
405
+ Annual covered earnings — one <code>year amount</code> per line
406
+ </label>
407
+ <textarea
408
+ id={`earn-${stream.id}`}
409
+ className="earnings-textarea"
410
+ value={(stream.earnings ?? []).map((r) => `${r.year} ${r.amount}`).join('\n')}
411
+ onChange={(e) =>
412
+ setStream((s) => {
413
+ s.piaMonthly = null
414
+ s.earnings = e.target.value
415
+ .split('\n')
416
+ .map((line) => line.trim().split(/[\s,]+/))
417
+ .filter((parts) => parts.length >= 2)
418
+ .map((parts) => ({ year: Number(parts[0]), amount: Number(parts[1]) }))
419
+ .filter((r) => Number.isFinite(r.year) && Number.isFinite(r.amount))
420
+ })
421
+ }
422
+ placeholder={'1995 28500\n1996 31200\n…'}
423
+ />
424
+ <div className="add-row">
425
+ <label className="btn btn-secondary btn-small" style={{ cursor: 'pointer' }}>
426
+ Import mySSA statement (XML)
427
+ <input type="file" accept=".xml,application/xml,text/xml" className="sr-only" onChange={handleXml} />
428
+ </label>
429
+ </div>
430
+ {xmlNote ? (
431
+ <p className={xmlNote.kind === 'error' ? 'error-text' : 'field-hint'} style={{ marginTop: '0.4rem' }}>
432
+ {xmlNote.text}
433
+ </p>
434
+ ) : null}
435
+
436
+ <div className="earnings-projection" style={{ marginTop: '0.6rem' }}>
437
+ <CheckboxField
438
+ label="Project future earnings until retirement"
439
+ help="An SSA statement assumes you keep earning until full retirement age. If you'll stop sooner, that overstates your benefit; leaving the gap years at $0 understates it. This fills the years between your last reported year and your retirement age at an assumed wage."
440
+ value={stream.earningsProjection != null}
441
+ onCommit={(on) =>
442
+ setStream((s) => {
443
+ s.earningsProjection = on ? { assumedAnnualEarnings: null, throughAge: null } : null
444
+ })
445
+ }
446
+ />
447
+ {stream.earningsProjection != null ? (
448
+ <div className="form-grid">
449
+ <MoneyField
450
+ label="Assumed annual earnings"
451
+ help="Covered wages to assume for each projected year, in today's dollars."
452
+ hint={
453
+ mostRecentEarnings !== null
454
+ ? `Blank = reuse your most recent year (${fmtMoney(mostRecentEarnings)}).`
455
+ : 'Blank = reuse your most recent year.'
456
+ }
457
+ value={stream.earningsProjection.assumedAnnualEarnings}
458
+ allowNull
459
+ onCommit={(v) =>
460
+ setStream((s) => {
461
+ if (s.earningsProjection) s.earningsProjection.assumedAnnualEarnings = v
462
+ })
463
+ }
464
+ />
465
+ <NumberField
466
+ label="Work through age"
467
+ help="Project earnings up to (but not including) this age — your last full working year. Defaults to this person's retirement age."
468
+ hint={person.retirementAge !== null ? `Blank = retirement age (${person.retirementAge}).` : 'Blank = retirement age.'}
469
+ value={stream.earningsProjection.throughAge}
470
+ allowNull
471
+ min={50}
472
+ max={75}
473
+ onCommit={(v) =>
474
+ setStream((s) => {
475
+ if (s.earningsProjection) s.earningsProjection.throughAge = v === null ? null : Math.round(v)
476
+ })
477
+ }
478
+ />
479
+ </div>
480
+ ) : null}
481
+ </div>
482
+
483
+ {resolved.piaMonthly !== null ? (
484
+ <p className="field-hint" style={{ marginTop: '0.4rem' }}>
485
+ Computed PIA: <strong>{fmtMoney(resolved.piaMonthly)}/mo</strong> at full retirement age.
486
+ {projectedYears > 0 ? (
487
+ <>
488
+ {' '}
489
+ Includes <strong>{projectedYears}</strong> projected {projectedYears === 1 ? 'year' : 'years'} (
490
+ {projectedRange}) at {fmtMoney(projectedAmount ?? 0)}/yr — fewer zero years than the SSA statement
491
+ would assume if you retire early.
492
+ </>
493
+ ) : null}
494
+ </p>
495
+ ) : null}
496
+ {stream.earningsProjection != null && projectedYears === 0 && resolved.piaMonthly !== null ? (
497
+ <p className="field-hint" style={{ marginTop: '0.4rem', color: 'var(--warn)' }}>
498
+ No years were projected — your reported earnings already reach your retirement age, or no retirement age is
499
+ set on the Household step.
500
+ </p>
501
+ ) : null}
502
+
503
+ {resolved.detail ? (
504
+ <AimeExplainer detail={resolved.detail} sampleEarnings={projectedAmount ?? mostRecentEarnings} />
505
+ ) : null}
506
+
507
+ <EligibilityNote
508
+ stream={stream}
509
+ onCommitCredits={(v) => setStream((s) => (s.coveredQuarters = v))}
510
+ />
511
+ </div>
512
+ )}
513
+
514
+ <FormerSpousesEditor stream={stream} setStream={setStream} householdIsSingle={plan.household.people.length === 1} />
515
+
516
+ {resolved.warning ? <p className="field-hint" style={{ color: 'var(--warn)' }}>{resolved.warning}</p> : null}
517
+ </div>
518
+ )
519
+ }
520
+
521
+ export function SocialSecuritySection() {
522
+ const { plan } = usePlan()
523
+ const couple = plan.household.people.length === 2
524
+ return (
525
+ <section>
526
+ <div className="card">
527
+ <h2>Social Security</h2>
528
+ <p className="card-hint">
529
+ Birth dates and planning ages come from the Household form — here you add each person's benefit and a claiming
530
+ age. Monthly precision, the earnings test, spousal and survivor benefits, and an optional trust-fund cut (see
531
+ Assumptions) are all modeled.
532
+ </p>
533
+ {couple ? (
534
+ <div className="callout callout--info">
535
+ For couples, the survivor keeps the larger of the two benefits, so delaying the <em>higher</em> earner's
536
+ claim protects both lifetimes. The analysis page works out the best combination for your whole plan.
537
+ </div>
538
+ ) : null}
539
+ {plan.household.people.map((p, i) => (
540
+ <PersonSsCard key={p.id} person={p} personIndex={i} />
541
+ ))}
542
+ <div className="callout callout--info" style={{ marginTop: '1rem' }}>
543
+ <strong>Claiming early while still working?</strong> The earnings test withholds part of the benefit above an
544
+ annual wage limit before full retirement age — RetireGolden models that withholding, and credits the withheld
545
+ months back at full retirement age (recomputing the benefit as if you'd claimed that many months later), as an
546
+ annual approximation.
547
+ </div>
548
+ <p className="muted small" style={{ marginTop: '0.75rem' }}>
549
+ Not sure when to claim?{' '}
550
+ <Link to="../social-security-analysis">Compare claiming ages on the Social Security analysis page →</Link>
551
+ </p>
552
+ <LearnAboutScreen route="/plan/:planId/social-security" />
553
+ </div>
554
+ </section>
555
+ )
556
+ }