@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,305 @@
1
+ import { useMemo, useState } from 'react'
2
+ import type {
3
+ Activity,
4
+ Alcohol,
5
+ BmiCategory,
6
+ Diabetes,
7
+ LongevityAnswers,
8
+ ParentalLongevity,
9
+ SelfRatedHealth,
10
+ Sex,
11
+ Smoking,
12
+ } from '@retiregolden/engine/longevity/types'
13
+ import { computeLongevity } from './model'
14
+ import { saveLongevity, saveLongevityPartner } from './storage'
15
+
16
+ const defaultAnswers = (): LongevityAnswers => ({
17
+ age: 55,
18
+ sex: 'average',
19
+ bmiCategory: 'normal',
20
+ smoking: 'never',
21
+ alcohol: 'moderate',
22
+ activity: 'moderate',
23
+ diabetes: 'no',
24
+ selfRatedHealth: 'good',
25
+ parentalLongevity: 'unknown',
26
+ })
27
+
28
+ type Step =
29
+ | 'age'
30
+ | 'sex'
31
+ | 'bmi'
32
+ | 'smoking'
33
+ | 'alcohol'
34
+ | 'activity'
35
+ | 'diabetes'
36
+ | 'health'
37
+ | 'parents'
38
+
39
+ const STEPS: Step[] = [
40
+ 'age',
41
+ 'sex',
42
+ 'bmi',
43
+ 'smoking',
44
+ 'alcohol',
45
+ 'activity',
46
+ 'diabetes',
47
+ 'health',
48
+ 'parents',
49
+ ]
50
+
51
+ function mergeInitial(initial?: Partial<LongevityAnswers>): LongevityAnswers {
52
+ return { ...defaultAnswers(), ...initial }
53
+ }
54
+
55
+ export interface LongevityWizardProps {
56
+ initialAnswers?: Partial<LongevityAnswers>
57
+ onComplete: () => void
58
+ /** Which localStorage slot to write on completion (default primary). */
59
+ storageTarget?: 'primary' | 'partner'
60
+ }
61
+
62
+ export function LongevityWizard({
63
+ initialAnswers,
64
+ onComplete,
65
+ storageTarget = 'primary',
66
+ }: LongevityWizardProps) {
67
+ const [answers, setAnswers] = useState<LongevityAnswers>(() => mergeInitial(initialAnswers))
68
+ const [stepIndex, setStepIndex] = useState(0)
69
+ const step = STEPS[stepIndex]!
70
+
71
+ const canNext = useMemo(() => {
72
+ if (step === 'age') {
73
+ return Number.isFinite(answers.age) && answers.age >= 18 && answers.age <= 110
74
+ }
75
+ return true
76
+ }, [step, answers.age])
77
+
78
+ function patch<K extends keyof LongevityAnswers>(key: K, value: LongevityAnswers[K]) {
79
+ setAnswers((a) => ({ ...a, [key]: value }))
80
+ }
81
+
82
+ function goNext() {
83
+ if (stepIndex < STEPS.length - 1) setStepIndex((i) => i + 1)
84
+ else {
85
+ const result = computeLongevity(answers)
86
+ const payload = {
87
+ version: 1 as const,
88
+ answers,
89
+ result,
90
+ updatedAt: new Date().toISOString(),
91
+ }
92
+ if (storageTarget === 'partner') {
93
+ saveLongevityPartner(payload)
94
+ } else {
95
+ saveLongevity(payload)
96
+ }
97
+ onComplete()
98
+ }
99
+ }
100
+
101
+ function goBack() {
102
+ if (stepIndex > 0) setStepIndex((i) => i - 1)
103
+ }
104
+
105
+ return (
106
+ <div className="wizard">
107
+ <p className="wizard-progress">
108
+ Step {stepIndex + 1} of {STEPS.length}
109
+ </p>
110
+
111
+ {step === 'age' && (
112
+ <fieldset className="wizard-fieldset">
113
+ <legend>Your current age</legend>
114
+ <label className="field">
115
+ <span>Age (whole years)</span>
116
+ <input
117
+ type="number"
118
+ min={18}
119
+ max={110}
120
+ value={answers.age}
121
+ onChange={(e) => patch('age', Number.parseInt(e.target.value, 10) || 0)}
122
+ />
123
+ </label>
124
+ <p className="field-hint">Use 18–110. We use this to read the SSA period life table row.</p>
125
+ </fieldset>
126
+ )}
127
+
128
+ {step === 'sex' && (
129
+ <fieldset className="wizard-fieldset">
130
+ <legend>Life table column</legend>
131
+ <p className="field-hint">
132
+ SSA publishes separate male and female period expectancies. Pick the row to use, or
133
+ average both.
134
+ </p>
135
+ <RadioRow
136
+ name="sex"
137
+ value={answers.sex}
138
+ onChange={(v) => patch('sex', v as Sex)}
139
+ options={[
140
+ { value: 'male', label: 'Male table' },
141
+ { value: 'female', label: 'Female table' },
142
+ { value: 'average', label: 'Average of male and female' },
143
+ ]}
144
+ />
145
+ </fieldset>
146
+ )}
147
+
148
+ {step === 'bmi' && (
149
+ <fieldset className="wizard-fieldset">
150
+ <legend>BMI category</legend>
151
+ <p className="field-hint">Pick the closest category; this is a coarse adjustment only.</p>
152
+ <RadioRow
153
+ name="bmi"
154
+ value={answers.bmiCategory}
155
+ onChange={(v) => patch('bmiCategory', v as BmiCategory)}
156
+ options={[
157
+ { value: 'underweight', label: 'Underweight' },
158
+ { value: 'normal', label: 'Normal' },
159
+ { value: 'overweight', label: 'Overweight' },
160
+ { value: 'obese_i', label: 'Obese (class I)' },
161
+ { value: 'obese_ii_plus', label: 'Obese (class II or higher)' },
162
+ ]}
163
+ />
164
+ </fieldset>
165
+ )}
166
+
167
+ {step === 'smoking' && (
168
+ <fieldset className="wizard-fieldset">
169
+ <legend>Smoking</legend>
170
+ <RadioRow
171
+ name="smoking"
172
+ value={answers.smoking}
173
+ onChange={(v) => patch('smoking', v as Smoking)}
174
+ options={[
175
+ { value: 'never', label: 'Never smoked' },
176
+ { value: 'former_15y', label: 'Former, quit 15+ years ago' },
177
+ { value: 'former_lt15y', label: 'Former, quit less than 15 years ago' },
178
+ { value: 'current_light', label: 'Current, light / occasional' },
179
+ { value: 'current_moderate', label: 'Current, about a pack per day' },
180
+ { value: 'current_heavy', label: 'Current, more than a pack per day' },
181
+ ]}
182
+ />
183
+ </fieldset>
184
+ )}
185
+
186
+ {step === 'alcohol' && (
187
+ <fieldset className="wizard-fieldset">
188
+ <legend>Alcohol</legend>
189
+ <RadioRow
190
+ name="alcohol"
191
+ value={answers.alcohol}
192
+ onChange={(v) => patch('alcohol', v as Alcohol)}
193
+ options={[
194
+ { value: 'never', label: 'None / rarely' },
195
+ { value: 'light', label: 'Light (e.g. 1–4 drinks per week)' },
196
+ { value: 'moderate', label: 'Moderate' },
197
+ { value: 'heavy', label: 'Heavy' },
198
+ ]}
199
+ />
200
+ </fieldset>
201
+ )}
202
+
203
+ {step === 'activity' && (
204
+ <fieldset className="wizard-fieldset">
205
+ <legend>Physical activity</legend>
206
+ <RadioRow
207
+ name="activity"
208
+ value={answers.activity}
209
+ onChange={(v) => patch('activity', v as Activity)}
210
+ options={[
211
+ { value: 'high', label: 'High (most days, 30+ minutes)' },
212
+ { value: 'moderate', label: 'Moderate (weekly exercise)' },
213
+ { value: 'low', label: 'Low (occasional)' },
214
+ { value: 'sedentary', label: 'Mostly sedentary' },
215
+ ]}
216
+ />
217
+ </fieldset>
218
+ )}
219
+
220
+ {step === 'diabetes' && (
221
+ <fieldset className="wizard-fieldset">
222
+ <legend>Diabetes</legend>
223
+ <RadioRow
224
+ name="diabetes"
225
+ value={answers.diabetes}
226
+ onChange={(v) => patch('diabetes', v as Diabetes)}
227
+ options={[
228
+ { value: 'no', label: 'No' },
229
+ { value: 'yes_controlled', label: 'Yes — generally controlled' },
230
+ { value: 'yes_uncontrolled', label: 'Yes — often not well controlled' },
231
+ ]}
232
+ />
233
+ </fieldset>
234
+ )}
235
+
236
+ {step === 'health' && (
237
+ <fieldset className="wizard-fieldset">
238
+ <legend>Overall health (self-rated)</legend>
239
+ <RadioRow
240
+ name="health"
241
+ value={answers.selfRatedHealth}
242
+ onChange={(v) => patch('selfRatedHealth', v as SelfRatedHealth)}
243
+ options={[
244
+ { value: 'excellent', label: 'Excellent' },
245
+ { value: 'good', label: 'Good' },
246
+ { value: 'fair', label: 'Fair' },
247
+ { value: 'poor', label: 'Poor' },
248
+ { value: 'very_poor', label: 'Very poor' },
249
+ ]}
250
+ />
251
+ </fieldset>
252
+ )}
253
+
254
+ {step === 'parents' && (
255
+ <fieldset className="wizard-fieldset">
256
+ <legend>Parents lived to age 80 or older?</legend>
257
+ <p className="field-hint">Rough family-longevity signal; skip if unsure.</p>
258
+ <RadioRow
259
+ name="parents"
260
+ value={answers.parentalLongevity}
261
+ onChange={(v) => patch('parentalLongevity', v as ParentalLongevity)}
262
+ options={[
263
+ { value: 'both_80', label: 'Both did' },
264
+ { value: 'one_80', label: 'One did' },
265
+ { value: 'neither_80', label: 'Neither / not to 80' },
266
+ { value: 'unknown', label: 'Unknown / not applicable' },
267
+ ]}
268
+ />
269
+ </fieldset>
270
+ )}
271
+
272
+ <div className="wizard-actions">
273
+ <button type="button" className="btn btn-secondary" onClick={goBack} disabled={stepIndex === 0}>
274
+ Back
275
+ </button>
276
+ <button type="button" className="btn btn-primary" onClick={goNext} disabled={!canNext}>
277
+ {stepIndex === STEPS.length - 1 ? 'See results' : 'Next'}
278
+ </button>
279
+ </div>
280
+ </div>
281
+ )
282
+ }
283
+
284
+ function RadioRow<T extends string>({
285
+ name,
286
+ value,
287
+ onChange,
288
+ options,
289
+ }: {
290
+ name: string
291
+ value: T
292
+ onChange: (v: string) => void
293
+ options: { value: T; label: string }[]
294
+ }) {
295
+ return (
296
+ <div className="radio-stack" role="radiogroup" aria-label={name}>
297
+ {options.map((o) => (
298
+ <label key={o.value} className="radio-option">
299
+ <input type="radio" name={name} value={o.value} checked={value === o.value} onChange={() => onChange(o.value)} />
300
+ <span>{o.label}</span>
301
+ </label>
302
+ ))}
303
+ </div>
304
+ )
305
+ }
@@ -0,0 +1,15 @@
1
+ import { STORAGE_KEYS } from '../data/localStore'
2
+
3
+ /** localStorage key for saved longevity answers + last result (primary / household member A) */
4
+ export const LONGEVITY_STORAGE_KEY = STORAGE_KEYS.longevity
5
+
6
+ /** Second saved profile for Social Security couple survivor overlay (partner / household member B) */
7
+ export const LONGEVITY_PARTNER_STORAGE_KEY = STORAGE_KEYS.longevityPartner
8
+
9
+ /** SSA period life table vintage embedded in `ssaPeriod2022.ts` */
10
+ export const BASELINE_CITATION = {
11
+ label: 'SSA period life table, 2022 (2025 Trustees Report)',
12
+ url: 'https://www.ssa.gov/oact/STATS/table4c6.html',
13
+ note:
14
+ '“Life expectancy” column is average remaining years for Social Security area population, by sex, using 2022 mortality rates.',
15
+ } as const
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Conservative multipliers applied to **population remaining life expectancy**.
3
+ * These are illustrative adjustments for UX/education — not individualized medical
4
+ * underwriting. Magnitudes kept small; combined product is clamped in `model.ts`.
5
+ */
6
+ import type {
7
+ Activity,
8
+ Alcohol,
9
+ BmiCategory,
10
+ Diabetes,
11
+ LongevityAnswers,
12
+ ParentalLongevity,
13
+ SelfRatedHealth,
14
+ Smoking,
15
+ } from '@retiregolden/engine/longevity/types'
16
+
17
+ export function bmiMultiplier(c: BmiCategory): number {
18
+ switch (c) {
19
+ case 'underweight':
20
+ return 0.97
21
+ case 'normal':
22
+ return 1
23
+ case 'overweight':
24
+ return 0.96
25
+ case 'obese_i':
26
+ return 0.92
27
+ case 'obese_ii_plus':
28
+ return 0.86
29
+ }
30
+ }
31
+
32
+ export function smokingMultiplier(s: Smoking): number {
33
+ switch (s) {
34
+ case 'never':
35
+ return 1
36
+ case 'former_15y':
37
+ return 0.98
38
+ case 'former_lt15y':
39
+ return 0.95
40
+ case 'current_light':
41
+ return 0.9
42
+ case 'current_moderate':
43
+ return 0.82
44
+ case 'current_heavy':
45
+ return 0.72
46
+ }
47
+ }
48
+
49
+ export function alcoholMultiplier(a: Alcohol): number {
50
+ switch (a) {
51
+ case 'never':
52
+ return 1
53
+ case 'light':
54
+ return 1.01
55
+ case 'moderate':
56
+ return 1
57
+ case 'heavy':
58
+ return 0.92
59
+ }
60
+ }
61
+
62
+ export function activityMultiplier(a: Activity): number {
63
+ switch (a) {
64
+ case 'high':
65
+ return 1.03
66
+ case 'moderate':
67
+ return 1
68
+ case 'low':
69
+ return 0.96
70
+ case 'sedentary':
71
+ return 0.91
72
+ }
73
+ }
74
+
75
+ export function diabetesMultiplier(d: Diabetes): number {
76
+ switch (d) {
77
+ case 'no':
78
+ return 1
79
+ case 'yes_controlled':
80
+ return 0.92
81
+ case 'yes_uncontrolled':
82
+ return 0.85
83
+ }
84
+ }
85
+
86
+ export function selfRatedHealthMultiplier(h: SelfRatedHealth): number {
87
+ switch (h) {
88
+ case 'excellent':
89
+ return 1.03
90
+ case 'good':
91
+ return 1.01
92
+ case 'fair':
93
+ return 0.94
94
+ case 'poor':
95
+ return 0.86
96
+ case 'very_poor':
97
+ return 0.76
98
+ }
99
+ }
100
+
101
+ export function parentalMultiplier(p: ParentalLongevity): number {
102
+ switch (p) {
103
+ case 'both_80':
104
+ return 1.02
105
+ case 'one_80':
106
+ return 1
107
+ case 'neither_80':
108
+ return 0.98
109
+ case 'unknown':
110
+ return 1
111
+ }
112
+ }
113
+
114
+ /** Product of all category multipliers (uncapped). */
115
+ export function combinedMultiplier(a: LongevityAnswers): number {
116
+ return (
117
+ bmiMultiplier(a.bmiCategory) *
118
+ smokingMultiplier(a.smoking) *
119
+ alcoholMultiplier(a.alcohol) *
120
+ activityMultiplier(a.activity) *
121
+ diabetesMultiplier(a.diabetes) *
122
+ selfRatedHealthMultiplier(a.selfRatedHealth) *
123
+ parentalMultiplier(a.parentalLongevity)
124
+ )
125
+ }
@@ -0,0 +1,31 @@
1
+ import { combinedMultiplier } from './factors'
2
+ import { baselineRemainingYears } from '@retiregolden/engine/longevity/ssaPeriod2022'
3
+ import type { LongevityAnswers, LongevityResult } from '@retiregolden/engine/longevity/types'
4
+
5
+ const MULT_MIN = 0.55
6
+ const MULT_MAX = 1.12
7
+
8
+ /** Narrow illustrative band around central estimate (not a confidence interval). */
9
+ const BAND_LOW = 0.9
10
+ const BAND_HIGH = 1.08
11
+
12
+ export function computeLongevity(answers: LongevityAnswers): LongevityResult {
13
+ const baseline = baselineRemainingYears(answers.age, answers.sex)
14
+ const raw = combinedMultiplier(answers)
15
+ const applied = Math.min(MULT_MAX, Math.max(MULT_MIN, raw))
16
+ const central = baseline * applied
17
+ const bandLow = central * BAND_LOW
18
+ const bandHigh = central * BAND_HIGH
19
+ const illustrativePlanningAge =
20
+ answers.age + Math.max(0, Math.round(central))
21
+
22
+ return {
23
+ baselineRemainingYears: baseline,
24
+ rawMultiplier: raw,
25
+ appliedMultiplier: applied,
26
+ centralRemainingYears: central,
27
+ bandLowRemainingYears: bandLow,
28
+ bandHighRemainingYears: bandHigh,
29
+ illustrativePlanningAge,
30
+ }
31
+ }
@@ -0,0 +1,129 @@
1
+ import type { LongevityAnswers, LongevityPersisted, LongevityResult } from '@retiregolden/engine/longevity/types'
2
+
3
+ const SEX: LongevityAnswers['sex'][] = ['male', 'female', 'average']
4
+ const BMI: LongevityAnswers['bmiCategory'][] = [
5
+ 'underweight',
6
+ 'normal',
7
+ 'overweight',
8
+ 'obese_i',
9
+ 'obese_ii_plus',
10
+ ]
11
+ const SMOKING: LongevityAnswers['smoking'][] = [
12
+ 'never',
13
+ 'former_15y',
14
+ 'former_lt15y',
15
+ 'current_light',
16
+ 'current_moderate',
17
+ 'current_heavy',
18
+ ]
19
+ const ALCOHOL: LongevityAnswers['alcohol'][] = ['never', 'light', 'moderate', 'heavy']
20
+ const ACTIVITY: LongevityAnswers['activity'][] = ['high', 'moderate', 'low', 'sedentary']
21
+ const DIABETES: LongevityAnswers['diabetes'][] = ['no', 'yes_controlled', 'yes_uncontrolled']
22
+ const HEALTH: LongevityAnswers['selfRatedHealth'][] = [
23
+ 'excellent',
24
+ 'good',
25
+ 'fair',
26
+ 'poor',
27
+ 'very_poor',
28
+ ]
29
+ const PARENTS: LongevityAnswers['parentalLongevity'][] = [
30
+ 'both_80',
31
+ 'one_80',
32
+ 'neither_80',
33
+ 'unknown',
34
+ ]
35
+
36
+ function isRecord(x: unknown): x is Record<string, unknown> {
37
+ return typeof x === 'object' && x !== null && !Array.isArray(x)
38
+ }
39
+
40
+ function num(x: unknown): number | null {
41
+ return typeof x === 'number' && Number.isFinite(x) ? x : null
42
+ }
43
+
44
+ function str(x: unknown): string | null {
45
+ return typeof x === 'string' ? x : null
46
+ }
47
+
48
+ function oneOf<T extends string>(x: unknown, allowed: readonly T[]): T | null {
49
+ return typeof x === 'string' && (allowed as readonly string[]).includes(x) ? (x as T) : null
50
+ }
51
+
52
+ function parseAnswers(raw: unknown): LongevityAnswers | null {
53
+ if (!isRecord(raw)) return null
54
+ const age = num(raw.age)
55
+ if (age == null || age < 18 || age > 110) return null
56
+ const sex = oneOf(raw.sex, SEX)
57
+ const bmiCategory = oneOf(raw.bmiCategory, BMI)
58
+ const smoking = oneOf(raw.smoking, SMOKING)
59
+ const alcohol = oneOf(raw.alcohol, ALCOHOL)
60
+ const activity = oneOf(raw.activity, ACTIVITY)
61
+ const diabetes = oneOf(raw.diabetes, DIABETES)
62
+ const selfRatedHealth = oneOf(raw.selfRatedHealth, HEALTH)
63
+ const parentalLongevity = oneOf(raw.parentalLongevity, PARENTS)
64
+ if (
65
+ !sex ||
66
+ !bmiCategory ||
67
+ !smoking ||
68
+ !alcohol ||
69
+ !activity ||
70
+ !diabetes ||
71
+ !selfRatedHealth ||
72
+ !parentalLongevity
73
+ ) {
74
+ return null
75
+ }
76
+ return {
77
+ age,
78
+ sex,
79
+ bmiCategory,
80
+ smoking,
81
+ alcohol,
82
+ activity,
83
+ diabetes,
84
+ selfRatedHealth,
85
+ parentalLongevity,
86
+ }
87
+ }
88
+
89
+ function parseResult(raw: unknown): LongevityResult | null {
90
+ if (!isRecord(raw)) return null
91
+ const baselineRemainingYears = num(raw.baselineRemainingYears)
92
+ const rawMultiplier = num(raw.rawMultiplier)
93
+ const appliedMultiplier = num(raw.appliedMultiplier)
94
+ const centralRemainingYears = num(raw.centralRemainingYears)
95
+ const bandLowRemainingYears = num(raw.bandLowRemainingYears)
96
+ const bandHighRemainingYears = num(raw.bandHighRemainingYears)
97
+ const illustrativePlanningAge = num(raw.illustrativePlanningAge)
98
+ if (
99
+ baselineRemainingYears == null ||
100
+ rawMultiplier == null ||
101
+ appliedMultiplier == null ||
102
+ centralRemainingYears == null ||
103
+ bandLowRemainingYears == null ||
104
+ bandHighRemainingYears == null ||
105
+ illustrativePlanningAge == null
106
+ ) {
107
+ return null
108
+ }
109
+ return {
110
+ baselineRemainingYears,
111
+ rawMultiplier,
112
+ appliedMultiplier,
113
+ centralRemainingYears,
114
+ bandLowRemainingYears,
115
+ bandHighRemainingYears,
116
+ illustrativePlanningAge,
117
+ }
118
+ }
119
+
120
+ /** Returns null if JSON shape does not match v1 persisted longevity (schema drift / corruption). */
121
+ export function parseLongevityPersistedLoose(raw: unknown): LongevityPersisted | null {
122
+ if (!isRecord(raw)) return null
123
+ if (raw.version !== 1) return null
124
+ const answers = parseAnswers(raw.answers)
125
+ const result = parseResult(raw.result)
126
+ const updatedAt = str(raw.updatedAt)
127
+ if (!answers || !result || !updatedAt) return null
128
+ return { version: 1, answers, result, updatedAt }
129
+ }
@@ -0,0 +1,40 @@
1
+ import { readLocal, removeLocal, writeLocal } from '../data/localStore'
2
+ import { LONGEVITY_PARTNER_STORAGE_KEY, LONGEVITY_STORAGE_KEY } from './constants'
3
+ import { parseLongevityPersistedLoose } from './persistedGuard'
4
+ import type { LongevityPersisted } from '@retiregolden/engine/longevity/types'
5
+
6
+ export function loadLongevity(): LongevityPersisted | null {
7
+ try {
8
+ const raw = readLocal(LONGEVITY_STORAGE_KEY)
9
+ if (!raw) return null
10
+ return parseLongevityPersistedLoose(JSON.parse(raw) as unknown)
11
+ } catch {
12
+ return null
13
+ }
14
+ }
15
+
16
+ export function saveLongevity(data: LongevityPersisted): void {
17
+ writeLocal(LONGEVITY_STORAGE_KEY, JSON.stringify(data))
18
+ }
19
+
20
+ export function clearLongevity(): void {
21
+ removeLocal(LONGEVITY_STORAGE_KEY)
22
+ }
23
+
24
+ export function loadLongevityPartner(): LongevityPersisted | null {
25
+ try {
26
+ const raw = readLocal(LONGEVITY_PARTNER_STORAGE_KEY)
27
+ if (!raw) return null
28
+ return parseLongevityPersistedLoose(JSON.parse(raw) as unknown)
29
+ } catch {
30
+ return null
31
+ }
32
+ }
33
+
34
+ export function saveLongevityPartner(data: LongevityPersisted): void {
35
+ writeLocal(LONGEVITY_PARTNER_STORAGE_KEY, JSON.stringify(data))
36
+ }
37
+
38
+ export function clearLongevityPartner(): void {
39
+ removeLocal(LONGEVITY_PARTNER_STORAGE_KEY)
40
+ }