@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,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Why this number" panels (trust-and-transparency-layer, steps 2–3):
|
|
3
|
+
* expandable explainers that trace the two probabilistic headline outputs —
|
|
4
|
+
* the Monte Carlo success % and the optimizer recommendation — to their
|
|
5
|
+
* method, inputs, and the evidence already computed for them. Pure rendering
|
|
6
|
+
* over existing payloads; no engine work happens here.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { Link } from 'react-router-dom'
|
|
10
|
+
|
|
11
|
+
import type { MonteCarloSummary } from '@retiregolden/engine/montecarlo/run'
|
|
12
|
+
import type { ExactLedgerTournament } from '@retiregolden/engine/projection/optimizePlan'
|
|
13
|
+
import { fmtMoney, fmtMoneyCompact } from './format'
|
|
14
|
+
|
|
15
|
+
function fmtSignedMoney(v: number): string {
|
|
16
|
+
return `${v >= 0 ? '+' : '−'}${fmtMoney(Math.abs(v))}`
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Decomposes the success gauge: what counts as success, how the markets were
|
|
21
|
+
* generated (model, paths, seed, precision), which ledger years the failing
|
|
22
|
+
* paths actually deplete in, and how wide the first decade's outcomes spread
|
|
23
|
+
* (sequence-of-returns sensitivity). Everything renders from the existing
|
|
24
|
+
* `MonteCarloSummary`.
|
|
25
|
+
*/
|
|
26
|
+
export function WhySuccessPanel({
|
|
27
|
+
summary,
|
|
28
|
+
modelLabel,
|
|
29
|
+
seed,
|
|
30
|
+
planId,
|
|
31
|
+
}: {
|
|
32
|
+
summary: MonteCarloSummary
|
|
33
|
+
modelLabel: string
|
|
34
|
+
seed: number
|
|
35
|
+
planId: string
|
|
36
|
+
}) {
|
|
37
|
+
const pct = Math.round(summary.successRate * 100)
|
|
38
|
+
const failing = summary.downsideRisk.failingPathCount
|
|
39
|
+
const depletions = summary.depletionYearCounts
|
|
40
|
+
const earliestDepletion = depletions.length > 0 ? depletions[0]!.year : null
|
|
41
|
+
const medianDepletion = (() => {
|
|
42
|
+
if (failing === 0) return null
|
|
43
|
+
let seen = 0
|
|
44
|
+
for (const row of depletions) {
|
|
45
|
+
seen += row.count
|
|
46
|
+
if (seen >= failing / 2) return row.year
|
|
47
|
+
}
|
|
48
|
+
return depletions[depletions.length - 1]?.year ?? null
|
|
49
|
+
})()
|
|
50
|
+
// "First decade" = the 10th projected year (or the last year on short plans).
|
|
51
|
+
const decade = summary.fan[Math.min(9, summary.fan.length - 1)]
|
|
52
|
+
const precision = summary.pathCount >= 10_000 ? '±0.5' : '±1.5'
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<details className="ss-explainer">
|
|
56
|
+
<summary>Why this number?</summary>
|
|
57
|
+
<p>
|
|
58
|
+
<strong>What it counts.</strong> {pct}% means investable assets lasted to the end of the plan in{' '}
|
|
59
|
+
{(summary.pathCount - failing).toLocaleString()} of the {summary.pathCount.toLocaleString()} simulated markets
|
|
60
|
+
({failing.toLocaleString()} depleted early). It is a statistic about this model of your plan — not a
|
|
61
|
+
probability about your actual life.
|
|
62
|
+
</p>
|
|
63
|
+
<p>
|
|
64
|
+
<strong>How the markets were made.</strong> Each path re-runs your full year-by-year projection — taxes, RMDs,
|
|
65
|
+
IRMAA, withdrawals, everything — under markets generated by the <strong>{modelLabel}</strong> model
|
|
66
|
+
(seed {seed}, reproducible). Your expected returns and inflation from{' '}
|
|
67
|
+
<Link to={`/plan/${planId}/assumptions-card`}>your assumptions</Link> are the center of the distribution; the
|
|
68
|
+
model only shapes the variation around them. At {summary.pathCount.toLocaleString()} paths the success rate is
|
|
69
|
+
good to about {precision} points, so treat small differences between runs as noise.
|
|
70
|
+
</p>
|
|
71
|
+
{failing > 0 ? (
|
|
72
|
+
<p>
|
|
73
|
+
<strong>Which years drive it.</strong> Failing paths first run out between {earliestDepletion} and{' '}
|
|
74
|
+
{depletions[depletions.length - 1]!.year}
|
|
75
|
+
{medianDepletion !== null ? ` (median ${medianDepletion})` : ''} — see the depletion chart below for the full
|
|
76
|
+
curve. Early depletions usually trace to weak markets in the first retirement decade: by {decade?.year}, the
|
|
77
|
+
worst 10% of paths hold {fmtMoneyCompact(decade?.p10 ?? 0)} versus a median of{' '}
|
|
78
|
+
{fmtMoneyCompact(decade?.p50 ?? 0)} — the same spending drawn from that smaller base is what sinks those
|
|
79
|
+
paths (sequence-of-returns risk).
|
|
80
|
+
</p>
|
|
81
|
+
) : (
|
|
82
|
+
<p>
|
|
83
|
+
<strong>Which years drive it.</strong> No simulated path depleted. The spread is still real: by{' '}
|
|
84
|
+
{decade?.year}, the worst 10% of paths hold {fmtMoneyCompact(decade?.p10 ?? 0)} versus a median of{' '}
|
|
85
|
+
{fmtMoneyCompact(decade?.p50 ?? 0)}.
|
|
86
|
+
</p>
|
|
87
|
+
)}
|
|
88
|
+
<p>
|
|
89
|
+
<strong>What would change it.</strong> The number moves with your assumptions, the model choice, spending, and
|
|
90
|
+
retirement timing — that's why identical plans score differently across tools. To see exactly what this run
|
|
91
|
+
assumed, open <Link to={`/plan/${planId}/assumptions-card`}>the assumptions card</Link>; to see how the engine
|
|
92
|
+
itself is checked, see <Link to="/how-tested">how RetireGolden is tested</Link>.
|
|
93
|
+
</p>
|
|
94
|
+
</details>
|
|
95
|
+
)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Surfaces the exact-ledger tournament behind an optimizer recommendation:
|
|
100
|
+
* the objective it was ranked by, every simple candidate it beat (with dollar
|
|
101
|
+
* margins on the full projection), and how the winner was refined. Acceptance
|
|
102
|
+
* (step 3): every recommendation shows its beaten alternatives and margins.
|
|
103
|
+
*/
|
|
104
|
+
export function WhyRecommendationPanel({
|
|
105
|
+
tournament,
|
|
106
|
+
objectiveLabel,
|
|
107
|
+
}: {
|
|
108
|
+
tournament: ExactLedgerTournament
|
|
109
|
+
objectiveLabel: string
|
|
110
|
+
}) {
|
|
111
|
+
const estateObjective = tournament.policyId === 'max-after-tax-estate'
|
|
112
|
+
const noWinner = tournament.winnerSource === 'none'
|
|
113
|
+
// Estate deltas are the ranking metric only under the estate objective; for
|
|
114
|
+
// other objectives keep the tournament's fixed generation order rather than
|
|
115
|
+
// presenting an estate sort as the hierarchy.
|
|
116
|
+
const candidates = estateObjective
|
|
117
|
+
? [...tournament.candidates].sort((a, b) => b.afterTaxEstateDelta - a.afterTaxEstateDelta)
|
|
118
|
+
: tournament.candidates
|
|
119
|
+
const winnerDelta = tournament.winnerValidation?.afterTaxEstateDelta ?? 0
|
|
120
|
+
// Runner-up = best recommendation-eligible challenger. Candidates that
|
|
121
|
+
// shorten how long the money lasts are hard-rejected by every objective, so
|
|
122
|
+
// a higher estate delta on one of those is not a "next-best alternative".
|
|
123
|
+
const runnerUp = tournament.candidates
|
|
124
|
+
.filter((c) => c.id !== tournament.winnerCandidateId && c.moneyLastsYearsDelta >= 0)
|
|
125
|
+
.reduce<(typeof tournament.candidates)[number] | null>(
|
|
126
|
+
(best, c) => (best === null || c.afterTaxEstateDelta > best.afterTaxEstateDelta ? c : best),
|
|
127
|
+
null,
|
|
128
|
+
)
|
|
129
|
+
const marginOverRunnerUp =
|
|
130
|
+
runnerUp === null
|
|
131
|
+
? null
|
|
132
|
+
: tournament.winnerSource === 'incumbent'
|
|
133
|
+
? -runnerUp.afterTaxEstateDelta
|
|
134
|
+
: winnerDelta - runnerUp.afterTaxEstateDelta
|
|
135
|
+
// Only claim "ahead by $X" when it is true on the metric being quoted.
|
|
136
|
+
const showMargin = !noWinner && estateObjective && marginOverRunnerUp !== null && marginOverRunnerUp > 0
|
|
137
|
+
|
|
138
|
+
const winnerPhrase =
|
|
139
|
+
tournament.winnerSource === 'milp'
|
|
140
|
+
? "the solver's schedule"
|
|
141
|
+
: (tournament.winnerLabel ?? 'the recommendation')
|
|
142
|
+
|
|
143
|
+
return (
|
|
144
|
+
<details className="ss-explainer">
|
|
145
|
+
<summary>Why this recommendation?</summary>
|
|
146
|
+
{noWinner ? (
|
|
147
|
+
<p>
|
|
148
|
+
Every candidate below was re-run through your full year-by-year projection and ranked by{' '}
|
|
149
|
+
<strong>{objectiveLabel}</strong>
|
|
150
|
+
{estateObjective ? '' : ' (the estate deltas shown are context, not the ranking metric)'}.{' '}
|
|
151
|
+
<strong>None cleared the recommendation threshold</strong>, so no schedule is recommended — the table shows
|
|
152
|
+
what was considered and why nothing won.
|
|
153
|
+
</p>
|
|
154
|
+
) : (
|
|
155
|
+
<p>
|
|
156
|
+
Every candidate below was re-run through your full year-by-year projection and ranked by{' '}
|
|
157
|
+
<strong>{objectiveLabel}</strong>
|
|
158
|
+
{estateObjective ? '' : ' (the estate deltas shown are context, not the ranking metric)'} — no candidate is
|
|
159
|
+
recommended off the solver's simplified model alone. The winner is <strong>{winnerPhrase}</strong>
|
|
160
|
+
{showMargin ? (
|
|
161
|
+
<>
|
|
162
|
+
, ahead of the next-best eligible alternative by <strong>{fmtMoney(marginOverRunnerUp!)}</strong> of
|
|
163
|
+
projected after-tax estate
|
|
164
|
+
</>
|
|
165
|
+
) : null}
|
|
166
|
+
{tournament.winnerSource === 'candidate' && tournament.marginOverMilpDollars > 0
|
|
167
|
+
? `, and it beat the solver's own schedule by ${fmtMoney(tournament.marginOverMilpDollars)}`
|
|
168
|
+
: ''}
|
|
169
|
+
.{tournament.searchRefined ? ` A bounded local search (${tournament.searchSimulations} extra full-projection runs) then fine-tuned the winning schedule's per-year amounts.` : ''}
|
|
170
|
+
</p>
|
|
171
|
+
)}
|
|
172
|
+
<div className="year-table-wrap" style={{ border: 'none' }}>
|
|
173
|
+
<table className="compare-table">
|
|
174
|
+
<thead>
|
|
175
|
+
<tr>
|
|
176
|
+
<th>Alternative considered</th>
|
|
177
|
+
<th>Conversions executed</th>
|
|
178
|
+
<th>After-tax estate</th>
|
|
179
|
+
<th>Lifetime tax</th>
|
|
180
|
+
<th>Money lasts</th>
|
|
181
|
+
</tr>
|
|
182
|
+
</thead>
|
|
183
|
+
<tbody>
|
|
184
|
+
<tr>
|
|
185
|
+
<td>
|
|
186
|
+
Your current plan (baseline)
|
|
187
|
+
{tournament.winnerSource === 'incumbent' ? <strong> — winner</strong> : ''}
|
|
188
|
+
</td>
|
|
189
|
+
<td>—</td>
|
|
190
|
+
<td>±$0</td>
|
|
191
|
+
<td>±$0</td>
|
|
192
|
+
<td>—</td>
|
|
193
|
+
</tr>
|
|
194
|
+
{tournament.winnerSource === 'milp' && tournament.winnerValidation ? (
|
|
195
|
+
<tr>
|
|
196
|
+
<td>
|
|
197
|
+
Solver's schedule (post-processed)<strong> — winner</strong>
|
|
198
|
+
</td>
|
|
199
|
+
<td>{fmtMoneyCompact(tournament.winnerValidation.executedConversionTotal)}</td>
|
|
200
|
+
<td>{fmtSignedMoney(tournament.winnerValidation.afterTaxEstateDelta)}</td>
|
|
201
|
+
<td>{fmtSignedMoney(tournament.winnerValidation.lifetimeTaxDelta)}</td>
|
|
202
|
+
<td>
|
|
203
|
+
{tournament.winnerValidation.moneyLastsYearsDelta === 0
|
|
204
|
+
? 'unchanged'
|
|
205
|
+
: `${tournament.winnerValidation.moneyLastsYearsDelta > 0 ? '+' : ''}${tournament.winnerValidation.moneyLastsYearsDelta} yr`}
|
|
206
|
+
</td>
|
|
207
|
+
</tr>
|
|
208
|
+
) : null}
|
|
209
|
+
{candidates.map((c) => (
|
|
210
|
+
<tr key={c.id}>
|
|
211
|
+
<td>
|
|
212
|
+
{c.label}
|
|
213
|
+
{c.id === tournament.winnerCandidateId ? <strong> — winner</strong> : ''}
|
|
214
|
+
</td>
|
|
215
|
+
<td>{fmtMoneyCompact(c.executedConversionTotal)}</td>
|
|
216
|
+
<td>{fmtSignedMoney(c.afterTaxEstateDelta)}</td>
|
|
217
|
+
<td>{fmtSignedMoney(c.lifetimeTaxDelta)}</td>
|
|
218
|
+
<td>{c.moneyLastsYearsDelta === 0 ? 'unchanged' : `${c.moneyLastsYearsDelta > 0 ? '+' : ''}${c.moneyLastsYearsDelta} yr`}</td>
|
|
219
|
+
</tr>
|
|
220
|
+
))}
|
|
221
|
+
</tbody>
|
|
222
|
+
</table>
|
|
223
|
+
</div>
|
|
224
|
+
<p className="field-hint" style={{ margin: '0.4rem 0 0' }}>
|
|
225
|
+
Deltas are versus your current plan, measured on the full projection (heir tax on pre-tax balances included).
|
|
226
|
+
Candidates that shorten how long the money lasts are rejected regardless of estate gain. The solver's own
|
|
227
|
+
schedule competes too; it only supplies the recommendation when nothing simpler beats it (and appears above
|
|
228
|
+
when it wins). See <Link to="/how-tested">how RetireGolden is tested</Link> for the harnesses behind this
|
|
229
|
+
pipeline.
|
|
230
|
+
</p>
|
|
231
|
+
</details>
|
|
232
|
+
)
|
|
233
|
+
}
|
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared form fields. Numeric fields hold local text state while focused and
|
|
3
|
+
* commit parsed values on change, so partial input ("1,2") never fights the
|
|
4
|
+
* plan state; money fields accept "$450k"-style shorthand.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { useEffect, useId, useLayoutEffect, useRef, useState, type ReactNode } from 'react'
|
|
8
|
+
|
|
9
|
+
import { LearnLink, type LearnHook } from '../learn/LearnLink'
|
|
10
|
+
import { fmtMoney, parseAmount } from './format'
|
|
11
|
+
|
|
12
|
+
/** External citation shown inside a ⓘ help bubble (cite-the-authority pattern). */
|
|
13
|
+
export interface SourceLink {
|
|
14
|
+
/** Link text, e.g. the publisher ("IRS", "CMS"). */
|
|
15
|
+
label: string
|
|
16
|
+
url: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface BaseProps {
|
|
20
|
+
label: string
|
|
21
|
+
/** Short inline note shown below the field — only for things that change what you type. */
|
|
22
|
+
hint?: string
|
|
23
|
+
/** Longer explanation behind an ⓘ help button (hover/focus/click). Prefer this over hint for background detail. */
|
|
24
|
+
help?: string
|
|
25
|
+
/** Optional "Learn more" link to a Learning Center article, shown inside the ⓘ help bubble. */
|
|
26
|
+
learn?: LearnHook
|
|
27
|
+
/** Optional citation to the authority behind the parameter (Rev. Proc. / statute / agency figure). */
|
|
28
|
+
source?: SourceLink
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The one help affordance on a label: a single ⓘ button that, on hover/focus,
|
|
33
|
+
* reveals a bubble holding the field's explanation, any short input note, and —
|
|
34
|
+
* when the concept has a durable article — a "Learn more" link. All field help
|
|
35
|
+
* lives in this bubble, so labels never carry separate sub-text under the input.
|
|
36
|
+
*
|
|
37
|
+
* It is an accessible disclosure, not a passive tooltip, because the bubble can
|
|
38
|
+
* hold an interactive link: hover or keyboard-focus reveals it, clicking pins it
|
|
39
|
+
* open (for touch and to reach the link), and Escape or an outside click closes
|
|
40
|
+
* a pinned bubble. The bubble is positioned with fixed coordinates clamped to
|
|
41
|
+
* the viewport, so it never clips at a screen edge or inside a scrolling panel.
|
|
42
|
+
*/
|
|
43
|
+
export function HelpTip({ text, hint, learn, source, id }: { text?: string; hint?: string; learn?: LearnHook; source?: SourceLink; id?: string }) {
|
|
44
|
+
const auto = useId()
|
|
45
|
+
const bubbleId = id ?? `${auto}-help`
|
|
46
|
+
const textId = `${bubbleId}-text`
|
|
47
|
+
const [hovered, setHovered] = useState(false)
|
|
48
|
+
const [focused, setFocused] = useState(false)
|
|
49
|
+
const [pinned, setPinned] = useState(false)
|
|
50
|
+
const wrapRef = useRef<HTMLSpanElement>(null)
|
|
51
|
+
const btnRef = useRef<HTMLButtonElement>(null)
|
|
52
|
+
const bubbleRef = useRef<HTMLSpanElement>(null)
|
|
53
|
+
const closeTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
|
|
54
|
+
const open = hovered || focused || pinned
|
|
55
|
+
|
|
56
|
+
const cancelClose = () => {
|
|
57
|
+
if (closeTimer.current) clearTimeout(closeTimer.current)
|
|
58
|
+
closeTimer.current = undefined
|
|
59
|
+
}
|
|
60
|
+
// Brief delay so the pointer can cross the gap from the ⓘ into the bubble.
|
|
61
|
+
const scheduleClose = () => {
|
|
62
|
+
cancelClose()
|
|
63
|
+
closeTimer.current = setTimeout(() => setHovered(false), 120)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Position the open bubble by the ⓘ, clamped to the viewport (flipping below
|
|
67
|
+
// when there's no room above), and keep it tracking on scroll/resize. Written
|
|
68
|
+
// imperatively (not via state) so it lands before paint with no extra render.
|
|
69
|
+
useLayoutEffect(() => {
|
|
70
|
+
if (!open) return
|
|
71
|
+
const bubble = bubbleRef.current
|
|
72
|
+
const button = btnRef.current
|
|
73
|
+
if (!bubble || !button) return
|
|
74
|
+
const place = () => {
|
|
75
|
+
const btn = button.getBoundingClientRect()
|
|
76
|
+
const bub = bubble.getBoundingClientRect()
|
|
77
|
+
const margin = 8
|
|
78
|
+
const left = Math.max(
|
|
79
|
+
margin,
|
|
80
|
+
Math.min(btn.left + btn.width / 2 - bub.width / 2, window.innerWidth - bub.width - margin),
|
|
81
|
+
)
|
|
82
|
+
const above = btn.top - bub.height - margin
|
|
83
|
+
bubble.style.left = `${left}px`
|
|
84
|
+
bubble.style.top = `${above >= margin ? above : btn.bottom + margin}px`
|
|
85
|
+
}
|
|
86
|
+
place()
|
|
87
|
+
window.addEventListener('scroll', place, true)
|
|
88
|
+
window.addEventListener('resize', place)
|
|
89
|
+
return () => {
|
|
90
|
+
window.removeEventListener('scroll', place, true)
|
|
91
|
+
window.removeEventListener('resize', place)
|
|
92
|
+
}
|
|
93
|
+
}, [open, text, hint])
|
|
94
|
+
|
|
95
|
+
// Close a pinned bubble on Escape or a click outside.
|
|
96
|
+
useEffect(() => {
|
|
97
|
+
if (!pinned) return
|
|
98
|
+
const onPointerDown = (e: PointerEvent) => {
|
|
99
|
+
const t = e.target as Node
|
|
100
|
+
if (!wrapRef.current?.contains(t) && !bubbleRef.current?.contains(t)) setPinned(false)
|
|
101
|
+
}
|
|
102
|
+
const onKeyDown = (e: KeyboardEvent) => {
|
|
103
|
+
if (e.key === 'Escape') setPinned(false)
|
|
104
|
+
}
|
|
105
|
+
document.addEventListener('pointerdown', onPointerDown)
|
|
106
|
+
document.addEventListener('keydown', onKeyDown)
|
|
107
|
+
return () => {
|
|
108
|
+
document.removeEventListener('pointerdown', onPointerDown)
|
|
109
|
+
document.removeEventListener('keydown', onKeyDown)
|
|
110
|
+
}
|
|
111
|
+
}, [pinned])
|
|
112
|
+
|
|
113
|
+
return (
|
|
114
|
+
<span
|
|
115
|
+
className={pinned ? 'help-tip help-tip--pinned' : 'help-tip'}
|
|
116
|
+
ref={wrapRef}
|
|
117
|
+
onMouseEnter={() => {
|
|
118
|
+
cancelClose()
|
|
119
|
+
setHovered(true)
|
|
120
|
+
}}
|
|
121
|
+
onMouseLeave={scheduleClose}
|
|
122
|
+
onFocus={() => setFocused(true)}
|
|
123
|
+
onBlur={(e) => {
|
|
124
|
+
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setFocused(false)
|
|
125
|
+
}}
|
|
126
|
+
>
|
|
127
|
+
<button
|
|
128
|
+
ref={btnRef}
|
|
129
|
+
type="button"
|
|
130
|
+
aria-label="More information"
|
|
131
|
+
aria-describedby={text || hint ? textId : undefined}
|
|
132
|
+
aria-expanded={pinned}
|
|
133
|
+
aria-controls={bubbleId}
|
|
134
|
+
// Prevent the surrounding <label> (e.g. CheckboxField) from toggling its control.
|
|
135
|
+
onClick={(e) => {
|
|
136
|
+
e.preventDefault()
|
|
137
|
+
e.stopPropagation()
|
|
138
|
+
setPinned((p) => !p)
|
|
139
|
+
}}
|
|
140
|
+
>
|
|
141
|
+
i
|
|
142
|
+
</button>
|
|
143
|
+
<span
|
|
144
|
+
ref={bubbleRef}
|
|
145
|
+
className={open ? 'help-tip-bubble help-tip-bubble--open' : 'help-tip-bubble'}
|
|
146
|
+
id={bubbleId}
|
|
147
|
+
onMouseEnter={cancelClose}
|
|
148
|
+
onMouseLeave={scheduleClose}
|
|
149
|
+
>
|
|
150
|
+
{text || hint ? (
|
|
151
|
+
<span id={textId} className="help-tip-text">
|
|
152
|
+
{text}
|
|
153
|
+
{text && hint ? <span className="help-tip-hint">{hint}</span> : hint}
|
|
154
|
+
</span>
|
|
155
|
+
) : null}
|
|
156
|
+
{source ? (
|
|
157
|
+
<a className="help-tip-source" href={source.url} target="_blank" rel="noopener noreferrer">
|
|
158
|
+
Source: {source.label} ↗
|
|
159
|
+
</a>
|
|
160
|
+
) : null}
|
|
161
|
+
{learn ? <LearnLink {...learn} variant="tip" /> : null}
|
|
162
|
+
</span>
|
|
163
|
+
</span>
|
|
164
|
+
)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function ReadonlyField({ label, help, learn, value }: BaseProps & { value: ReactNode }) {
|
|
168
|
+
const id = useId()
|
|
169
|
+
return (
|
|
170
|
+
<div className="field">
|
|
171
|
+
<span className="field-label-row">
|
|
172
|
+
<span className="field-label" id={id}>
|
|
173
|
+
{label}
|
|
174
|
+
</span>
|
|
175
|
+
{help || learn ? <HelpTip text={help} learn={learn} id={`${id}-help`} /> : null}
|
|
176
|
+
</span>
|
|
177
|
+
<p className="field-readonly" aria-labelledby={id}>
|
|
178
|
+
{value}
|
|
179
|
+
</p>
|
|
180
|
+
</div>
|
|
181
|
+
)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function FieldShell({ label, hint, help, learn, source, id, children }: BaseProps & { id: string; children: ReactNode }) {
|
|
185
|
+
return (
|
|
186
|
+
<div className="field">
|
|
187
|
+
<span className="field-label-row">
|
|
188
|
+
<label className="field-label" htmlFor={id}>
|
|
189
|
+
{label}
|
|
190
|
+
</label>
|
|
191
|
+
{help || hint || learn || source ? <HelpTip text={help} hint={hint} learn={learn} source={source} id={`${id}-help`} /> : null}
|
|
192
|
+
</span>
|
|
193
|
+
{children}
|
|
194
|
+
</div>
|
|
195
|
+
)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
interface NumericProps extends BaseProps {
|
|
199
|
+
value: number | null
|
|
200
|
+
onCommit: (value: number | null) => void
|
|
201
|
+
/** When false (default for most), clearing the field commits 0 instead of null. */
|
|
202
|
+
allowNull?: boolean
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function useLocalText(formatted: string) {
|
|
206
|
+
const [text, setText] = useState(formatted)
|
|
207
|
+
const [focused, setFocused] = useState(false)
|
|
208
|
+
// Derived-state-during-render: adopt the external value unless the user is typing.
|
|
209
|
+
const [prevFormatted, setPrevFormatted] = useState(formatted)
|
|
210
|
+
if (formatted !== prevFormatted) {
|
|
211
|
+
setPrevFormatted(formatted)
|
|
212
|
+
if (!focused) setText(formatted)
|
|
213
|
+
}
|
|
214
|
+
return { text, setText, focused, setFocused }
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function MoneyField({ label, hint, help, learn, source, value, onCommit, allowNull }: NumericProps) {
|
|
218
|
+
const id = useId()
|
|
219
|
+
const { text, setText, setFocused } = useLocalText(value === null ? '' : fmtMoney(value))
|
|
220
|
+
return (
|
|
221
|
+
<FieldShell label={label} hint={hint} help={help} learn={learn} source={source} id={id}>
|
|
222
|
+
<div className="input-affix">
|
|
223
|
+
<span aria-hidden>$</span>
|
|
224
|
+
<input
|
|
225
|
+
id={id}
|
|
226
|
+
inputMode="decimal"
|
|
227
|
+
value={text.replace(/^\$/, '')}
|
|
228
|
+
onFocus={() => setFocused(true)}
|
|
229
|
+
onBlur={() => setFocused(false)}
|
|
230
|
+
onChange={(e) => {
|
|
231
|
+
setText(e.target.value)
|
|
232
|
+
const parsed = parseAmount(e.target.value)
|
|
233
|
+
if (parsed !== null) onCommit(parsed)
|
|
234
|
+
else if (e.target.value.trim() === '') onCommit(allowNull ? null : 0)
|
|
235
|
+
}}
|
|
236
|
+
/>
|
|
237
|
+
</div>
|
|
238
|
+
</FieldShell>
|
|
239
|
+
)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export function NumberField({
|
|
243
|
+
label,
|
|
244
|
+
hint,
|
|
245
|
+
help,
|
|
246
|
+
learn,
|
|
247
|
+
source,
|
|
248
|
+
value,
|
|
249
|
+
onCommit,
|
|
250
|
+
allowNull,
|
|
251
|
+
suffix,
|
|
252
|
+
step,
|
|
253
|
+
min,
|
|
254
|
+
max,
|
|
255
|
+
}: NumericProps & { suffix?: string; step?: number; min?: number; max?: number }) {
|
|
256
|
+
const id = useId()
|
|
257
|
+
const { text, setText, setFocused } = useLocalText(value === null ? '' : String(value))
|
|
258
|
+
const input = (
|
|
259
|
+
<input
|
|
260
|
+
id={id}
|
|
261
|
+
type="number"
|
|
262
|
+
inputMode="decimal"
|
|
263
|
+
value={text}
|
|
264
|
+
step={step}
|
|
265
|
+
min={min}
|
|
266
|
+
max={max}
|
|
267
|
+
onFocus={() => setFocused(true)}
|
|
268
|
+
onBlur={() => setFocused(false)}
|
|
269
|
+
onChange={(e) => {
|
|
270
|
+
setText(e.target.value)
|
|
271
|
+
const n = Number(e.target.value)
|
|
272
|
+
if (e.target.value.trim() === '') onCommit(allowNull ? null : 0)
|
|
273
|
+
else if (Number.isFinite(n)) onCommit(n)
|
|
274
|
+
}}
|
|
275
|
+
/>
|
|
276
|
+
)
|
|
277
|
+
return (
|
|
278
|
+
<FieldShell label={label} hint={hint} help={help} learn={learn} source={source} id={id}>
|
|
279
|
+
{suffix ? (
|
|
280
|
+
<div className="input-affix">
|
|
281
|
+
{input}
|
|
282
|
+
<span aria-hidden>{suffix}</span>
|
|
283
|
+
</div>
|
|
284
|
+
) : (
|
|
285
|
+
input
|
|
286
|
+
)}
|
|
287
|
+
</FieldShell>
|
|
288
|
+
)
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export function PercentField(props: NumericProps & { step?: number; min?: number; max?: number }) {
|
|
292
|
+
return <NumberField {...props} suffix="%" step={props.step ?? 0.1} />
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export function TextField({
|
|
296
|
+
label,
|
|
297
|
+
hint,
|
|
298
|
+
help,
|
|
299
|
+
learn,
|
|
300
|
+
source,
|
|
301
|
+
value,
|
|
302
|
+
onCommit,
|
|
303
|
+
}: BaseProps & { value: string; onCommit: (v: string) => void }) {
|
|
304
|
+
const id = useId()
|
|
305
|
+
return (
|
|
306
|
+
<FieldShell label={label} hint={hint} help={help} learn={learn} source={source} id={id}>
|
|
307
|
+
<input id={id} type="text" value={value} onChange={(e) => onCommit(e.target.value)} />
|
|
308
|
+
</FieldShell>
|
|
309
|
+
)
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
export function DateField({
|
|
313
|
+
label,
|
|
314
|
+
hint,
|
|
315
|
+
help,
|
|
316
|
+
learn,
|
|
317
|
+
source,
|
|
318
|
+
value,
|
|
319
|
+
onCommit,
|
|
320
|
+
}: BaseProps & { value: string; onCommit: (v: string) => void }) {
|
|
321
|
+
const id = useId()
|
|
322
|
+
return (
|
|
323
|
+
<FieldShell label={label} hint={hint} help={help} learn={learn} source={source} id={id}>
|
|
324
|
+
<input id={id} type="date" value={value} onChange={(e) => onCommit(e.target.value)} />
|
|
325
|
+
</FieldShell>
|
|
326
|
+
)
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
export function SelectField<T extends string>({
|
|
330
|
+
label,
|
|
331
|
+
hint,
|
|
332
|
+
help,
|
|
333
|
+
learn,
|
|
334
|
+
source,
|
|
335
|
+
value,
|
|
336
|
+
options,
|
|
337
|
+
onCommit,
|
|
338
|
+
describedBy,
|
|
339
|
+
}: BaseProps & {
|
|
340
|
+
value: T
|
|
341
|
+
options: ReadonlyArray<{ value: T; label: string }>
|
|
342
|
+
onCommit: (v: T) => void
|
|
343
|
+
/** id of visible text outside the field (e.g. a card hint) that describes the current selection. */
|
|
344
|
+
describedBy?: string
|
|
345
|
+
}) {
|
|
346
|
+
const id = useId()
|
|
347
|
+
return (
|
|
348
|
+
<FieldShell label={label} hint={hint} help={help} learn={learn} source={source} id={id}>
|
|
349
|
+
<select id={id} value={value} aria-describedby={describedBy} onChange={(e) => onCommit(e.target.value as T)}>
|
|
350
|
+
{options.map((o) => (
|
|
351
|
+
<option key={o.value} value={o.value}>
|
|
352
|
+
{o.label}
|
|
353
|
+
</option>
|
|
354
|
+
))}
|
|
355
|
+
</select>
|
|
356
|
+
</FieldShell>
|
|
357
|
+
)
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
export function CheckboxField({
|
|
361
|
+
label,
|
|
362
|
+
hint,
|
|
363
|
+
help,
|
|
364
|
+
learn,
|
|
365
|
+
source,
|
|
366
|
+
value,
|
|
367
|
+
onCommit,
|
|
368
|
+
}: BaseProps & { value: boolean; onCommit: (v: boolean) => void }) {
|
|
369
|
+
const id = useId()
|
|
370
|
+
return (
|
|
371
|
+
<div className="field field--checkbox">
|
|
372
|
+
<span className="field-label-row">
|
|
373
|
+
<label className="field-label" htmlFor={id}>
|
|
374
|
+
{label}
|
|
375
|
+
</label>
|
|
376
|
+
{help || hint || learn || source ? <HelpTip text={help} hint={hint} learn={learn} source={source} id={`${id}-help`} /> : null}
|
|
377
|
+
</span>
|
|
378
|
+
<input id={id} type="checkbox" checked={value} onChange={(e) => onCommit(e.target.checked)} />
|
|
379
|
+
</div>
|
|
380
|
+
)
|
|
381
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** Number formatting for the planner. All engine amounts are nominal dollars. */
|
|
2
|
+
|
|
3
|
+
const money0 = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 })
|
|
4
|
+
|
|
5
|
+
export function fmtMoney(v: number): string {
|
|
6
|
+
if (!Number.isFinite(v)) return '—'
|
|
7
|
+
return money0.format(v)
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Compact KPI form: $1.24M, $310k, −$8.2k. */
|
|
11
|
+
export function fmtMoneyCompact(v: number): string {
|
|
12
|
+
if (!Number.isFinite(v)) return '—'
|
|
13
|
+
const sign = v < 0 ? '−' : ''
|
|
14
|
+
const a = Math.abs(v)
|
|
15
|
+
if (a >= 1e6) return `${sign}$${(a / 1e6).toFixed(2)}M`
|
|
16
|
+
if (a >= 10_000) return `${sign}$${Math.round(a / 1000)}k`
|
|
17
|
+
return `${sign}$${Math.round(a).toLocaleString('en-US')}`
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function fmtPct(v: number, digits = 0): string {
|
|
21
|
+
if (!Number.isFinite(v)) return '—'
|
|
22
|
+
return `${(v * 100).toFixed(digits)}%`
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Parses a user-typed money/number string ("1,200,000", "$45k") to a number, or null. */
|
|
26
|
+
export function parseAmount(text: string): number | null {
|
|
27
|
+
const cleaned = text.trim().replace(/[$,\s]/g, '').toLowerCase()
|
|
28
|
+
if (cleaned === '') return null
|
|
29
|
+
const mult = cleaned.endsWith('m') ? 1e6 : cleaned.endsWith('k') ? 1e3 : 1
|
|
30
|
+
const core = mult === 1 ? cleaned : cleaned.slice(0, -1)
|
|
31
|
+
const n = Number(core)
|
|
32
|
+
return Number.isFinite(n) ? n * mult : null
|
|
33
|
+
}
|