@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,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plan workspace state: loads a plan by id, applies edits immutably (clone →
|
|
3
|
+
* mutate → Zod re-parse), and autosaves to IndexedDB on a debounce. Edits
|
|
4
|
+
* that fail validation still update the screen (so the user can finish
|
|
5
|
+
* typing) but are not persisted; the issues surface in the save indicator
|
|
6
|
+
* and section forms.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'
|
|
10
|
+
import { Link } from 'react-router-dom'
|
|
11
|
+
|
|
12
|
+
import { parsePlan, type Plan } from '@retiregolden/engine/model/plan'
|
|
13
|
+
import { loadPlan, savePlan } from '../data/planStore'
|
|
14
|
+
import { EXAMPLE_PLAN_ID_PREFIX, isExamplePlanId } from '../data/planOrigin'
|
|
15
|
+
import { getExampleById } from './examples/registry'
|
|
16
|
+
import { saveFreshDemo } from './examples/loadExample'
|
|
17
|
+
import { PlanCtx, type PlanContextValue, type SaveState } from './planContextCore'
|
|
18
|
+
|
|
19
|
+
const AUTOSAVE_MS = 600
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Mount with key={planId}: a plan switch remounts the provider, so initial
|
|
23
|
+
* state is naturally "loading" and the load effect only sets state
|
|
24
|
+
* asynchronously.
|
|
25
|
+
*/
|
|
26
|
+
export function PlanProvider({ planId, children }: { planId: string; children: ReactNode }) {
|
|
27
|
+
const [plan, setPlan] = useState<Plan | null>(null)
|
|
28
|
+
const [loadError, setLoadError] = useState<string | null>(null)
|
|
29
|
+
const [saveState, setSaveState] = useState<SaveState>('loading')
|
|
30
|
+
const [issues, setIssues] = useState<string[]>([])
|
|
31
|
+
const timer = useRef<number | null>(null)
|
|
32
|
+
const latestValid = useRef<Plan | null>(null)
|
|
33
|
+
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
let cancelled = false
|
|
36
|
+
const adopt = (loaded: Plan) => {
|
|
37
|
+
setPlan(loaded)
|
|
38
|
+
latestValid.current = loaded
|
|
39
|
+
setSaveState('saved')
|
|
40
|
+
}
|
|
41
|
+
void (async () => {
|
|
42
|
+
const r = await loadPlan(planId)
|
|
43
|
+
if (cancelled) return
|
|
44
|
+
if (r.ok) {
|
|
45
|
+
adopt(r.plan)
|
|
46
|
+
return
|
|
47
|
+
}
|
|
48
|
+
// A shared or bookmarked example URL can reference a demo that was never
|
|
49
|
+
// seeded in this browser (plans are per-device). Seed a fresh copy from
|
|
50
|
+
// the registry instead of dead-ending the advertised on-ramp — but ONLY
|
|
51
|
+
// when the record is missing ('not_object'). Other reasons (bad_version,
|
|
52
|
+
// newer_than_app, …) mean a record EXISTS and failed to open; seeding
|
|
53
|
+
// over it would silently discard the on-device edits the product copy
|
|
54
|
+
// promises are kept.
|
|
55
|
+
if (isExamplePlanId(planId) && r.reason === 'not_object') {
|
|
56
|
+
const example = getExampleById(planId.slice(EXAMPLE_PLAN_ID_PREFIX.length))
|
|
57
|
+
if (example) {
|
|
58
|
+
const seeded = await saveFreshDemo(example)
|
|
59
|
+
if (cancelled) return
|
|
60
|
+
if (seeded.ok) {
|
|
61
|
+
adopt(seeded.plan)
|
|
62
|
+
return
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
setLoadError(r.reason)
|
|
67
|
+
})()
|
|
68
|
+
return () => {
|
|
69
|
+
cancelled = true
|
|
70
|
+
}
|
|
71
|
+
}, [planId])
|
|
72
|
+
|
|
73
|
+
// savePlan resolves { ok: false } on validation failure, but the IndexedDB
|
|
74
|
+
// write itself can still reject (quota, private mode) — degrade to 'error'
|
|
75
|
+
// instead of leaving 'saving' stuck plus an unhandled rejection.
|
|
76
|
+
const runSave = useCallback((toSave: Plan) => {
|
|
77
|
+
setSaveState('saving')
|
|
78
|
+
void savePlan(toSave)
|
|
79
|
+
.then((r) => {
|
|
80
|
+
setSaveState(r.ok ? 'saved' : 'error')
|
|
81
|
+
})
|
|
82
|
+
.catch(() => {
|
|
83
|
+
setSaveState('error')
|
|
84
|
+
})
|
|
85
|
+
}, [])
|
|
86
|
+
|
|
87
|
+
const scheduleSave = useCallback(() => {
|
|
88
|
+
if (timer.current !== null) window.clearTimeout(timer.current)
|
|
89
|
+
timer.current = window.setTimeout(() => {
|
|
90
|
+
timer.current = null
|
|
91
|
+
const toSave = latestValid.current
|
|
92
|
+
if (toSave) runSave(toSave)
|
|
93
|
+
}, AUTOSAVE_MS)
|
|
94
|
+
}, [runSave])
|
|
95
|
+
|
|
96
|
+
/** Runs a debounced save immediately; no-op when nothing is pending. */
|
|
97
|
+
const flushPendingSave = useCallback(() => {
|
|
98
|
+
if (timer.current === null) return
|
|
99
|
+
window.clearTimeout(timer.current)
|
|
100
|
+
timer.current = null
|
|
101
|
+
const toSave = latestValid.current
|
|
102
|
+
if (toSave) runSave(toSave)
|
|
103
|
+
}, [runSave])
|
|
104
|
+
|
|
105
|
+
const discardPendingSave = useCallback(() => {
|
|
106
|
+
if (timer.current !== null) {
|
|
107
|
+
window.clearTimeout(timer.current)
|
|
108
|
+
timer.current = null
|
|
109
|
+
}
|
|
110
|
+
latestValid.current = null
|
|
111
|
+
}, [])
|
|
112
|
+
|
|
113
|
+
const update = useCallback(
|
|
114
|
+
(mutator: (draft: Plan) => void) => {
|
|
115
|
+
setPlan((current) => {
|
|
116
|
+
if (!current) return current
|
|
117
|
+
const draft = structuredClone(current)
|
|
118
|
+
mutator(draft)
|
|
119
|
+
const parsed = parsePlan(draft)
|
|
120
|
+
if (parsed.ok) {
|
|
121
|
+
latestValid.current = parsed.plan
|
|
122
|
+
setIssues([])
|
|
123
|
+
setSaveState('dirty')
|
|
124
|
+
scheduleSave()
|
|
125
|
+
return parsed.plan
|
|
126
|
+
}
|
|
127
|
+
setIssues(parsed.issues)
|
|
128
|
+
setSaveState('invalid')
|
|
129
|
+
return draft
|
|
130
|
+
})
|
|
131
|
+
},
|
|
132
|
+
[scheduleSave],
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
// Flush pending saves when the page is hidden or torn down. The unmount
|
|
136
|
+
// cleanup alone is not enough: it never runs on tab close, and a mobile OS
|
|
137
|
+
// can kill a hidden PWA without any unmount — so `pagehide` and
|
|
138
|
+
// `visibilitychange → hidden` both flush too.
|
|
139
|
+
useEffect(() => {
|
|
140
|
+
const onPageHide = () => flushPendingSave()
|
|
141
|
+
const onVisibilityChange = () => {
|
|
142
|
+
if (document.visibilityState === 'hidden') flushPendingSave()
|
|
143
|
+
}
|
|
144
|
+
window.addEventListener('pagehide', onPageHide)
|
|
145
|
+
document.addEventListener('visibilitychange', onVisibilityChange)
|
|
146
|
+
return () => {
|
|
147
|
+
window.removeEventListener('pagehide', onPageHide)
|
|
148
|
+
document.removeEventListener('visibilitychange', onVisibilityChange)
|
|
149
|
+
flushPendingSave()
|
|
150
|
+
}
|
|
151
|
+
}, [flushPendingSave])
|
|
152
|
+
|
|
153
|
+
if (loadError) {
|
|
154
|
+
// 'not_object' = no record in this browser (cross-device link, stale
|
|
155
|
+
// bookmark). Everything else = a stored plan that exists but failed to
|
|
156
|
+
// open (version/migration mismatch) — telling that user "not stored here"
|
|
157
|
+
// would be wrong and alarming.
|
|
158
|
+
const missing = loadError === 'not_object'
|
|
159
|
+
return (
|
|
160
|
+
<div className="card empty-state">
|
|
161
|
+
<h2>{missing ? 'Plan not found' : 'This plan could not be opened'}</h2>
|
|
162
|
+
<p className="muted">
|
|
163
|
+
{missing ? (
|
|
164
|
+
<>
|
|
165
|
+
This plan isn't stored in this browser. Plans live only on the device where they were created, so
|
|
166
|
+
a link from another device or an old bookmark won't open here. You can restore one from a backup
|
|
167
|
+
file via Data & privacy on the planner home.
|
|
168
|
+
</>
|
|
169
|
+
) : (
|
|
170
|
+
<>
|
|
171
|
+
The plan is stored on this device, but its data doesn't match what this version of the app can
|
|
172
|
+
read — usually a version mismatch (for example, a backup made by a newer version). Your data has not
|
|
173
|
+
been changed. Try reloading to pick up the latest app version, or restore a backup via Data &
|
|
174
|
+
privacy on the planner home.
|
|
175
|
+
</>
|
|
176
|
+
)}
|
|
177
|
+
</p>
|
|
178
|
+
<div className="picker-actions">
|
|
179
|
+
<Link to="/" className="btn btn-primary">
|
|
180
|
+
Your plans
|
|
181
|
+
</Link>
|
|
182
|
+
<Link to="/examples" className="btn btn-secondary">
|
|
183
|
+
Browse examples
|
|
184
|
+
</Link>
|
|
185
|
+
</div>
|
|
186
|
+
<details className="ss-explainer">
|
|
187
|
+
<summary>Technical details</summary>
|
|
188
|
+
<p className="muted">Load failed with reason code: {loadError}</p>
|
|
189
|
+
</details>
|
|
190
|
+
</div>
|
|
191
|
+
)
|
|
192
|
+
}
|
|
193
|
+
if (!plan) {
|
|
194
|
+
return <div className="skeleton" style={{ height: '14rem', marginTop: '1rem' }} aria-label="Loading plan" />
|
|
195
|
+
}
|
|
196
|
+
const contextValue: PlanContextValue = { plan, update, discardPendingSave, saveState, issues }
|
|
197
|
+
return <PlanCtx.Provider value={contextValue}>{children}</PlanCtx.Provider>
|
|
198
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Planner home: adaptive welcome for newcomers, plans-first for returning users.
|
|
3
|
+
* Composes section components from ./home/.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { Link, useNavigate } from 'react-router-dom'
|
|
7
|
+
|
|
8
|
+
import { createEmptyPlan } from '@retiregolden/engine/model/plan'
|
|
9
|
+
import { DataAndPrivacyCard } from './home/DataAndPrivacyCard'
|
|
10
|
+
import { GettingStartedPaths } from './home/GettingStartedPaths'
|
|
11
|
+
import { GettingStartedReopener } from './home/GettingStartedReopener'
|
|
12
|
+
import { StartHereLinks } from './home/StartHereLinks'
|
|
13
|
+
import { useHomeData } from './home/useHomeData'
|
|
14
|
+
import { useHomeMode } from './home/useHomeMode'
|
|
15
|
+
import { WelcomeHero } from './home/WelcomeHero'
|
|
16
|
+
import { YourPlans } from './home/YourPlans'
|
|
17
|
+
|
|
18
|
+
const GETTING_STARTED_PANEL_ID = 'home-getting-started-panel'
|
|
19
|
+
|
|
20
|
+
export function PlanPickerPage() {
|
|
21
|
+
const navigate = useNavigate()
|
|
22
|
+
const {
|
|
23
|
+
plans,
|
|
24
|
+
notice,
|
|
25
|
+
undoPlan,
|
|
26
|
+
undoDelete,
|
|
27
|
+
dismissUndo,
|
|
28
|
+
fileInput,
|
|
29
|
+
createAndOpen,
|
|
30
|
+
handleExportAll,
|
|
31
|
+
handleImportFile,
|
|
32
|
+
handleDuplicate,
|
|
33
|
+
handleDelete,
|
|
34
|
+
handleClearAll,
|
|
35
|
+
openPlan,
|
|
36
|
+
dialogs,
|
|
37
|
+
} = useHomeData()
|
|
38
|
+
|
|
39
|
+
const { mode, welcomeExpanded, dismissWelcome, showWelcome } = useHomeMode(plans)
|
|
40
|
+
const isLoading = plans === null
|
|
41
|
+
const isFirstRun = !isLoading && mode === 'first-run'
|
|
42
|
+
|
|
43
|
+
const gettingStartedBlock = (
|
|
44
|
+
<>
|
|
45
|
+
<WelcomeHero
|
|
46
|
+
onTryExample={() => navigate('/examples')}
|
|
47
|
+
onDismiss={dismissWelcome}
|
|
48
|
+
showDismiss={mode === 'returning'}
|
|
49
|
+
headingLevel={mode === 'returning' ? 'h2' : 'h1'}
|
|
50
|
+
/>
|
|
51
|
+
<GettingStartedPaths onCreatePlan={createAndOpen} />
|
|
52
|
+
<StartHereLinks />
|
|
53
|
+
</>
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<section className="page picker-page">
|
|
58
|
+
<div role="status" aria-live="polite">
|
|
59
|
+
{notice ? <div className="callout callout--info">{notice}</div> : null}
|
|
60
|
+
{undoPlan ? (
|
|
61
|
+
<div className="undo-toast">
|
|
62
|
+
<span>
|
|
63
|
+
Deleted “{undoPlan.name}”.
|
|
64
|
+
</span>
|
|
65
|
+
<div className="undo-toast-actions">
|
|
66
|
+
<button type="button" className="btn btn-secondary btn-small" onClick={() => void undoDelete()}>
|
|
67
|
+
Undo
|
|
68
|
+
</button>
|
|
69
|
+
<button type="button" className="btn btn-ghost btn-small" onClick={dismissUndo} aria-label="Dismiss">
|
|
70
|
+
Dismiss
|
|
71
|
+
</button>
|
|
72
|
+
</div>
|
|
73
|
+
</div>
|
|
74
|
+
) : null}
|
|
75
|
+
</div>
|
|
76
|
+
|
|
77
|
+
{isLoading ? (
|
|
78
|
+
<>
|
|
79
|
+
<div className="skeleton" style={{ height: '2rem', marginBottom: '1rem' }} aria-hidden="true" />
|
|
80
|
+
<div className="skeleton" style={{ height: '8rem' }} aria-label="Loading plans" />
|
|
81
|
+
</>
|
|
82
|
+
) : isFirstRun ? (
|
|
83
|
+
gettingStartedBlock
|
|
84
|
+
) : (
|
|
85
|
+
<>
|
|
86
|
+
<GettingStartedReopener
|
|
87
|
+
expanded={welcomeExpanded}
|
|
88
|
+
onToggle={() => (welcomeExpanded ? dismissWelcome() : showWelcome())}
|
|
89
|
+
panelId={GETTING_STARTED_PANEL_ID}
|
|
90
|
+
>
|
|
91
|
+
{gettingStartedBlock}
|
|
92
|
+
</GettingStartedReopener>
|
|
93
|
+
|
|
94
|
+
<YourPlans
|
|
95
|
+
plans={plans}
|
|
96
|
+
headingLevel="h1"
|
|
97
|
+
actions={
|
|
98
|
+
<>
|
|
99
|
+
<button type="button" className="btn btn-primary" onClick={() => void createAndOpen(createEmptyPlan())}>
|
|
100
|
+
New plan
|
|
101
|
+
</button>
|
|
102
|
+
<Link to="/compare" className="btn btn-secondary">
|
|
103
|
+
Compare plans
|
|
104
|
+
</Link>
|
|
105
|
+
</>
|
|
106
|
+
}
|
|
107
|
+
onOpenPlan={openPlan}
|
|
108
|
+
onDuplicate={handleDuplicate}
|
|
109
|
+
onDelete={handleDelete}
|
|
110
|
+
/>
|
|
111
|
+
</>
|
|
112
|
+
)}
|
|
113
|
+
|
|
114
|
+
<DataAndPrivacyCard
|
|
115
|
+
plans={plans}
|
|
116
|
+
fileInput={fileInput}
|
|
117
|
+
onExportAll={handleExportAll}
|
|
118
|
+
onImportFile={handleImportFile}
|
|
119
|
+
onClearAll={handleClearAll}
|
|
120
|
+
/>
|
|
121
|
+
{dialogs}
|
|
122
|
+
</section>
|
|
123
|
+
)
|
|
124
|
+
}
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workspace shell: left rail (entry sections + results surfaces), editable
|
|
3
|
+
* plan name, autosave indicator, and a sticky KPI bar that re-simulates the
|
|
4
|
+
* deterministic projection live as the plan changes.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { useEffect } from 'react'
|
|
8
|
+
import { Link, NavLink, Outlet, useLocation, useNavigate, useParams } from 'react-router-dom'
|
|
9
|
+
|
|
10
|
+
import { duplicatePlan } from '../data/planStore'
|
|
11
|
+
import { DEFAULT_PATH_COUNT } from '../mc/pool'
|
|
12
|
+
import { useDialogs } from './dialogs'
|
|
13
|
+
import { isPlanIncomplete } from './planCompleteness'
|
|
14
|
+
import { ExamplePreviewBanner } from './examples/ExamplePreviewBanner'
|
|
15
|
+
import { EXAMPLE_SAVE_INDICATOR } from './examples/exampleCopy'
|
|
16
|
+
import { PlanProvider } from './PlanContext'
|
|
17
|
+
import { usePlan } from './planContextCore'
|
|
18
|
+
import { fmtMoneyCompact } from './format'
|
|
19
|
+
import { successBand } from './successBand'
|
|
20
|
+
import { useMcSuccessRate } from './useMcSuccessRate'
|
|
21
|
+
import { useProjection } from './useProjection'
|
|
22
|
+
|
|
23
|
+
const railClass = ({ isActive }: { isActive: boolean }) => (isActive ? 'rail-link rail-link--active' : 'rail-link')
|
|
24
|
+
|
|
25
|
+
/** Section labels by route segment — drives the tab title and the page h1. */
|
|
26
|
+
const SECTION_TITLES: Record<string, string> = {
|
|
27
|
+
household: 'Household',
|
|
28
|
+
'social-security': 'Social Security',
|
|
29
|
+
accounts: 'Accounts',
|
|
30
|
+
insurance: 'Insurance',
|
|
31
|
+
income: 'Income',
|
|
32
|
+
'income-floor': 'Income floor',
|
|
33
|
+
spending: 'Spending',
|
|
34
|
+
strategy: 'Strategy',
|
|
35
|
+
assumptions: 'Assumptions',
|
|
36
|
+
insights: 'Insights',
|
|
37
|
+
optimize: 'Roth & Tax Optimizer',
|
|
38
|
+
'spending-solver': 'How much can I spend?',
|
|
39
|
+
'social-security-analysis': 'Social Security Optimizer',
|
|
40
|
+
results: 'Results',
|
|
41
|
+
'monte-carlo': 'Monte Carlo',
|
|
42
|
+
scenarios: 'Scenarios',
|
|
43
|
+
survivor: 'Survivor transition',
|
|
44
|
+
relocation: 'Relocation Compare',
|
|
45
|
+
report: 'Report',
|
|
46
|
+
'assumptions-card': 'Assumptions card',
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function sectionTitleOf(pathname: string): string | null {
|
|
50
|
+
// /plan/:planId/<section>[/...] — the segment after the plan id.
|
|
51
|
+
const segments = pathname.split('/').filter(Boolean)
|
|
52
|
+
const section = segments[2]
|
|
53
|
+
return section !== undefined ? (SECTION_TITLES[section] ?? null) : null
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function SaveIndicator() {
|
|
57
|
+
const { plan, saveState, issues } = usePlan()
|
|
58
|
+
const isExample = plan.origin === 'example'
|
|
59
|
+
const text =
|
|
60
|
+
saveState === 'saved'
|
|
61
|
+
? isExample
|
|
62
|
+
? EXAMPLE_SAVE_INDICATOR
|
|
63
|
+
: 'Stored on this device'
|
|
64
|
+
: saveState === 'saving' || saveState === 'dirty'
|
|
65
|
+
? 'Storing…'
|
|
66
|
+
: saveState === 'invalid'
|
|
67
|
+
? `Fix ${issues.length} issue${issues.length === 1 ? '' : 's'} to store`
|
|
68
|
+
: saveState === 'error'
|
|
69
|
+
? 'Could not store locally'
|
|
70
|
+
: ''
|
|
71
|
+
const title = isExample
|
|
72
|
+
? "This example is saved on this device under its own slot — your edits stick across reloads, but it stays out of Your plans until you use 'Save to my plans'. 'Load a fresh copy' resets it."
|
|
73
|
+
: "Plans live only in this browser — nothing is sent to a server. Use 'Download plan backup' on the planner home to keep a copy."
|
|
74
|
+
return (
|
|
75
|
+
<span
|
|
76
|
+
className={saveState === 'invalid' || saveState === 'error' ? 'save-state save-state--error' : 'save-state'}
|
|
77
|
+
role="status"
|
|
78
|
+
aria-live="polite"
|
|
79
|
+
title={title}
|
|
80
|
+
>
|
|
81
|
+
{text}
|
|
82
|
+
</span>
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function KpiBar() {
|
|
87
|
+
const { plan } = usePlan()
|
|
88
|
+
const { result, summary, deflate } = useProjection(plan)
|
|
89
|
+
const mcRate = useMcSuccessRate(plan, !isPlanIncomplete(plan))
|
|
90
|
+
const endYear = result.endYear
|
|
91
|
+
const depleted = summary.depletionYear !== null
|
|
92
|
+
const endingToday = deflate(endYear, result.endingNetWorth)
|
|
93
|
+
|
|
94
|
+
if (isPlanIncomplete(plan)) {
|
|
95
|
+
return (
|
|
96
|
+
<div className="kpi-bar kpi-bar--incomplete" aria-label="Plan headline results">
|
|
97
|
+
<div className="kpi">
|
|
98
|
+
<span className="kpi-label">Plan status</span>
|
|
99
|
+
<span className="kpi-value kpi-value--pending">Getting started</span>
|
|
100
|
+
<span className="kpi-sub kpi-sub--wrap">
|
|
101
|
+
Add income sources or account balances to complete the picture — results appear as you enter them.
|
|
102
|
+
</span>
|
|
103
|
+
</div>
|
|
104
|
+
</div>
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return (
|
|
109
|
+
<div className="kpi-bar" aria-label="Plan headline results">
|
|
110
|
+
<div className="kpi">
|
|
111
|
+
<span className="kpi-label">Ending net worth</span>
|
|
112
|
+
<span className="kpi-value">{fmtMoneyCompact(result.endingNetWorth)}</span>
|
|
113
|
+
<span className="kpi-sub">{fmtMoneyCompact(endingToday)} today's $ · {endYear}</span>
|
|
114
|
+
</div>
|
|
115
|
+
<div className="kpi">
|
|
116
|
+
<span className="kpi-label">Money lasts</span>
|
|
117
|
+
{depleted ? (
|
|
118
|
+
<Link
|
|
119
|
+
className="kpi-value kpi-value--bad kpi-value-link"
|
|
120
|
+
to="insights"
|
|
121
|
+
title="See what would change this in Insights"
|
|
122
|
+
>
|
|
123
|
+
until {summary.depletionYear}
|
|
124
|
+
</Link>
|
|
125
|
+
) : (
|
|
126
|
+
<span className="kpi-value kpi-value--good">full plan</span>
|
|
127
|
+
)}
|
|
128
|
+
<span className="kpi-sub">{depleted ? 'steady markets · depletes' : `steady markets · ${endYear}`}</span>
|
|
129
|
+
</div>
|
|
130
|
+
<div className="kpi">
|
|
131
|
+
<span className="kpi-label">Market success</span>
|
|
132
|
+
{mcRate !== null ? (
|
|
133
|
+
<Link
|
|
134
|
+
className="kpi-value kpi-value-link"
|
|
135
|
+
style={{ color: successBand(mcRate).color }}
|
|
136
|
+
to="monte-carlo"
|
|
137
|
+
title={`Share of ${DEFAULT_PATH_COUNT.toLocaleString()} varied-market simulations where the money lasts — open Monte Carlo for the full picture`}
|
|
138
|
+
>
|
|
139
|
+
{Math.round(mcRate * 100)}%
|
|
140
|
+
</Link>
|
|
141
|
+
) : (
|
|
142
|
+
<span className="kpi-value kpi-value--pending" aria-label="Simulating markets">
|
|
143
|
+
…
|
|
144
|
+
</span>
|
|
145
|
+
)}
|
|
146
|
+
<span className="kpi-sub">of {DEFAULT_PATH_COUNT.toLocaleString()} varied markets</span>
|
|
147
|
+
</div>
|
|
148
|
+
<div className="kpi">
|
|
149
|
+
<span className="kpi-label">Lifetime tax</span>
|
|
150
|
+
<span className="kpi-value">{fmtMoneyCompact(summary.lifetimeTaxesAndPenalties)}</span>
|
|
151
|
+
<span className="kpi-sub">nominal $ · federal + state + penalties</span>
|
|
152
|
+
</div>
|
|
153
|
+
<div className="kpi">
|
|
154
|
+
<span className="kpi-label">Roth converted</span>
|
|
155
|
+
<span className="kpi-value">{fmtMoneyCompact(summary.lifetimeRothConversions)}</span>
|
|
156
|
+
<span className="kpi-sub">ending Roth {fmtMoneyCompact(summary.endingByCategory.roth)}</span>
|
|
157
|
+
</div>
|
|
158
|
+
</div>
|
|
159
|
+
)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function PlanName() {
|
|
163
|
+
const { plan, update } = usePlan()
|
|
164
|
+
return (
|
|
165
|
+
<input
|
|
166
|
+
className="plan-name-input"
|
|
167
|
+
value={plan.name}
|
|
168
|
+
aria-label="Plan name"
|
|
169
|
+
onChange={(e) =>
|
|
170
|
+
update((d) => {
|
|
171
|
+
d.name = e.target.value || 'My plan'
|
|
172
|
+
})
|
|
173
|
+
}
|
|
174
|
+
/>
|
|
175
|
+
)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function WorkspaceInner() {
|
|
179
|
+
const { plan, discardPendingSave } = usePlan()
|
|
180
|
+
const navigate = useNavigate()
|
|
181
|
+
const location = useLocation()
|
|
182
|
+
const { prompt, alert, dialogs } = useDialogs()
|
|
183
|
+
|
|
184
|
+
// Page identity: retitle the tab per section so history and multi-tab
|
|
185
|
+
// comparison work, and give every plan page exactly one h1 (sr-only — the
|
|
186
|
+
// visual header is the plan name + KPI bar).
|
|
187
|
+
const sectionTitle = sectionTitleOf(location.pathname)
|
|
188
|
+
useEffect(() => {
|
|
189
|
+
document.title = sectionTitle ? `${sectionTitle} · ${plan.name} · RetireGolden` : `${plan.name} · RetireGolden`
|
|
190
|
+
}, [sectionTitle, plan.name])
|
|
191
|
+
// Reset only when leaving the workspace entirely — a per-change cleanup
|
|
192
|
+
// would churn the title through 'RetireGolden' between sections.
|
|
193
|
+
useEffect(
|
|
194
|
+
() => () => {
|
|
195
|
+
document.title = 'RetireGolden'
|
|
196
|
+
},
|
|
197
|
+
[],
|
|
198
|
+
)
|
|
199
|
+
const handleDuplicate = async () => {
|
|
200
|
+
const name = await prompt({
|
|
201
|
+
title: 'Duplicate plan',
|
|
202
|
+
label: 'Name for the duplicated plan',
|
|
203
|
+
defaultValue: `Copy of ${plan.name}`,
|
|
204
|
+
confirmLabel: 'Duplicate',
|
|
205
|
+
})
|
|
206
|
+
if (name === null) return
|
|
207
|
+
if (plan.origin === 'example') discardPendingSave()
|
|
208
|
+
const r = await duplicatePlan(plan.id, { name, source: plan })
|
|
209
|
+
if (r.ok) navigate(`/plan/${r.plan.id}/results`)
|
|
210
|
+
else await alert({ title: 'Duplicate plan', body: `Could not duplicate this plan: ${r.issues.join('; ')}` })
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return (
|
|
214
|
+
<div className="planner-shell">
|
|
215
|
+
<ExamplePreviewBanner />
|
|
216
|
+
<div className="workspace-head">
|
|
217
|
+
<div className="workspace-head-main">
|
|
218
|
+
<nav className="workspace-breadcrumb" aria-label="Breadcrumb">
|
|
219
|
+
<ol>
|
|
220
|
+
<li>
|
|
221
|
+
<Link to="/">Your plans</Link>
|
|
222
|
+
</li>
|
|
223
|
+
<li aria-current="page">{plan.name}</li>
|
|
224
|
+
</ol>
|
|
225
|
+
</nav>
|
|
226
|
+
<PlanName />
|
|
227
|
+
</div>
|
|
228
|
+
<div className="workspace-head-actions">
|
|
229
|
+
<button type="button" className="btn btn-secondary btn-small" onClick={() => void handleDuplicate()}>
|
|
230
|
+
Duplicate plan
|
|
231
|
+
</button>
|
|
232
|
+
<SaveIndicator />
|
|
233
|
+
</div>
|
|
234
|
+
</div>
|
|
235
|
+
<KpiBar />
|
|
236
|
+
<div className="workspace">
|
|
237
|
+
{/* The global skip link lands on #main-content, which still puts the
|
|
238
|
+
19-link rail between keyboard users and the page content. This
|
|
239
|
+
second link lets them clear the rail in one stop. */}
|
|
240
|
+
<a className="skip-link" href="#plan-content">
|
|
241
|
+
Skip section navigation
|
|
242
|
+
</a>
|
|
243
|
+
<nav className="workspace-rail" aria-label="Plan sections">
|
|
244
|
+
<NavLink to="/" className="rail-link rail-link--back" end>
|
|
245
|
+
← Your plans
|
|
246
|
+
</NavLink>
|
|
247
|
+
<span className="rail-group">Enter</span>
|
|
248
|
+
<NavLink to="household" className={railClass}>Household</NavLink>
|
|
249
|
+
<NavLink to="social-security" className={railClass}>Social Security</NavLink>
|
|
250
|
+
<NavLink to="accounts" className={railClass}>Accounts</NavLink>
|
|
251
|
+
<NavLink to="insurance" className={railClass}>Insurance</NavLink>
|
|
252
|
+
<NavLink to="income" className={railClass}>Income</NavLink>
|
|
253
|
+
<NavLink to="income-floor" className={railClass}>Income floor</NavLink>
|
|
254
|
+
<NavLink to="spending" className={railClass}>Spending</NavLink>
|
|
255
|
+
<NavLink to="strategy" className={railClass}>Strategy</NavLink>
|
|
256
|
+
<NavLink to="assumptions" className={railClass}>Assumptions</NavLink>
|
|
257
|
+
<span className="rail-group">Optimize</span>
|
|
258
|
+
<NavLink to="insights" className={railClass}>Insights</NavLink>
|
|
259
|
+
<NavLink to="optimize" className={railClass}>Roth & Tax Optimizer</NavLink>
|
|
260
|
+
<NavLink to="spending-solver" className={railClass}>How much can I spend?</NavLink>
|
|
261
|
+
<NavLink to="social-security-analysis" className={railClass}>Social Security Optimizer</NavLink>
|
|
262
|
+
<span className="rail-group">Explore</span>
|
|
263
|
+
<NavLink to="results" className={railClass}>Results</NavLink>
|
|
264
|
+
<NavLink to="monte-carlo" className={railClass}>Monte Carlo</NavLink>
|
|
265
|
+
<NavLink to="scenarios" className={railClass}>Scenarios</NavLink>
|
|
266
|
+
{plan.household.filingStatus === 'marriedFilingJointly' && plan.household.people.length === 2 ? (
|
|
267
|
+
<NavLink to="survivor" className={railClass}>Survivor transition</NavLink>
|
|
268
|
+
) : null}
|
|
269
|
+
<NavLink to="relocation" className={railClass}>Relocation Compare</NavLink>
|
|
270
|
+
<NavLink to="/compare" className={railClass}>Compare plans</NavLink>
|
|
271
|
+
</nav>
|
|
272
|
+
<div id="plan-content" tabIndex={-1}>
|
|
273
|
+
<h1 className="sr-only">{sectionTitle ? `${sectionTitle} — ${plan.name}` : plan.name}</h1>
|
|
274
|
+
<Outlet />
|
|
275
|
+
</div>
|
|
276
|
+
</div>
|
|
277
|
+
{dialogs}
|
|
278
|
+
</div>
|
|
279
|
+
)
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export function PlanWorkspace() {
|
|
283
|
+
const { planId } = useParams()
|
|
284
|
+
if (!planId) return null
|
|
285
|
+
return (
|
|
286
|
+
<PlanProvider planId={planId}>
|
|
287
|
+
<WorkspaceInner />
|
|
288
|
+
</PlanProvider>
|
|
289
|
+
)
|
|
290
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shows where the engine's tax/limit/benefit defaults come from — one row per
|
|
3
|
+
* assumption group with its key figures and a link to a citable source.
|
|
4
|
+
* Rendered on the Disclaimer page and pointed to from Assumptions.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
LATEST_PACK_YEAR,
|
|
9
|
+
PARAMETER_DATA_AS_OF,
|
|
10
|
+
PARAMETER_PROVENANCE,
|
|
11
|
+
} from '@retiregolden/engine/params'
|
|
12
|
+
|
|
13
|
+
export function ProvenancePanel() {
|
|
14
|
+
return (
|
|
15
|
+
<div className="provenance">
|
|
16
|
+
<p className="muted small">
|
|
17
|
+
Applies to the {LATEST_PACK_YEAR} tax year · figures compiled {PARAMETER_DATA_AS_OF}.
|
|
18
|
+
Verify any number that matters against the official source.
|
|
19
|
+
</p>
|
|
20
|
+
<table className="provenance-table">
|
|
21
|
+
<thead>
|
|
22
|
+
<tr>
|
|
23
|
+
<th scope="col">Figures</th>
|
|
24
|
+
<th scope="col">Source</th>
|
|
25
|
+
</tr>
|
|
26
|
+
</thead>
|
|
27
|
+
<tbody>
|
|
28
|
+
{PARAMETER_PROVENANCE.map((s) => (
|
|
29
|
+
<tr key={s.id}>
|
|
30
|
+
<th scope="row">
|
|
31
|
+
<span className="provenance-label">{s.label}</span>
|
|
32
|
+
<span className="provenance-figures muted small">{s.figures}</span>
|
|
33
|
+
</th>
|
|
34
|
+
<td>
|
|
35
|
+
<a href={s.url} target="_blank" rel="noopener noreferrer">
|
|
36
|
+
{s.publisher} ↗
|
|
37
|
+
</a>
|
|
38
|
+
</td>
|
|
39
|
+
</tr>
|
|
40
|
+
))}
|
|
41
|
+
</tbody>
|
|
42
|
+
</table>
|
|
43
|
+
</div>
|
|
44
|
+
)
|
|
45
|
+
}
|