@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,107 @@
1
+ /**
2
+ * Social Security break-even education (V7 phase 2). Pure cumulative-benefit math
3
+ * for a single person's own retirement benefit across a few claim ages, with COLA
4
+ * and an optional investment-growth assumption. This is the simple pedagogical
5
+ * lens; the whole-plan claiming sweep (ssAnalysis) is the more complete answer.
6
+ *
7
+ * Models one person's benefit only — spousal/survivor interactions are out of
8
+ * scope here (the sweep handles those). COLA accrues from age 62 for every claim
9
+ * age (SSA applies COLAs from 62 even before you claim). With a growth rate, each
10
+ * year's benefit compounds to the comparison age, so a higher return lets the
11
+ * early claimant's head start hold longer and pushes break-even later.
12
+ *
13
+ * @see DOCS/features/social-security.md §3.2
14
+ */
15
+
16
+ import { claimFactor } from '@retiregolden/engine/socialSecurity/claimFactor'
17
+
18
+ export interface BreakEvenInput {
19
+ dob: { year: number; month: number; day: number }
20
+ /** Monthly PIA at full retirement age, today's dollars. */
21
+ piaMonthly: number
22
+ /** Whole-year claim ages to compare (e.g. [62, fra, 70]). */
23
+ claimAges: number[]
24
+ /** Annual cost-of-living adjustment, percent. */
25
+ colaPct: number
26
+ /** Annual investment return on benefits received, percent. 0 = straight cumulative. */
27
+ growthPct: number
28
+ /** Highest age to chart (e.g. planning age). */
29
+ throughAge: number
30
+ }
31
+
32
+ export interface BreakEvenPoint {
33
+ age: number
34
+ /** Cumulative value at this age, keyed by claim age. */
35
+ cumulative: Record<number, number>
36
+ }
37
+
38
+ export interface BreakEvenCrossing {
39
+ early: number
40
+ late: number
41
+ /** Age (to 0.1yr) the later claim's cumulative overtakes the earlier; null if not within throughAge. */
42
+ age: number | null
43
+ }
44
+
45
+ export interface BreakEvenResult {
46
+ series: BreakEvenPoint[]
47
+ crossings: BreakEvenCrossing[]
48
+ /** Benefit as a fraction of PIA, by claim age. */
49
+ factors: Record<number, number>
50
+ }
51
+
52
+ const FIRST_AGE = 62
53
+
54
+ export function computeBreakEven(input: BreakEvenInput): BreakEvenResult {
55
+ const { dob, piaMonthly, claimAges, colaPct, growthPct, throughAge } = input
56
+ const cola = colaPct / 100
57
+ const g = growthPct / 100
58
+
59
+ const factors: Record<number, number> = {}
60
+ for (const a of claimAges) {
61
+ factors[a] = claimFactor(dob.year, dob.month, dob.day, { years: a, months: 0 })
62
+ }
63
+
64
+ /** Annual benefit received at `age` for a person who claimed at `a` (0 before claim). */
65
+ const annualBenefit = (a: number, age: number): number =>
66
+ age < a ? 0 : piaMonthly * factors[a]! * 12 * Math.pow(1 + cola, age - FIRST_AGE)
67
+
68
+ const series: BreakEvenPoint[] = []
69
+ const balance: Record<number, number> = {}
70
+ for (const a of claimAges) balance[a] = 0
71
+
72
+ for (let age = FIRST_AGE; age <= throughAge; age++) {
73
+ const cumulative: Record<number, number> = {}
74
+ for (const a of claimAges) {
75
+ balance[a] = balance[a]! * (1 + g) + annualBenefit(a, age)
76
+ cumulative[a] = Math.round(balance[a]!)
77
+ }
78
+ series.push({ age, cumulative })
79
+ }
80
+
81
+ const sorted = [...claimAges].sort((x, y) => x - y)
82
+ const crossings: BreakEvenCrossing[] = []
83
+ for (let i = 0; i < sorted.length; i++) {
84
+ for (let j = i + 1; j < sorted.length; j++) {
85
+ const early = sorted[i]!
86
+ const late = sorted[j]!
87
+ let crossAge: number | null = null
88
+ let prevDiff: number | null = null
89
+ for (const pt of series) {
90
+ // Before the earlier claim begins, both are $0 — nothing to compare yet.
91
+ if (pt.cumulative[early]! <= 0) continue
92
+ const diff = pt.cumulative[late]! - pt.cumulative[early]!
93
+ if (diff >= 0) {
94
+ crossAge =
95
+ prevDiff !== null && prevDiff < 0
96
+ ? pt.age - 1 + -prevDiff / (diff - prevDiff)
97
+ : pt.age
98
+ break
99
+ }
100
+ prevDiff = diff
101
+ }
102
+ crossings.push({ early, late, age: crossAge === null ? null : Math.round(crossAge * 10) / 10 })
103
+ }
104
+ }
105
+
106
+ return { series, crossings, factors }
107
+ }
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Mortality-weighted expected present value of a Social Security claiming
3
+ * strategy — the "benefits only" actuarial view: weight each future year's
4
+ * benefit by the probability of being
5
+ * alive to receive it, and discount at a real rate (benefits are CPI-indexed,
6
+ * so working in today's dollars with a real rate makes COLA and inflation
7
+ * cancel).
8
+ *
9
+ * Survival curve: the repo's SSA period table stores remaining-life-expectancy
10
+ * e(x), not death probabilities, so we recover one-year survival from the
11
+ * standard identity p(x) = l(x+1)/l(x) = (e(x) − 0.5) / (e(x+1) + 0.5)
12
+ * (half-year-of-life convention). This avoids transcribing a second large
13
+ * q(x) table and matches the SSA e(x) data already used by the longevity
14
+ * model. An optional multiplier scales e(x) to reflect a person's longevity
15
+ * questionnaire result (approximate).
16
+ *
17
+ * Simplifications: annual (not monthly) cashflows; claim months ignored
18
+ * (whole-year claim ages, matching the analysis grid); couples assume
19
+ * independent lifetimes; spousal top-up and survivor step-up mirror the
20
+ * projection engine. See DOCS/features/social-security.md.
21
+ */
22
+
23
+ import { FEMALE, MALE } from '@retiregolden/engine/longevity/ssaPeriod2022'
24
+ import type { Sex } from '@retiregolden/engine/longevity/types'
25
+ import { claimFactor, spousalBenefitFactor, type ClaimAge } from '@retiregolden/engine/socialSecurity/claimFactor'
26
+
27
+ function table(sex: Sex): readonly number[] {
28
+ return sex === 'male' ? MALE : sex === 'female' ? FEMALE : MALE // 'average' handled in remainingYears
29
+ }
30
+
31
+ /** Remaining life expectancy e(x) at integer age, optionally scaled by a longevity multiplier. */
32
+ function remainingYears(age: number, sex: Sex, multiplier: number): number {
33
+ const i = Math.max(0, Math.min(Math.round(age), MALE.length - 1))
34
+ const raw = sex === 'average' ? (MALE[i]! + FEMALE[i]!) / 2 : table(sex)[i]!
35
+ return raw * multiplier
36
+ }
37
+
38
+ /** One-year survival probability p(age) = P(alive at age+1 | alive at age). */
39
+ function oneYearSurvival(age: number, sex: Sex, multiplier: number): number {
40
+ const ex = remainingYears(age, sex, multiplier)
41
+ const exNext = remainingYears(age + 1, sex, multiplier)
42
+ const p = (ex - 0.5) / (exNext + 0.5)
43
+ return Math.max(0, Math.min(1, p))
44
+ }
45
+
46
+ export interface SurvivalCurve {
47
+ /** P(alive at integer `toAge` | alive at integer `fromAge`). 1 when toAge ≤ fromAge. */
48
+ survival(fromAge: number, toAge: number): number
49
+ }
50
+
51
+ export function survivalCurve(sex: Sex, longevityMultiplier = 1): SurvivalCurve {
52
+ const m = longevityMultiplier > 0 ? longevityMultiplier : 1
53
+ // Precompute cumulative survival from age 0 so survival(a,b) = S(b)/S(a).
54
+ const cum: number[] = [1]
55
+ for (let age = 0; age < MALE.length; age++) {
56
+ cum.push(cum[age]! * oneYearSurvival(age, sex, m))
57
+ }
58
+ return {
59
+ survival(fromAge: number, toAge: number): number {
60
+ if (toAge <= fromAge) return 1
61
+ const a = Math.max(0, Math.min(Math.round(fromAge), cum.length - 1))
62
+ const b = Math.max(0, Math.min(Math.round(toAge), cum.length - 1))
63
+ if (cum[a]! <= 0) return 0
64
+ return cum[b]! / cum[a]!
65
+ },
66
+ }
67
+ }
68
+
69
+ export interface ClaimantInput {
70
+ /** Whole age today (the present value's t=0 reference). */
71
+ currentAge: number
72
+ dob: { year: number; month: number; day: number }
73
+ sex: Sex
74
+ piaMonthly: number
75
+ claimAge: ClaimAge
76
+ /** Scales SSA remaining-years (e.g. from the longevity questionnaire); default 1. */
77
+ longevityMultiplier?: number
78
+ /**
79
+ * Optional monthly benefit floor the claimant receives instead of their own
80
+ * once claimed, if larger — e.g. a divorced-spousal benefit on an ex's record.
81
+ * Already reduced for the claim age by the caller.
82
+ */
83
+ benefitFloorMonthly?: number
84
+ }
85
+
86
+ export interface ExpectedPvOptions {
87
+ /** Real annual discount rate, e.g. 0.02 for ~2% (≈ long TIPS yield). */
88
+ discountRate: number
89
+ /** Highest age to sum benefits through; defaults to the table's top. */
90
+ maxAge?: number
91
+ }
92
+
93
+ /** Annual benefit (today's dollars) once claimed, ignoring claim months. */
94
+ function annualBenefit(c: ClaimantInput): number {
95
+ const factor = claimFactor(c.dob.year, c.dob.month, c.dob.day, { years: c.claimAge.years, months: 0 })
96
+ const own = c.piaMonthly * factor
97
+ return Math.max(own, c.benefitFloorMonthly ?? 0) * 12
98
+ }
99
+
100
+ /** Expected PV of one person's own retirement benefit. */
101
+ export function expectedPvSingle(c: ClaimantInput, opts: ExpectedPvOptions): number {
102
+ const maxAge = opts.maxAge ?? MALE.length - 1
103
+ const curve = survivalCurve(c.sex, c.longevityMultiplier)
104
+ const benefit = annualBenefit(c)
105
+ const startAge = Math.max(c.currentAge, c.claimAge.years)
106
+ let pv = 0
107
+ for (let age = startAge; age <= maxAge; age++) {
108
+ const t = age - c.currentAge
109
+ const discount = Math.pow(1 + opts.discountRate, -t)
110
+ pv += curve.survival(c.currentAge, age) * benefit * discount
111
+ }
112
+ return pv
113
+ }
114
+
115
+ /**
116
+ * Expected PV of a couple's combined benefits under a claiming strategy,
117
+ * including the spousal top-up (lower earner lifted to 50% of the higher PIA
118
+ * while both alive and both claimed) and the survivor step-up (survivor keeps
119
+ * the larger of the two benefits). Lifetimes assumed independent.
120
+ */
121
+ export function expectedPvCouple(a: ClaimantInput, b: ClaimantInput, opts: ExpectedPvOptions): number {
122
+ const maxAge = opts.maxAge ?? MALE.length - 1
123
+ const curveA = survivalCurve(a.sex, a.longevityMultiplier)
124
+ const curveB = survivalCurve(b.sex, b.longevityMultiplier)
125
+ const benefitA = annualBenefit(a)
126
+ const benefitB = annualBenefit(b)
127
+ const lowerIsA = a.piaMonthly < b.piaMonthly
128
+ const higher = lowerIsA ? b : a
129
+ const lower = lowerIsA ? a : b
130
+ // Spousal benefit (50% of higher PIA) reduced for the lower earner's own claim age.
131
+ const spousalAnnual =
132
+ 0.5 * higher.piaMonthly * spousalBenefitFactor(lower.dob.year, lower.dob.month, lower.dob.day, lower.claimAge) * 12
133
+
134
+ let pv = 0
135
+ // Step over calendar years from now until both spouses are past the table.
136
+ for (let t = 0; ; t++) {
137
+ const ageA = a.currentAge + t
138
+ const ageB = b.currentAge + t
139
+ if (ageA > maxAge && ageB > maxAge) break
140
+ const sA = curveA.survival(a.currentAge, ageA)
141
+ const sB = curveB.survival(b.currentAge, ageB)
142
+
143
+ const aClaimed = ageA >= a.claimAge.years
144
+ const bClaimed = ageB >= b.claimAge.years
145
+ const ownA = aClaimed ? benefitA : 0
146
+ const ownB = bClaimed ? benefitB : 0
147
+
148
+ // Both alive: spousal top-up lifts the lower earner once both have claimed.
149
+ let bothAlive = ownA + ownB
150
+ if (aClaimed && bClaimed) {
151
+ const lowerOwn = lowerIsA ? ownA : ownB
152
+ const otherOwn = lowerIsA ? ownB : ownA
153
+ bothAlive = otherOwn + Math.max(lowerOwn, spousalAnnual)
154
+ }
155
+
156
+ // One survivor: keeps the larger of the two claimed benefits.
157
+ const survivorBenefit = Math.max(ownA, ownB)
158
+
159
+ const expected =
160
+ sA * sB * bothAlive + sA * (1 - sB) * survivorBenefit + sB * (1 - sA) * survivorBenefit
161
+ pv += expected * Math.pow(1 + opts.discountRate, -t)
162
+ }
163
+ return pv
164
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Social Security explainer helpers (V7 phase 1). Pure functions that turn the
3
+ * earnings-mode PIA computation into teaching points: which bend-point tier the
4
+ * next dollar lands in, how many zero years drag down the average, the marginal
5
+ * value of replacing a zero year, and a rough credits-of-coverage eligibility
6
+ * estimate. No engine behavior depends on these — they only feed copy.
7
+ *
8
+ * @see DOCS/features/social-security.md §3.1
9
+ * @see DOCS/features/social-security.md
10
+ */
11
+
12
+ import type { PiaFromEarningsResult, YearEarning } from '@retiregolden/engine/socialSecurity/piaFromEarnings'
13
+ import { bendPointsForEligibilityYearOrLatest } from '@retiregolden/engine/socialSecurity/ssaWageData'
14
+
15
+ /**
16
+ * SSA quarter-of-coverage ("credit") earnings amount — 2025 figure. One credit
17
+ * is earned per this much in covered wages, up to 4 per year. Used only to
18
+ * estimate eligibility for the warning; the user can override the credit count.
19
+ * @see https://www.ssa.gov/oact/cola/QC.html
20
+ */
21
+ export const CREDIT_EARNINGS = 1_810
22
+ export const CREDITS_FOR_ELIGIBILITY = 40
23
+
24
+ export interface BendTier {
25
+ /** Replacement rate applied to the next dollar of AIME. */
26
+ label: '90%' | '32%' | '15%'
27
+ marginalRate: number
28
+ first: number
29
+ second: number
30
+ }
31
+
32
+ /** Which bend-point tier the next dollar of AIME falls into for this eligibility year. */
33
+ export function bendTierForAime(aime: number, eligibilityYear: number): BendTier {
34
+ const bp = bendPointsForEligibilityYearOrLatest(eligibilityYear)
35
+ if (aime < bp.first) return { label: '90%', marginalRate: 0.9, first: bp.first, second: bp.second }
36
+ if (aime < bp.second) return { label: '32%', marginalRate: 0.32, first: bp.first, second: bp.second }
37
+ return { label: '15%', marginalRate: 0.15, first: bp.first, second: bp.second }
38
+ }
39
+
40
+ export interface ComputationSummary {
41
+ /** Years averaged into AIME (the top 35, or fewer if the history is short). */
42
+ computationYearCount: number
43
+ /** How many of those averaged years are $0 — each is a "replace a zero year" opportunity. */
44
+ zeroYearsInAime: number
45
+ /** AIME divisor in months (12 × computationYearCount). */
46
+ divisorMonths: number
47
+ }
48
+
49
+ export function summarizeComputation(result: PiaFromEarningsResult): ComputationSummary {
50
+ const zeroYearsInAime = result.yearsUsedInAime.filter((v) => v === 0).length
51
+ return {
52
+ computationYearCount: result.computationYearCount,
53
+ zeroYearsInAime,
54
+ divisorMonths: 12 * result.computationYearCount,
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Marginal monthly-PIA gain from replacing one $0 computation year with a year
60
+ * of `indexedAnnual` covered earnings: AIME rises by indexedAnnual / divisor,
61
+ * then the next-dollar bend rate applies. A rough teaching estimate, not the
62
+ * exact recomputation (which would re-sort the top 35).
63
+ */
64
+ export function replaceZeroYearGain(result: PiaFromEarningsResult, indexedAnnual: number): number {
65
+ if (indexedAnnual <= 0 || result.computationYearCount <= 0) return 0
66
+ const aimeDelta = indexedAnnual / (12 * result.computationYearCount)
67
+ return aimeDelta * bendTierForAime(result.aime, result.eligibilityYear).marginalRate
68
+ }
69
+
70
+ export interface CreditEstimate {
71
+ credits: number
72
+ eligible: boolean
73
+ /** True when derived from earnings rather than a user-entered count. */
74
+ estimated: boolean
75
+ }
76
+
77
+ /**
78
+ * Estimate covered-work credits (max 40). An explicit `override` wins; otherwise
79
+ * count up to 4 credits per year with covered earnings, using the current credit
80
+ * threshold (conservative for low-wage older years — the user can override).
81
+ */
82
+ export function estimateCredits(earnings: YearEarning[], override: number | null | undefined): CreditEstimate {
83
+ if (override != null) {
84
+ return { credits: override, eligible: override >= CREDITS_FOR_ELIGIBILITY, estimated: false }
85
+ }
86
+ let credits = 0
87
+ for (const e of earnings) {
88
+ if (e.amount > 0) credits += Math.min(4, Math.floor(e.amount / CREDIT_EARNINGS))
89
+ }
90
+ credits = Math.min(CREDITS_FOR_ELIGIBILITY, credits)
91
+ return { credits, eligible: credits >= CREDITS_FOR_ELIGIBILITY, estimated: true }
92
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * "What you paid in vs. what you get back" — a pure education/context readout
3
+ * for Social Security, NOT a working-years tax inside the projection (the engine
4
+ * never taxes pre-retirement wages; this is an analysis helper called from the SS
5
+ * analysis panel). Cited in DOCS/domain/domain-rules-reference.md §4.
6
+ *
7
+ * - **Paid in** = the OASDI (Social Security portion) payroll tax over the
8
+ * entered earnings history, capped at each year's taxable wage base. Employee
9
+ * share = 6.2%; self-employed = 12.4% (employee + employer combined). The 1.45%
10
+ * Medicare HI is intentionally excluded (the "get back" side is the SS
11
+ * benefit, not Medicare). The employer's 6.2% is returned as optional context.
12
+ * - **Get back** reuses the tested `expectedPvSingle` path (mortality-weighted
13
+ * expected PV at the chosen claim age), so the "return" is the survival-
14
+ * weighted PV of lifetime benefits in today's dollars.
15
+ *
16
+ * Documented simplifications: the OASDI rate is applied uniformly over the
17
+ * career (historical rates were slightly lower pre-1990); the self-employed
18
+ * half-deductibility of SE tax is ignored; the figure is an individual-level
19
+ * illustration, not the program's actuarial return, and excludes disability and
20
+ * survivor insurance value, spousal benefits, and the Medicare portion.
21
+ */
22
+
23
+ import { WAGE_BASE_BY_YEAR } from '@retiregolden/engine/socialSecurity/ssaWageData'
24
+
25
+ export interface FicaPaidInResult {
26
+ /** Employee (or self-employed total) OASDI paid over the earnings history, today's $. */
27
+ paidIn: number
28
+ /** Employer-side OASDI (context only; 0 for self-employed since it's in paidIn). */
29
+ employerPaid: number
30
+ }
31
+
32
+ export interface FicaPaidInOptions {
33
+ /** Employee-side OASDI rate as a percent (e.g. 6.2). From the parameter pack. */
34
+ oasdiEmployeeRatePct: number
35
+ /** Self-employed ⇒ paidIn doubles (employee + employer combined = 12.4%). */
36
+ selfEmployed: boolean
37
+ /**
38
+ * Per-year OASDI taxable wage base. Defaults to the SSA historical series
39
+ * (`WAGE_BASE_BY_YEAR`); the caller may override (e.g., a grown future cap).
40
+ */
41
+ wageBaseByYear?: Readonly<Record<number, number>>
42
+ /** Wage base for earnings years beyond the table (defaults to the latest known). */
43
+ wageBaseFallback?: number
44
+ }
45
+
46
+ /**
47
+ * Sum the OASDI payroll tax over an earnings history (employee or self-employed),
48
+ * capped at each year's taxable wage base. Today's dollars (nominal history; no
49
+ * indexing — this is "what you literally paid," not wage-indexed like AIME).
50
+ */
51
+ export function ficaOasdiPaidIn(
52
+ earnings: ReadonlyArray<{ year: number; amount: number }>,
53
+ opts: FicaPaidInOptions,
54
+ ): FicaPaidInResult {
55
+ const table = opts.wageBaseByYear ?? WAGE_BASE_BY_YEAR
56
+ const fallback = opts.wageBaseFallback ?? latestWageBase(table)
57
+ const rate = opts.oasdiEmployeeRatePct / 100
58
+ let employee = 0
59
+ let employer = 0
60
+ for (const { year, amount } of earnings) {
61
+ if (amount <= 0) continue
62
+ const cap = table[year] ?? fallback
63
+ const capped = cap > 0 ? Math.min(amount, cap) : amount
64
+ if (opts.selfEmployed) {
65
+ employee += capped * rate * 2 // 12.4% total (employee + employer)
66
+ } else {
67
+ employee += capped * rate // 6.2% employee
68
+ employer += capped * rate // 6.2% employer (context)
69
+ }
70
+ }
71
+ return { paidIn: employee, employerPaid: opts.selfEmployed ? 0 : employer }
72
+ }
73
+
74
+ function latestWageBase(table: Readonly<Record<number, number>>): number {
75
+ let latest = 0
76
+ for (const y of Object.keys(table)) {
77
+ const year = Number(y)
78
+ if (Number.isFinite(year) && table[year]! > latest) latest = table[year]!
79
+ }
80
+ return latest
81
+ }
@@ -0,0 +1,138 @@
1
+ import type { HouseholdMode, PiaSourceMode, QuickPiaKind, SsFormSnapshot, SsPersisted } from './storage'
2
+
3
+ function isRecord(x: unknown): x is Record<string, unknown> {
4
+ return typeof x === 'object' && x !== null && !Array.isArray(x)
5
+ }
6
+
7
+ function num(x: unknown): number | null {
8
+ return typeof x === 'number' && Number.isFinite(x) ? x : null
9
+ }
10
+
11
+ function str(x: unknown): string | null {
12
+ return typeof x === 'string' ? x : null
13
+ }
14
+
15
+ function oneOf<T extends string>(x: unknown, allowed: readonly T[]): T | undefined {
16
+ if (x === undefined) return undefined
17
+ return typeof x === 'string' && (allowed as readonly string[]).includes(x) ? (x as T) : undefined
18
+ }
19
+
20
+ function claimAgesList(x: unknown): number[] | null {
21
+ if (!Array.isArray(x)) return null
22
+ const out: number[] = []
23
+ for (const a of x) {
24
+ const n = num(a)
25
+ if (n == null || n < 62 || n > 70) return null
26
+ out.push(n)
27
+ }
28
+ return out.length >= 2 ? out : null
29
+ }
30
+
31
+ function partnerClaimAgesList(x: unknown): number[] | null {
32
+ if (!Array.isArray(x)) return null
33
+ const out: number[] = []
34
+ for (const a of x) {
35
+ const n = num(a)
36
+ if (n == null || n < 62 || n > 70) return null
37
+ out.push(n)
38
+ }
39
+ return out.length >= 1 ? out : null
40
+ }
41
+
42
+ /** Returns null if JSON shape does not match v1 SS persisted form (schema drift / corruption). */
43
+ export function parseSsPersistedLoose(raw: unknown): SsPersisted | null {
44
+ if (!isRecord(raw)) return null
45
+ if (raw.version !== 1) return null
46
+ const formRaw = raw.form
47
+ if (!isRecord(formRaw)) return null
48
+
49
+ const dob = str(formRaw.dob)
50
+ const piaMonthly = num(formRaw.piaMonthly)
51
+ const claimAges = claimAgesList(formRaw.claimAges)
52
+ const endAge = num(formRaw.endAge)
53
+ const colaPercent = num(formRaw.colaPercent)
54
+ const discountPercent = num(formRaw.discountPercent)
55
+ if (!dob || piaMonthly == null || !claimAges || endAge == null || colaPercent == null || discountPercent == null) {
56
+ return null
57
+ }
58
+ if (endAge < 63 || endAge > 110) return null
59
+
60
+ let partnerClaimAges: number[] | undefined
61
+ if (formRaw.partnerClaimAges !== undefined) {
62
+ const pa = partnerClaimAgesList(formRaw.partnerClaimAges)
63
+ if (pa == null) return null
64
+ partnerClaimAges = pa
65
+ }
66
+
67
+ const householdMode = oneOf<HouseholdMode>(formRaw.householdMode, ['single', 'couple'])
68
+ const piaSource = oneOf<PiaSourceMode>(formRaw.piaSource, ['quick', 'earnings'])
69
+ const partnerPiaSource = oneOf<PiaSourceMode>(formRaw.partnerPiaSource, ['quick', 'earnings'])
70
+ const quickPiaKind = oneOf<QuickPiaKind>(formRaw.quickPiaKind, ['authoritative', 'ssa_estimate'])
71
+ const partnerQuickPiaKind = oneOf<QuickPiaKind>(formRaw.partnerQuickPiaKind, [
72
+ 'authoritative',
73
+ 'ssa_estimate',
74
+ ])
75
+
76
+ const form: SsFormSnapshot = {
77
+ householdMode,
78
+ dob,
79
+ piaSource,
80
+ piaMonthly,
81
+ quickPiaKind,
82
+ ssaEstimateWorkThroughAge:
83
+ formRaw.ssaEstimateWorkThroughAge === undefined
84
+ ? undefined
85
+ : formRaw.ssaEstimateWorkThroughAge === null
86
+ ? null
87
+ : num(formRaw.ssaEstimateWorkThroughAge),
88
+ earningsPaste: typeof formRaw.earningsPaste === 'string' ? formRaw.earningsPaste : undefined,
89
+ lastEarningsYear:
90
+ formRaw.lastEarningsYear === undefined
91
+ ? undefined
92
+ : formRaw.lastEarningsYear === null
93
+ ? null
94
+ : num(formRaw.lastEarningsYear),
95
+ claimAges,
96
+ partnerDob: typeof formRaw.partnerDob === 'string' ? formRaw.partnerDob : undefined,
97
+ partnerPiaSource,
98
+ partnerPiaMonthly:
99
+ formRaw.partnerPiaMonthly === undefined ? undefined : num(formRaw.partnerPiaMonthly) ?? undefined,
100
+ partnerQuickPiaKind,
101
+ partnerSsaEstimateWorkThroughAge:
102
+ formRaw.partnerSsaEstimateWorkThroughAge === undefined
103
+ ? undefined
104
+ : formRaw.partnerSsaEstimateWorkThroughAge === null
105
+ ? null
106
+ : num(formRaw.partnerSsaEstimateWorkThroughAge),
107
+ partnerEarningsPaste:
108
+ typeof formRaw.partnerEarningsPaste === 'string' ? formRaw.partnerEarningsPaste : undefined,
109
+ partnerLastEarningsYear:
110
+ formRaw.partnerLastEarningsYear === undefined
111
+ ? undefined
112
+ : formRaw.partnerLastEarningsYear === null
113
+ ? null
114
+ : num(formRaw.partnerLastEarningsYear),
115
+ partnerClaimAges,
116
+ survivorOverlayEnabled:
117
+ typeof formRaw.survivorOverlayEnabled === 'boolean' ? formRaw.survivorOverlayEnabled : undefined,
118
+ survivorPrimaryDeathAge:
119
+ formRaw.survivorPrimaryDeathAge === undefined
120
+ ? undefined
121
+ : formRaw.survivorPrimaryDeathAge === null
122
+ ? null
123
+ : num(formRaw.survivorPrimaryDeathAge),
124
+ survivorPartnerDeathAge:
125
+ formRaw.survivorPartnerDeathAge === undefined
126
+ ? undefined
127
+ : formRaw.survivorPartnerDeathAge === null
128
+ ? null
129
+ : num(formRaw.survivorPartnerDeathAge),
130
+ endAge,
131
+ colaPercent,
132
+ discountPercent,
133
+ }
134
+
135
+ const updatedAt = str(raw.updatedAt)
136
+ if (!updatedAt) return null
137
+ return { version: 1, form, updatedAt }
138
+ }
@@ -0,0 +1,48 @@
1
+ import { loadLongevity } from '../longevity/storage'
2
+ import { loadSs, type SsFormSnapshot } from './storage'
3
+
4
+ export const CLAIM_OPTIONS = [62, 63, 64, 65, 66, 67, 68, 69, 70] as const
5
+
6
+ export function parseDob(iso: string): { y: number; m: number; d: number } | null {
7
+ const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso.trim())
8
+ if (!m) return null
9
+ const y = Number(m[1])
10
+ const mo = Number(m[2])
11
+ const d = Number(m[3])
12
+ if (!y || mo < 1 || mo > 12 || d < 1 || d > 31) return null
13
+ return { y, m: mo, d }
14
+ }
15
+
16
+ export const SS_CHART_LINE_COLORS = [
17
+ '#0d9488',
18
+ '#6366f1',
19
+ '#a855f7',
20
+ '#ea580c',
21
+ '#0891b2',
22
+ '#16a34a',
23
+ '#ca8a04',
24
+ '#dc2626',
25
+ '#db2777',
26
+ ]
27
+
28
+ export function defaultEndAge(): number {
29
+ const L = loadLongevity()
30
+ if (L?.result?.illustrativePlanningAge) return L.result.illustrativePlanningAge
31
+ return 90
32
+ }
33
+
34
+ export function initialSsForm(): SsFormSnapshot {
35
+ const saved = loadSs()
36
+ if (saved?.form) return saved.form
37
+ return {
38
+ householdMode: 'single',
39
+ dob: '1962-06-15',
40
+ piaSource: 'quick',
41
+ quickPiaKind: 'authoritative',
42
+ piaMonthly: 3200,
43
+ claimAges: [62, 67, 70],
44
+ endAge: defaultEndAge(),
45
+ colaPercent: 0,
46
+ discountPercent: 0,
47
+ }
48
+ }