@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,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-plan compare: load two independent saved plans and show headline
|
|
3
|
+
* deterministic results side by side. This intentionally complements
|
|
4
|
+
* Scenarios, which compares variants inside one plan.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { useEffect, useMemo, useState } from 'react'
|
|
8
|
+
import { Link } from 'react-router-dom'
|
|
9
|
+
|
|
10
|
+
import { listUserPlanSummaries, loadPlan, type PlanSummary } from '../data/planStore'
|
|
11
|
+
import type { Plan } from '@retiregolden/engine/model/plan'
|
|
12
|
+
import type { ProjectionSummary } from '@retiregolden/engine/projection/compare'
|
|
13
|
+
import { fmtMoneyCompact } from './format'
|
|
14
|
+
import { projectPlan, type ProjectionView } from './useProjection'
|
|
15
|
+
|
|
16
|
+
interface ComparedPlan {
|
|
17
|
+
plan: Plan
|
|
18
|
+
view: ProjectionView
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function resultLabel(summary: ProjectionSummary, endYear: number): string {
|
|
22
|
+
return summary.depletionYear === null ? `Full plan through ${endYear}` : `Depletes in ${summary.depletionYear}`
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function primaryAgeIn(plan: Plan, year: number | null): string {
|
|
26
|
+
if (year === null) return '—'
|
|
27
|
+
const dobYear = Number(plan.household.people[0]?.dob.slice(0, 4))
|
|
28
|
+
return Number.isFinite(dobYear) ? String(year - dobYear) : '—'
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function deltaClass(value: number): string | undefined {
|
|
32
|
+
if (Math.abs(value) < 0.5) return undefined
|
|
33
|
+
return value > 0 ? 'delta-pos' : 'delta-neg'
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function moneyDelta(value: number): string {
|
|
37
|
+
const sign = value > 0 ? '+' : ''
|
|
38
|
+
return `${sign}${fmtMoneyCompact(value)}`
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function MetricRow({
|
|
42
|
+
label,
|
|
43
|
+
a,
|
|
44
|
+
b,
|
|
45
|
+
delta,
|
|
46
|
+
higherIsGood = true,
|
|
47
|
+
}: {
|
|
48
|
+
label: string
|
|
49
|
+
a: string
|
|
50
|
+
b: string
|
|
51
|
+
delta?: number
|
|
52
|
+
higherIsGood?: boolean
|
|
53
|
+
}) {
|
|
54
|
+
const adjustedDelta = delta === undefined ? undefined : higherIsGood ? delta : -delta
|
|
55
|
+
return (
|
|
56
|
+
<tr>
|
|
57
|
+
<th>{label}</th>
|
|
58
|
+
<td>{a}</td>
|
|
59
|
+
<td>{b}</td>
|
|
60
|
+
<td className={adjustedDelta === undefined ? undefined : deltaClass(adjustedDelta)}>
|
|
61
|
+
{delta === undefined ? '—' : moneyDelta(delta)}
|
|
62
|
+
</td>
|
|
63
|
+
</tr>
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function ComparePlansPage() {
|
|
68
|
+
const [summaries, setSummaries] = useState<PlanSummary[] | null>(null)
|
|
69
|
+
const [leftId, setLeftId] = useState('')
|
|
70
|
+
const [rightId, setRightId] = useState('')
|
|
71
|
+
const [left, setLeft] = useState<ComparedPlan | null>(null)
|
|
72
|
+
const [right, setRight] = useState<ComparedPlan | null>(null)
|
|
73
|
+
const [notice, setNotice] = useState<string | null>(null)
|
|
74
|
+
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
void listUserPlanSummaries().then((items) => {
|
|
77
|
+
setSummaries(items)
|
|
78
|
+
setLeftId(items[0]?.id ?? '')
|
|
79
|
+
setRightId(items.find((p) => p.id !== items[0]?.id)?.id ?? '')
|
|
80
|
+
})
|
|
81
|
+
}, [])
|
|
82
|
+
|
|
83
|
+
useEffect(() => {
|
|
84
|
+
let cancelled = false
|
|
85
|
+
async function loadCompared(id: string, setter: (plan: ComparedPlan | null) => void) {
|
|
86
|
+
if (!id) {
|
|
87
|
+
setter(null)
|
|
88
|
+
return
|
|
89
|
+
}
|
|
90
|
+
const r = await loadPlan(id)
|
|
91
|
+
if (cancelled) return
|
|
92
|
+
if (r.ok) setter({ plan: r.plan, view: projectPlan(r.plan) })
|
|
93
|
+
else {
|
|
94
|
+
setter(null)
|
|
95
|
+
setNotice(`Could not load one of those plans (${r.reason}).`)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
void loadCompared(leftId, setLeft)
|
|
99
|
+
void loadCompared(rightId, setRight)
|
|
100
|
+
return () => {
|
|
101
|
+
cancelled = true
|
|
102
|
+
}
|
|
103
|
+
}, [leftId, rightId])
|
|
104
|
+
|
|
105
|
+
const options = summaries ?? []
|
|
106
|
+
const canCompare = left !== null && right !== null && left.plan.id !== right.plan.id
|
|
107
|
+
const rows = useMemo(() => {
|
|
108
|
+
if (!canCompare) return []
|
|
109
|
+
const l = left.view.summary
|
|
110
|
+
const r = right.view.summary
|
|
111
|
+
return [
|
|
112
|
+
{
|
|
113
|
+
label: 'Ending net worth',
|
|
114
|
+
a: fmtMoneyCompact(l.endingNetWorth),
|
|
115
|
+
b: fmtMoneyCompact(r.endingNetWorth),
|
|
116
|
+
delta: r.endingNetWorth - l.endingNetWorth,
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
label: 'Ending investable',
|
|
120
|
+
a: fmtMoneyCompact(l.endingInvestable),
|
|
121
|
+
b: fmtMoneyCompact(r.endingInvestable),
|
|
122
|
+
delta: r.endingInvestable - l.endingInvestable,
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
label: 'After-tax estate',
|
|
126
|
+
a: fmtMoneyCompact(l.endingAfterTaxEstate),
|
|
127
|
+
b: fmtMoneyCompact(r.endingAfterTaxEstate),
|
|
128
|
+
delta: r.endingAfterTaxEstate - l.endingAfterTaxEstate,
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
label: 'Success % (deterministic)',
|
|
132
|
+
a: l.depletionYear === null ? '100%' : '0%',
|
|
133
|
+
b: r.depletionYear === null ? '100%' : '0%',
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
label: 'Depletion age (primary)',
|
|
137
|
+
a: primaryAgeIn(left.plan, l.depletionYear),
|
|
138
|
+
b: primaryAgeIn(right.plan, r.depletionYear),
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
label: 'Lifetime tax + penalties',
|
|
142
|
+
a: fmtMoneyCompact(l.lifetimeTaxesAndPenalties),
|
|
143
|
+
b: fmtMoneyCompact(r.lifetimeTaxesAndPenalties),
|
|
144
|
+
delta: r.lifetimeTaxesAndPenalties - l.lifetimeTaxesAndPenalties,
|
|
145
|
+
higherIsGood: false,
|
|
146
|
+
},
|
|
147
|
+
]
|
|
148
|
+
}, [canCompare, left, right])
|
|
149
|
+
|
|
150
|
+
return (
|
|
151
|
+
<section className="page planner-shell" style={{ textAlign: 'left' }}>
|
|
152
|
+
<div className="results-toolbar">
|
|
153
|
+
<Link to="/" className="btn btn-secondary btn-small">
|
|
154
|
+
Back to plans
|
|
155
|
+
</Link>
|
|
156
|
+
</div>
|
|
157
|
+
<h1>Compare plans</h1>
|
|
158
|
+
<p className="lede">
|
|
159
|
+
Compare two saved plans side by side. Use this for A/B planning after duplicating a plan, or for year-over-year
|
|
160
|
+
tracking across independently saved plans.
|
|
161
|
+
</p>
|
|
162
|
+
{notice ? <div className="callout callout--warn">{notice}</div> : null}
|
|
163
|
+
{summaries === null ? (
|
|
164
|
+
<div className="skeleton" style={{ height: '8rem' }} aria-label="Loading plans" />
|
|
165
|
+
) : summaries.length < 2 ? (
|
|
166
|
+
<div className="empty-state">
|
|
167
|
+
<h2>Two plans are needed</h2>
|
|
168
|
+
<p>Duplicate an existing plan or create another plan before comparing.</p>
|
|
169
|
+
</div>
|
|
170
|
+
) : (
|
|
171
|
+
<>
|
|
172
|
+
<div className="card compare-selectors">
|
|
173
|
+
<label>
|
|
174
|
+
Plan A
|
|
175
|
+
<select value={leftId} onChange={(e) => setLeftId(e.target.value)}>
|
|
176
|
+
{options.map((s) => (
|
|
177
|
+
<option key={s.id} value={s.id}>{s.name}</option>
|
|
178
|
+
))}
|
|
179
|
+
</select>
|
|
180
|
+
</label>
|
|
181
|
+
<label>
|
|
182
|
+
Plan B
|
|
183
|
+
<select value={rightId} onChange={(e) => setRightId(e.target.value)}>
|
|
184
|
+
{options.map((s) => (
|
|
185
|
+
<option key={s.id} value={s.id}>{s.name}</option>
|
|
186
|
+
))}
|
|
187
|
+
</select>
|
|
188
|
+
</label>
|
|
189
|
+
</div>
|
|
190
|
+
{!canCompare ? (
|
|
191
|
+
<div className="callout callout--info">Choose two different plans to compare.</div>
|
|
192
|
+
) : (
|
|
193
|
+
<div className="year-table-wrap">
|
|
194
|
+
<table className="year-table compare-table">
|
|
195
|
+
<thead>
|
|
196
|
+
<tr>
|
|
197
|
+
<th>Metric</th>
|
|
198
|
+
<th>{left.plan.name}</th>
|
|
199
|
+
<th>{right.plan.name}</th>
|
|
200
|
+
<th>Plan B − Plan A</th>
|
|
201
|
+
</tr>
|
|
202
|
+
</thead>
|
|
203
|
+
<tbody>
|
|
204
|
+
<tr>
|
|
205
|
+
<th>Money lasts</th>
|
|
206
|
+
<td>{resultLabel(left.view.summary, left.view.result.endYear)}</td>
|
|
207
|
+
<td>{resultLabel(right.view.summary, right.view.result.endYear)}</td>
|
|
208
|
+
<td>—</td>
|
|
209
|
+
</tr>
|
|
210
|
+
{rows.map((row) => <MetricRow key={row.label} {...row} />)}
|
|
211
|
+
</tbody>
|
|
212
|
+
</table>
|
|
213
|
+
</div>
|
|
214
|
+
)}
|
|
215
|
+
</>
|
|
216
|
+
)}
|
|
217
|
+
</section>
|
|
218
|
+
)
|
|
219
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/** The full disclaimer, linked from the navigation and the plan picker. */
|
|
2
|
+
|
|
3
|
+
import { Link } from 'react-router-dom'
|
|
4
|
+
|
|
5
|
+
import { PARAMETER_DATA_AS_OF, PARAMETER_DATA_BASIS } from '@retiregolden/engine/params'
|
|
6
|
+
import { ProvenancePanel } from './ProvenancePanel'
|
|
7
|
+
|
|
8
|
+
export function DisclaimerPage() {
|
|
9
|
+
return (
|
|
10
|
+
<article className="page" style={{ maxWidth: '46rem', margin: '0 auto', textAlign: 'left' }}>
|
|
11
|
+
<h1>Disclaimer</h1>
|
|
12
|
+
<p className="lede">Please read this before relying on anything RetireGolden shows you.</p>
|
|
13
|
+
|
|
14
|
+
<h2>Educational use only</h2>
|
|
15
|
+
<p>
|
|
16
|
+
RetireGolden is an educational modeling tool. It is <strong>not</strong> tax advice, legal advice, investment
|
|
17
|
+
advice, accounting advice, insurance advice, or medical advice, and nothing it produces creates an
|
|
18
|
+
advisor–client relationship of any kind. Decisions about retirement, Social Security claiming, Roth
|
|
19
|
+
conversions, withdrawals, healthcare coverage, or anything else with real-world consequences should be made
|
|
20
|
+
with qualified professionals who know your full situation.
|
|
21
|
+
</p>
|
|
22
|
+
|
|
23
|
+
<h2>Models are simplifications</h2>
|
|
24
|
+
<ul>
|
|
25
|
+
<li>Projections depend entirely on your inputs and assumptions. Small changes in returns, inflation, or longevity produce very different outcomes.</li>
|
|
26
|
+
<li>Monte Carlo success rates are statistics about a simplified model, not probabilities about your actual life.</li>
|
|
27
|
+
<li>The tax engine covers common federal rules (brackets, Social Security taxation, capital-gains stacking, RMDs, IRMAA, ACA credits) at planning precision — not return-filing precision. State income tax models each state's brackets, standard deduction, Social Security treatment, and major retirement-income exclusions, with a flat-rate override for corrections; credits, local/city taxes, and income-tested exclusions are simplified or omitted. Many real-world provisions are approximated.</li>
|
|
28
|
+
<li>Life-expectancy estimates are population statistics with coarse lifestyle adjustments — they say nothing certain about any individual.</li>
|
|
29
|
+
<li>Historical market data in the bootstrap models is approximate and the past does not predict the future.</li>
|
|
30
|
+
</ul>
|
|
31
|
+
|
|
32
|
+
<h2>Rules change</h2>
|
|
33
|
+
<p>
|
|
34
|
+
Tax law, contribution limits, Medicare premiums, ACA subsidies, and Social Security rules change frequently —
|
|
35
|
+
sometimes retroactively. Parameter tables here are updated on a best-effort basis and may be out of date or
|
|
36
|
+
wrong. Verify any number that matters against official sources (irs.gov, ssa.gov, medicare.gov,
|
|
37
|
+
healthcare.gov).
|
|
38
|
+
</p>
|
|
39
|
+
<p>
|
|
40
|
+
<strong>Tax data as of {PARAMETER_DATA_AS_OF}</strong>, reflecting {PARAMETER_DATA_BASIS}. Figures for later
|
|
41
|
+
years carry these rules forward (so bracket creep is modeled, but scheduled future rate changes are not) until
|
|
42
|
+
the next refresh.
|
|
43
|
+
</p>
|
|
44
|
+
|
|
45
|
+
<h2>Where the numbers come from</h2>
|
|
46
|
+
<p>
|
|
47
|
+
These are the defaults the engine applies. They are summaries at planning precision — see each linked source
|
|
48
|
+
for the authoritative figures. For the harnesses that check the engine itself against independent
|
|
49
|
+
implementations, see <Link to="/how-tested">How RetireGolden is tested</Link>.
|
|
50
|
+
</p>
|
|
51
|
+
<ProvenancePanel />
|
|
52
|
+
|
|
53
|
+
<h2>Your data stays with you</h2>
|
|
54
|
+
<p>
|
|
55
|
+
RetireGolden has no server-side storage and no accounts. Everything you enter lives only in this browser
|
|
56
|
+
(IndexedDB and localStorage) and is never transmitted anywhere. That also means <strong>we cannot recover it</strong>:
|
|
57
|
+
clearing your browser data, switching devices, or using the "Clear all data" button erases it permanently.
|
|
58
|
+
Download a plan backup from the planner home if you want to keep or move your plans.
|
|
59
|
+
</p>
|
|
60
|
+
|
|
61
|
+
<h2>No warranty</h2>
|
|
62
|
+
<p>
|
|
63
|
+
The software is provided "as is", without warranty of any kind, express or implied, including fitness for a
|
|
64
|
+
particular purpose. Calculations may contain errors. You bear all responsibility for decisions made using
|
|
65
|
+
this tool.
|
|
66
|
+
</p>
|
|
67
|
+
|
|
68
|
+
<h2>Software license & third-party notices</h2>
|
|
69
|
+
<p>
|
|
70
|
+
RetireGolden is free and open-source software, licensed under the{' '}
|
|
71
|
+
<a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener noreferrer">
|
|
72
|
+
GNU Affero General Public License v3.0 (AGPL-3.0)
|
|
73
|
+
</a>
|
|
74
|
+
. © 2026 RetireGolden, LLC. "RetireGolden" and the RetireGolden logo are trademarks of RetireGolden, LLC.
|
|
75
|
+
The source code is available on{' '}
|
|
76
|
+
<a href="https://github.com/RetireGolden/RetireGolden" target="_blank" rel="noopener noreferrer">
|
|
77
|
+
GitHub
|
|
78
|
+
</a>
|
|
79
|
+
. It bundles third-party packages (React, Recharts, HiGHS-WASM, Zod, and their dependencies) distributed
|
|
80
|
+
under their respective permissive licenses — see the{' '}
|
|
81
|
+
<a href="/THIRD-PARTY-NOTICES.txt" target="_blank" rel="noopener noreferrer">
|
|
82
|
+
full third-party notices
|
|
83
|
+
</a>
|
|
84
|
+
.
|
|
85
|
+
</p>
|
|
86
|
+
</article>
|
|
87
|
+
)
|
|
88
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "How RetireGolden is tested" (trust-and-transparency-layer, step 5): the
|
|
3
|
+
* in-app validation story — one auditable ledger, external-oracle golden
|
|
4
|
+
* suites, the independent-optimizer parity harness, the asset-location
|
|
5
|
+
* invariance guarantee, and the simplifications stated as plainly as the
|
|
6
|
+
* strengths. Harness counts are derived from the source tree at build time
|
|
7
|
+
* (`import.meta.glob` keys), so they cannot go stale.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { Link } from 'react-router-dom'
|
|
11
|
+
|
|
12
|
+
// Sibling-workspace globs (engine, app harness tests) resolve when this file
|
|
13
|
+
// is built inside the RetireGolden monorepo — retiregolden.app's build. In an
|
|
14
|
+
// external consumer of the published package they match nothing (the tarball
|
|
15
|
+
// ships without test files), so the counts degrade to zero rather than lie.
|
|
16
|
+
/** Suites checked against third-party implementations that share no code with RetireGolden. */
|
|
17
|
+
const EXTERNAL_ORACLE_SUITES = Object.keys(
|
|
18
|
+
import.meta.glob(['../**/*.external.golden.test.ts', '../../../engine/src/**/*.external.golden.test.ts']),
|
|
19
|
+
)
|
|
20
|
+
/** All golden suites (fixed expected-value fixtures), external and internal. */
|
|
21
|
+
const GOLDEN_SUITES = Object.keys(
|
|
22
|
+
import.meta.glob(['../**/*.golden.test.ts', '../../../engine/src/**/*.golden.test.ts']),
|
|
23
|
+
)
|
|
24
|
+
/** Every automated test file in the source tree (planner-ui + engine + app harness). */
|
|
25
|
+
const ALL_TEST_FILES = Object.keys(
|
|
26
|
+
import.meta.glob([
|
|
27
|
+
'../**/*.test.{ts,tsx}',
|
|
28
|
+
'../../../engine/src/**/*.test.ts',
|
|
29
|
+
'../../../../app/src/**/*.test.{ts,tsx}',
|
|
30
|
+
]),
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
/** "federalTaxSocialSecurity.external.golden.test.ts" → "federal tax social security". */
|
|
34
|
+
function suiteName(path: string): string {
|
|
35
|
+
const base = path.split('/').pop()!.replace(/\.external\.golden\.test\.ts$/, '')
|
|
36
|
+
return base.replace(/([a-z0-9])([A-Z])/g, '$1 $2').toLowerCase()
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// True when built inside the RetireGolden monorepo (retiregolden.app). A
|
|
40
|
+
// build from the published tarball has no test files to count, and a trust
|
|
41
|
+
// page must not report zeros as if the suites didn't exist — so counts and
|
|
42
|
+
// the pinned-suite list degrade to count-free prose plus a pointer at the
|
|
43
|
+
// upstream tree where they are derived.
|
|
44
|
+
const HAS_SUITE_DATA = ALL_TEST_FILES.length > 0
|
|
45
|
+
|
|
46
|
+
export function HowTestedPage() {
|
|
47
|
+
return (
|
|
48
|
+
<article className="page" style={{ maxWidth: '46rem', margin: '0 auto', textAlign: 'left' }}>
|
|
49
|
+
<h1>How RetireGolden is tested</h1>
|
|
50
|
+
<p className="lede">
|
|
51
|
+
Retirement tools notoriously disagree — the same plan can score 60% in one tool and 90% in another, and most
|
|
52
|
+
tools won't show you why. RetireGolden's answer is to show its work: what the engine computes, what it's checked
|
|
53
|
+
against, and what it deliberately simplifies.
|
|
54
|
+
</p>
|
|
55
|
+
|
|
56
|
+
<h2>One auditable ledger</h2>
|
|
57
|
+
<p>
|
|
58
|
+
Every result — the success rate, the conversion recommendation, the "how much can I spend?" answer — comes from
|
|
59
|
+
a single year-by-year ledger you can open and read on the Results page (or download as CSV). There is no
|
|
60
|
+
separate "estimate" engine: Monte Carlo, the optimizer, and the reports all price against the same exact
|
|
61
|
+
projection. If two numbers in RetireGolden ever disagree, that's a bug, not a methodology difference.
|
|
62
|
+
</p>
|
|
63
|
+
|
|
64
|
+
<h2>Checked against independent implementations</h2>
|
|
65
|
+
<p>
|
|
66
|
+
{HAS_SUITE_DATA ? `${EXTERNAL_ORACLE_SUITES.length} external-oracle` : 'External-oracle'} golden suites pin
|
|
67
|
+
RetireGolden's calculations to third-party references that share no code with it:
|
|
68
|
+
</p>
|
|
69
|
+
<ul>
|
|
70
|
+
<li>
|
|
71
|
+
<strong>Federal & state taxes</strong> — golden cases cross-checked against independent open tax engines
|
|
72
|
+
(PolicyEngine-US and PSLmodels Tax-Calculator), covering brackets, Social Security taxability, and
|
|
73
|
+
capital-gains stacking.
|
|
74
|
+
</li>
|
|
75
|
+
<li>
|
|
76
|
+
<strong>Medicare/IRMAA, ACA, and RMDs</strong> — checked against the published CMS premium tables, the IRS
|
|
77
|
+
applicable-percentage schedule, and the Pub 590-B life-expectancy tables.
|
|
78
|
+
</li>
|
|
79
|
+
<li>
|
|
80
|
+
<strong>Social Security</strong> — benefit computation and claiming math validated against the SSA's
|
|
81
|
+
published rules (bend points, actuarial factors, family maximums, survivor limits).
|
|
82
|
+
</li>
|
|
83
|
+
</ul>
|
|
84
|
+
{HAS_SUITE_DATA && (
|
|
85
|
+
<p className="muted small">
|
|
86
|
+
Suites currently pinned: {EXTERNAL_ORACLE_SUITES.map(suiteName).join(' · ')}.
|
|
87
|
+
</p>
|
|
88
|
+
)}
|
|
89
|
+
<p>
|
|
90
|
+
The Roth-conversion optimizer additionally runs through a <strong>parity harness</strong>: a shared fixture
|
|
91
|
+
matrix is solved both by RetireGolden and by an independent open-source conversion optimizer (pinned version),
|
|
92
|
+
and both tools' schedules are priced on RetireGolden's own exact ledger. As of July 2026 the harness passes on
|
|
93
|
+
every fixture, with RetireGolden's schedules ahead on projected after-tax estate. It re-runs on a maintenance
|
|
94
|
+
cadence.
|
|
95
|
+
</p>
|
|
96
|
+
|
|
97
|
+
<h2>Recommendations are arbitrated, not trusted</h2>
|
|
98
|
+
<p>
|
|
99
|
+
No recommendation reaches you straight from a solver. Candidate schedules — including simple strategies like
|
|
100
|
+
bracket-fill — are re-run through your full projection and ranked in a tournament; the winner must beat the
|
|
101
|
+
alternatives on the exact ledger, and the "Why this recommendation?" panel on the optimizer page shows you the
|
|
102
|
+
beaten alternatives and their dollar margins.
|
|
103
|
+
</p>
|
|
104
|
+
|
|
105
|
+
<h2>Asset-location invariance, proven</h2>
|
|
106
|
+
<p>
|
|
107
|
+
A known six-figure bug class in conversion tools: quietly assuming Roth dollars are invested more aggressively
|
|
108
|
+
than traditional dollars, so "conversion benefit" is really a hidden allocation change. RetireGolden holds
|
|
109
|
+
portfolio-wide asset allocation constant across account types, and a dedicated fixture suite
|
|
110
|
+
(<code>assetLocationInvariance</code>) proves it: a tax-free conversion between identically-allocated accounts
|
|
111
|
+
leaves every year's totals identical to the dollar, the entire estate benefit of a conversion is the tax term,
|
|
112
|
+
and conversion candidates can never smuggle in an allocation change. If you deliberately allocate accounts
|
|
113
|
+
differently, the engine prices that too — visibly, as your explicit setting.
|
|
114
|
+
</p>
|
|
115
|
+
|
|
116
|
+
<h2>Golden suites and regression gates</h2>
|
|
117
|
+
<p>
|
|
118
|
+
{HAS_SUITE_DATA ? (
|
|
119
|
+
<>
|
|
120
|
+
{GOLDEN_SUITES.length} golden suites hold fixed expected values for the tax engine, RMDs, Social Security,
|
|
121
|
+
and full-plan projections, out of {ALL_TEST_FILES.length} automated test files overall.
|
|
122
|
+
</>
|
|
123
|
+
) : (
|
|
124
|
+
<>
|
|
125
|
+
Golden suites hold fixed expected values for the tax engine, RMDs, Social Security, and full-plan
|
|
126
|
+
projections.
|
|
127
|
+
</>
|
|
128
|
+
)}{' '}
|
|
129
|
+
A deterministic case runner replays the whole example-plan library and diffs engine output on every change, so
|
|
130
|
+
unintended result drift is caught before it ships. New engine features land behind no-op defaults, guarded by
|
|
131
|
+
"feature-off is byte-identical" regressions.
|
|
132
|
+
</p>
|
|
133
|
+
{!HAS_SUITE_DATA && (
|
|
134
|
+
<p className="muted small">
|
|
135
|
+
Suite counts and names are derived from the RetireGolden source tree at build time; this build was produced
|
|
136
|
+
outside that tree, so they aren't shown here. The live suites are public at{' '}
|
|
137
|
+
<a href="https://github.com/RetireGolden/RetireGolden">github.com/RetireGolden/RetireGolden</a>.
|
|
138
|
+
</p>
|
|
139
|
+
)}
|
|
140
|
+
|
|
141
|
+
<h2>What RetireGolden deliberately simplifies</h2>
|
|
142
|
+
<p>Honest scope beats false precision. RetireGolden is planning-grade, not filing-grade:</p>
|
|
143
|
+
<ul>
|
|
144
|
+
<li>The optimizer's in-solve model uses documented simplifications (for example, a single long-term-gains rate inside the solve); recommendations are then re-priced and validated on the exact ledger before you see them.</li>
|
|
145
|
+
<li>Future-year tax parameters beyond published law are stand-ins: current rules carry forward with bracket indexation, but scheduled or speculative future law changes are not modeled.</li>
|
|
146
|
+
<li>AMT is a planning screen, not a filing computation; per-lot basis and many credit phase-outs are simplified by design.</li>
|
|
147
|
+
<li>Monte Carlo success rates are statistics about a simplified model of your plan — not probabilities about your actual life.</li>
|
|
148
|
+
</ul>
|
|
149
|
+
|
|
150
|
+
<h2>Where the inputs come from</h2>
|
|
151
|
+
<p>
|
|
152
|
+
Every tax and benefit parameter cites its source — see the{' '}
|
|
153
|
+
<Link to="/disclaimer">full provenance table on the Disclaimer page</Link>. Inside a plan, the assumptions card
|
|
154
|
+
enumerates every live assumption with its provenance and a copy-export, so you can replicate a run in another
|
|
155
|
+
tool and see exactly why the answers differ.
|
|
156
|
+
</p>
|
|
157
|
+
</article>
|
|
158
|
+
)
|
|
159
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A visually hidden ARIA live region. Rendering a new `message` (different text
|
|
3
|
+
* content) prompts assistive tech to announce it; identical re-renders stay
|
|
4
|
+
* silent, so it will not double-announce on unrelated re-renders. Keep the
|
|
5
|
+
* component mounted (render it with an empty message rather than conditionally)
|
|
6
|
+
* so the region exists before its text first changes — some screen readers skip
|
|
7
|
+
* the announcement otherwise. Use `assertive` for errors that should interrupt.
|
|
8
|
+
*/
|
|
9
|
+
export function LiveStatus({ message, assertive = false }: { message: string; assertive?: boolean }) {
|
|
10
|
+
return (
|
|
11
|
+
<span className="sr-only" role={assertive ? 'alert' : 'status'} aria-live={assertive ? 'assertive' : 'polite'}>
|
|
12
|
+
{message}
|
|
13
|
+
</span>
|
|
14
|
+
)
|
|
15
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Life-expectancy questionnaire in a modal, seeded from the person's profile.
|
|
3
|
+
* On completion the wizard's planning age is applied straight to the plan —
|
|
4
|
+
* the standalone /longevity page is no longer part of the main navigation.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { useCallback } from 'react'
|
|
8
|
+
|
|
9
|
+
import { LongevityWizard } from '../longevity/LongevityWizard'
|
|
10
|
+
import { loadLongevity, loadLongevityPartner } from '../longevity/storage'
|
|
11
|
+
import type { Person } from '@retiregolden/engine/model/plan'
|
|
12
|
+
import { Modal } from './Modal'
|
|
13
|
+
|
|
14
|
+
export interface LongevityModalProps {
|
|
15
|
+
person: Person
|
|
16
|
+
/** 0 = primary slot, 1 = partner slot (matches the questionnaire's storage). */
|
|
17
|
+
personIndex: number
|
|
18
|
+
onApply: (planningAge: number) => void
|
|
19
|
+
onClose: () => void
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function ageToday(dob: string): number {
|
|
23
|
+
const y = Number(dob.slice(0, 4))
|
|
24
|
+
const m = Number(dob.slice(5, 7))
|
|
25
|
+
const now = new Date()
|
|
26
|
+
let age = now.getFullYear() - y
|
|
27
|
+
if (now.getMonth() + 1 < m) age -= 1
|
|
28
|
+
return Math.min(110, Math.max(18, age))
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function LongevityModal({ person, personIndex, onApply, onClose }: LongevityModalProps) {
|
|
32
|
+
const target = personIndex === 0 ? 'primary' : 'partner'
|
|
33
|
+
|
|
34
|
+
const handleComplete = useCallback(() => {
|
|
35
|
+
const saved = personIndex === 0 ? loadLongevity() : loadLongevityPartner()
|
|
36
|
+
if (saved) {
|
|
37
|
+
onApply(Math.min(120, Math.max(60, Math.round(saved.result.illustrativePlanningAge))))
|
|
38
|
+
}
|
|
39
|
+
onClose()
|
|
40
|
+
}, [personIndex, onApply, onClose])
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<Modal title={`Estimate planning age — ${person.name}`} onClose={onClose} width="40rem">
|
|
44
|
+
<p className="card-hint">
|
|
45
|
+
A short questionnaire combining a Social Security period life table with conservative lifestyle adjustments.
|
|
46
|
+
The result becomes {person.name}'s planning age; you can still override it afterwards.
|
|
47
|
+
</p>
|
|
48
|
+
<LongevityWizard
|
|
49
|
+
initialAnswers={{ age: ageToday(person.dob), sex: person.sex }}
|
|
50
|
+
storageTarget={target}
|
|
51
|
+
onComplete={handleComplete}
|
|
52
|
+
/>
|
|
53
|
+
</Modal>
|
|
54
|
+
)
|
|
55
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/** Accessible modal dialog: backdrop + Escape close, focus moved on open. */
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, type ReactNode, type RefObject } from 'react'
|
|
4
|
+
|
|
5
|
+
export interface ModalProps {
|
|
6
|
+
title: string
|
|
7
|
+
onClose: () => void
|
|
8
|
+
children: ReactNode
|
|
9
|
+
/** Max width CSS value (default 36rem). */
|
|
10
|
+
width?: string
|
|
11
|
+
/** Element to focus when the dialog opens (defaults to the panel itself). */
|
|
12
|
+
initialFocus?: RefObject<HTMLElement | null>
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function Modal({ title, onClose, children, width, initialFocus }: ModalProps) {
|
|
16
|
+
const panelRef = useRef<HTMLDivElement>(null)
|
|
17
|
+
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
const previouslyFocused = document.activeElement as HTMLElement | null
|
|
20
|
+
;(initialFocus?.current ?? panelRef.current)?.focus()
|
|
21
|
+
|
|
22
|
+
const focusable = () =>
|
|
23
|
+
Array.from(
|
|
24
|
+
panelRef.current?.querySelectorAll<HTMLElement>(
|
|
25
|
+
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
|
|
26
|
+
) ?? [],
|
|
27
|
+
).filter((el) => el.offsetParent !== null || el === document.activeElement)
|
|
28
|
+
|
|
29
|
+
const onKey = (e: KeyboardEvent) => {
|
|
30
|
+
if (e.key === 'Escape') {
|
|
31
|
+
onClose()
|
|
32
|
+
return
|
|
33
|
+
}
|
|
34
|
+
if (e.key !== 'Tab') return
|
|
35
|
+
// Keep Tab focus cycling within the dialog.
|
|
36
|
+
const items = focusable()
|
|
37
|
+
if (items.length === 0) {
|
|
38
|
+
e.preventDefault()
|
|
39
|
+
panelRef.current?.focus()
|
|
40
|
+
return
|
|
41
|
+
}
|
|
42
|
+
const first = items[0]!
|
|
43
|
+
const last = items[items.length - 1]!
|
|
44
|
+
const active = document.activeElement
|
|
45
|
+
if (e.shiftKey && (active === first || active === panelRef.current)) {
|
|
46
|
+
e.preventDefault()
|
|
47
|
+
last.focus()
|
|
48
|
+
} else if (!e.shiftKey && active === last) {
|
|
49
|
+
e.preventDefault()
|
|
50
|
+
first.focus()
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
window.addEventListener('keydown', onKey)
|
|
54
|
+
const prevOverflow = document.body.style.overflow
|
|
55
|
+
document.body.style.overflow = 'hidden'
|
|
56
|
+
return () => {
|
|
57
|
+
window.removeEventListener('keydown', onKey)
|
|
58
|
+
document.body.style.overflow = prevOverflow
|
|
59
|
+
// Return focus to whatever opened the dialog.
|
|
60
|
+
previouslyFocused?.focus?.()
|
|
61
|
+
}
|
|
62
|
+
}, [onClose, initialFocus])
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<div className="modal-backdrop" onMouseDown={(e) => e.target === e.currentTarget && onClose()}>
|
|
66
|
+
<div
|
|
67
|
+
className="modal-panel"
|
|
68
|
+
role="dialog"
|
|
69
|
+
aria-modal="true"
|
|
70
|
+
aria-label={title}
|
|
71
|
+
tabIndex={-1}
|
|
72
|
+
ref={panelRef}
|
|
73
|
+
style={width ? { maxWidth: width } : undefined}
|
|
74
|
+
>
|
|
75
|
+
<div className="modal-head">
|
|
76
|
+
<h2>{title}</h2>
|
|
77
|
+
<button type="button" className="btn-ghost modal-close" onClick={onClose} aria-label="Close dialog">
|
|
78
|
+
<svg
|
|
79
|
+
viewBox="0 0 24 24"
|
|
80
|
+
width="16"
|
|
81
|
+
height="16"
|
|
82
|
+
fill="none"
|
|
83
|
+
stroke="currentColor"
|
|
84
|
+
strokeWidth="2"
|
|
85
|
+
strokeLinecap="round"
|
|
86
|
+
aria-hidden="true"
|
|
87
|
+
>
|
|
88
|
+
<line x1="6" y1="6" x2="18" y2="18" />
|
|
89
|
+
<line x1="18" y1="6" x2="6" y2="18" />
|
|
90
|
+
</svg>
|
|
91
|
+
</button>
|
|
92
|
+
</div>
|
|
93
|
+
<div className="modal-body">{children}</div>
|
|
94
|
+
</div>
|
|
95
|
+
</div>
|
|
96
|
+
)
|
|
97
|
+
}
|