@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.
- package/LICENSE +661 -0
- package/README.md +181 -0
- package/package.json +77 -0
- package/src/App.tsx +246 -0
- package/src/RouteErrorBoundary.tsx +45 -0
- package/src/assets/hero.png +0 -0
- package/src/assets/react.svg +1 -0
- package/src/assets/vite.svg +1 -0
- package/src/data/fedInvestClient.ts +113 -0
- package/src/data/localStore.ts +42 -0
- package/src/data/planOrigin.ts +24 -0
- package/src/data/planStore.ts +165 -0
- package/src/data/v2Backup.ts +101 -0
- package/src/import/ImportPage.tsx +347 -0
- package/src/import/ReviewChecklistView.tsx +38 -0
- package/src/import/brokerCsv.ts +395 -0
- package/src/import/csv.ts +133 -0
- package/src/import/genericCsv.ts +224 -0
- package/src/import/projectionLab.ts +350 -0
- package/src/import/reviewChecklist.ts +33 -0
- package/src/import/tenForty.ts +275 -0
- package/src/index.css +630 -0
- package/src/index.ts +16 -0
- package/src/learn/ArticleBody.tsx +78 -0
- package/src/learn/ArticlePage.tsx +57 -0
- package/src/learn/GlossaryPage.tsx +33 -0
- package/src/learn/LearnAboutScreen.tsx +41 -0
- package/src/learn/LearnCards.tsx +41 -0
- package/src/learn/LearnLink.tsx +91 -0
- package/src/learn/LearningCenterPage.tsx +114 -0
- package/src/learn/SourcesPage.tsx +98 -0
- package/src/learn/components/ArticleFigure.tsx +34 -0
- package/src/learn/components/ArticleShell.tsx +86 -0
- package/src/learn/components/ComparisonTable.tsx +42 -0
- package/src/learn/components/FormulaBlock.tsx +34 -0
- package/src/learn/components/PurchasingPowerChart.tsx +41 -0
- package/src/learn/components/RelatedArticles.tsx +27 -0
- package/src/learn/components/ScenarioCard.tsx +24 -0
- package/src/learn/components/SourceList.tsx +23 -0
- package/src/learn/components/charts.tsx +21 -0
- package/src/learn/content/about-retiregolden.ts +100 -0
- package/src/learn/content/aca-premium-tax-credits-and-magi.ts +103 -0
- package/src/learn/content/account-types-overview.ts +106 -0
- package/src/learn/content/after-tax-estate.ts +111 -0
- package/src/learn/content/agi-magi-and-taxable-income.ts +112 -0
- package/src/learn/content/appealing-irmaa-ssa-44.ts +95 -0
- package/src/learn/content/assumption-general-inflation.ts +82 -0
- package/src/learn/content/assumption-healthcare-inflation.ts +85 -0
- package/src/learn/content/assumption-heir-tax-rate.ts +79 -0
- package/src/learn/content/assumption-investment-returns.ts +90 -0
- package/src/learn/content/assumption-longevity-planning-age.ts +78 -0
- package/src/learn/content/assumption-recent-magi.ts +83 -0
- package/src/learn/content/assumption-social-security-cola.ts +89 -0
- package/src/learn/content/assumption-social-security-trust-fund.ts +83 -0
- package/src/learn/content/assumption-state-tax-override.ts +79 -0
- package/src/learn/content/beneficiaries-and-account-titling.ts +99 -0
- package/src/learn/content/break-even-useful-lens.ts +94 -0
- package/src/learn/content/building-a-retirement-spending-budget.ts +100 -0
- package/src/learn/content/cola-and-inflation-protection.ts +102 -0
- package/src/learn/content/divorced-spousal-and-survivor-records.ts +104 -0
- package/src/learn/content/dynamic-spending-guardrails.ts +90 -0
- package/src/learn/content/earnings-test-before-fra.ts +100 -0
- package/src/learn/content/employer-match-and-contribution-order.ts +104 -0
- package/src/learn/content/examplePlanArticles.ts +525 -0
- package/src/learn/content/fees-expense-ratios-and-compounding-drag.ts +98 -0
- package/src/learn/content/fi-number-and-four-percent-rule.ts +64 -0
- package/src/learn/content/filling-a-tax-bracket-with-roth-conversions.ts +98 -0
- package/src/learn/content/funded-ratio.ts +70 -0
- package/src/learn/content/healthcare-after-65.ts +103 -0
- package/src/learn/content/healthcare-before-65.ts +104 -0
- package/src/learn/content/historical-vs-random-return-models.ts +101 -0
- package/src/learn/content/how-assumptions-change-the-answer.ts +105 -0
- package/src/learn/content/how-much-can-i-spend.ts +105 -0
- package/src/learn/content/how-social-security-is-taxed.ts +95 -0
- package/src/learn/content/how-the-optimizer-thinks.ts +102 -0
- package/src/learn/content/how-the-optimizer-values-after-tax-estate.ts +97 -0
- package/src/learn/content/how-to-model-accumulation.ts +67 -0
- package/src/learn/content/how-to-read-a-retirement-projection.ts +115 -0
- package/src/learn/content/hsas-and-qualified-medical-expenses.ts +108 -0
- package/src/learn/content/hsas-as-retirement-accounts.ts +101 -0
- package/src/learn/content/inflation-risk.ts +98 -0
- package/src/learn/content/inherited-ira-10-year-rule.ts +105 -0
- package/src/learn/content/insurance-in-your-retirement-plan.ts +103 -0
- package/src/learn/content/irmaa-two-year-lookback.ts +99 -0
- package/src/learn/content/long-term-care-costs-and-insurance.ts +103 -0
- package/src/learn/content/long-term-care-insurance-as-risk-transfer.ts +98 -0
- package/src/learn/content/longevity-risk.ts +99 -0
- package/src/learn/content/marginal-vs-effective-tax-rate.ts +98 -0
- package/src/learn/content/medicare-part-b-vs-part-d-irmaa.ts +102 -0
- package/src/learn/content/mortality-weighted-social-security.ts +113 -0
- package/src/learn/content/moving-to-retiregolden.ts +86 -0
- package/src/learn/content/niit-high-income-investment-tax.ts +98 -0
- package/src/learn/content/ordinary-income-vs-capital-gains.ts +103 -0
- package/src/learn/content/paying-conversion-taxes-taxable-vs-ira.ts +102 -0
- package/src/learn/content/pensions-and-annuities.ts +101 -0
- package/src/learn/content/permanent-life-insurance-in-a-plan.ts +106 -0
- package/src/learn/content/pia-aime-and-bend-points.ts +103 -0
- package/src/learn/content/planner-overview.ts +106 -0
- package/src/learn/content/planning-for-couples-and-survivor-years.ts +108 -0
- package/src/learn/content/privacy-what-stays-in-your-browser.ts +99 -0
- package/src/learn/content/qcds-qualified-charitable-distributions.ts +101 -0
- package/src/learn/content/reading-the-results-page.ts +96 -0
- package/src/learn/content/reading-the-social-security-analysis-page.ts +106 -0
- package/src/learn/content/real-estate-home-equity-and-debt.ts +100 -0
- package/src/learn/content/reports-csv-exports-and-sharing.ts +101 -0
- package/src/learn/content/risk-based-guardrails.ts +100 -0
- package/src/learn/content/rmds-required-minimum-distributions.ts +100 -0
- package/src/learn/content/roth-conversion-basics.ts +104 -0
- package/src/learn/content/rsus-and-espp.ts +101 -0
- package/src/learn/content/rule-of-55-and-72t.ts +107 -0
- package/src/learn/content/savings-rate-biggest-lever.ts +66 -0
- package/src/learn/content/seed-your-plan-from-your-tax-return.ts +93 -0
- package/src/learn/content/sensitivity-testing-what-changes-the-answer.ts +104 -0
- package/src/learn/content/sequence-of-returns-risk.ts +98 -0
- package/src/learn/content/social-security-bridge.ts +67 -0
- package/src/learn/content/social-security-claiming-age-basics.ts +113 -0
- package/src/learn/content/social-security-taxes-vs-benefits.ts +76 -0
- package/src/learn/content/spending-profiles-and-the-retirement-smile.ts +92 -0
- package/src/learn/content/spousal-and-survivor-benefits.ts +120 -0
- package/src/learn/content/ssdi-and-retirement-planning.ts +72 -0
- package/src/learn/content/standard-deduction-senior-deduction-and-itemizing.ts +97 -0
- package/src/learn/content/state-income-taxes-in-retirement.ts +97 -0
- package/src/learn/content/step-up-in-basis.ts +102 -0
- package/src/learn/content/survivor-planning-for-couples.ts +110 -0
- package/src/learn/content/survivor-spending-in-couple-plans.ts +98 -0
- package/src/learn/content/tax-cliffs-and-bracket-edges.ts +105 -0
- package/src/learn/content/tax-loss-and-gain-harvesting.ts +99 -0
- package/src/learn/content/taxable-brokerage-basis-and-capital-gains.ts +99 -0
- package/src/learn/content/three-big-questions-spending-time-risk.ts +103 -0
- package/src/learn/content/tips-ladders.ts +92 -0
- package/src/learn/content/todays-dollars-vs-future-dollars.ts +107 -0
- package/src/learn/content/traditional-vs-roth-contributions.ts +113 -0
- package/src/learn/content/troubleshooting-surprising-results.ts +105 -0
- package/src/learn/content/trust-fund-haircut-scenarios.ts +101 -0
- package/src/learn/content/understanding-monte-carlo-success-rate.ts +118 -0
- package/src/learn/content/understanding-your-plan-assumptions.ts +134 -0
- package/src/learn/content/using-assumptions-and-provenance.ts +98 -0
- package/src/learn/content/using-scenarios-to-compare-choices.ts +99 -0
- package/src/learn/content/what-changes-when-you-move-states.ts +141 -0
- package/src/learn/content/what-is-fire.ts +65 -0
- package/src/learn/content/what-monte-carlo-proves.ts +98 -0
- package/src/learn/content/what-retiregolden-models.ts +103 -0
- package/src/learn/content/what-retirement-healthcare-really-costs.ts +117 -0
- package/src/learn/content/why-95-percent-is-not-a-guarantee.ts +98 -0
- package/src/learn/content/why-roth-conversions-raise-other-costs.ts +106 -0
- package/src/learn/content/why-small-tax-cliffs-can-matter.ts +109 -0
- package/src/learn/content/widows-penalty-and-survivor-brackets.ts +106 -0
- package/src/learn/content/withdrawal-order-basics.ts +105 -0
- package/src/learn/glossary.ts +191 -0
- package/src/learn/inlineMarkdown.tsx +54 -0
- package/src/learn/learn.css +537 -0
- package/src/learn/learningRegistry.ts +502 -0
- package/src/longevity/LongevityResults.tsx +85 -0
- package/src/longevity/LongevityWizard.tsx +305 -0
- package/src/longevity/constants.ts +15 -0
- package/src/longevity/factors.ts +125 -0
- package/src/longevity/model.ts +31 -0
- package/src/longevity/persistedGuard.ts +129 -0
- package/src/longevity/storage.ts +40 -0
- package/src/mc/messages.ts +118 -0
- package/src/mc/monteCarlo.worker.ts +44 -0
- package/src/mc/pool.ts +267 -0
- package/src/mc/runRequest.ts +125 -0
- package/src/optimize/messages.ts +84 -0
- package/src/optimize/optimize.worker.ts +29 -0
- package/src/optimize/runOptimize.ts +92 -0
- package/src/optimize/runSpendingSolve.ts +47 -0
- package/src/optimize/runner.ts +21 -0
- package/src/optimize/spendingMessages.ts +44 -0
- package/src/optimize/spendingRunner.ts +21 -0
- package/src/optimize/spendingSolve.worker.ts +18 -0
- package/src/planner/AssumptionsCardPage.tsx +136 -0
- package/src/planner/BucketLensCard.tsx +114 -0
- package/src/planner/ComparePlansPage.tsx +219 -0
- package/src/planner/DisclaimerPage.tsx +88 -0
- package/src/planner/HowTestedPage.tsx +159 -0
- package/src/planner/LiveStatus.tsx +15 -0
- package/src/planner/LongevityModal.tsx +55 -0
- package/src/planner/Modal.tsx +97 -0
- package/src/planner/MonteCarloPage.tsx +907 -0
- package/src/planner/OptimizePage.tsx +611 -0
- package/src/planner/PlanContext.tsx +198 -0
- package/src/planner/PlanPickerPage.tsx +124 -0
- package/src/planner/PlanWorkspace.tsx +290 -0
- package/src/planner/ProvenancePanel.tsx +45 -0
- package/src/planner/RelocationComparePage.tsx +485 -0
- package/src/planner/ReportPage.tsx +375 -0
- package/src/planner/ResultsPage.tsx +817 -0
- package/src/planner/ScenariosPage.tsx +285 -0
- package/src/planner/SocialSecuritySection.tsx +556 -0
- package/src/planner/SpendingSolverPage.tsx +512 -0
- package/src/planner/SsAnalysisPage.tsx +1134 -0
- package/src/planner/SurvivalPercentileModal.tsx +161 -0
- package/src/planner/SurvivorTransitionPage.tsx +286 -0
- package/src/planner/assumptionsExport.ts +371 -0
- package/src/planner/bucketLens.ts +89 -0
- package/src/planner/chartFrame.ts +8 -0
- package/src/planner/chartStyle.ts +11 -0
- package/src/planner/dialogViews.tsx +184 -0
- package/src/planner/dialogs.tsx +133 -0
- package/src/planner/examples/ExampleLibrary.tsx +189 -0
- package/src/planner/examples/ExamplePreviewBanner.tsx +55 -0
- package/src/planner/examples/ExamplesPage.tsx +25 -0
- package/src/planner/examples/OpenExampleButton.tsx +61 -0
- package/src/planner/examples/buildAggressiveSaver.ts +102 -0
- package/src/planner/examples/buildAnnuityEstate.ts +137 -0
- package/src/planner/examples/buildBaristaFire.ts +115 -0
- package/src/planner/examples/buildBracketFillRoth.ts +65 -0
- package/src/planner/examples/buildBridgeEarlyRetirement.ts +94 -0
- package/src/planner/examples/buildBrokerageNoHsa.ts +109 -0
- package/src/planner/examples/buildCoastFire.ts +88 -0
- package/src/planner/examples/buildContext.ts +20 -0
- package/src/planner/examples/buildEarlyCareerMatch.ts +93 -0
- package/src/planner/examples/buildEarlyRetireeAca.ts +61 -0
- package/src/planner/examples/buildExampleCouple.ts +103 -0
- package/src/planner/examples/buildFixedTargetSpending.ts +74 -0
- package/src/planner/examples/buildGlidepathAllocation.ts +131 -0
- package/src/planner/examples/buildGuardrailsFlex.ts +120 -0
- package/src/planner/examples/buildHsaPropertyDepth.ts +109 -0
- package/src/planner/examples/buildHsaStealthRetirement.ts +97 -0
- package/src/planner/examples/buildLeanFatFire.ts +109 -0
- package/src/planner/examples/buildLtcShock.ts +62 -0
- package/src/planner/examples/buildMovingStateTax.ts +53 -0
- package/src/planner/examples/buildNoAnnuityBrokerage.ts +92 -0
- package/src/planner/examples/buildRmdIrmaa.ts +55 -0
- package/src/planner/examples/buildSalaryGrowthEscalation.ts +96 -0
- package/src/planner/examples/buildStaticAllocationControl.ts +96 -0
- package/src/planner/examples/buildSurvivorYears.ts +62 -0
- package/src/planner/examples/buildUnderSavedSingle.ts +51 -0
- package/src/planner/examples/exampleCopy.ts +23 -0
- package/src/planner/examples/loadExample.ts +90 -0
- package/src/planner/examples/registry.ts +313 -0
- package/src/planner/explainPanels.tsx +233 -0
- package/src/planner/fields.tsx +381 -0
- package/src/planner/format.ts +33 -0
- package/src/planner/home/DataAndPrivacyCard.tsx +56 -0
- package/src/planner/home/GettingStartedPaths.tsx +46 -0
- package/src/planner/home/GettingStartedReopener.tsx +32 -0
- package/src/planner/home/StartHereLinks.tsx +22 -0
- package/src/planner/home/WelcomeHero.tsx +39 -0
- package/src/planner/home/YourPlans.tsx +72 -0
- package/src/planner/home/importErrorMessage.ts +22 -0
- package/src/planner/home/startHereSlugs.ts +7 -0
- package/src/planner/home/useHomeData.ts +190 -0
- package/src/planner/home/useHomeMode.ts +47 -0
- package/src/planner/householdActions.ts +22 -0
- package/src/planner/insights/InsightCardView.tsx +340 -0
- package/src/planner/insights/InsightsPage.tsx +204 -0
- package/src/planner/insights/categoryLabels.ts +11 -0
- package/src/planner/learnLinks.ts +85 -0
- package/src/planner/marketModelPicker.ts +172 -0
- package/src/planner/optimizePageChart.ts +40 -0
- package/src/planner/optimizePageClaim.ts +64 -0
- package/src/planner/planCompleteness.ts +27 -0
- package/src/planner/planContextCore.ts +26 -0
- package/src/planner/planner.css +2304 -0
- package/src/planner/provenanceLinks.ts +25 -0
- package/src/planner/sections/AccountFields.tsx +872 -0
- package/src/planner/sections/AccountsSection.tsx +89 -0
- package/src/planner/sections/AllocationPanel.tsx +261 -0
- package/src/planner/sections/AssumptionsSection.tsx +256 -0
- package/src/planner/sections/HouseholdSection.tsx +243 -0
- package/src/planner/sections/IncomeFloorSection.tsx +418 -0
- package/src/planner/sections/IncomeSection.tsx +170 -0
- package/src/planner/sections/InsuranceSection.tsx +362 -0
- package/src/planner/sections/SpendingSection.tsx +904 -0
- package/src/planner/sections/StrategySection.tsx +349 -0
- package/src/planner/sections/UpdateBalancesPanel.tsx +182 -0
- package/src/planner/sections/sectionHelpers.ts +48 -0
- package/src/planner/sections/shared.tsx +15 -0
- package/src/planner/sections.tsx +15 -0
- package/src/planner/ssAnalysis.ts +325 -0
- package/src/planner/successBand.ts +20 -0
- package/src/planner/survivorAnalysis.ts +277 -0
- package/src/planner/usStates.ts +19 -0
- package/src/planner/useMcSuccessRate.ts +77 -0
- package/src/planner/useProjection.ts +63 -0
- package/src/relocation/messages.ts +21 -0
- package/src/relocation/relocation.worker.ts +18 -0
- package/src/relocation/runRelocation.ts +17 -0
- package/src/relocation/runner.ts +22 -0
- package/src/report/brandingContext.ts +15 -0
- package/src/report/downloadReport.ts +34 -0
- package/src/report/reportHtml.ts +547 -0
- package/src/routes/LearnRoutes.tsx +46 -0
- package/src/routes/PlanRoutes.tsx +55 -0
- package/src/routes/RouteFallback.tsx +9 -0
- package/src/socialSecurity/breakEven.ts +107 -0
- package/src/socialSecurity/expectedPv.ts +164 -0
- package/src/socialSecurity/explain.ts +92 -0
- package/src/socialSecurity/ficaReturn.ts +81 -0
- package/src/socialSecurity/persistedSsGuard.ts +138 -0
- package/src/socialSecurity/ssFormUtils.ts +48 -0
- package/src/socialSecurity/ssaStatementXml.ts +156 -0
- package/src/socialSecurity/storage.ts +69 -0
- package/src/socialSecurity/survivorSwitching.ts +153 -0
- package/src/testSupport/samplePlan.ts +2 -0
- 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
|
+
}
|