@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,243 @@
|
|
|
1
|
+
/** Household section: people, filing status, state, longevity. */
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react'
|
|
4
|
+
|
|
5
|
+
import { removePartner } from '../householdActions'
|
|
6
|
+
import { usePlan } from '../planContextCore'
|
|
7
|
+
import { CheckboxField, DateField, NumberField, SelectField, TextField } from '../fields'
|
|
8
|
+
import { LEARN } from '../learnLinks'
|
|
9
|
+
import { LongevityModal } from '../LongevityModal'
|
|
10
|
+
import { SurvivalPercentileModal } from '../SurvivalPercentileModal'
|
|
11
|
+
import { US_STATES } from '../usStates'
|
|
12
|
+
import { Issues } from './shared'
|
|
13
|
+
import { MONTH_OPTIONS, newId } from './sectionHelpers'
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Household
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
export function HouseholdSection() {
|
|
20
|
+
const { plan, update } = usePlan()
|
|
21
|
+
const couple = plan.household.people.length === 2
|
|
22
|
+
const [longevityFor, setLongevityFor] = useState<number | null>(null)
|
|
23
|
+
const [percentileFor, setPercentileFor] = useState<number | null>(null)
|
|
24
|
+
return (
|
|
25
|
+
<section>
|
|
26
|
+
<div className="card">
|
|
27
|
+
<h2>Household</h2>
|
|
28
|
+
<p className="card-hint">
|
|
29
|
+
Who the plan is for. The projection runs from this year until the later planning age, pays wages until each
|
|
30
|
+
person's retirement age, and models survivor rules (filing status, Social Security step-up) after the first
|
|
31
|
+
death. Hover any ⓘ for details.
|
|
32
|
+
</p>
|
|
33
|
+
<div className="form-grid">
|
|
34
|
+
<SelectField
|
|
35
|
+
label="Filing status"
|
|
36
|
+
help="Sets the federal tax brackets, deductions, and IRMAA tiers. Married filing jointly requires two people; the death year remains joint, then the survivor normally files single."
|
|
37
|
+
value={plan.household.filingStatus}
|
|
38
|
+
options={[
|
|
39
|
+
{ value: 'single', label: 'Single' },
|
|
40
|
+
{ value: 'marriedFilingJointly', label: 'Married filing jointly' },
|
|
41
|
+
]}
|
|
42
|
+
onCommit={(v) => update((d) => void (d.household.filingStatus = v))}
|
|
43
|
+
/>
|
|
44
|
+
{couple && plan.household.filingStatus === 'marriedFilingJointly' ? (
|
|
45
|
+
<CheckboxField
|
|
46
|
+
label="Qualifying dependent for survivor"
|
|
47
|
+
help="Opt in when the surviving spouse would qualify for qualifying surviving spouse filing status. RetireGolden does not model dependents, so this uses joint brackets and deductions for the two years after the spouse's death."
|
|
48
|
+
value={plan.household.hasQualifyingDependent}
|
|
49
|
+
onCommit={(v) => update((d) => void (d.household.hasQualifyingDependent = v))}
|
|
50
|
+
/>
|
|
51
|
+
) : null}
|
|
52
|
+
<SelectField
|
|
53
|
+
label="State (starting residence)"
|
|
54
|
+
help="Drives state income tax. Several states are modeled with real brackets and retirement-income rules; others fall back to the flat effective-rate override under Assumptions. Add a move below to change states mid-retirement."
|
|
55
|
+
value={plan.household.state}
|
|
56
|
+
options={US_STATES}
|
|
57
|
+
onCommit={(v) => update((d) => void (d.household.state = v))}
|
|
58
|
+
/>
|
|
59
|
+
</div>
|
|
60
|
+
{plan.household.people.map((person, i) => (
|
|
61
|
+
<div className="item-row" key={person.id} style={{ marginTop: '1rem' }}>
|
|
62
|
+
<div className="item-row-head">
|
|
63
|
+
<span className="item-row-title">
|
|
64
|
+
<span className="type-chip">{i === 0 ? 'Primary' : 'Partner'}</span>
|
|
65
|
+
{person.name}
|
|
66
|
+
</span>
|
|
67
|
+
{i === 1 ? (
|
|
68
|
+
<button
|
|
69
|
+
type="button"
|
|
70
|
+
className="btn-ghost btn-ghost-danger"
|
|
71
|
+
onClick={() => update((d) => removePartner(d, person.id))}
|
|
72
|
+
>
|
|
73
|
+
Remove
|
|
74
|
+
</button>
|
|
75
|
+
) : null}
|
|
76
|
+
</div>
|
|
77
|
+
<div className="form-grid">
|
|
78
|
+
<TextField label="Name" value={person.name} onCommit={(v) => update((d) => void (d.household.people[i]!.name = v || 'Person'))} />
|
|
79
|
+
<DateField label="Date of birth" value={person.dob} onCommit={(v) => update((d) => void (d.household.people[i]!.dob = v))} />
|
|
80
|
+
<SelectField
|
|
81
|
+
label="Sex"
|
|
82
|
+
help="Only used as the baseline for the life-expectancy estimate (SSA period life tables differ by sex). Pick 'Average' to use a blended table."
|
|
83
|
+
value={person.sex}
|
|
84
|
+
options={[
|
|
85
|
+
{ value: 'female', label: 'Female' },
|
|
86
|
+
{ value: 'male', label: 'Male' },
|
|
87
|
+
{ value: 'average', label: 'Average' },
|
|
88
|
+
]}
|
|
89
|
+
onCommit={(v) => update((d) => void (d.household.people[i]!.sex = v))}
|
|
90
|
+
/>
|
|
91
|
+
<NumberField
|
|
92
|
+
label="Retirement age"
|
|
93
|
+
help="Wages and payroll contributions stop in the year this age is reached. Leave blank if this person never has wages in the plan."
|
|
94
|
+
value={person.retirementAge}
|
|
95
|
+
allowNull
|
|
96
|
+
min={30}
|
|
97
|
+
max={80}
|
|
98
|
+
onCommit={(v) => update((d) => void (d.household.people[i]!.retirementAge = v))}
|
|
99
|
+
/>
|
|
100
|
+
<div className="field-with-action">
|
|
101
|
+
<NumberField
|
|
102
|
+
label="Planning age"
|
|
103
|
+
help="How long the plan runs for this person — the age the money must last to, not a prediction of death. Planning beyond average life expectancy is prudent; 'Calculate' estimates an age from a short health questionnaire, and 'Percentile' anchors it to a survival probability (e.g. the age you have a 25% chance of reaching). Typing a number always overrides."
|
|
104
|
+
learn={LEARN.longevity}
|
|
105
|
+
value={person.longevity.planningAge}
|
|
106
|
+
min={60}
|
|
107
|
+
max={120}
|
|
108
|
+
onCommit={(v) => update((d) => void (d.household.people[i]!.longevity = { planningAge: Math.round(v ?? 95), source: 'manual' }))}
|
|
109
|
+
/>
|
|
110
|
+
<button type="button" className="btn btn-secondary btn-small" onClick={() => setLongevityFor(i)}>
|
|
111
|
+
Calculate
|
|
112
|
+
</button>
|
|
113
|
+
<button type="button" className="btn btn-secondary btn-small" onClick={() => setPercentileFor(i)}>
|
|
114
|
+
Percentile
|
|
115
|
+
</button>
|
|
116
|
+
</div>
|
|
117
|
+
{person.longevity.source === 'percentile' && person.longevity.percentile ? (
|
|
118
|
+
<p className="field-hint" style={{ gridColumn: '1 / -1', margin: 0 }}>
|
|
119
|
+
Planning age {person.longevity.planningAge} = the age{' '}
|
|
120
|
+
{person.longevity.percentile.joint ? 'at least one of you' : person.name} had a{' '}
|
|
121
|
+
{person.longevity.percentile.pct}% chance of reaching when picked
|
|
122
|
+
{person.longevity.percentile.healthMultiplier !== undefined ||
|
|
123
|
+
person.longevity.percentile.partnerHealthMultiplier !== undefined
|
|
124
|
+
? ', health-adjusted'
|
|
125
|
+
: ''}{' '}
|
|
126
|
+
(SSA 2022 table). Re-open Percentile to refresh it as ages change.
|
|
127
|
+
</p>
|
|
128
|
+
) : null}
|
|
129
|
+
</div>
|
|
130
|
+
</div>
|
|
131
|
+
))}
|
|
132
|
+
{longevityFor !== null && plan.household.people[longevityFor] ? (
|
|
133
|
+
<LongevityModal
|
|
134
|
+
person={plan.household.people[longevityFor]!}
|
|
135
|
+
personIndex={longevityFor}
|
|
136
|
+
onApply={(age) =>
|
|
137
|
+
update((d) => {
|
|
138
|
+
d.household.people[longevityFor]!.longevity = { planningAge: age, source: 'model' }
|
|
139
|
+
})
|
|
140
|
+
}
|
|
141
|
+
onClose={() => setLongevityFor(null)}
|
|
142
|
+
/>
|
|
143
|
+
) : null}
|
|
144
|
+
{percentileFor !== null && plan.household.people[percentileFor] ? (
|
|
145
|
+
<SurvivalPercentileModal
|
|
146
|
+
person={plan.household.people[percentileFor]!}
|
|
147
|
+
personIndex={percentileFor}
|
|
148
|
+
partner={plan.household.people[1 - percentileFor] ?? null}
|
|
149
|
+
onApply={(longevity) =>
|
|
150
|
+
update((d) => {
|
|
151
|
+
d.household.people[percentileFor]!.longevity = longevity
|
|
152
|
+
})
|
|
153
|
+
}
|
|
154
|
+
onClose={() => setPercentileFor(null)}
|
|
155
|
+
/>
|
|
156
|
+
) : null}
|
|
157
|
+
{!couple ? (
|
|
158
|
+
<div className="add-row">
|
|
159
|
+
<button
|
|
160
|
+
type="button"
|
|
161
|
+
className="btn btn-secondary btn-small"
|
|
162
|
+
onClick={() =>
|
|
163
|
+
update((d) => {
|
|
164
|
+
d.household.people.push({
|
|
165
|
+
id: newId(),
|
|
166
|
+
name: 'Partner',
|
|
167
|
+
dob: '1965-01-01',
|
|
168
|
+
sex: 'average',
|
|
169
|
+
retirementAge: 65,
|
|
170
|
+
longevity: { planningAge: 95, source: 'manual' },
|
|
171
|
+
})
|
|
172
|
+
d.household.filingStatus = 'marriedFilingJointly'
|
|
173
|
+
})
|
|
174
|
+
}
|
|
175
|
+
>
|
|
176
|
+
+ Add partner
|
|
177
|
+
</button>
|
|
178
|
+
</div>
|
|
179
|
+
) : null}
|
|
180
|
+
|
|
181
|
+
<h3>Moving in retirement</h3>
|
|
182
|
+
<p className="card-hint">
|
|
183
|
+
Optional. Add a relocation and the projection splits the move year by month, then uses the new state's rules afterward.
|
|
184
|
+
</p>
|
|
185
|
+
{plan.household.stateMoves.map((move, i) => (
|
|
186
|
+
<div className="item-row" key={i}>
|
|
187
|
+
<div className="item-row-head">
|
|
188
|
+
<span className="item-row-title">
|
|
189
|
+
<span className="type-chip">Move</span>to {US_STATES.find((s) => s.value === move.state)?.label ?? move.state} in {MONTH_OPTIONS.find((m) => m.value === String(move.fromMonth))?.label ?? 'July'} {move.fromYear}
|
|
190
|
+
</span>
|
|
191
|
+
<button type="button" className="btn-ghost btn-ghost-danger" onClick={() => update((d) => void d.household.stateMoves.splice(i, 1))}>
|
|
192
|
+
Remove
|
|
193
|
+
</button>
|
|
194
|
+
</div>
|
|
195
|
+
<div className="form-grid">
|
|
196
|
+
<NumberField
|
|
197
|
+
label="Move year"
|
|
198
|
+
help="The calendar year of residence in the new state. The projection allocates this year by move month."
|
|
199
|
+
value={move.fromYear}
|
|
200
|
+
min={1900}
|
|
201
|
+
max={2200}
|
|
202
|
+
onCommit={(v) => update((d) => void (d.household.stateMoves[i]!.fromYear = Math.round(v ?? move.fromYear)))}
|
|
203
|
+
/>
|
|
204
|
+
<SelectField
|
|
205
|
+
label="Move month"
|
|
206
|
+
value={String(move.fromMonth)}
|
|
207
|
+
options={MONTH_OPTIONS}
|
|
208
|
+
onCommit={(v) => update((d) => void (d.household.stateMoves[i]!.fromMonth = Number(v)))}
|
|
209
|
+
/>
|
|
210
|
+
<SelectField
|
|
211
|
+
label="New state"
|
|
212
|
+
value={move.state}
|
|
213
|
+
options={US_STATES}
|
|
214
|
+
onCommit={(v) => update((d) => void (d.household.stateMoves[i]!.state = v))}
|
|
215
|
+
/>
|
|
216
|
+
</div>
|
|
217
|
+
</div>
|
|
218
|
+
))}
|
|
219
|
+
<div className="add-row">
|
|
220
|
+
<button
|
|
221
|
+
type="button"
|
|
222
|
+
className="btn btn-secondary btn-small"
|
|
223
|
+
onClick={() =>
|
|
224
|
+
update((d) => {
|
|
225
|
+
const lastYear = d.household.stateMoves[d.household.stateMoves.length - 1]?.fromYear
|
|
226
|
+
d.household.stateMoves.push({ fromYear: (lastYear ?? new Date().getFullYear()) + 1, fromMonth: 7, state: d.household.state })
|
|
227
|
+
})
|
|
228
|
+
}
|
|
229
|
+
>
|
|
230
|
+
+ Add a move
|
|
231
|
+
</button>
|
|
232
|
+
</div>
|
|
233
|
+
|
|
234
|
+
<Issues />
|
|
235
|
+
</div>
|
|
236
|
+
</section>
|
|
237
|
+
)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ---------------------------------------------------------------------------
|
|
241
|
+
// Accounts
|
|
242
|
+
// ---------------------------------------------------------------------------
|
|
243
|
+
|
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Income floor section (social-security-bridge-and-tips-ladder): TIPS ladders
|
|
3
|
+
* as plan artifacts — build a level real income floor or a Social Security
|
|
4
|
+
* bridge, see its buy-list and quoted cost on the embedded real-yield curve,
|
|
5
|
+
* read the funded ratio, and (opt-in only) sanity-check against live Treasury
|
|
6
|
+
* FedInvest prices.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { useMemo, useState } from 'react'
|
|
10
|
+
|
|
11
|
+
import type { TipsLadder } from '@retiregolden/engine/model/plan'
|
|
12
|
+
import { EMBEDDED_REAL_YIELD_CURVE } from '@retiregolden/engine/params'
|
|
13
|
+
import { buildLadder, type LadderBuild } from '@retiregolden/engine/ladder/ladderMath'
|
|
14
|
+
import { computeFundedRatio } from '@retiregolden/engine/ladder/fundedRatio'
|
|
15
|
+
import {
|
|
16
|
+
FEDINVEST_PAGE_URL,
|
|
17
|
+
latestPriceDateIso,
|
|
18
|
+
nearestTipsForYear,
|
|
19
|
+
type FedInvestSnapshot,
|
|
20
|
+
} from '@retiregolden/engine/ladder/fedInvest'
|
|
21
|
+
import { fetchFedInvestTips, importFedInvestCsv, readFedInvestCache } from '../../data/fedInvestClient'
|
|
22
|
+
import { LearnAboutScreen } from '../../learn/LearnAboutScreen'
|
|
23
|
+
import { LearnLink } from '../../learn/LearnLink'
|
|
24
|
+
import { LEARN } from '../learnLinks'
|
|
25
|
+
import { usePlan } from '../planContextCore'
|
|
26
|
+
import { provenanceSource } from '../provenanceLinks'
|
|
27
|
+
import { CheckboxField, MoneyField, NumberField, SelectField, TextField } from '../fields'
|
|
28
|
+
import { fmtMoney, fmtMoneyCompact } from '../format'
|
|
29
|
+
import { currentStartYear, useProjection } from '../useProjection'
|
|
30
|
+
import { Issues } from './shared'
|
|
31
|
+
|
|
32
|
+
const CURVE = EMBEDDED_REAL_YIELD_CURVE
|
|
33
|
+
|
|
34
|
+
/** Quote a ladder exactly the way the ledger prices it (same anchor rules). */
|
|
35
|
+
function quoteLadder(ladder: TipsLadder, startYear: number): LadderBuild | null {
|
|
36
|
+
const anchorYear = ladder.purchase ? ladder.purchase.year : startYear - 1
|
|
37
|
+
const effectiveStartYear = Math.max(ladder.startYear, anchorYear + 1)
|
|
38
|
+
if (ladder.endYear < effectiveStartYear || ladder.annualRealAmount <= 0) return null
|
|
39
|
+
return buildLadder({
|
|
40
|
+
annualRealIncome: ladder.annualRealAmount,
|
|
41
|
+
firstPayoutOffset: effectiveStartYear - anchorYear,
|
|
42
|
+
payoutYears: ladder.endYear - effectiveStartYear + 1,
|
|
43
|
+
curve: CURVE,
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function LadderRow({ ladder, index, startYear }: { ladder: TipsLadder; index: number; startYear: number }) {
|
|
48
|
+
const { plan, update } = usePlan()
|
|
49
|
+
const quote = useMemo(() => quoteLadder(ladder, startYear), [ladder, startYear])
|
|
50
|
+
const fundingOptions = plan.accounts
|
|
51
|
+
.filter((a) => a.type === 'cash' || a.type === 'taxable' || a.type === 'equityComp')
|
|
52
|
+
.map((a) => ({ value: a.id, label: a.name }))
|
|
53
|
+
const edit = (fn: (l: TipsLadder) => void) =>
|
|
54
|
+
update((d) => {
|
|
55
|
+
const l = d.incomeFloor?.ladders[index]
|
|
56
|
+
if (l) fn(l)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<div className="item-row">
|
|
61
|
+
<div className="item-row-head">
|
|
62
|
+
<span className="item-row-title">
|
|
63
|
+
<span className="type-chip">{ladder.purpose === 'bridge' ? 'Bridge' : 'Floor'}</span>
|
|
64
|
+
{ladder.name}
|
|
65
|
+
</span>
|
|
66
|
+
<button
|
|
67
|
+
type="button"
|
|
68
|
+
className="btn-ghost btn-ghost-danger"
|
|
69
|
+
onClick={() => update((d) => void d.incomeFloor?.ladders.splice(index, 1))}
|
|
70
|
+
>
|
|
71
|
+
Remove
|
|
72
|
+
</button>
|
|
73
|
+
</div>
|
|
74
|
+
<div className="form-grid">
|
|
75
|
+
<TextField label="Name" value={ladder.name} onCommit={(v) => edit((l) => void (l.name = v || 'TIPS ladder'))} />
|
|
76
|
+
<SelectField
|
|
77
|
+
label="Purpose"
|
|
78
|
+
help="Labeling only — a bridge covers the years until a delayed Social Security claim; a floor covers essential spending. The math is the same."
|
|
79
|
+
value={ladder.purpose}
|
|
80
|
+
options={[
|
|
81
|
+
{ value: 'floor', label: 'Essential-spending floor' },
|
|
82
|
+
{ value: 'bridge', label: 'Social Security bridge' },
|
|
83
|
+
]}
|
|
84
|
+
onCommit={(v) => edit((l) => void (l.purpose = v))}
|
|
85
|
+
/>
|
|
86
|
+
<MoneyField
|
|
87
|
+
label="Annual real income (today's $)"
|
|
88
|
+
help="The level inflation-adjusted income the ladder pays in each payout year. TIPS index to CPI, so this stays constant in today's dollars. Quotes price each rung on the embedded Treasury real-yield curve."
|
|
89
|
+
source={provenanceSource('real-yield-curve')}
|
|
90
|
+
value={ladder.annualRealAmount}
|
|
91
|
+
onCommit={(v) => edit((l) => void (l.annualRealAmount = Math.max(0, v ?? 0)))}
|
|
92
|
+
/>
|
|
93
|
+
<NumberField
|
|
94
|
+
label="First payout year"
|
|
95
|
+
value={ladder.startYear}
|
|
96
|
+
min={1900}
|
|
97
|
+
max={2200}
|
|
98
|
+
onCommit={(v) => edit((l) => void (l.startYear = Math.round(v ?? l.startYear)))}
|
|
99
|
+
/>
|
|
100
|
+
<NumberField
|
|
101
|
+
label="Last payout year"
|
|
102
|
+
value={ladder.endYear}
|
|
103
|
+
min={1900}
|
|
104
|
+
max={2200}
|
|
105
|
+
onCommit={(v) => edit((l) => void (l.endYear = Math.round(v ?? l.endYear)))}
|
|
106
|
+
/>
|
|
107
|
+
<CheckboxField
|
|
108
|
+
label="Purchase in the plan"
|
|
109
|
+
help="When on, the quoted cost is withdrawn from the funding account in the purchase year (realizing capital gains pro-rata if it sells appreciated holdings). When off, the ladder is treated as already owned."
|
|
110
|
+
value={ladder.purchase !== undefined}
|
|
111
|
+
onCommit={(v) =>
|
|
112
|
+
edit((l) => {
|
|
113
|
+
l.purchase = v
|
|
114
|
+
? { year: Math.min(startYear, l.startYear - 1), fundingAccountId: fundingOptions[0]?.value ?? '' }
|
|
115
|
+
: undefined
|
|
116
|
+
})
|
|
117
|
+
}
|
|
118
|
+
/>
|
|
119
|
+
{ladder.purchase ? (
|
|
120
|
+
<>
|
|
121
|
+
<SelectField
|
|
122
|
+
label="Funded from"
|
|
123
|
+
value={ladder.purchase.fundingAccountId}
|
|
124
|
+
options={fundingOptions.length > 0 ? fundingOptions : [{ value: '', label: 'No cash/taxable account' }]}
|
|
125
|
+
onCommit={(v) => edit((l) => void (l.purchase && (l.purchase.fundingAccountId = v)))}
|
|
126
|
+
/>
|
|
127
|
+
<NumberField
|
|
128
|
+
label="Purchase year"
|
|
129
|
+
hint="Must be before the first payout year."
|
|
130
|
+
value={ladder.purchase.year}
|
|
131
|
+
min={1900}
|
|
132
|
+
max={2200}
|
|
133
|
+
onCommit={(v) => edit((l) => void (l.purchase && (l.purchase.year = Math.round(v ?? l.purchase.year))))}
|
|
134
|
+
/>
|
|
135
|
+
</>
|
|
136
|
+
) : null}
|
|
137
|
+
</div>
|
|
138
|
+
{quote ? (
|
|
139
|
+
<>
|
|
140
|
+
<p className="card-hint">
|
|
141
|
+
Quoted cost <strong>{fmtMoney(quote.totalCost)}</strong> (today's $) for {quote.rungs.length} rung
|
|
142
|
+
{quote.rungs.length === 1 ? '' : 's'} — real yields as of {CURVE.asOfIso}. That's{' '}
|
|
143
|
+
{((ladder.annualRealAmount / quote.totalCost) * 100).toFixed(2)}% of cost per year, inflation-protected.
|
|
144
|
+
</p>
|
|
145
|
+
<details>
|
|
146
|
+
<summary>Buy-list (planning-grade)</summary>
|
|
147
|
+
<div className="year-table-wrap">
|
|
148
|
+
<table>
|
|
149
|
+
<thead>
|
|
150
|
+
<tr>
|
|
151
|
+
<th>Matures</th>
|
|
152
|
+
<th>Face (today's $)</th>
|
|
153
|
+
<th>Coupon</th>
|
|
154
|
+
<th>Est. cost</th>
|
|
155
|
+
</tr>
|
|
156
|
+
</thead>
|
|
157
|
+
<tbody>
|
|
158
|
+
{quote.rungs.map((rung) => (
|
|
159
|
+
<tr key={rung.maturityOffset}>
|
|
160
|
+
<td>{(ladder.purchase ? ladder.purchase.year : startYear - 1) + rung.maturityOffset}</td>
|
|
161
|
+
<td>{fmtMoney(rung.face)}</td>
|
|
162
|
+
<td>{rung.couponRatePct.toFixed(3)}%</td>
|
|
163
|
+
<td>{fmtMoney(rung.cost)}</td>
|
|
164
|
+
</tr>
|
|
165
|
+
))}
|
|
166
|
+
</tbody>
|
|
167
|
+
</table>
|
|
168
|
+
</div>
|
|
169
|
+
<p className="card-hint">
|
|
170
|
+
Execute at your brokerage or TreasuryDirect — RetireGolden never places orders. Annual coupons and par-rung
|
|
171
|
+
pricing are planning simplifications; real quotes differ slightly.
|
|
172
|
+
</p>
|
|
173
|
+
</details>
|
|
174
|
+
</>
|
|
175
|
+
) : (
|
|
176
|
+
<p className="card-hint">Set an amount and a payout window (after the purchase year) to see a quote.</p>
|
|
177
|
+
)}
|
|
178
|
+
</div>
|
|
179
|
+
)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Funded-ratio card — shared with the Results page (step 4 of the plan). */
|
|
183
|
+
export function FundedRatioCard() {
|
|
184
|
+
const { plan } = usePlan()
|
|
185
|
+
const { result, deflate } = useProjection(plan)
|
|
186
|
+
const startYear = result.startYear
|
|
187
|
+
const fr = useMemo(() => {
|
|
188
|
+
const primary = plan.household.people[0]
|
|
189
|
+
const retirementYear =
|
|
190
|
+
primary && primary.retirementAge !== null ? Number(primary.dob.slice(0, 4)) + primary.retirementAge : startYear
|
|
191
|
+
return computeFundedRatio({
|
|
192
|
+
years: result.years,
|
|
193
|
+
startYear,
|
|
194
|
+
deflate,
|
|
195
|
+
curve: CURVE,
|
|
196
|
+
fromYear: Math.max(retirementYear, startYear),
|
|
197
|
+
})
|
|
198
|
+
}, [plan, result, deflate, startYear])
|
|
199
|
+
|
|
200
|
+
if (!fr) return null
|
|
201
|
+
return (
|
|
202
|
+
<div className="card">
|
|
203
|
+
<h2>Funded ratio</h2>
|
|
204
|
+
<p className="card-hint">
|
|
205
|
+
Pension accounting for your household: essential spending valued on the TIPS curve vs. the guaranteed income
|
|
206
|
+
dedicated to it. <LearnLink {...LEARN.fundedRatio} />
|
|
207
|
+
</p>
|
|
208
|
+
<div className="stat-grid">
|
|
209
|
+
<div>
|
|
210
|
+
<div className={`stat-value ${fr.fundedRatioPct >= 100 ? 'stat-value--good' : 'stat-value--neutral'}`}>
|
|
211
|
+
{Math.round(fr.fundedRatioPct)}%
|
|
212
|
+
</div>
|
|
213
|
+
<div className="muted">of the essential floor is funded by guaranteed income</div>
|
|
214
|
+
</div>
|
|
215
|
+
<div>
|
|
216
|
+
<div className="stat-value stat-value--sm">{fmtMoneyCompact(fr.essentialSpendingPv)}</div>
|
|
217
|
+
<div className="muted">essential spending, valued today</div>
|
|
218
|
+
</div>
|
|
219
|
+
<div>
|
|
220
|
+
<div className="stat-value stat-value--sm">{fmtMoneyCompact(fr.guaranteedIncomePv)}</div>
|
|
221
|
+
<div className="muted">guaranteed income, valued today</div>
|
|
222
|
+
</div>
|
|
223
|
+
<div>
|
|
224
|
+
<div className="stat-value stat-value--sm">{fmtMoneyCompact(fr.unfundedPv)}</div>
|
|
225
|
+
<div className="muted">gap riding on the portfolio</div>
|
|
226
|
+
</div>
|
|
227
|
+
</div>
|
|
228
|
+
<p className="card-hint">
|
|
229
|
+
{plan.expenses.requiredAnnual === undefined
|
|
230
|
+
? 'Tip: you have not separated required spending from lifestyle on the Spending page, so the "floor" here is your whole budget and the ratio reads low.'
|
|
231
|
+
: `Counted from ${fr.fromYear} through ${fr.toYear}, discounted at Treasury real yields as of ${CURVE.asOfIso}.`}
|
|
232
|
+
</p>
|
|
233
|
+
</div>
|
|
234
|
+
)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function LivePricesCard() {
|
|
238
|
+
const { plan } = usePlan()
|
|
239
|
+
const startYear = currentStartYear()
|
|
240
|
+
// Cache-first with zero network: a previously fetched/imported day shows
|
|
241
|
+
// immediately; the fetch button only appears for a fresh look.
|
|
242
|
+
const [snapshot, setSnapshot] = useState<FedInvestSnapshot | null>(() => readFedInvestCache())
|
|
243
|
+
const [loading, setLoading] = useState(false)
|
|
244
|
+
const [error, setError] = useState<string | null>(null)
|
|
245
|
+
const ladders = plan.incomeFloor?.ladders
|
|
246
|
+
const rungYears = useMemo(() => {
|
|
247
|
+
const years = new Set<number>()
|
|
248
|
+
for (const ladder of ladders ?? []) {
|
|
249
|
+
for (let y = Math.max(ladder.startYear, startYear + 1); y <= ladder.endYear; y++) years.add(y)
|
|
250
|
+
}
|
|
251
|
+
return [...years].sort((a, b) => a - b)
|
|
252
|
+
}, [ladders, startYear])
|
|
253
|
+
|
|
254
|
+
if (!ladders || ladders.length === 0) return null
|
|
255
|
+
|
|
256
|
+
const load = async () => {
|
|
257
|
+
setLoading(true)
|
|
258
|
+
setError(null)
|
|
259
|
+
try {
|
|
260
|
+
setSnapshot(await fetchFedInvestTips())
|
|
261
|
+
} catch (e) {
|
|
262
|
+
setError(e instanceof Error ? e.message : 'The price fetch failed.')
|
|
263
|
+
} finally {
|
|
264
|
+
setLoading(false)
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return (
|
|
269
|
+
<div className="card">
|
|
270
|
+
<h2>Live TIPS prices (optional)</h2>
|
|
271
|
+
<p className="card-hint">
|
|
272
|
+
Your plan always works offline on the embedded yield curve. If you want to sanity-check the quote against real
|
|
273
|
+
securities, this button asks the U.S. Treasury's FedInvest service for the latest end-of-day TIPS prices. It is
|
|
274
|
+
the app's only network request, sends nothing but a date, and is cached on this device for the day.
|
|
275
|
+
</p>
|
|
276
|
+
{snapshot === null ? (
|
|
277
|
+
<button type="button" className="btn btn-secondary" onClick={() => void load()} disabled={loading}>
|
|
278
|
+
{loading ? 'Fetching…' : 'Fetch live prices from Treasury FedInvest'}
|
|
279
|
+
</button>
|
|
280
|
+
) : (
|
|
281
|
+
<>
|
|
282
|
+
<p className="card-hint">
|
|
283
|
+
{snapshot.priceDateIso === null
|
|
284
|
+
? `${snapshot.tips.length} TIPS from an imported file — dated by your download (the FedInvest CSV carries no date)`
|
|
285
|
+
: `${snapshot.tips.length} TIPS priced ${snapshot.priceDateIso}`}{' '}
|
|
286
|
+
— per $100 face, before inflation index ratio.
|
|
287
|
+
</p>
|
|
288
|
+
{/* An imported or previous-day snapshot is not fresh: keep the fetch available. */}
|
|
289
|
+
{snapshot.source === 'import' || snapshot.priceDateIso !== latestPriceDateIso() ? (
|
|
290
|
+
<button type="button" className="btn btn-secondary btn-small" onClick={() => void load()} disabled={loading}>
|
|
291
|
+
{loading ? 'Fetching…' : 'Fetch the latest prices from Treasury FedInvest'}
|
|
292
|
+
</button>
|
|
293
|
+
) : null}
|
|
294
|
+
<div className="table-scroll">
|
|
295
|
+
<table>
|
|
296
|
+
<thead>
|
|
297
|
+
<tr>
|
|
298
|
+
<th>Rung year</th>
|
|
299
|
+
<th>Nearest real TIPS (CUSIP)</th>
|
|
300
|
+
<th>Coupon</th>
|
|
301
|
+
<th>Matures</th>
|
|
302
|
+
<th>End-of-day price</th>
|
|
303
|
+
</tr>
|
|
304
|
+
</thead>
|
|
305
|
+
<tbody>
|
|
306
|
+
{rungYears.map((year) => {
|
|
307
|
+
const match = nearestTipsForYear(snapshot.tips, year)
|
|
308
|
+
return (
|
|
309
|
+
<tr key={year}>
|
|
310
|
+
<td>{year}</td>
|
|
311
|
+
<td>{match ? match.cusip : '— none matures nearby —'}</td>
|
|
312
|
+
<td>{match ? `${match.ratePct.toFixed(3)}%` : ''}</td>
|
|
313
|
+
<td>{match ? match.maturityIso : ''}</td>
|
|
314
|
+
<td>{match ? match.endOfDayPrice.toFixed(2) : ''}</td>
|
|
315
|
+
</tr>
|
|
316
|
+
)
|
|
317
|
+
})}
|
|
318
|
+
</tbody>
|
|
319
|
+
</table>
|
|
320
|
+
</div>
|
|
321
|
+
</>
|
|
322
|
+
)}
|
|
323
|
+
{error ? (
|
|
324
|
+
<>
|
|
325
|
+
<p className="card-hint" role="alert">
|
|
326
|
+
{error} Your plan still works on the embedded curve.
|
|
327
|
+
</p>
|
|
328
|
+
<p className="card-hint">
|
|
329
|
+
Treasury's service does not allow direct browser requests from other sites (CORS), so the fetch can be
|
|
330
|
+
blocked even when you are online. Zero-network alternative: download <code>securityprice.csv</code>{' '}
|
|
331
|
+
yourself from{' '}
|
|
332
|
+
<a href={FEDINVEST_PAGE_URL} target="_blank" rel="noreferrer">
|
|
333
|
+
FedInvest
|
|
334
|
+
</a>{' '}
|
|
335
|
+
(pick the latest date, CSV format) and import it here.
|
|
336
|
+
</p>
|
|
337
|
+
<label className="btn btn-secondary btn-small">
|
|
338
|
+
Import securityprice.csv
|
|
339
|
+
<input
|
|
340
|
+
type="file"
|
|
341
|
+
accept=".csv,text/csv"
|
|
342
|
+
style={{ display: 'none' }}
|
|
343
|
+
onChange={(e) => {
|
|
344
|
+
const file = e.target.files?.[0]
|
|
345
|
+
if (!file) return
|
|
346
|
+
void file.text().then(
|
|
347
|
+
(text) => {
|
|
348
|
+
try {
|
|
349
|
+
setSnapshot(importFedInvestCsv(text))
|
|
350
|
+
setError(null)
|
|
351
|
+
} catch (err) {
|
|
352
|
+
setError(err instanceof Error ? err.message : 'That file could not be read.')
|
|
353
|
+
}
|
|
354
|
+
},
|
|
355
|
+
() => setError('That file could not be read.'),
|
|
356
|
+
)
|
|
357
|
+
}}
|
|
358
|
+
/>
|
|
359
|
+
</label>
|
|
360
|
+
</>
|
|
361
|
+
) : null}
|
|
362
|
+
</div>
|
|
363
|
+
)
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
export function IncomeFloorSection() {
|
|
367
|
+
const { plan, update } = usePlan()
|
|
368
|
+
const startYear = currentStartYear()
|
|
369
|
+
const ladders = plan.incomeFloor?.ladders ?? []
|
|
370
|
+
|
|
371
|
+
return (
|
|
372
|
+
<section>
|
|
373
|
+
<div className="card">
|
|
374
|
+
<h2>TIPS ladders</h2>
|
|
375
|
+
<p className="card-hint">
|
|
376
|
+
A TIPS ladder turns a lump sum into guaranteed, inflation-adjusted income — a DIY real pension for a Social
|
|
377
|
+
Security bridge or an essential-spending floor. Cash flows run through your full ledger with real TIPS
|
|
378
|
+
taxation (federally ordinary, state-exempt, accretion taxed as it accrues).{' '}
|
|
379
|
+
<LearnLink {...LEARN.tipsLadders} />
|
|
380
|
+
</p>
|
|
381
|
+
{ladders.map((ladder, i) => (
|
|
382
|
+
<LadderRow key={ladder.id} ladder={ladder} index={i} startYear={startYear} />
|
|
383
|
+
))}
|
|
384
|
+
<div className="add-row">
|
|
385
|
+
<button
|
|
386
|
+
type="button"
|
|
387
|
+
className="btn btn-secondary btn-small"
|
|
388
|
+
onClick={() =>
|
|
389
|
+
update((d) => {
|
|
390
|
+
const ladder: TipsLadder = {
|
|
391
|
+
id: crypto.randomUUID(),
|
|
392
|
+
name: 'Income floor ladder',
|
|
393
|
+
purpose: 'floor',
|
|
394
|
+
startYear: startYear + 1,
|
|
395
|
+
endYear: startYear + 20,
|
|
396
|
+
annualRealAmount: 12_000,
|
|
397
|
+
}
|
|
398
|
+
if (d.incomeFloor) d.incomeFloor.ladders.push(ladder)
|
|
399
|
+
else d.incomeFloor = { ladders: [ladder] }
|
|
400
|
+
})
|
|
401
|
+
}
|
|
402
|
+
>
|
|
403
|
+
+ TIPS ladder
|
|
404
|
+
</button>
|
|
405
|
+
</div>
|
|
406
|
+
<p className="card-hint">
|
|
407
|
+
Curve provenance: {CURVE.source}, as of {CURVE.asOfIso}. Looking for a bridge sized from your own benefit?
|
|
408
|
+
The Social Security Optimizer has a one-click bridge panel. <LearnLink {...LEARN.socialSecurityBridge} />
|
|
409
|
+
</p>
|
|
410
|
+
<Issues />
|
|
411
|
+
</div>
|
|
412
|
+
|
|
413
|
+
<FundedRatioCard />
|
|
414
|
+
<LivePricesCard />
|
|
415
|
+
<LearnAboutScreen route="/plan/:planId/income-floor" />
|
|
416
|
+
</section>
|
|
417
|
+
)
|
|
418
|
+
}
|