@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,371 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Assumptions snapshot: every live assumption behind a plan's projections —
|
|
3
|
+
* economy, per-account returns/allocations, longevity, law toggles, and the
|
|
4
|
+
* parameter-pack provenance — each tagged with where it comes from (user-set,
|
|
5
|
+
* app default, or a published source). Feeds the "Your assumptions" card and
|
|
6
|
+
* its copy-export, whose purpose is answering "why do tools disagree?": hand
|
|
7
|
+
* the exported assumptions to another tool (or a human) to see exactly where
|
|
8
|
+
* the inputs differ.
|
|
9
|
+
*
|
|
10
|
+
* Pure module (no React) so the snapshot and both export renderings are unit
|
|
11
|
+
* testable, including the JSON round-trip guarantee. Scope: the export carries
|
|
12
|
+
* the *assumptions* (what this card shows) — incomes, expenses, goals, and the
|
|
13
|
+
* rest of the plan's inputs travel in the full plan backup, not here.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
accountAllocation,
|
|
18
|
+
expectedAccountReturnPct,
|
|
19
|
+
resolveAssetClassParams,
|
|
20
|
+
DEFAULT_ASSET_CLASS_PARAMS,
|
|
21
|
+
} from '@retiregolden/engine/allocation/assetClasses'
|
|
22
|
+
import { createEmptyPlan, ASSET_CLASS_IDS, type Account, type Plan } from '@retiregolden/engine/model/plan'
|
|
23
|
+
import {
|
|
24
|
+
LATEST_PACK_YEAR,
|
|
25
|
+
PARAMETER_DATA_AS_OF,
|
|
26
|
+
PARAMETER_PROVENANCE,
|
|
27
|
+
} from '@retiregolden/engine/params'
|
|
28
|
+
import { fmtMoney } from './format'
|
|
29
|
+
|
|
30
|
+
/** Where a shown value comes from. */
|
|
31
|
+
export type AssumptionProvenance = 'user-set' | 'app-default' | 'published-source'
|
|
32
|
+
|
|
33
|
+
export interface AssumptionRow {
|
|
34
|
+
/** Stable id, unique within its group. */
|
|
35
|
+
id: string
|
|
36
|
+
label: string
|
|
37
|
+
/** Human-readable value as shown on the card. */
|
|
38
|
+
value: string
|
|
39
|
+
provenance: AssumptionProvenance
|
|
40
|
+
/** `PARAMETER_PROVENANCE` entry id when a citable source backs the row. */
|
|
41
|
+
sourceId?: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface AssumptionGroup {
|
|
45
|
+
id: string
|
|
46
|
+
label: string
|
|
47
|
+
rows: AssumptionRow[]
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface AssumptionsSnapshot {
|
|
51
|
+
planName: string
|
|
52
|
+
/** Tax parameter-pack vintage the engine applies. */
|
|
53
|
+
packYear: number
|
|
54
|
+
dataAsOf: string
|
|
55
|
+
groups: AssumptionGroup[]
|
|
56
|
+
/**
|
|
57
|
+
* Machine-readable section: the raw assumptions object plus the per-account
|
|
58
|
+
* return model. Round-trips: `JSON.parse` of the JSON export returns these
|
|
59
|
+
* values exactly as the plan holds them. This is the card's contents, not a
|
|
60
|
+
* full plan export — replicating Results/Monte Carlo/optimizer numbers also
|
|
61
|
+
* needs the incomes, expenses, and goals from a plan backup.
|
|
62
|
+
*/
|
|
63
|
+
machine: {
|
|
64
|
+
assumptions: Plan['assumptions']
|
|
65
|
+
household: {
|
|
66
|
+
filingStatus: Plan['household']['filingStatus']
|
|
67
|
+
state: string
|
|
68
|
+
stateMoves: Plan['household']['stateMoves']
|
|
69
|
+
people: { name: string; dob: string; retirementAge: number | null; planningAge: number; longevitySource: string }[]
|
|
70
|
+
}
|
|
71
|
+
accounts: {
|
|
72
|
+
name: string
|
|
73
|
+
type: Account['type']
|
|
74
|
+
balance: number
|
|
75
|
+
annualReturnPct: number | null
|
|
76
|
+
allocation: unknown
|
|
77
|
+
}[]
|
|
78
|
+
strategies: Plan['strategies']
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const PROVENANCE_LABEL: Record<AssumptionProvenance, string> = {
|
|
83
|
+
'user-set': 'you set this',
|
|
84
|
+
'app-default': 'app default',
|
|
85
|
+
'published-source': 'published source',
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Canonical app defaults, taken from the same factory that seeds new plans. */
|
|
89
|
+
function defaultAssumptions(): Plan['assumptions'] {
|
|
90
|
+
return createEmptyPlan({ newId: () => 'defaults' }).assumptions
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const pct = (v: number) => `${v}%`
|
|
94
|
+
|
|
95
|
+
function userOrDefault(current: unknown, fallback: unknown): AssumptionProvenance {
|
|
96
|
+
return JSON.stringify(current) === JSON.stringify(fallback) ? 'app-default' : 'user-set'
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function economyGroup(plan: Plan, defaults: Plan['assumptions']): AssumptionGroup {
|
|
100
|
+
const a = plan.assumptions
|
|
101
|
+
return {
|
|
102
|
+
id: 'economy',
|
|
103
|
+
label: 'Economy & rates',
|
|
104
|
+
rows: [
|
|
105
|
+
{ id: 'inflation', label: 'General inflation', value: `${pct(a.inflationPct)}/yr`, provenance: userOrDefault(a.inflationPct, defaults.inflationPct) },
|
|
106
|
+
{ id: 'healthcare-inflation', label: 'Healthcare extra inflation', value: `+${pct(a.healthcareExtraInflationPct)}/yr on top of general`, provenance: userOrDefault(a.healthcareExtraInflationPct, defaults.healthcareExtraInflationPct) },
|
|
107
|
+
{ id: 'default-return', label: 'Default nominal return', value: `${pct(a.defaultReturnPct)}/yr (accounts without their own)`, provenance: userOrDefault(a.defaultReturnPct, defaults.defaultReturnPct) },
|
|
108
|
+
{ id: 'swr', label: 'Safe withdrawal rate (FI lens)', value: `${pct(a.safeWithdrawalRatePct ?? 4)}/yr`, provenance: userOrDefault(a.safeWithdrawalRatePct ?? 4, defaults.safeWithdrawalRatePct ?? 4) },
|
|
109
|
+
],
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Return-bearing account types the card enumerates (income streams, property, and debt live elsewhere). */
|
|
114
|
+
const INVESTABLE_TYPE_LABEL = {
|
|
115
|
+
cash: 'Cash',
|
|
116
|
+
taxable: 'Taxable brokerage',
|
|
117
|
+
equityComp: 'Equity comp',
|
|
118
|
+
traditional: 'Traditional',
|
|
119
|
+
roth: 'Roth',
|
|
120
|
+
hsa: 'HSA',
|
|
121
|
+
} as const
|
|
122
|
+
|
|
123
|
+
type InvestableAccount = Extract<Account, { type: keyof typeof INVESTABLE_TYPE_LABEL }>
|
|
124
|
+
|
|
125
|
+
function investableAccounts(plan: Plan): InvestableAccount[] {
|
|
126
|
+
return plan.accounts.filter((acct): acct is InvestableAccount => acct.type in INVESTABLE_TYPE_LABEL)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function allocationSummary(account: Account): string | null {
|
|
130
|
+
const policy = accountAllocation(account)
|
|
131
|
+
if (!policy) return null
|
|
132
|
+
switch (policy.mode) {
|
|
133
|
+
case 'static': {
|
|
134
|
+
const parts = ASSET_CLASS_IDS.filter((id) => policy.weights[id] > 0).map(
|
|
135
|
+
(id) => `${policy.weights[id]}% ${DEFAULT_ASSET_CLASS_PARAMS[id].label.toLowerCase()}`,
|
|
136
|
+
)
|
|
137
|
+
return `static ${parts.join(' / ')}${policy.rebalancing === 'annual' ? ', rebalanced annually' : ''}`
|
|
138
|
+
}
|
|
139
|
+
case 'linear':
|
|
140
|
+
return `linear glidepath ${policy.startYear}–${policy.endYear}`
|
|
141
|
+
case 'staged':
|
|
142
|
+
return `staged glidepath (${policy.stages.length} stages)`
|
|
143
|
+
case 'custom':
|
|
144
|
+
return `custom glidepath (${policy.targets.length} targets)`
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function accountsGroup(plan: Plan, startYear: number): AssumptionGroup {
|
|
149
|
+
const rows: AssumptionRow[] = investableAccounts(plan).map((acct, i) => {
|
|
150
|
+
const allocation = allocationSummary(acct)
|
|
151
|
+
const own = acct.annualReturnPct
|
|
152
|
+
const returnPct = expectedAccountReturnPct(acct, plan.assumptions, startYear)
|
|
153
|
+
const value = allocation
|
|
154
|
+
? `${pct(Math.round(returnPct * 100) / 100)}/yr blended — ${allocation}`
|
|
155
|
+
: `${pct(returnPct)}/yr${own === null ? ' (plan default)' : ''}`
|
|
156
|
+
return {
|
|
157
|
+
id: `account-${i}`,
|
|
158
|
+
label: `${acct.name} (${INVESTABLE_TYPE_LABEL[acct.type]}, ${fmtMoney(acct.balance)})`,
|
|
159
|
+
value,
|
|
160
|
+
provenance: (allocation || own !== null ? 'user-set' : 'app-default') as AssumptionProvenance,
|
|
161
|
+
}
|
|
162
|
+
})
|
|
163
|
+
return { id: 'accounts', label: 'Account returns', rows }
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function assetClassGroup(plan: Plan): AssumptionGroup | null {
|
|
167
|
+
const usesAllocation = plan.accounts.some((acct) => accountAllocation(acct) !== undefined)
|
|
168
|
+
if (!usesAllocation && plan.assumptions.assetClassParams === undefined) return null
|
|
169
|
+
const resolved = resolveAssetClassParams(plan.assumptions.assetClassParams)
|
|
170
|
+
return {
|
|
171
|
+
id: 'asset-classes',
|
|
172
|
+
label: 'Asset-class parameters',
|
|
173
|
+
rows: ASSET_CLASS_IDS.map((id) => {
|
|
174
|
+
const p = resolved[id]
|
|
175
|
+
const overridden = plan.assumptions.assetClassParams?.[id] !== undefined
|
|
176
|
+
return {
|
|
177
|
+
id,
|
|
178
|
+
label: p.label,
|
|
179
|
+
value: `${pct(p.returnPct)}/yr return · ${pct(p.volatilityPct)} volatility · yields ${pct(p.interestYieldPct)} interest / ${pct(p.dividendYieldPct)} dividends (${p.qualifiedRatioPct}% qualified)`,
|
|
180
|
+
provenance: (overridden ? 'user-set' : 'published-source') as AssumptionProvenance,
|
|
181
|
+
}
|
|
182
|
+
}),
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function longevityGroup(plan: Plan): AssumptionGroup {
|
|
187
|
+
return {
|
|
188
|
+
id: 'longevity',
|
|
189
|
+
label: 'Household & longevity',
|
|
190
|
+
rows: [
|
|
191
|
+
{
|
|
192
|
+
id: 'filing',
|
|
193
|
+
label: 'Filing status & state',
|
|
194
|
+
value: `${plan.household.filingStatus === 'marriedFilingJointly' ? 'Married filing jointly' : 'Single'} · ${plan.household.state}${plan.household.stateMoves.length > 0 ? ` (moves: ${plan.household.stateMoves.map((m) => `${m.state} in ${m.fromYear}`).join(', ')})` : ''}`,
|
|
195
|
+
provenance: 'user-set',
|
|
196
|
+
},
|
|
197
|
+
...plan.household.people.map((p, i): AssumptionRow => ({
|
|
198
|
+
id: `person-${i}`,
|
|
199
|
+
label: `${p.name} — retirement & planning age`,
|
|
200
|
+
value: `${p.retirementAge !== null ? `retires at ${p.retirementAge}, ` : ''}plan runs to age ${p.longevity.planningAge}${p.longevity.source === 'percentile' && p.longevity.percentile ? ` (${p.longevity.percentile.pct}% survival percentile${p.longevity.percentile.joint ? ', joint' : ''})` : ''}`,
|
|
201
|
+
// 'model' and 'percentile' both derive from the SSA period table.
|
|
202
|
+
provenance: p.longevity.source === 'manual' ? 'user-set' : 'published-source',
|
|
203
|
+
sourceId: undefined,
|
|
204
|
+
})),
|
|
205
|
+
],
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function lawTogglesGroup(plan: Plan, defaults: Plan['assumptions']): AssumptionGroup {
|
|
210
|
+
const a = plan.assumptions
|
|
211
|
+
const heirByClass = a.heirTaxByClass
|
|
212
|
+
return {
|
|
213
|
+
id: 'law-toggles',
|
|
214
|
+
label: 'Law & policy toggles',
|
|
215
|
+
rows: [
|
|
216
|
+
{
|
|
217
|
+
id: 'ss-cola',
|
|
218
|
+
label: 'Social Security COLA',
|
|
219
|
+
value: a.ssCola.mode === 'matchInflation' ? 'matches general inflation' : `fixed ${pct(a.ssCola.annualPct)}/yr`,
|
|
220
|
+
provenance: userOrDefault(a.ssCola, defaults.ssCola),
|
|
221
|
+
sourceId: 'social-security',
|
|
222
|
+
},
|
|
223
|
+
{
|
|
224
|
+
id: 'ss-haircut',
|
|
225
|
+
label: 'Social Security trust-fund cut',
|
|
226
|
+
value: a.ssHaircut ? `${pct(a.ssHaircut.cutPct)} cut from ${a.ssHaircut.fromYear}` : 'not modeled (scheduled benefits)',
|
|
227
|
+
provenance: userOrDefault(a.ssHaircut, defaults.ssHaircut),
|
|
228
|
+
sourceId: 'social-security',
|
|
229
|
+
},
|
|
230
|
+
{
|
|
231
|
+
id: 'state-tax',
|
|
232
|
+
label: 'State income tax',
|
|
233
|
+
value: a.stateEffectiveTaxPct > 0 ? `flat ${pct(a.stateEffectiveTaxPct)} override (modeled brackets bypassed)` : `modeled ${plan.household.state} brackets`,
|
|
234
|
+
provenance: a.stateEffectiveTaxPct > 0 ? 'user-set' : 'published-source',
|
|
235
|
+
sourceId: 'state-income-tax',
|
|
236
|
+
},
|
|
237
|
+
{
|
|
238
|
+
id: 'local-tax',
|
|
239
|
+
label: 'Local income tax',
|
|
240
|
+
value: a.localIncomeTaxPct > 0 ? `flat ${pct(a.localIncomeTaxPct)}` : 'none',
|
|
241
|
+
provenance: userOrDefault(a.localIncomeTaxPct, defaults.localIncomeTaxPct),
|
|
242
|
+
},
|
|
243
|
+
{
|
|
244
|
+
id: 'recent-magi',
|
|
245
|
+
label: 'Recent annual MAGI (IRMAA lookback seed)',
|
|
246
|
+
value: a.recentAnnualMagi > 0 ? fmtMoney(a.recentAnnualMagi) : 'not set',
|
|
247
|
+
provenance: userOrDefault(a.recentAnnualMagi, defaults.recentAnnualMagi),
|
|
248
|
+
sourceId: 'medicare-irmaa',
|
|
249
|
+
},
|
|
250
|
+
{
|
|
251
|
+
id: 'heir-tax',
|
|
252
|
+
label: 'Heir tax on inherited pre-tax balances',
|
|
253
|
+
value: heirByClass
|
|
254
|
+
? `traditional ${pct(heirByClass.traditional ?? a.heirTaxRatePct)} / HSA ${pct(heirByClass.hsa ?? a.heirTaxRatePct)}`
|
|
255
|
+
: pct(a.heirTaxRatePct),
|
|
256
|
+
provenance: heirByClass ? 'user-set' : userOrDefault(a.heirTaxRatePct, defaults.heirTaxRatePct),
|
|
257
|
+
},
|
|
258
|
+
],
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function strategyGroup(plan: Plan): AssumptionGroup {
|
|
263
|
+
const rc = plan.strategies.rothConversion
|
|
264
|
+
const conversionValue =
|
|
265
|
+
rc.mode === 'none'
|
|
266
|
+
? 'none'
|
|
267
|
+
: rc.mode === 'manual' || rc.mode === 'optimized'
|
|
268
|
+
? `${rc.mode} schedule (${rc.conversions.length} year${rc.conversions.length === 1 ? '' : 's'}, ${fmtMoney(rc.conversions.reduce((s, c) => s + c.amount, 0))} total)`
|
|
269
|
+
: `fill to ${rc.target} through ${rc.endYear}`
|
|
270
|
+
return {
|
|
271
|
+
id: 'strategy',
|
|
272
|
+
label: 'Strategy settings',
|
|
273
|
+
rows: [
|
|
274
|
+
{
|
|
275
|
+
id: 'withdrawal-order',
|
|
276
|
+
label: 'Withdrawal order',
|
|
277
|
+
value: plan.strategies.withdrawalOrder.mode,
|
|
278
|
+
provenance: plan.strategies.withdrawalOrder.mode === 'sequential' ? 'app-default' : 'user-set',
|
|
279
|
+
},
|
|
280
|
+
{ id: 'roth-conversion', label: 'Roth conversions', value: conversionValue, provenance: rc.mode === 'none' ? 'app-default' : 'user-set' },
|
|
281
|
+
{ id: 'qcd', label: 'Qualified charitable distributions', value: plan.strategies.qcdAnnual > 0 ? `${fmtMoney(plan.strategies.qcdAnnual)}/yr` : 'none', provenance: plan.strategies.qcdAnnual > 0 ? 'user-set' : 'app-default' },
|
|
282
|
+
],
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function taxParametersGroup(): AssumptionGroup {
|
|
287
|
+
return {
|
|
288
|
+
id: 'tax-parameters',
|
|
289
|
+
label: `Tax & benefit parameters (${LATEST_PACK_YEAR} pack)`,
|
|
290
|
+
rows: PARAMETER_PROVENANCE.map((s) => ({
|
|
291
|
+
id: s.id,
|
|
292
|
+
label: s.label,
|
|
293
|
+
value: s.figures,
|
|
294
|
+
provenance: 'published-source' as AssumptionProvenance,
|
|
295
|
+
sourceId: s.id,
|
|
296
|
+
})),
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export function buildAssumptionsSnapshot(plan: Plan, startYear: number): AssumptionsSnapshot {
|
|
301
|
+
const defaults = defaultAssumptions()
|
|
302
|
+
const groups = [
|
|
303
|
+
economyGroup(plan, defaults),
|
|
304
|
+
accountsGroup(plan, startYear),
|
|
305
|
+
assetClassGroup(plan),
|
|
306
|
+
longevityGroup(plan),
|
|
307
|
+
lawTogglesGroup(plan, defaults),
|
|
308
|
+
strategyGroup(plan),
|
|
309
|
+
taxParametersGroup(),
|
|
310
|
+
].filter((g): g is AssumptionGroup => g !== null && g.rows.length > 0)
|
|
311
|
+
|
|
312
|
+
return {
|
|
313
|
+
planName: plan.name,
|
|
314
|
+
packYear: LATEST_PACK_YEAR,
|
|
315
|
+
dataAsOf: PARAMETER_DATA_AS_OF,
|
|
316
|
+
groups,
|
|
317
|
+
machine: {
|
|
318
|
+
assumptions: plan.assumptions,
|
|
319
|
+
household: {
|
|
320
|
+
filingStatus: plan.household.filingStatus,
|
|
321
|
+
state: plan.household.state,
|
|
322
|
+
stateMoves: plan.household.stateMoves,
|
|
323
|
+
people: plan.household.people.map((p) => ({
|
|
324
|
+
name: p.name,
|
|
325
|
+
dob: p.dob,
|
|
326
|
+
retirementAge: p.retirementAge,
|
|
327
|
+
planningAge: p.longevity.planningAge,
|
|
328
|
+
longevitySource: p.longevity.source,
|
|
329
|
+
})),
|
|
330
|
+
},
|
|
331
|
+
accounts: investableAccounts(plan).map((acct) => ({
|
|
332
|
+
name: acct.name,
|
|
333
|
+
type: acct.type,
|
|
334
|
+
balance: acct.balance,
|
|
335
|
+
annualReturnPct: acct.annualReturnPct,
|
|
336
|
+
allocation: accountAllocation(acct) ?? null,
|
|
337
|
+
})),
|
|
338
|
+
strategies: plan.strategies,
|
|
339
|
+
},
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** Human-readable export: every card row, grouped, with provenance tags and sources. */
|
|
344
|
+
export function assumptionsExportText(snapshot: AssumptionsSnapshot): string {
|
|
345
|
+
const sourceById = new Map(PARAMETER_PROVENANCE.map((s) => [s.id, s]))
|
|
346
|
+
const lines: string[] = [
|
|
347
|
+
`RetireGolden assumptions — ${snapshot.planName}`,
|
|
348
|
+
`Tax parameters: ${snapshot.packYear} pack, compiled ${snapshot.dataAsOf}.`,
|
|
349
|
+
'',
|
|
350
|
+
]
|
|
351
|
+
for (const group of snapshot.groups) {
|
|
352
|
+
lines.push(`## ${group.label}`)
|
|
353
|
+
for (const row of group.rows) {
|
|
354
|
+
const source = row.sourceId ? sourceById.get(row.sourceId) : undefined
|
|
355
|
+
const sourceNote = source ? ` [${source.publisher}: ${source.url}]` : ''
|
|
356
|
+
lines.push(`- ${row.label}: ${row.value} (${PROVENANCE_LABEL[row.provenance]})${sourceNote}`)
|
|
357
|
+
}
|
|
358
|
+
lines.push('')
|
|
359
|
+
}
|
|
360
|
+
lines.push(
|
|
361
|
+
'Replication notes: deterministic results apply these returns and inflation every year;',
|
|
362
|
+
'Monte Carlo varies markets around them (model, path count, and seed are chosen on the',
|
|
363
|
+
'Monte Carlo page). Success = investable assets never deplete before the end of the plan.',
|
|
364
|
+
)
|
|
365
|
+
return lines.join('\n')
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** Machine-readable export; parses back to the exact values the plan holds. */
|
|
369
|
+
export function assumptionsExportJson(snapshot: AssumptionsSnapshot): string {
|
|
370
|
+
return JSON.stringify(snapshot, null, 2)
|
|
371
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bucket reporting lens (spending-paths & SWR-lenses plan, Goal 5).
|
|
3
|
+
*
|
|
4
|
+
* Buckets are hugely popular and academically shaky: Estrada's bucket studies
|
|
5
|
+
* and Kitces' reviews find no systematic benefit over a total-return portfolio
|
|
6
|
+
* with rebalancing — the comfort is real, the mechanism mostly isn't. The
|
|
7
|
+
* honest version this lens implements: keep investing (and simulating) total
|
|
8
|
+
* return, and *report* the ledger's balances as buckets — "years 1–2 of net
|
|
9
|
+
* spending", "years 3–10", "the rest" — purely as a reading of the same
|
|
10
|
+
* numbers. Nothing here feeds back into the engine; it is presentation only.
|
|
11
|
+
*
|
|
12
|
+
* Mapping: for each projection year, the net portfolio need of a future year
|
|
13
|
+
* is its total spending (including taxes and penalties) minus all income that
|
|
14
|
+
* year, floored at 0 — the dollars that must come from the portfolio. Bucket k
|
|
15
|
+
* claims the (projected, nominal) need of the next `spans[k]` years,
|
|
16
|
+
* cumulatively, capped by what is actually left; the final bucket is the
|
|
17
|
+
* remainder. Buckets therefore reconcile to the ledger's investable total by
|
|
18
|
+
* construction, every year — the acceptance criterion.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type { ProjectionResult, YearResult } from '@retiregolden/engine/projection/types'
|
|
22
|
+
|
|
23
|
+
export interface BucketYearRow {
|
|
24
|
+
year: number
|
|
25
|
+
/** This year's net portfolio need (spending + tax + penalties − income, ≥ 0). */
|
|
26
|
+
need: number
|
|
27
|
+
/** One balance per bucket; sums exactly to `investableTotal`. */
|
|
28
|
+
buckets: number[]
|
|
29
|
+
investableTotal: number
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Net dollars year `y` must draw from the portfolio (nominal, floored at 0). */
|
|
33
|
+
export function netPortfolioNeed(y: YearResult): number {
|
|
34
|
+
return Math.max(0, y.expenses.total + y.tax + y.penalties - y.incomes.total)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Partition every year's investable total into `spans.length + 1` buckets:
|
|
39
|
+
* bucket k holds the projected need of the next `spans[k]` years (starting
|
|
40
|
+
* with the current year, cumulatively after earlier buckets), the last bucket
|
|
41
|
+
* holds the remainder. Needs beyond the projection horizon are unknown and
|
|
42
|
+
* count as 0 — near the end of the plan the leading buckets naturally drain.
|
|
43
|
+
*/
|
|
44
|
+
export function bucketLens(result: ProjectionResult, spans: number[]): BucketYearRow[] {
|
|
45
|
+
const years = result.years
|
|
46
|
+
const needs = years.map(netPortfolioNeed)
|
|
47
|
+
return years.map((y, i) => {
|
|
48
|
+
let remaining = y.investableTotal
|
|
49
|
+
const buckets: number[] = []
|
|
50
|
+
let cursor = i
|
|
51
|
+
for (const span of spans) {
|
|
52
|
+
let bucketNeed = 0
|
|
53
|
+
for (let k = 0; k < span; k++) {
|
|
54
|
+
if (cursor + k >= needs.length) break
|
|
55
|
+
bucketNeed += needs[cursor + k]!
|
|
56
|
+
}
|
|
57
|
+
cursor += span
|
|
58
|
+
const claimed = Math.min(remaining, bucketNeed)
|
|
59
|
+
buckets.push(claimed)
|
|
60
|
+
remaining -= claimed
|
|
61
|
+
}
|
|
62
|
+
buckets.push(remaining)
|
|
63
|
+
return { year: y.year, need: needs[i]!, buckets, investableTotal: y.investableTotal }
|
|
64
|
+
})
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface BucketPreset {
|
|
68
|
+
id: 'three' | 'two'
|
|
69
|
+
label: string
|
|
70
|
+
/** Year spans of the leading buckets; the growth bucket is the remainder. */
|
|
71
|
+
spans: number[]
|
|
72
|
+
bucketLabels: string[]
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The two classic constructions the community actually uses. */
|
|
76
|
+
export const BUCKET_PRESETS: readonly BucketPreset[] = [
|
|
77
|
+
{
|
|
78
|
+
id: 'three',
|
|
79
|
+
label: '3 buckets (2 yrs / 8 yrs / growth)',
|
|
80
|
+
spans: [2, 8],
|
|
81
|
+
bucketLabels: ['Bucket 1 — next 2 years of net spending', 'Bucket 2 — years 3–10', 'Bucket 3 — growth (the rest)'],
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
id: 'two',
|
|
85
|
+
label: '2 buckets (3 yrs / growth)',
|
|
86
|
+
spans: [3],
|
|
87
|
+
bucketLabels: ['Bucket 1 — next 3 years of net spending', 'Bucket 2 — growth (the rest)'],
|
|
88
|
+
},
|
|
89
|
+
]
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { CSSProperties } from 'react'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Height for a shared `.chart-frame` wrapper, passed via the custom property the
|
|
5
|
+
* class reads (UI/UX round 2, Step 5). Keeps the reusable width:100% frame in
|
|
6
|
+
* CSS while the per-chart height stays a value token, not an inline style block.
|
|
7
|
+
*/
|
|
8
|
+
export const frameH = (px: number): CSSProperties => ({ ['--chart-frame-h']: `${px}px` }) as CSSProperties
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** Shared recharts tooltip surface, matching the app's card styling. */
|
|
2
|
+
|
|
3
|
+
import type { CSSProperties } from 'react'
|
|
4
|
+
|
|
5
|
+
export const chartTooltipStyle: CSSProperties = {
|
|
6
|
+
backgroundColor: 'var(--surface-1)',
|
|
7
|
+
border: '1px solid var(--border)',
|
|
8
|
+
borderRadius: 8,
|
|
9
|
+
color: 'var(--fg)',
|
|
10
|
+
boxShadow: 'var(--shadow-card)',
|
|
11
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The dialog bodies used by useDialogs (./dialogs.tsx): confirm (with an
|
|
3
|
+
* optional typed-confirmation gate for unrecoverable actions), prompt, alert,
|
|
4
|
+
* and a multi-option choice — all rendered on the shared focus-managed Modal.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { useId, useRef, useState, type ReactNode } from 'react'
|
|
8
|
+
|
|
9
|
+
import { Modal } from './Modal'
|
|
10
|
+
|
|
11
|
+
export interface ConfirmOptions {
|
|
12
|
+
title: string
|
|
13
|
+
body: ReactNode
|
|
14
|
+
confirmLabel?: string
|
|
15
|
+
cancelLabel?: string
|
|
16
|
+
/** Style the confirm button as destructive. */
|
|
17
|
+
danger?: boolean
|
|
18
|
+
/**
|
|
19
|
+
* Require typing this word (case-insensitive) before the confirm button
|
|
20
|
+
* enables — for unrecoverable actions like clearing all local data.
|
|
21
|
+
*/
|
|
22
|
+
typedConfirmation?: string
|
|
23
|
+
/** Optional side action shown with the buttons (e.g. "Download backup"). Does not close the dialog. */
|
|
24
|
+
extraAction?: { label: string; onClick: () => void }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface PromptOptions {
|
|
28
|
+
title: string
|
|
29
|
+
label: string
|
|
30
|
+
defaultValue?: string
|
|
31
|
+
confirmLabel?: string
|
|
32
|
+
cancelLabel?: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface AlertOptions {
|
|
36
|
+
title: string
|
|
37
|
+
body: ReactNode
|
|
38
|
+
closeLabel?: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface ChoiceOption<T extends string> {
|
|
42
|
+
value: T
|
|
43
|
+
label: string
|
|
44
|
+
/** Explanatory line under the button label. */
|
|
45
|
+
description?: string
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface ChoiceOptions<T extends string> {
|
|
49
|
+
title: string
|
|
50
|
+
body: ReactNode
|
|
51
|
+
choices: ReadonlyArray<ChoiceOption<T>>
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function ConfirmDialog({ opts, onResult }: { opts: ConfirmOptions; onResult: (ok: boolean) => void }) {
|
|
55
|
+
const [typed, setTyped] = useState('')
|
|
56
|
+
const inputId = useId()
|
|
57
|
+
const inputRef = useRef<HTMLInputElement>(null)
|
|
58
|
+
const gate = opts.typedConfirmation
|
|
59
|
+
const ready = !gate || typed.trim().toLowerCase() === gate.toLowerCase()
|
|
60
|
+
return (
|
|
61
|
+
<Modal title={opts.title} onClose={() => onResult(false)} initialFocus={gate ? inputRef : undefined}>
|
|
62
|
+
<div className="dialog-body">{opts.body}</div>
|
|
63
|
+
{gate ? (
|
|
64
|
+
<div className="field dialog-typed-field">
|
|
65
|
+
<label className="field-label" htmlFor={inputId}>
|
|
66
|
+
Type <strong>{gate}</strong> to confirm
|
|
67
|
+
</label>
|
|
68
|
+
<input
|
|
69
|
+
id={inputId}
|
|
70
|
+
ref={inputRef}
|
|
71
|
+
type="text"
|
|
72
|
+
value={typed}
|
|
73
|
+
autoComplete="off"
|
|
74
|
+
spellCheck={false}
|
|
75
|
+
onChange={(e) => setTyped(e.target.value)}
|
|
76
|
+
/>
|
|
77
|
+
</div>
|
|
78
|
+
) : null}
|
|
79
|
+
<div className="dialog-actions">
|
|
80
|
+
{opts.extraAction ? (
|
|
81
|
+
<button type="button" className="btn btn-secondary dialog-extra-action" onClick={opts.extraAction.onClick}>
|
|
82
|
+
{opts.extraAction.label}
|
|
83
|
+
</button>
|
|
84
|
+
) : null}
|
|
85
|
+
<button type="button" className="btn btn-secondary" onClick={() => onResult(false)}>
|
|
86
|
+
{opts.cancelLabel ?? 'Cancel'}
|
|
87
|
+
</button>
|
|
88
|
+
<button
|
|
89
|
+
type="button"
|
|
90
|
+
className={opts.danger ? 'btn btn-secondary btn-danger' : 'btn btn-primary'}
|
|
91
|
+
disabled={!ready}
|
|
92
|
+
onClick={() => onResult(true)}
|
|
93
|
+
>
|
|
94
|
+
{opts.confirmLabel ?? 'OK'}
|
|
95
|
+
</button>
|
|
96
|
+
</div>
|
|
97
|
+
</Modal>
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function PromptDialog({ opts, onResult }: { opts: PromptOptions; onResult: (value: string | null) => void }) {
|
|
102
|
+
const [value, setValue] = useState(opts.defaultValue ?? '')
|
|
103
|
+
const inputId = useId()
|
|
104
|
+
const inputRef = useRef<HTMLInputElement>(null)
|
|
105
|
+
return (
|
|
106
|
+
<Modal title={opts.title} onClose={() => onResult(null)} initialFocus={inputRef}>
|
|
107
|
+
<form
|
|
108
|
+
onSubmit={(e) => {
|
|
109
|
+
e.preventDefault()
|
|
110
|
+
onResult(value)
|
|
111
|
+
}}
|
|
112
|
+
>
|
|
113
|
+
<div className="field">
|
|
114
|
+
<label className="field-label" htmlFor={inputId}>
|
|
115
|
+
{opts.label}
|
|
116
|
+
</label>
|
|
117
|
+
<input
|
|
118
|
+
id={inputId}
|
|
119
|
+
ref={inputRef}
|
|
120
|
+
type="text"
|
|
121
|
+
value={value}
|
|
122
|
+
autoComplete="off"
|
|
123
|
+
onChange={(e) => setValue(e.target.value)}
|
|
124
|
+
onFocus={(e) => e.target.select()}
|
|
125
|
+
/>
|
|
126
|
+
</div>
|
|
127
|
+
<div className="dialog-actions">
|
|
128
|
+
<button type="button" className="btn btn-secondary" onClick={() => onResult(null)}>
|
|
129
|
+
{opts.cancelLabel ?? 'Cancel'}
|
|
130
|
+
</button>
|
|
131
|
+
<button type="submit" className="btn btn-primary">
|
|
132
|
+
{opts.confirmLabel ?? 'OK'}
|
|
133
|
+
</button>
|
|
134
|
+
</div>
|
|
135
|
+
</form>
|
|
136
|
+
</Modal>
|
|
137
|
+
)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function AlertDialog({ opts, onResult }: { opts: AlertOptions; onResult: () => void }) {
|
|
141
|
+
return (
|
|
142
|
+
<Modal title={opts.title} onClose={onResult}>
|
|
143
|
+
<div className="dialog-body">{opts.body}</div>
|
|
144
|
+
<div className="dialog-actions">
|
|
145
|
+
<button type="button" className="btn btn-primary" onClick={onResult}>
|
|
146
|
+
{opts.closeLabel ?? 'Close'}
|
|
147
|
+
</button>
|
|
148
|
+
</div>
|
|
149
|
+
</Modal>
|
|
150
|
+
)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function ChoiceDialog({
|
|
154
|
+
opts,
|
|
155
|
+
onResult,
|
|
156
|
+
}: {
|
|
157
|
+
opts: ChoiceOptions<string>
|
|
158
|
+
onResult: (value: string | null) => void
|
|
159
|
+
}) {
|
|
160
|
+
return (
|
|
161
|
+
<Modal title={opts.title} onClose={() => onResult(null)}>
|
|
162
|
+
<div className="dialog-body">{opts.body}</div>
|
|
163
|
+
<div className="dialog-choices">
|
|
164
|
+
{opts.choices.map((choice) => (
|
|
165
|
+
<button
|
|
166
|
+
key={choice.value}
|
|
167
|
+
type="button"
|
|
168
|
+
className="dialog-choice"
|
|
169
|
+
aria-label={choice.description ? `${choice.label} — ${choice.description}` : choice.label}
|
|
170
|
+
onClick={() => onResult(choice.value)}
|
|
171
|
+
>
|
|
172
|
+
<span className="dialog-choice-label">{choice.label}</span>
|
|
173
|
+
{choice.description ? <span className="dialog-choice-desc">{choice.description}</span> : null}
|
|
174
|
+
</button>
|
|
175
|
+
))}
|
|
176
|
+
</div>
|
|
177
|
+
<div className="dialog-actions">
|
|
178
|
+
<button type="button" className="btn btn-secondary" onClick={() => onResult(null)}>
|
|
179
|
+
Cancel
|
|
180
|
+
</button>
|
|
181
|
+
</div>
|
|
182
|
+
</Modal>
|
|
183
|
+
)
|
|
184
|
+
}
|