@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,1134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Explore → Social Security: two views of the claiming decision.
|
|
3
|
+
* - "In your plan": sweep every claim-age combination through the full
|
|
4
|
+
* projection, ranked by ending after-tax estate; apply the winner.
|
|
5
|
+
* - "Benefits only": mortality-weighted expected present value of the
|
|
6
|
+
* benefits alone (the actuarial / insurance lens).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { useMemo, useState } from 'react'
|
|
10
|
+
import {
|
|
11
|
+
CartesianGrid,
|
|
12
|
+
Legend,
|
|
13
|
+
Line,
|
|
14
|
+
LineChart,
|
|
15
|
+
ResponsiveContainer,
|
|
16
|
+
Tooltip,
|
|
17
|
+
XAxis,
|
|
18
|
+
YAxis,
|
|
19
|
+
} from 'recharts'
|
|
20
|
+
|
|
21
|
+
import { runMonteCarlo } from '../mc/pool'
|
|
22
|
+
import { sizeBridge, type BridgeSizing } from '@retiregolden/engine/ladder/bridge'
|
|
23
|
+
import { EMBEDDED_REAL_YIELD_CURVE } from '@retiregolden/engine/params'
|
|
24
|
+
import type { TipsLadder } from '@retiregolden/engine/model/plan'
|
|
25
|
+
import { computeBreakEven } from '../socialSecurity/breakEven'
|
|
26
|
+
import { rankSwitchStrategies } from '../socialSecurity/survivorSwitching'
|
|
27
|
+
import { survivorBenefitMonthly } from '@retiregolden/engine/socialSecurity/survivorBenefit'
|
|
28
|
+
import { ficaOasdiPaidIn } from '../socialSecurity/ficaReturn'
|
|
29
|
+
import { expectedPvSingle } from '../socialSecurity/expectedPv'
|
|
30
|
+
import { claimFactor } from '@retiregolden/engine/socialSecurity/claimFactor'
|
|
31
|
+
import { objectivePolicies, type ObjectivePolicyId } from '@retiregolden/engine/decisions'
|
|
32
|
+
import { effectiveBirthYear, fraForBirthYear, fraTotalMonths, survivorFraForBirthYear } from '@retiregolden/engine/socialSecurity/nra'
|
|
33
|
+
import { packForYear } from '@retiregolden/engine/params'
|
|
34
|
+
import { usePlan } from './planContextCore'
|
|
35
|
+
import { CheckboxField, HelpTip, SelectField } from './fields'
|
|
36
|
+
import { LEARN } from './learnLinks'
|
|
37
|
+
import { LearnLink } from '../learn/LearnLink'
|
|
38
|
+
import { LearnAboutScreen } from '../learn/LearnAboutScreen'
|
|
39
|
+
import { fmtMoney, fmtMoneyCompact } from './format'
|
|
40
|
+
import { currentStartYear, projectPlan, seedFromPlanId } from './useProjection'
|
|
41
|
+
import {
|
|
42
|
+
benefitsOnlyRanking,
|
|
43
|
+
candidateClaimAges,
|
|
44
|
+
claimingPeople,
|
|
45
|
+
dobParts,
|
|
46
|
+
planWithClaimAges,
|
|
47
|
+
refineClaimingMonthly,
|
|
48
|
+
sweepClaimingStrategies,
|
|
49
|
+
type MonthlyClaim,
|
|
50
|
+
type MonthlyRefinement,
|
|
51
|
+
type SweepRow,
|
|
52
|
+
} from './ssAnalysis'
|
|
53
|
+
import { chartTooltipStyle } from './chartStyle'
|
|
54
|
+
|
|
55
|
+
type Tab = 'plan' | 'benefits' | 'breakeven'
|
|
56
|
+
|
|
57
|
+
const BREAKEVEN_COLORS = ['var(--chart-2)', 'var(--chart-1)', 'var(--chart-3)']
|
|
58
|
+
|
|
59
|
+
const OBJECTIVE_CHOICES: ReadonlyArray<{ value: ObjectivePolicyId; label: string }> = [
|
|
60
|
+
'max-after-tax-estate',
|
|
61
|
+
'max-spending-durability',
|
|
62
|
+
'min-lifetime-tax-estate-floor',
|
|
63
|
+
'protect-survivor-liquidity',
|
|
64
|
+
'bridge-durability',
|
|
65
|
+
].map((id) => ({ value: id as ObjectivePolicyId, label: objectivePolicies[id as ObjectivePolicyId].label }))
|
|
66
|
+
|
|
67
|
+
function ageLabel(claim: Record<string, number>, ids: string[]): string {
|
|
68
|
+
return ids.map((id) => claim[id]).join(' / ')
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Background tint for a heatmap cell scaled 0 (worst) … 1 (best). */
|
|
72
|
+
function heatColor(t: number): string {
|
|
73
|
+
return `color-mix(in srgb, var(--good) ${Math.round(t * 70)}%, var(--surface-1))`
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function EmptyState() {
|
|
77
|
+
return (
|
|
78
|
+
<div className="empty-state">
|
|
79
|
+
<h2>No Social Security to analyze yet</h2>
|
|
80
|
+
<p>Add a benefit for at least one person on the Social Security entry form, then come back here.</p>
|
|
81
|
+
</div>
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function SsAnalysisPage() {
|
|
86
|
+
const { plan, update } = usePlan()
|
|
87
|
+
const [tab, setTab] = useState<Tab>('plan')
|
|
88
|
+
const people = useMemo(() => claimingPeople(plan), [plan])
|
|
89
|
+
|
|
90
|
+
if (people.length === 0) {
|
|
91
|
+
return (
|
|
92
|
+
<section>
|
|
93
|
+
<div className="card">
|
|
94
|
+
<h2>Social Security Optimizer</h2>
|
|
95
|
+
<EmptyState />
|
|
96
|
+
</div>
|
|
97
|
+
</section>
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const personName = (id: string) => plan.household.people.find((p) => p.id === id)?.name ?? id
|
|
102
|
+
const applyStrategy = (claim: Record<string, number>) =>
|
|
103
|
+
update((d) => {
|
|
104
|
+
for (const s of d.incomes) {
|
|
105
|
+
if (s.type === 'socialSecurity' && claim[s.personId] !== undefined) {
|
|
106
|
+
s.claimAge = { years: claim[s.personId]!, months: 0 }
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
return (
|
|
112
|
+
<section>
|
|
113
|
+
<div className="card">
|
|
114
|
+
<h2>Social Security Optimizer</h2>
|
|
115
|
+
<div className="seg" role="tablist" style={{ marginBottom: '1rem' }}>
|
|
116
|
+
<button type="button" role="tab" aria-pressed={tab === 'plan'} onClick={() => setTab('plan')}>
|
|
117
|
+
In your plan
|
|
118
|
+
</button>
|
|
119
|
+
<button type="button" role="tab" aria-pressed={tab === 'benefits'} onClick={() => setTab('benefits')}>
|
|
120
|
+
Benefits only
|
|
121
|
+
</button>
|
|
122
|
+
<button type="button" role="tab" aria-pressed={tab === 'breakeven'} onClick={() => setTab('breakeven')}>
|
|
123
|
+
Break-even
|
|
124
|
+
</button>
|
|
125
|
+
</div>
|
|
126
|
+
{tab === 'plan' ? (
|
|
127
|
+
<InYourPlanTab personIds={people.map((p) => p.person.id)} personName={personName} applyStrategy={applyStrategy} />
|
|
128
|
+
) : tab === 'benefits' ? (
|
|
129
|
+
<BenefitsOnlyTab personIds={people.map((p) => p.person.id)} personName={personName} applyStrategy={applyStrategy} />
|
|
130
|
+
) : (
|
|
131
|
+
<BreakEvenTab personIds={people.map((p) => p.person.id)} personName={personName} />
|
|
132
|
+
)}
|
|
133
|
+
</div>
|
|
134
|
+
|
|
135
|
+
<BridgePanel />
|
|
136
|
+
|
|
137
|
+
<LearnAboutScreen route="/plan/:planId/social-security-analysis" />
|
|
138
|
+
</section>
|
|
139
|
+
)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
// Social Security bridge (social-security-bridge-and-tips-ladder, step 3)
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
interface BridgeComparisonRow {
|
|
147
|
+
name: string
|
|
148
|
+
endingAfterTaxEstate: number
|
|
149
|
+
depletionYear: number | null
|
|
150
|
+
successRate: number | null
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Packages the delay-and-bridge strategy as a plan artifact: sizes the bridge
|
|
155
|
+
* from each claimant's own numbers (forgone age-62 benefit × gap years),
|
|
156
|
+
* quotes the TIPS ladder on the embedded curve, adds it to the plan in one
|
|
157
|
+
* click, and prices "bridge + delayed claim" against "claim at 62" on the
|
|
158
|
+
* same deterministic ledger and the same seeded Monte Carlo paths.
|
|
159
|
+
*/
|
|
160
|
+
function BridgePanel() {
|
|
161
|
+
const { plan, update } = usePlan()
|
|
162
|
+
const startYear = currentStartYear()
|
|
163
|
+
const people = useMemo(() => claimingPeople(plan), [plan])
|
|
164
|
+
|
|
165
|
+
const existingLadders = plan.incomeFloor?.ladders
|
|
166
|
+
const sized = useMemo(() => {
|
|
167
|
+
const out: Array<{ personId: string; name: string; bridge: BridgeSizing; ladder: TipsLadder }> = []
|
|
168
|
+
for (const { person, stream, pia } of people) {
|
|
169
|
+
const { y, m, d } = dobParts(person)
|
|
170
|
+
const bridge = sizeBridge({
|
|
171
|
+
piaMonthly: pia,
|
|
172
|
+
dob: { year: y, month: m, day: d },
|
|
173
|
+
claimAge: stream.claimAge,
|
|
174
|
+
currentYear: startYear,
|
|
175
|
+
retirementYear: person.retirementAge !== null ? y + person.retirementAge : startYear,
|
|
176
|
+
curve: EMBEDDED_REAL_YIELD_CURVE,
|
|
177
|
+
})
|
|
178
|
+
if (!bridge) continue
|
|
179
|
+
// A plan ladder already covering this whole window means the bridge is
|
|
180
|
+
// bought — offering it again would double-count purchases and flows.
|
|
181
|
+
// Same coverage rule as the ss-bridge-gap detector and the generator
|
|
182
|
+
// (full coverage: one year short is a real unfunded gap year).
|
|
183
|
+
const covered = existingLadders?.some((l) => l.startYear <= bridge.startYear && l.endYear >= bridge.endYear)
|
|
184
|
+
if (covered) continue
|
|
185
|
+
out.push({
|
|
186
|
+
personId: person.id,
|
|
187
|
+
name: person.name,
|
|
188
|
+
bridge,
|
|
189
|
+
ladder: {
|
|
190
|
+
id: `bridge-${person.id}-${bridge.startYear}`,
|
|
191
|
+
name: `SS bridge (${person.name})`,
|
|
192
|
+
purpose: 'bridge',
|
|
193
|
+
startYear: bridge.startYear,
|
|
194
|
+
endYear: bridge.endYear,
|
|
195
|
+
annualRealAmount: bridge.annualRealAmount,
|
|
196
|
+
},
|
|
197
|
+
})
|
|
198
|
+
}
|
|
199
|
+
return out
|
|
200
|
+
}, [people, startYear, existingLadders])
|
|
201
|
+
|
|
202
|
+
const fundingOptions = plan.accounts
|
|
203
|
+
.filter((a) => a.type === 'cash' || a.type === 'taxable' || a.type === 'equityComp')
|
|
204
|
+
.map((a) => ({ value: a.id, label: a.name }))
|
|
205
|
+
const [fundingId, setFundingId] = useState<string>('')
|
|
206
|
+
const funding = fundingOptions.find((o) => o.value === fundingId) ?? fundingOptions[0]
|
|
207
|
+
const [rows, setRows] = useState<BridgeComparisonRow[] | null>(null)
|
|
208
|
+
const [comparing, setComparing] = useState(false)
|
|
209
|
+
const [compareError, setCompareError] = useState<string | null>(null)
|
|
210
|
+
|
|
211
|
+
const alreadyBridged = (plan.incomeFloor?.ladders ?? []).some((l) => l.purpose === 'bridge')
|
|
212
|
+
if (sized.length === 0) {
|
|
213
|
+
return alreadyBridged ? (
|
|
214
|
+
<div className="card">
|
|
215
|
+
<h2>Social Security bridge</h2>
|
|
216
|
+
<p className="card-hint">
|
|
217
|
+
Your plan already holds a bridge ladder covering the gap years — see it on the Income floor page.{' '}
|
|
218
|
+
<LearnLink {...LEARN.socialSecurityBridge} />
|
|
219
|
+
</p>
|
|
220
|
+
</div>
|
|
221
|
+
) : null
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Never append a ladder id the plan already holds: the coverage filter above
|
|
225
|
+
// normally empties `sized` once a bridge is bought, but a user-edited window
|
|
226
|
+
// can re-open the offer while the deterministic id still exists — appending
|
|
227
|
+
// it again would collide ids and double-count flows.
|
|
228
|
+
const laddersWithFunding = (existing: TipsLadder[] | undefined): TipsLadder[] =>
|
|
229
|
+
sized
|
|
230
|
+
.filter(({ ladder }) => !existing?.some((l) => l.id === ladder.id))
|
|
231
|
+
.map(({ ladder }) => ({
|
|
232
|
+
...ladder,
|
|
233
|
+
purchase: funding ? { year: startYear, fundingAccountId: funding.value } : undefined,
|
|
234
|
+
}))
|
|
235
|
+
|
|
236
|
+
const planWithBridge = () => {
|
|
237
|
+
const next = structuredClone(plan)
|
|
238
|
+
const ladders = laddersWithFunding(next.incomeFloor?.ladders)
|
|
239
|
+
if (next.incomeFloor) next.incomeFloor.ladders.push(...ladders)
|
|
240
|
+
else next.incomeFloor = { ladders }
|
|
241
|
+
return next
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const addToPlan = () =>
|
|
245
|
+
update((d) => {
|
|
246
|
+
const ladders = laddersWithFunding(d.incomeFloor?.ladders)
|
|
247
|
+
if (d.incomeFloor) d.incomeFloor.ladders.push(...ladders)
|
|
248
|
+
else d.incomeFloor = { ladders }
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
const runComparison = async () => {
|
|
252
|
+
setComparing(true)
|
|
253
|
+
setCompareError(null)
|
|
254
|
+
try {
|
|
255
|
+
const claimEarly = planWithClaimAges(
|
|
256
|
+
plan,
|
|
257
|
+
Object.fromEntries(sized.map((s) => [s.personId, 62])),
|
|
258
|
+
)
|
|
259
|
+
const bridged = planWithBridge()
|
|
260
|
+
const variants: Array<{ name: string; plan: typeof plan }> = [
|
|
261
|
+
{ name: 'Claim at 62, no bridge', plan: claimEarly },
|
|
262
|
+
{ name: 'Current claim ages, no bridge', plan },
|
|
263
|
+
{ name: 'Current claim ages + TIPS bridge', plan: bridged },
|
|
264
|
+
]
|
|
265
|
+
const out: BridgeComparisonRow[] = []
|
|
266
|
+
for (const v of variants) {
|
|
267
|
+
const projection = projectPlan(v.plan, startYear)
|
|
268
|
+
// Same seed ⇒ identical market paths across variants (same-path delta).
|
|
269
|
+
const mc = await runMonteCarlo(v.plan, {
|
|
270
|
+
startYear,
|
|
271
|
+
pathCount: 500,
|
|
272
|
+
seed: seedFromPlanId(plan.id),
|
|
273
|
+
model: { type: 'lognormal', inflationMeanPct: plan.assumptions.inflationPct },
|
|
274
|
+
})
|
|
275
|
+
out.push({
|
|
276
|
+
name: v.name,
|
|
277
|
+
endingAfterTaxEstate: projection.summary.endingAfterTaxEstate,
|
|
278
|
+
depletionYear: projection.summary.depletionYear,
|
|
279
|
+
successRate: mc.successRate,
|
|
280
|
+
})
|
|
281
|
+
}
|
|
282
|
+
setRows(out)
|
|
283
|
+
} catch (e) {
|
|
284
|
+
setCompareError(e instanceof Error ? e.message : 'The comparison failed.')
|
|
285
|
+
} finally {
|
|
286
|
+
setComparing(false)
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const totalCost = sized.reduce((sum, s) => sum + s.bridge.ladderCost, 0)
|
|
291
|
+
return (
|
|
292
|
+
<div className="card">
|
|
293
|
+
<h2>Social Security bridge</h2>
|
|
294
|
+
<p className="card-hint">
|
|
295
|
+
Delaying is the cheapest inflation-protected annuity you can buy — the bridge pays you the forgone age-62
|
|
296
|
+
benefit until your claim starts, so the delay never cuts lifestyle. Sized from your own plan; quoted on
|
|
297
|
+
Treasury real yields as of {EMBEDDED_REAL_YIELD_CURVE.asOfIso}. <LearnLink {...LEARN.socialSecurityBridge} />
|
|
298
|
+
</p>
|
|
299
|
+
<div className="year-table-wrap">
|
|
300
|
+
<table>
|
|
301
|
+
<thead>
|
|
302
|
+
<tr>
|
|
303
|
+
<th>Person</th>
|
|
304
|
+
<th>Bridge pays</th>
|
|
305
|
+
<th>Years</th>
|
|
306
|
+
<th>TIPS ladder cost (today's $)</th>
|
|
307
|
+
</tr>
|
|
308
|
+
</thead>
|
|
309
|
+
<tbody>
|
|
310
|
+
{sized.map((s) => (
|
|
311
|
+
<tr key={s.personId}>
|
|
312
|
+
<td>{s.name}</td>
|
|
313
|
+
<td>
|
|
314
|
+
{fmtMoney(s.bridge.annualRealAmount)}/yr ({fmtMoney(s.bridge.monthlyAge62Benefit)}/mo, real)
|
|
315
|
+
</td>
|
|
316
|
+
<td>
|
|
317
|
+
{s.bridge.startYear}–{s.bridge.endYear}
|
|
318
|
+
</td>
|
|
319
|
+
<td>{fmtMoney(s.bridge.ladderCost)}</td>
|
|
320
|
+
</tr>
|
|
321
|
+
))}
|
|
322
|
+
</tbody>
|
|
323
|
+
</table>
|
|
324
|
+
</div>
|
|
325
|
+
<div className="form-grid">
|
|
326
|
+
<SelectField
|
|
327
|
+
label="Fund the bridge from"
|
|
328
|
+
help="The ladder cost is withdrawn from this account in the purchase year — a transfer into the ladder, not spending."
|
|
329
|
+
value={funding?.value ?? ''}
|
|
330
|
+
options={fundingOptions.length > 0 ? fundingOptions : [{ value: '', label: 'No cash/taxable account' }]}
|
|
331
|
+
onCommit={setFundingId}
|
|
332
|
+
/>
|
|
333
|
+
</div>
|
|
334
|
+
<div className="add-row">
|
|
335
|
+
<button type="button" className="btn btn-primary btn-small" onClick={addToPlan} disabled={!funding}>
|
|
336
|
+
Add bridge ladder{sized.length > 1 ? 's' : ''} to plan ({fmtMoneyCompact(totalCost)})
|
|
337
|
+
</button>{' '}
|
|
338
|
+
<button
|
|
339
|
+
type="button"
|
|
340
|
+
className="btn btn-secondary btn-small"
|
|
341
|
+
onClick={() => void runComparison()}
|
|
342
|
+
disabled={comparing || !funding}
|
|
343
|
+
>
|
|
344
|
+
{comparing ? 'Comparing…' : 'Compare vs claiming at 62'}
|
|
345
|
+
</button>
|
|
346
|
+
</div>
|
|
347
|
+
{!funding ? (
|
|
348
|
+
<p className="card-hint">
|
|
349
|
+
Add a cash or taxable account to fund the bridge — without one there is nothing to buy the ladder with, so
|
|
350
|
+
the add and compare actions stay off.
|
|
351
|
+
</p>
|
|
352
|
+
) : null}
|
|
353
|
+
{compareError ? (
|
|
354
|
+
<p className="card-hint" role="alert">
|
|
355
|
+
{compareError}
|
|
356
|
+
</p>
|
|
357
|
+
) : null}
|
|
358
|
+
{rows ? (
|
|
359
|
+
<div className="year-table-wrap">
|
|
360
|
+
<table>
|
|
361
|
+
<thead>
|
|
362
|
+
<tr>
|
|
363
|
+
<th>Strategy</th>
|
|
364
|
+
<th>Market success</th>
|
|
365
|
+
<th>Money lasts</th>
|
|
366
|
+
<th>Ending after-tax estate</th>
|
|
367
|
+
</tr>
|
|
368
|
+
</thead>
|
|
369
|
+
<tbody>
|
|
370
|
+
{rows.map((row) => (
|
|
371
|
+
<tr key={row.name}>
|
|
372
|
+
<td>{row.name}</td>
|
|
373
|
+
<td>{row.successRate !== null ? `${Math.round(row.successRate * 100)}%` : '—'}</td>
|
|
374
|
+
<td>{row.depletionYear === null ? 'full plan' : `until ${row.depletionYear}`}</td>
|
|
375
|
+
<td>{fmtMoneyCompact(row.endingAfterTaxEstate)}</td>
|
|
376
|
+
</tr>
|
|
377
|
+
))}
|
|
378
|
+
</tbody>
|
|
379
|
+
</table>
|
|
380
|
+
<p className="card-hint">
|
|
381
|
+
All three run on the same steady-markets ledger and the same 500 seeded market paths, so every difference
|
|
382
|
+
is the strategy — not luck of the draw.
|
|
383
|
+
</p>
|
|
384
|
+
</div>
|
|
385
|
+
) : null}
|
|
386
|
+
</div>
|
|
387
|
+
)
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
interface TabProps {
|
|
391
|
+
personIds: string[]
|
|
392
|
+
personName: (id: string) => string
|
|
393
|
+
applyStrategy: (claim: Record<string, number>) => void
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function currentClaim(plan: ReturnType<typeof usePlan>['plan'], ids: string[]): Record<string, number> {
|
|
397
|
+
const out: Record<string, number> = {}
|
|
398
|
+
for (const s of plan.incomes) {
|
|
399
|
+
if (s.type === 'socialSecurity' && ids.includes(s.personId)) out[s.personId] = s.claimAge.years
|
|
400
|
+
}
|
|
401
|
+
return out
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function fmtClaim(c: MonthlyClaim): string {
|
|
405
|
+
return c.months > 0 ? `${c.years}y ${c.months}m` : `${c.years}`
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function fmtObjectiveDelta(label: string, value: number): string {
|
|
409
|
+
const sign = value >= 0 ? '+' : ''
|
|
410
|
+
if (label.toLowerCase().includes('years')) return `${sign}${value} yr`
|
|
411
|
+
return `${sign}${fmtMoneyCompact(value)}`
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function InYourPlanTab({ personIds, personName, applyStrategy }: TabProps) {
|
|
415
|
+
const { plan, update } = usePlan()
|
|
416
|
+
const startYear = currentStartYear()
|
|
417
|
+
const [objectiveId, setObjectiveId] = useState<ObjectivePolicyId>('max-after-tax-estate')
|
|
418
|
+
const sweep = useMemo(() => sweepClaimingStrategies(plan, startYear, objectiveId), [plan, startYear, objectiveId])
|
|
419
|
+
const [mc, setMc] = useState<Record<string, number> | null>(null)
|
|
420
|
+
const [mcRunning, setMcRunning] = useState(false)
|
|
421
|
+
const [mcError, setMcError] = useState<string | null>(null)
|
|
422
|
+
const [refined, setRefined] = useState<MonthlyRefinement | null>(null)
|
|
423
|
+
|
|
424
|
+
const best = sweep.ranked[0]
|
|
425
|
+
const current = currentClaim(plan, personIds)
|
|
426
|
+
const currentRow = sweep.rows.find((r) => personIds.every((id) => r.claimByPersonId[id] === current[id]))
|
|
427
|
+
const keyOf = (r: SweepRow) => personIds.map((id) => r.claimByPersonId[id]).join('-')
|
|
428
|
+
|
|
429
|
+
const applyMonthly = (claim: Record<string, MonthlyClaim>) =>
|
|
430
|
+
update((d) => {
|
|
431
|
+
for (const s of d.incomes) {
|
|
432
|
+
if (s.type === 'socialSecurity' && claim[s.personId] !== undefined) s.claimAge = { ...claim[s.personId]! }
|
|
433
|
+
}
|
|
434
|
+
})
|
|
435
|
+
|
|
436
|
+
const runRobustness = async () => {
|
|
437
|
+
setMcRunning(true)
|
|
438
|
+
setMcError(null)
|
|
439
|
+
try {
|
|
440
|
+
const top = sweep.ranked.slice(0, 5)
|
|
441
|
+
const out: Record<string, number> = {}
|
|
442
|
+
for (const row of top) {
|
|
443
|
+
const candidate = planWithClaimAges(plan, row.claimByPersonId)
|
|
444
|
+
const summary = await runMonteCarlo(candidate, {
|
|
445
|
+
startYear,
|
|
446
|
+
pathCount: 500,
|
|
447
|
+
seed: seedFromPlanId(plan.id),
|
|
448
|
+
model: { type: 'lognormal', inflationMeanPct: plan.assumptions.inflationPct },
|
|
449
|
+
})
|
|
450
|
+
out[keyOf(row)] = summary.successRate
|
|
451
|
+
}
|
|
452
|
+
setMc(out)
|
|
453
|
+
} catch (e: unknown) {
|
|
454
|
+
setMcError(e instanceof Error ? e.message : String(e))
|
|
455
|
+
} finally {
|
|
456
|
+
setMcRunning(false)
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
return (
|
|
461
|
+
<div>
|
|
462
|
+
<p className="card-hint">
|
|
463
|
+
Each claim-age combination is run through your full plan — taxes, Roth conversions, IRMAA, ACA, and RMDs
|
|
464
|
+
included — and ranked by the objective you choose{' '}
|
|
465
|
+
<HelpTip text="Ending net worth minus the income tax heirs owe on inherited pre-tax (traditional) balances, at the heir tax rate in Assumptions. This is the deterministic, single-planning-age view; the Benefits-only tab adds the mortality-weighted insurance angle." />.
|
|
466
|
+
Results assume your expected returns; use the robustness check to see how the ranking holds up across markets.
|
|
467
|
+
</p>
|
|
468
|
+
<div className="form-grid" style={{ marginBottom: '0.75rem', maxWidth: '26rem' }}>
|
|
469
|
+
<SelectField
|
|
470
|
+
label="Rank claim ages by"
|
|
471
|
+
help="Every whole-year Social Security claim-age candidate is evaluated on your full year-by-year projection, then those same evaluations are re-ranked by this objective."
|
|
472
|
+
hint={objectivePolicies[objectiveId].description}
|
|
473
|
+
value={objectiveId}
|
|
474
|
+
options={OBJECTIVE_CHOICES}
|
|
475
|
+
onCommit={setObjectiveId}
|
|
476
|
+
/>
|
|
477
|
+
</div>
|
|
478
|
+
|
|
479
|
+
{best ? (
|
|
480
|
+
<div className="callout callout--info">
|
|
481
|
+
<strong>Best by {objectivePolicies[objectiveId].label.toLowerCase()}: claim at {ageLabel(best.claimByPersonId, personIds)}</strong>
|
|
482
|
+
{personIds.length === 2 ? ` (${personIds.map(personName).join(' / ')})` : ''} — after-tax estate{' '}
|
|
483
|
+
{fmtMoneyCompact(best.summary.endingAfterTaxEstate)}
|
|
484
|
+
{currentRow && keyOf(currentRow) !== keyOf(best) ? (
|
|
485
|
+
<>
|
|
486
|
+
{' '}
|
|
487
|
+
vs {fmtMoneyCompact(currentRow.summary.endingAfterTaxEstate)} at your current{' '}
|
|
488
|
+
{ageLabel(current, personIds)} (
|
|
489
|
+
<span className="delta-pos">
|
|
490
|
+
+{fmtMoneyCompact(best.summary.endingAfterTaxEstate - currentRow.summary.endingAfterTaxEstate)}
|
|
491
|
+
</span>
|
|
492
|
+
).
|
|
493
|
+
</>
|
|
494
|
+
) : ' — your current choice.'}
|
|
495
|
+
{objectiveId !== 'max-after-tax-estate' ? (
|
|
496
|
+
<>
|
|
497
|
+
{' '}
|
|
498
|
+
Ranked on {sweep.primaryMetricLabel.toLowerCase()} ({fmtObjectiveDelta(sweep.primaryMetricLabel, best.primaryValue)} vs current);
|
|
499
|
+
estate is shown for context.
|
|
500
|
+
</>
|
|
501
|
+
) : null}
|
|
502
|
+
{best && keyOf(best) !== keyOf(currentRow ?? best) ? (
|
|
503
|
+
<div style={{ marginTop: '0.6rem' }}>
|
|
504
|
+
<button type="button" className="btn btn-primary btn-small" onClick={() => applyStrategy(best.claimByPersonId)}>
|
|
505
|
+
Apply {ageLabel(best.claimByPersonId, personIds)}
|
|
506
|
+
</button>
|
|
507
|
+
</div>
|
|
508
|
+
) : null}
|
|
509
|
+
</div>
|
|
510
|
+
) : null}
|
|
511
|
+
|
|
512
|
+
{personIds.length === 2 ? <CoupleStrategyPanel personName={personName} best={best} /> : null}
|
|
513
|
+
|
|
514
|
+
{personIds.length === 1 ? (
|
|
515
|
+
<SingleSweepTable personIds={personIds} sweep={sweep} current={current} applyStrategy={applyStrategy} />
|
|
516
|
+
) : (
|
|
517
|
+
<CoupleHeatmap personIds={personIds} personName={personName} sweep={sweep} current={current} applyStrategy={applyStrategy} />
|
|
518
|
+
)}
|
|
519
|
+
|
|
520
|
+
<div className="add-row" style={{ marginTop: '1rem' }}>
|
|
521
|
+
{best ? (
|
|
522
|
+
<button type="button" className="btn btn-secondary btn-small" onClick={() => setRefined(refineClaimingMonthly(plan, best.claimByPersonId, startYear))}>
|
|
523
|
+
Refine to the month
|
|
524
|
+
</button>
|
|
525
|
+
) : null}
|
|
526
|
+
<button type="button" className="btn btn-secondary btn-small" disabled={mcRunning} onClick={() => void runRobustness()}>
|
|
527
|
+
{mcRunning ? 'Running…' : 'Check robustness (Monte Carlo, top 5)'}
|
|
528
|
+
</button>
|
|
529
|
+
</div>
|
|
530
|
+
{mcError ? (
|
|
531
|
+
<div className="error-recovery" role="alert">
|
|
532
|
+
<p className="error-text">Robustness check error: {mcError}</p>
|
|
533
|
+
<button type="button" className="btn btn-secondary btn-small" disabled={mcRunning} onClick={() => void runRobustness()}>
|
|
534
|
+
Run again
|
|
535
|
+
</button>
|
|
536
|
+
</div>
|
|
537
|
+
) : null}
|
|
538
|
+
|
|
539
|
+
{refined && best ? (
|
|
540
|
+
<div className="callout callout--info" style={{ marginTop: '0.75rem' }}>
|
|
541
|
+
<strong>To the month: claim at {personIds.map((id) => fmtClaim(refined.claimByPersonId[id]!)).join(' / ')}</strong>
|
|
542
|
+
{personIds.length === 2 ? ` (${personIds.map(personName).join(' / ')})` : ''} — after-tax estate{' '}
|
|
543
|
+
{fmtMoneyCompact(refined.summary.endingAfterTaxEstate)}
|
|
544
|
+
{refined.summary.endingAfterTaxEstate > best.summary.endingAfterTaxEstate ? (
|
|
545
|
+
<>
|
|
546
|
+
{' '}
|
|
547
|
+
(<span className="delta-pos">+{fmtMoneyCompact(refined.summary.endingAfterTaxEstate - best.summary.endingAfterTaxEstate)}</span>{' '}
|
|
548
|
+
over the whole-year pick).
|
|
549
|
+
</>
|
|
550
|
+
) : ' — the whole-year pick is already optimal to the month.'}
|
|
551
|
+
{personIds.some((id) => refined.claimByPersonId[id]!.months > 0) ? (
|
|
552
|
+
<div style={{ marginTop: '0.6rem' }}>
|
|
553
|
+
<button type="button" className="btn btn-primary btn-small" onClick={() => applyMonthly(refined.claimByPersonId)}>
|
|
554
|
+
Apply {personIds.map((id) => fmtClaim(refined.claimByPersonId[id]!)).join(' / ')}
|
|
555
|
+
</button>
|
|
556
|
+
</div>
|
|
557
|
+
) : null}
|
|
558
|
+
</div>
|
|
559
|
+
) : null}
|
|
560
|
+
{mc ? (
|
|
561
|
+
<table className="claim-table" style={{ marginTop: '0.75rem' }}>
|
|
562
|
+
<thead>
|
|
563
|
+
<tr>
|
|
564
|
+
<th>Strategy (claim ages)</th>
|
|
565
|
+
<th>After-tax estate</th>
|
|
566
|
+
<th>Success %</th>
|
|
567
|
+
</tr>
|
|
568
|
+
</thead>
|
|
569
|
+
<tbody>
|
|
570
|
+
{sweep.ranked.slice(0, 5).map((r) => (
|
|
571
|
+
<tr key={keyOf(r)}>
|
|
572
|
+
<td>{ageLabel(r.claimByPersonId, personIds)}</td>
|
|
573
|
+
<td>{fmtMoneyCompact(r.summary.endingAfterTaxEstate)}</td>
|
|
574
|
+
<td>{mc[keyOf(r)] !== undefined ? `${Math.round(mc[keyOf(r)]! * 100)}%` : '—'}</td>
|
|
575
|
+
</tr>
|
|
576
|
+
))}
|
|
577
|
+
</tbody>
|
|
578
|
+
</table>
|
|
579
|
+
) : null}
|
|
580
|
+
</div>
|
|
581
|
+
)
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* Couple primer beside the heatmap: which spouse has the higher benefit, why
|
|
586
|
+
* survivor protection usually argues for delaying that one, and whether the
|
|
587
|
+
* recommended strategy follows the common "lower earlier / higher later" pattern.
|
|
588
|
+
*/
|
|
589
|
+
function CoupleStrategyPanel({ personName, best }: { personName: (id: string) => string; best?: SweepRow }) {
|
|
590
|
+
const { plan } = usePlan()
|
|
591
|
+
const people = claimingPeople(plan)
|
|
592
|
+
if (people.length !== 2) return null
|
|
593
|
+
const [higher, lower] = [...people].sort((a, b) => b.pia - a.pia)
|
|
594
|
+
if (!higher || !lower) return null
|
|
595
|
+
const samePia = higher.pia === lower.pia
|
|
596
|
+
const higherName = personName(higher.person.id)
|
|
597
|
+
const lowerName = personName(lower.person.id)
|
|
598
|
+
|
|
599
|
+
let patternNote = null
|
|
600
|
+
if (best && !samePia) {
|
|
601
|
+
const higherClaim = best.claimByPersonId[higher.person.id]
|
|
602
|
+
const lowerClaim = best.claimByPersonId[lower.person.id]
|
|
603
|
+
if (higherClaim !== undefined && lowerClaim !== undefined) {
|
|
604
|
+
patternNote =
|
|
605
|
+
higherClaim >= lowerClaim ? (
|
|
606
|
+
<>
|
|
607
|
+
{' '}
|
|
608
|
+
The best strategy above follows the common pattern: <strong>{lowerName}</strong> claims at {lowerClaim} and{' '}
|
|
609
|
+
<strong>{higherName}</strong> delays to {higherClaim}, locking in the larger survivor check.
|
|
610
|
+
</>
|
|
611
|
+
) : (
|
|
612
|
+
<>
|
|
613
|
+
{' '}
|
|
614
|
+
Here the best strategy has <strong>{higherName}</strong> ({higherClaim}) claim before{' '}
|
|
615
|
+
<strong>{lowerName}</strong> ({lowerClaim}) — taxes, longevity, or portfolio assumptions outweigh the usual
|
|
616
|
+
survivor-protection delay.
|
|
617
|
+
</>
|
|
618
|
+
)
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
return (
|
|
623
|
+
<div className="callout callout--info">
|
|
624
|
+
{samePia ? (
|
|
625
|
+
<>
|
|
626
|
+
Both benefits are about {fmtMoneyCompact(higher.pia * 12)}/yr. When the first spouse dies the survivor keeps the
|
|
627
|
+
larger of the two checks, so delaying either claim raises the survivor floor.
|
|
628
|
+
</>
|
|
629
|
+
) : (
|
|
630
|
+
<>
|
|
631
|
+
<strong>{higherName}</strong> has the higher benefit ({fmtMoneyCompact(higher.pia * 12)}/yr vs{' '}
|
|
632
|
+
{fmtMoneyCompact(lower.pia * 12)}/yr for {lowerName}). After the first death the survivor keeps only the larger
|
|
633
|
+
check, so delaying <strong>{higherName}</strong>’s claim protects whoever lives longest.
|
|
634
|
+
</>
|
|
635
|
+
)}
|
|
636
|
+
{patternNote}
|
|
637
|
+
</div>
|
|
638
|
+
)
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function SingleSweepTable({
|
|
642
|
+
personIds,
|
|
643
|
+
sweep,
|
|
644
|
+
current,
|
|
645
|
+
applyStrategy,
|
|
646
|
+
}: {
|
|
647
|
+
personIds: string[]
|
|
648
|
+
sweep: ReturnType<typeof sweepClaimingStrategies>
|
|
649
|
+
current: Record<string, number>
|
|
650
|
+
applyStrategy: (claim: Record<string, number>) => void
|
|
651
|
+
}) {
|
|
652
|
+
const id = personIds[0]!
|
|
653
|
+
const byAge = [...sweep.rows].sort((a, b) => a.claimByPersonId[id]! - b.claimByPersonId[id]!)
|
|
654
|
+
const bestKey = sweep.ranked[0]!.claimByPersonId[id]
|
|
655
|
+
return (
|
|
656
|
+
<div className="year-table-wrap" style={{ border: 'none' }}>
|
|
657
|
+
<table className="claim-table">
|
|
658
|
+
<thead>
|
|
659
|
+
<tr>
|
|
660
|
+
<th>Claim at</th>
|
|
661
|
+
<th>After-tax estate</th>
|
|
662
|
+
<th>Lifetime tax</th>
|
|
663
|
+
<th>Depletes</th>
|
|
664
|
+
<th aria-label="apply" />
|
|
665
|
+
</tr>
|
|
666
|
+
</thead>
|
|
667
|
+
<tbody>
|
|
668
|
+
{byAge.map((r) => {
|
|
669
|
+
const age = r.claimByPersonId[id]!
|
|
670
|
+
return (
|
|
671
|
+
<tr key={age} className={(age === bestKey ? 'claim-row--best ' : '') + (age === current[id] ? 'claim-row--current' : '')}>
|
|
672
|
+
<td>{age}</td>
|
|
673
|
+
<td>{fmtMoneyCompact(r.summary.endingAfterTaxEstate)}</td>
|
|
674
|
+
<td>{fmtMoneyCompact(r.summary.lifetimeTaxesAndPenalties)}</td>
|
|
675
|
+
<td>{r.summary.depletionYear ?? 'never'}</td>
|
|
676
|
+
<td>
|
|
677
|
+
<button
|
|
678
|
+
type="button"
|
|
679
|
+
className="btn btn-secondary btn-small"
|
|
680
|
+
disabled={age === current[id]}
|
|
681
|
+
onClick={() => applyStrategy(r.claimByPersonId)}
|
|
682
|
+
>
|
|
683
|
+
Use
|
|
684
|
+
</button>
|
|
685
|
+
</td>
|
|
686
|
+
</tr>
|
|
687
|
+
)
|
|
688
|
+
})}
|
|
689
|
+
</tbody>
|
|
690
|
+
</table>
|
|
691
|
+
</div>
|
|
692
|
+
)
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
function CoupleHeatmap({
|
|
696
|
+
personIds,
|
|
697
|
+
personName,
|
|
698
|
+
sweep,
|
|
699
|
+
current,
|
|
700
|
+
applyStrategy,
|
|
701
|
+
}: {
|
|
702
|
+
personIds: string[]
|
|
703
|
+
personName: (id: string) => string
|
|
704
|
+
sweep: ReturnType<typeof sweepClaimingStrategies>
|
|
705
|
+
current: Record<string, number>
|
|
706
|
+
applyStrategy: (claim: Record<string, number>) => void
|
|
707
|
+
}) {
|
|
708
|
+
const { plan } = usePlan()
|
|
709
|
+
const [rowId, colId] = personIds
|
|
710
|
+
const rowAges = candidateClaimAges(plan.household.people.find((p) => p.id === rowId)!, currentStartYear())
|
|
711
|
+
const colAges = candidateClaimAges(plan.household.people.find((p) => p.id === colId)!, currentStartYear())
|
|
712
|
+
const estate = (ra: number, ca: number) =>
|
|
713
|
+
sweep.rows.find((r) => r.claimByPersonId[rowId!] === ra && r.claimByPersonId[colId!] === ca)?.summary.endingAfterTaxEstate ?? 0
|
|
714
|
+
const values = sweep.rows.map((r) => r.summary.endingAfterTaxEstate)
|
|
715
|
+
const min = Math.min(...values)
|
|
716
|
+
const max = Math.max(...values)
|
|
717
|
+
const norm = (v: number) => (max > min ? (v - min) / (max - min) : 1)
|
|
718
|
+
const bestKey = `${sweep.ranked[0]!.claimByPersonId[rowId!]}-${sweep.ranked[0]!.claimByPersonId[colId!]}`
|
|
719
|
+
|
|
720
|
+
return (
|
|
721
|
+
<>
|
|
722
|
+
<p className="card-hint" style={{ marginTop: '0.5rem' }}>
|
|
723
|
+
After-tax estate by claim age — rows: {personName(rowId!)}, columns: {personName(colId!)}. Greener is better;
|
|
724
|
+
click a cell to apply it.
|
|
725
|
+
</p>
|
|
726
|
+
<div className="year-table-wrap" style={{ border: 'none' }}>
|
|
727
|
+
<table className="claim-table heatmap">
|
|
728
|
+
<thead>
|
|
729
|
+
<tr>
|
|
730
|
+
<th>{personName(rowId!)} ↓ / {personName(colId!)} →</th>
|
|
731
|
+
{colAges.map((ca) => (
|
|
732
|
+
<th key={ca}>{ca}</th>
|
|
733
|
+
))}
|
|
734
|
+
</tr>
|
|
735
|
+
</thead>
|
|
736
|
+
<tbody>
|
|
737
|
+
{rowAges.map((ra) => (
|
|
738
|
+
<tr key={ra}>
|
|
739
|
+
<th>{ra}</th>
|
|
740
|
+
{colAges.map((ca) => {
|
|
741
|
+
const v = estate(ra, ca)
|
|
742
|
+
const isBest = `${ra}-${ca}` === bestKey
|
|
743
|
+
const isCurrent = current[rowId!] === ra && current[colId!] === ca
|
|
744
|
+
return (
|
|
745
|
+
<td
|
|
746
|
+
key={ca}
|
|
747
|
+
style={{ background: heatColor(norm(v)), cursor: 'pointer', outline: isCurrent ? '2px solid var(--accent)' : undefined, fontWeight: isBest ? 700 : undefined }}
|
|
748
|
+
title={`${personName(rowId!)} ${ra} / ${personName(colId!)} ${ca}: ${fmtMoneyCompact(v)}${isBest ? ' (best)' : ''}`}
|
|
749
|
+
onClick={() => applyStrategy({ [rowId!]: ra, [colId!]: ca })}
|
|
750
|
+
>
|
|
751
|
+
{fmtMoneyCompact(v)}
|
|
752
|
+
</td>
|
|
753
|
+
)
|
|
754
|
+
})}
|
|
755
|
+
</tr>
|
|
756
|
+
))}
|
|
757
|
+
</tbody>
|
|
758
|
+
</table>
|
|
759
|
+
</div>
|
|
760
|
+
</>
|
|
761
|
+
)
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
/**
|
|
765
|
+
* Break-even education (V7 phase 2). The simple cumulative-benefit lens for one
|
|
766
|
+
* person's own retirement benefit, with COLA and an optional investment return.
|
|
767
|
+
* Deliberately lean copy — the conceptual narrative is the V9 Learning Center's
|
|
768
|
+
* job; the In-your-plan sweep is the complete answer.
|
|
769
|
+
*/
|
|
770
|
+
function BreakEvenTab({ personIds, personName }: { personIds: string[]; personName: (id: string) => string }) {
|
|
771
|
+
const { plan } = usePlan()
|
|
772
|
+
const people = claimingPeople(plan)
|
|
773
|
+
const [selectedId, setSelectedId] = useState(personIds[0]!)
|
|
774
|
+
const [growthPct, setGrowthPct] = useState(0)
|
|
775
|
+
|
|
776
|
+
const entry = people.find((p) => p.person.id === selectedId) ?? people[0]!
|
|
777
|
+
const { person, pia } = entry
|
|
778
|
+
const { y, m, d } = dobParts(person)
|
|
779
|
+
const fra = fraForBirthYear(effectiveBirthYear(y, m, d))
|
|
780
|
+
const currentAge = currentStartYear() - y
|
|
781
|
+
const throughAge = person.longevity.planningAge
|
|
782
|
+
const cola = plan.assumptions.ssCola.mode === 'fixed' ? plan.assumptions.ssCola.annualPct : plan.assumptions.inflationPct
|
|
783
|
+
|
|
784
|
+
const claimAges = Array.from(new Set([62, fra.years, 70]))
|
|
785
|
+
.sort((a, b) => a - b)
|
|
786
|
+
.filter((a) => a >= currentAge)
|
|
787
|
+
const result =
|
|
788
|
+
claimAges.length >= 2
|
|
789
|
+
? computeBreakEven({ dob: { year: y, month: m, day: d }, piaMonthly: pia, claimAges, colaPct: cola, growthPct, throughAge })
|
|
790
|
+
: null
|
|
791
|
+
|
|
792
|
+
const personSelect =
|
|
793
|
+
people.length === 2 ? (
|
|
794
|
+
<div className="seg" role="group" aria-label="Person" style={{ marginBottom: '0.75rem' }}>
|
|
795
|
+
{people.map((p) => (
|
|
796
|
+
<button key={p.person.id} type="button" aria-pressed={selectedId === p.person.id} onClick={() => setSelectedId(p.person.id)}>
|
|
797
|
+
{personName(p.person.id)}
|
|
798
|
+
</button>
|
|
799
|
+
))}
|
|
800
|
+
</div>
|
|
801
|
+
) : null
|
|
802
|
+
|
|
803
|
+
if (!result) {
|
|
804
|
+
return (
|
|
805
|
+
<div>
|
|
806
|
+
{personSelect}
|
|
807
|
+
<p className="card-hint">
|
|
808
|
+
{personName(selectedId)} is already past most claim ages, so there's nothing left to compare here.
|
|
809
|
+
</p>
|
|
810
|
+
</div>
|
|
811
|
+
)
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
const rows = result.series.map((pt) => {
|
|
815
|
+
const row: Record<string, number> = { age: pt.age }
|
|
816
|
+
for (const a of claimAges) row[`a${a}`] = pt.cumulative[a]!
|
|
817
|
+
return row
|
|
818
|
+
})
|
|
819
|
+
|
|
820
|
+
return (
|
|
821
|
+
<div>
|
|
822
|
+
<p className="card-hint">
|
|
823
|
+
Claim early and collect sooner, or wait for a bigger check? This compares the cumulative lifetime benefit from{' '}
|
|
824
|
+
{personName(selectedId)}'s own retirement benefit at each claim age — COLA {cola}%, checks invested at the chosen
|
|
825
|
+
return{' '}
|
|
826
|
+
<HelpTip text="Pedagogical view. It ignores spousal/survivor benefits, taxes, and the rest of your portfolio; the In-your-plan sweep is the complete answer. A higher assumed return rewards claiming early, pushing break-even later." />.
|
|
827
|
+
It's the simple lens — the In-your-plan tab is the complete one.
|
|
828
|
+
</p>
|
|
829
|
+
|
|
830
|
+
{personSelect}
|
|
831
|
+
|
|
832
|
+
<div className="seg" role="group" aria-label="Investment return on benefits" style={{ marginBottom: '1rem' }}>
|
|
833
|
+
{[0, 3, 5, 7].map((g) => (
|
|
834
|
+
<button key={g} type="button" aria-pressed={growthPct === g} onClick={() => setGrowthPct(g)}>
|
|
835
|
+
{g}% return
|
|
836
|
+
</button>
|
|
837
|
+
))}
|
|
838
|
+
</div>
|
|
839
|
+
|
|
840
|
+
<div className="callout callout--info">
|
|
841
|
+
{result.crossings.map((c) => (
|
|
842
|
+
<div key={`${c.early}v${c.late}`}>
|
|
843
|
+
<strong>
|
|
844
|
+
{c.early} vs {c.late}:
|
|
845
|
+
</strong>{' '}
|
|
846
|
+
{c.age === null ? (
|
|
847
|
+
<>claiming at {c.late} never catches up by age {throughAge}{growthPct > 0 ? ` at a ${growthPct}% return` : ''}.</>
|
|
848
|
+
) : (
|
|
849
|
+
<>
|
|
850
|
+
waiting until {c.late} pulls ahead around age <strong>{c.age}</strong>.
|
|
851
|
+
</>
|
|
852
|
+
)}
|
|
853
|
+
</div>
|
|
854
|
+
))}
|
|
855
|
+
</div>
|
|
856
|
+
|
|
857
|
+
<div className="chart-card">
|
|
858
|
+
<div style={{ width: '100%', height: 320 }}>
|
|
859
|
+
<ResponsiveContainer width="100%" height="100%">
|
|
860
|
+
<LineChart data={rows} margin={{ left: 12, right: 8, top: 8 }}>
|
|
861
|
+
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
|
|
862
|
+
<XAxis dataKey="age" tick={{ fill: 'var(--muted)', fontSize: 12 }} />
|
|
863
|
+
<YAxis tickFormatter={fmtMoneyCompact} tick={{ fill: 'var(--muted)', fontSize: 12 }} width={70} />
|
|
864
|
+
<Tooltip formatter={(v: unknown) => fmtMoney(Number(v))} labelFormatter={(l) => `Age ${l}`} contentStyle={chartTooltipStyle} />
|
|
865
|
+
<Legend />
|
|
866
|
+
{claimAges.map((a, i) => (
|
|
867
|
+
<Line key={a} dataKey={`a${a}`} name={`Claim ${a}`} stroke={BREAKEVEN_COLORS[i % BREAKEVEN_COLORS.length]} dot={false} strokeWidth={2} />
|
|
868
|
+
))}
|
|
869
|
+
</LineChart>
|
|
870
|
+
</ResponsiveContainer>
|
|
871
|
+
</div>
|
|
872
|
+
<p className="muted small">
|
|
873
|
+
Cumulative benefits received through each age{growthPct > 0 ? `, with each check invested at ${growthPct}%` : ''}.
|
|
874
|
+
</p>
|
|
875
|
+
</div>
|
|
876
|
+
</div>
|
|
877
|
+
)
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
function BenefitsOnlyTab({ personIds, personName, applyStrategy }: TabProps) {
|
|
881
|
+
const { plan } = usePlan()
|
|
882
|
+
const [discountPct, setDiscountPct] = useState(2)
|
|
883
|
+
const ranking = useMemo(
|
|
884
|
+
() => benefitsOnlyRanking(plan, discountPct / 100, currentStartYear()),
|
|
885
|
+
[plan, discountPct],
|
|
886
|
+
)
|
|
887
|
+
const best = ranking.ranked[0]
|
|
888
|
+
const current = currentClaim(plan, personIds)
|
|
889
|
+
const keyOf = (claim: Record<string, number>) => personIds.map((id) => claim[id]).join('-')
|
|
890
|
+
|
|
891
|
+
return (
|
|
892
|
+
<div>
|
|
893
|
+
<p className="card-hint">
|
|
894
|
+
The actuarial view: expected lifetime benefits weighted by the chance of being alive to receive them (SSA
|
|
895
|
+
mortality), ignoring your portfolio and taxes{' '}
|
|
896
|
+
<HelpTip text="The standard actuarial method: each future year's benefit is multiplied by the probability of survival and discounted to today. This isolates Social Security's longevity-insurance value — useful alongside the In-your-plan tab, which adds taxes and portfolio growth." />. When this disagrees with the In-your-plan tab, the gap is how much taxes and growth are pulling your answer.
|
|
897
|
+
</p>
|
|
898
|
+
<div className="form-grid" style={{ maxWidth: '22rem' }}>
|
|
899
|
+
<div className="field">
|
|
900
|
+
<span className="field-label-row">
|
|
901
|
+
<span className="field-label">Real discount rate: {discountPct}%</span>
|
|
902
|
+
<HelpTip text="The real (after-inflation) rate used to value future benefits — conventionally near the long-term TIPS yield (~2%). Higher rates favor claiming earlier; a very high personal rate (impatience or poor health) can make 62 optimal." />
|
|
903
|
+
</span>
|
|
904
|
+
<input type="range" min={0} max={8} step={0.5} value={discountPct} onChange={(e) => setDiscountPct(Number(e.target.value))} />
|
|
905
|
+
</div>
|
|
906
|
+
</div>
|
|
907
|
+
|
|
908
|
+
{best ? (
|
|
909
|
+
<div className="callout callout--info">
|
|
910
|
+
<strong>Highest expected value: claim at {ageLabel(best.claimByPersonId, personIds)}</strong>
|
|
911
|
+
{personIds.length === 2 ? ` (${personIds.map(personName).join(' / ')})` : ''} — expected PV{' '}
|
|
912
|
+
{fmtMoneyCompact(best.expectedPv)}.
|
|
913
|
+
{keyOf(best.claimByPersonId) !== keyOf(current) ? (
|
|
914
|
+
<div style={{ marginTop: '0.6rem' }}>
|
|
915
|
+
<button type="button" className="btn btn-primary btn-small" onClick={() => applyStrategy(best.claimByPersonId)}>
|
|
916
|
+
Apply {ageLabel(best.claimByPersonId, personIds)}
|
|
917
|
+
</button>
|
|
918
|
+
</div>
|
|
919
|
+
) : null}
|
|
920
|
+
</div>
|
|
921
|
+
) : null}
|
|
922
|
+
|
|
923
|
+
<div className="year-table-wrap" style={{ border: 'none' }}>
|
|
924
|
+
<table className="claim-table">
|
|
925
|
+
<thead>
|
|
926
|
+
<tr>
|
|
927
|
+
<th>Claim age{personIds.length === 2 ? 's' : ''}</th>
|
|
928
|
+
<th>Expected PV</th>
|
|
929
|
+
<th aria-label="apply" />
|
|
930
|
+
</tr>
|
|
931
|
+
</thead>
|
|
932
|
+
<tbody>
|
|
933
|
+
{ranking.ranked.slice(0, 10).map((r) => {
|
|
934
|
+
const isCurrent = keyOf(r.claimByPersonId) === keyOf(current)
|
|
935
|
+
return (
|
|
936
|
+
<tr key={keyOf(r.claimByPersonId)} className={isCurrent ? 'claim-row--current' : undefined}>
|
|
937
|
+
<td>{ageLabel(r.claimByPersonId, personIds)}</td>
|
|
938
|
+
<td>{fmtMoneyCompact(r.expectedPv)}</td>
|
|
939
|
+
<td>
|
|
940
|
+
<button type="button" className="btn btn-secondary btn-small" disabled={isCurrent} onClick={() => applyStrategy(r.claimByPersonId)}>
|
|
941
|
+
Use
|
|
942
|
+
</button>
|
|
943
|
+
</td>
|
|
944
|
+
</tr>
|
|
945
|
+
)
|
|
946
|
+
})}
|
|
947
|
+
</tbody>
|
|
948
|
+
</table>
|
|
949
|
+
</div>
|
|
950
|
+
{ranking.ranked.length > 10 ? <p className="muted small">Showing the top 10 of {ranking.ranked.length} combinations.</p> : null}
|
|
951
|
+
|
|
952
|
+
<SurvivorSwitchingPanel discountPct={discountPct} />
|
|
953
|
+
<FicaReturnPanel discountPct={discountPct} />
|
|
954
|
+
</div>
|
|
955
|
+
)
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
/**
|
|
959
|
+
* "What you paid in vs. what you get back" — an education/context readout (no
|
|
960
|
+
* engine-tax change). Paid-in = OASDI payroll tax over the entered earnings
|
|
961
|
+
* history (employee 6.2% / self-employed 12.4%, capped at the wage base); get-back
|
|
962
|
+
* = the survival-weighted expected PV of lifetime benefits at the chosen claim age
|
|
963
|
+
* (reusing the tested `expectedPvSingle` path). Only renders when an earnings
|
|
964
|
+
* history is present; Quick-PIA plans get a note.
|
|
965
|
+
*/
|
|
966
|
+
function FicaReturnPanel({ discountPct }: { discountPct: number }) {
|
|
967
|
+
const { plan } = usePlan()
|
|
968
|
+
const [selfEmployed, setSelfEmployed] = useState(false)
|
|
969
|
+
const people = claimingPeople(plan)
|
|
970
|
+
const startYear = currentStartYear()
|
|
971
|
+
const { pack } = packForYear(startYear)
|
|
972
|
+
const discountRate = discountPct / 100
|
|
973
|
+
|
|
974
|
+
const withEarnings = people.filter((p) => p.stream.earnings && p.stream.earnings.length > 0)
|
|
975
|
+
|
|
976
|
+
return (
|
|
977
|
+
<details className="ss-explainer" style={{ marginTop: '1.5rem' }}>
|
|
978
|
+
<summary>What you paid in vs. what you get back</summary>
|
|
979
|
+
<p className="card-hint">
|
|
980
|
+
An illustrative "return on your Social Security taxes": the OASDI payroll tax you paid over your earnings
|
|
981
|
+
history, beside the survival-weighted expected value of your lifetime benefits at your current claim age. This
|
|
982
|
+
is an individual-level illustration, not the program's actuarial return, and it excludes the insurance value of
|
|
983
|
+
disability/survivor benefits, spousal benefits, and Medicare.{' '}
|
|
984
|
+
<LearnLink {...LEARN.ssTaxesVsBenefits} variant="inline" />
|
|
985
|
+
</p>
|
|
986
|
+
<div className="form-grid" style={{ maxWidth: '24rem' }}>
|
|
987
|
+
<CheckboxField
|
|
988
|
+
label="Self-employed (12.4% OASDI)"
|
|
989
|
+
help="Toggles the OASDI rate from the employee share (6.2%) to the self-employed total (12.4%). A simplification applied uniformly to your earnings history."
|
|
990
|
+
value={selfEmployed}
|
|
991
|
+
onCommit={setSelfEmployed}
|
|
992
|
+
/>
|
|
993
|
+
</div>
|
|
994
|
+
{withEarnings.length === 0 ? (
|
|
995
|
+
<p className="muted small">Enter an earnings history on the Social Security step to see what you paid in.</p>
|
|
996
|
+
) : (
|
|
997
|
+
withEarnings.map(({ person, stream, pia }) => {
|
|
998
|
+
const { y, m, d } = dobParts(person)
|
|
999
|
+
const paidIn = ficaOasdiPaidIn(stream.earnings ?? [], {
|
|
1000
|
+
oasdiEmployeeRatePct: pack.socialSecurity.oasdiEmployeeRatePct,
|
|
1001
|
+
selfEmployed,
|
|
1002
|
+
})
|
|
1003
|
+
const getBack = expectedPvSingle(
|
|
1004
|
+
{
|
|
1005
|
+
currentAge: Math.max(0, startYear - y),
|
|
1006
|
+
dob: { year: y, month: m, day: d },
|
|
1007
|
+
sex: person.sex,
|
|
1008
|
+
piaMonthly: pia,
|
|
1009
|
+
claimAge: stream.claimAge,
|
|
1010
|
+
},
|
|
1011
|
+
{ discountRate },
|
|
1012
|
+
)
|
|
1013
|
+
const ratio = paidIn.paidIn > 0 ? getBack / paidIn.paidIn : 0
|
|
1014
|
+
return (
|
|
1015
|
+
<div key={person.id} className="callout callout--info" style={{ marginTop: '0.6rem' }}>
|
|
1016
|
+
<strong>{person.name}</strong>
|
|
1017
|
+
<div className="year-table-wrap" style={{ border: 'none' }}>
|
|
1018
|
+
<table className="claim-table">
|
|
1019
|
+
<tbody>
|
|
1020
|
+
<tr><td>Paid in (OASDI)</td><td>{fmtMoney(paidIn.paidIn)}</td></tr>
|
|
1021
|
+
{paidIn.employerPaid > 0 ? (
|
|
1022
|
+
<tr><td className="muted small">Employer paid (context)</td><td className="muted small">{fmtMoney(paidIn.employerPaid)}</td></tr>
|
|
1023
|
+
) : null}
|
|
1024
|
+
<tr><td>Expected lifetime benefits (PV)</td><td>{fmtMoneyCompact(getBack)}</td></tr>
|
|
1025
|
+
<tr><td>Ratio (get back ÷ paid in)</td><td>{ratio > 0 ? `${ratio.toFixed(2)}×` : '—'}</td></tr>
|
|
1026
|
+
</tbody>
|
|
1027
|
+
</table>
|
|
1028
|
+
</div>
|
|
1029
|
+
<p className="muted small" style={{ marginTop: '0.3rem' }}>
|
|
1030
|
+
At a {discountPct}% real discount rate. Excludes Medicare tax, disability/survivor insurance value, and
|
|
1031
|
+
spousal benefits; the OASDI rate is applied uniformly over your career.
|
|
1032
|
+
</p>
|
|
1033
|
+
</div>
|
|
1034
|
+
)
|
|
1035
|
+
})
|
|
1036
|
+
)}
|
|
1037
|
+
</details>
|
|
1038
|
+
)
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
/**
|
|
1042
|
+
* Survivor ↔ personal switching for a widowed single user: rank strategies that
|
|
1043
|
+
* sequence the survivor benefit and the person's own benefit. Shown only when the
|
|
1044
|
+
* single person has a deceased former spouse whose survivor benefit is preserved.
|
|
1045
|
+
*/
|
|
1046
|
+
function SurvivorSwitchingPanel({ discountPct }: { discountPct: number }) {
|
|
1047
|
+
const { plan } = usePlan()
|
|
1048
|
+
const people = claimingPeople(plan)
|
|
1049
|
+
if (plan.household.people.length !== 1 || people.length !== 1) return null
|
|
1050
|
+
const { person, pia, stream } = people[0]!
|
|
1051
|
+
const eligible = (stream.formerSpouses ?? []).filter(
|
|
1052
|
+
(r) => r.relationship === 'deceased' && r.marriageYears >= 0.75 && (r.remarriedAtAge === null || r.remarriedAtAge >= 60),
|
|
1053
|
+
)
|
|
1054
|
+
if (eligible.length === 0) return null
|
|
1055
|
+
// Pick the deceased ex whose **payable** survivor benefit is highest (not raw
|
|
1056
|
+
// PIA): after RIB-LIM + the deceased's claim-age factor, a lower-PIA ex who
|
|
1057
|
+
// delayed can beat a higher-PIA ex who claimed early. Each ex's payable is
|
|
1058
|
+
// computed at the claimant's survivor FRA (the maximum survivor benefit each
|
|
1059
|
+
// record can provide), mirroring how the ledger's `bestMaritalBenefit` picks
|
|
1060
|
+
// the largest candidate.
|
|
1061
|
+
const { y, m, d } = dobParts(person)
|
|
1062
|
+
const claimantEffYear = effectiveBirthYear(y, m, d)
|
|
1063
|
+
const survivorFraMonths = fraTotalMonths(survivorFraForBirthYear(claimantEffYear))
|
|
1064
|
+
const survivorFraClaimAge = { years: Math.floor(survivorFraMonths / 12), months: survivorFraMonths % 12 }
|
|
1065
|
+
let bestEx = eligible[0]!
|
|
1066
|
+
let bestPayable = 0
|
|
1067
|
+
for (const r of eligible) {
|
|
1068
|
+
const exDobYear = Number(r.dob.slice(0, 4))
|
|
1069
|
+
const exDobMonth = Number(r.dob.slice(5, 7))
|
|
1070
|
+
const exDobDay = Number(r.dob.slice(8, 10))
|
|
1071
|
+
const exFra = fraForBirthYear(effectiveBirthYear(exDobYear, exDobMonth, exDobDay))
|
|
1072
|
+
const exClaimAge = r.deceasedClaimAge ?? { years: exFra.years, months: exFra.extraMonths }
|
|
1073
|
+
const actual = r.piaMonthly * claimFactor(exDobYear, exDobMonth, exDobDay, exClaimAge)
|
|
1074
|
+
const payable = survivorBenefitMonthly({
|
|
1075
|
+
deceasedPiaMonthly: r.piaMonthly,
|
|
1076
|
+
deceasedActualMonthly: actual,
|
|
1077
|
+
survivorClaimAge: survivorFraClaimAge,
|
|
1078
|
+
survivorFraMonths,
|
|
1079
|
+
})
|
|
1080
|
+
if (payable > bestPayable) {
|
|
1081
|
+
bestPayable = payable
|
|
1082
|
+
bestEx = r
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
const exDobYear = Number(bestEx.dob.slice(0, 4))
|
|
1086
|
+
const exDobMonth = Number(bestEx.dob.slice(5, 7))
|
|
1087
|
+
const exDobDay = Number(bestEx.dob.slice(8, 10))
|
|
1088
|
+
const exFra = fraForBirthYear(effectiveBirthYear(exDobYear, exDobMonth, exDobDay))
|
|
1089
|
+
const exClaimAge = bestEx.deceasedClaimAge ?? { years: exFra.years, months: exFra.extraMonths }
|
|
1090
|
+
const survivorMonthly = bestEx.piaMonthly * claimFactor(exDobYear, exDobMonth, exDobDay, exClaimAge)
|
|
1091
|
+
if (survivorMonthly <= 0) return null
|
|
1092
|
+
|
|
1093
|
+
const ranked = rankSwitchStrategies(
|
|
1094
|
+
{
|
|
1095
|
+
dob: { year: y, month: m, day: d },
|
|
1096
|
+
sex: person.sex,
|
|
1097
|
+
currentAge: currentStartYear() - y,
|
|
1098
|
+
ownPiaMonthly: pia,
|
|
1099
|
+
survivorMonthly,
|
|
1100
|
+
deceasedPiaMonthly: bestEx.piaMonthly,
|
|
1101
|
+
},
|
|
1102
|
+
{ discountRate: discountPct / 100 },
|
|
1103
|
+
).slice(0, 5)
|
|
1104
|
+
|
|
1105
|
+
return (
|
|
1106
|
+
<div style={{ marginTop: '1.5rem' }}>
|
|
1107
|
+
<h3>Survivor vs. personal timing</h3>
|
|
1108
|
+
<p className="card-hint">
|
|
1109
|
+
As a widow(er) you can hold both a survivor benefit and your own, and switch between them. Survivor benefits stop
|
|
1110
|
+
growing at your full retirement age while your own grows to 70, so the order matters. Ranked by expected value at{' '}
|
|
1111
|
+
{discountPct}%{' '}
|
|
1112
|
+
<HelpTip text="Illustrative: the survivor base is the deceased's actual (claim-age-adjusted) benefit, the RIB-LIM widow's-limit caps it at 82.5% of the deceased's PIA when they claimed early, and the early-claim widow(er) reduction (up to 28.5% at 60) applies before the survivor's FRA — the same computation the projection ledger uses. Only one benefit is paid at a time — the larger of those claimed." />.
|
|
1113
|
+
</p>
|
|
1114
|
+
<div className="year-table-wrap" style={{ border: 'none' }}>
|
|
1115
|
+
<table className="claim-table">
|
|
1116
|
+
<thead>
|
|
1117
|
+
<tr>
|
|
1118
|
+
<th>Strategy</th>
|
|
1119
|
+
<th>Expected PV</th>
|
|
1120
|
+
</tr>
|
|
1121
|
+
</thead>
|
|
1122
|
+
<tbody>
|
|
1123
|
+
{ranked.map((r, i) => (
|
|
1124
|
+
<tr key={r.label} className={i === 0 ? 'claim-row--best' : undefined}>
|
|
1125
|
+
<td>{r.label}</td>
|
|
1126
|
+
<td>{fmtMoneyCompact(r.expectedPv)}</td>
|
|
1127
|
+
</tr>
|
|
1128
|
+
))}
|
|
1129
|
+
</tbody>
|
|
1130
|
+
</table>
|
|
1131
|
+
</div>
|
|
1132
|
+
</div>
|
|
1133
|
+
)
|
|
1134
|
+
}
|