@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.
Files changed (298) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +181 -0
  3. package/package.json +77 -0
  4. package/src/App.tsx +246 -0
  5. package/src/RouteErrorBoundary.tsx +45 -0
  6. package/src/assets/hero.png +0 -0
  7. package/src/assets/react.svg +1 -0
  8. package/src/assets/vite.svg +1 -0
  9. package/src/data/fedInvestClient.ts +113 -0
  10. package/src/data/localStore.ts +42 -0
  11. package/src/data/planOrigin.ts +24 -0
  12. package/src/data/planStore.ts +165 -0
  13. package/src/data/v2Backup.ts +101 -0
  14. package/src/import/ImportPage.tsx +347 -0
  15. package/src/import/ReviewChecklistView.tsx +38 -0
  16. package/src/import/brokerCsv.ts +395 -0
  17. package/src/import/csv.ts +133 -0
  18. package/src/import/genericCsv.ts +224 -0
  19. package/src/import/projectionLab.ts +350 -0
  20. package/src/import/reviewChecklist.ts +33 -0
  21. package/src/import/tenForty.ts +275 -0
  22. package/src/index.css +630 -0
  23. package/src/index.ts +16 -0
  24. package/src/learn/ArticleBody.tsx +78 -0
  25. package/src/learn/ArticlePage.tsx +57 -0
  26. package/src/learn/GlossaryPage.tsx +33 -0
  27. package/src/learn/LearnAboutScreen.tsx +41 -0
  28. package/src/learn/LearnCards.tsx +41 -0
  29. package/src/learn/LearnLink.tsx +91 -0
  30. package/src/learn/LearningCenterPage.tsx +114 -0
  31. package/src/learn/SourcesPage.tsx +98 -0
  32. package/src/learn/components/ArticleFigure.tsx +34 -0
  33. package/src/learn/components/ArticleShell.tsx +86 -0
  34. package/src/learn/components/ComparisonTable.tsx +42 -0
  35. package/src/learn/components/FormulaBlock.tsx +34 -0
  36. package/src/learn/components/PurchasingPowerChart.tsx +41 -0
  37. package/src/learn/components/RelatedArticles.tsx +27 -0
  38. package/src/learn/components/ScenarioCard.tsx +24 -0
  39. package/src/learn/components/SourceList.tsx +23 -0
  40. package/src/learn/components/charts.tsx +21 -0
  41. package/src/learn/content/about-retiregolden.ts +100 -0
  42. package/src/learn/content/aca-premium-tax-credits-and-magi.ts +103 -0
  43. package/src/learn/content/account-types-overview.ts +106 -0
  44. package/src/learn/content/after-tax-estate.ts +111 -0
  45. package/src/learn/content/agi-magi-and-taxable-income.ts +112 -0
  46. package/src/learn/content/appealing-irmaa-ssa-44.ts +95 -0
  47. package/src/learn/content/assumption-general-inflation.ts +82 -0
  48. package/src/learn/content/assumption-healthcare-inflation.ts +85 -0
  49. package/src/learn/content/assumption-heir-tax-rate.ts +79 -0
  50. package/src/learn/content/assumption-investment-returns.ts +90 -0
  51. package/src/learn/content/assumption-longevity-planning-age.ts +78 -0
  52. package/src/learn/content/assumption-recent-magi.ts +83 -0
  53. package/src/learn/content/assumption-social-security-cola.ts +89 -0
  54. package/src/learn/content/assumption-social-security-trust-fund.ts +83 -0
  55. package/src/learn/content/assumption-state-tax-override.ts +79 -0
  56. package/src/learn/content/beneficiaries-and-account-titling.ts +99 -0
  57. package/src/learn/content/break-even-useful-lens.ts +94 -0
  58. package/src/learn/content/building-a-retirement-spending-budget.ts +100 -0
  59. package/src/learn/content/cola-and-inflation-protection.ts +102 -0
  60. package/src/learn/content/divorced-spousal-and-survivor-records.ts +104 -0
  61. package/src/learn/content/dynamic-spending-guardrails.ts +90 -0
  62. package/src/learn/content/earnings-test-before-fra.ts +100 -0
  63. package/src/learn/content/employer-match-and-contribution-order.ts +104 -0
  64. package/src/learn/content/examplePlanArticles.ts +525 -0
  65. package/src/learn/content/fees-expense-ratios-and-compounding-drag.ts +98 -0
  66. package/src/learn/content/fi-number-and-four-percent-rule.ts +64 -0
  67. package/src/learn/content/filling-a-tax-bracket-with-roth-conversions.ts +98 -0
  68. package/src/learn/content/funded-ratio.ts +70 -0
  69. package/src/learn/content/healthcare-after-65.ts +103 -0
  70. package/src/learn/content/healthcare-before-65.ts +104 -0
  71. package/src/learn/content/historical-vs-random-return-models.ts +101 -0
  72. package/src/learn/content/how-assumptions-change-the-answer.ts +105 -0
  73. package/src/learn/content/how-much-can-i-spend.ts +105 -0
  74. package/src/learn/content/how-social-security-is-taxed.ts +95 -0
  75. package/src/learn/content/how-the-optimizer-thinks.ts +102 -0
  76. package/src/learn/content/how-the-optimizer-values-after-tax-estate.ts +97 -0
  77. package/src/learn/content/how-to-model-accumulation.ts +67 -0
  78. package/src/learn/content/how-to-read-a-retirement-projection.ts +115 -0
  79. package/src/learn/content/hsas-and-qualified-medical-expenses.ts +108 -0
  80. package/src/learn/content/hsas-as-retirement-accounts.ts +101 -0
  81. package/src/learn/content/inflation-risk.ts +98 -0
  82. package/src/learn/content/inherited-ira-10-year-rule.ts +105 -0
  83. package/src/learn/content/insurance-in-your-retirement-plan.ts +103 -0
  84. package/src/learn/content/irmaa-two-year-lookback.ts +99 -0
  85. package/src/learn/content/long-term-care-costs-and-insurance.ts +103 -0
  86. package/src/learn/content/long-term-care-insurance-as-risk-transfer.ts +98 -0
  87. package/src/learn/content/longevity-risk.ts +99 -0
  88. package/src/learn/content/marginal-vs-effective-tax-rate.ts +98 -0
  89. package/src/learn/content/medicare-part-b-vs-part-d-irmaa.ts +102 -0
  90. package/src/learn/content/mortality-weighted-social-security.ts +113 -0
  91. package/src/learn/content/moving-to-retiregolden.ts +86 -0
  92. package/src/learn/content/niit-high-income-investment-tax.ts +98 -0
  93. package/src/learn/content/ordinary-income-vs-capital-gains.ts +103 -0
  94. package/src/learn/content/paying-conversion-taxes-taxable-vs-ira.ts +102 -0
  95. package/src/learn/content/pensions-and-annuities.ts +101 -0
  96. package/src/learn/content/permanent-life-insurance-in-a-plan.ts +106 -0
  97. package/src/learn/content/pia-aime-and-bend-points.ts +103 -0
  98. package/src/learn/content/planner-overview.ts +106 -0
  99. package/src/learn/content/planning-for-couples-and-survivor-years.ts +108 -0
  100. package/src/learn/content/privacy-what-stays-in-your-browser.ts +99 -0
  101. package/src/learn/content/qcds-qualified-charitable-distributions.ts +101 -0
  102. package/src/learn/content/reading-the-results-page.ts +96 -0
  103. package/src/learn/content/reading-the-social-security-analysis-page.ts +106 -0
  104. package/src/learn/content/real-estate-home-equity-and-debt.ts +100 -0
  105. package/src/learn/content/reports-csv-exports-and-sharing.ts +101 -0
  106. package/src/learn/content/risk-based-guardrails.ts +100 -0
  107. package/src/learn/content/rmds-required-minimum-distributions.ts +100 -0
  108. package/src/learn/content/roth-conversion-basics.ts +104 -0
  109. package/src/learn/content/rsus-and-espp.ts +101 -0
  110. package/src/learn/content/rule-of-55-and-72t.ts +107 -0
  111. package/src/learn/content/savings-rate-biggest-lever.ts +66 -0
  112. package/src/learn/content/seed-your-plan-from-your-tax-return.ts +93 -0
  113. package/src/learn/content/sensitivity-testing-what-changes-the-answer.ts +104 -0
  114. package/src/learn/content/sequence-of-returns-risk.ts +98 -0
  115. package/src/learn/content/social-security-bridge.ts +67 -0
  116. package/src/learn/content/social-security-claiming-age-basics.ts +113 -0
  117. package/src/learn/content/social-security-taxes-vs-benefits.ts +76 -0
  118. package/src/learn/content/spending-profiles-and-the-retirement-smile.ts +92 -0
  119. package/src/learn/content/spousal-and-survivor-benefits.ts +120 -0
  120. package/src/learn/content/ssdi-and-retirement-planning.ts +72 -0
  121. package/src/learn/content/standard-deduction-senior-deduction-and-itemizing.ts +97 -0
  122. package/src/learn/content/state-income-taxes-in-retirement.ts +97 -0
  123. package/src/learn/content/step-up-in-basis.ts +102 -0
  124. package/src/learn/content/survivor-planning-for-couples.ts +110 -0
  125. package/src/learn/content/survivor-spending-in-couple-plans.ts +98 -0
  126. package/src/learn/content/tax-cliffs-and-bracket-edges.ts +105 -0
  127. package/src/learn/content/tax-loss-and-gain-harvesting.ts +99 -0
  128. package/src/learn/content/taxable-brokerage-basis-and-capital-gains.ts +99 -0
  129. package/src/learn/content/three-big-questions-spending-time-risk.ts +103 -0
  130. package/src/learn/content/tips-ladders.ts +92 -0
  131. package/src/learn/content/todays-dollars-vs-future-dollars.ts +107 -0
  132. package/src/learn/content/traditional-vs-roth-contributions.ts +113 -0
  133. package/src/learn/content/troubleshooting-surprising-results.ts +105 -0
  134. package/src/learn/content/trust-fund-haircut-scenarios.ts +101 -0
  135. package/src/learn/content/understanding-monte-carlo-success-rate.ts +118 -0
  136. package/src/learn/content/understanding-your-plan-assumptions.ts +134 -0
  137. package/src/learn/content/using-assumptions-and-provenance.ts +98 -0
  138. package/src/learn/content/using-scenarios-to-compare-choices.ts +99 -0
  139. package/src/learn/content/what-changes-when-you-move-states.ts +141 -0
  140. package/src/learn/content/what-is-fire.ts +65 -0
  141. package/src/learn/content/what-monte-carlo-proves.ts +98 -0
  142. package/src/learn/content/what-retiregolden-models.ts +103 -0
  143. package/src/learn/content/what-retirement-healthcare-really-costs.ts +117 -0
  144. package/src/learn/content/why-95-percent-is-not-a-guarantee.ts +98 -0
  145. package/src/learn/content/why-roth-conversions-raise-other-costs.ts +106 -0
  146. package/src/learn/content/why-small-tax-cliffs-can-matter.ts +109 -0
  147. package/src/learn/content/widows-penalty-and-survivor-brackets.ts +106 -0
  148. package/src/learn/content/withdrawal-order-basics.ts +105 -0
  149. package/src/learn/glossary.ts +191 -0
  150. package/src/learn/inlineMarkdown.tsx +54 -0
  151. package/src/learn/learn.css +537 -0
  152. package/src/learn/learningRegistry.ts +502 -0
  153. package/src/longevity/LongevityResults.tsx +85 -0
  154. package/src/longevity/LongevityWizard.tsx +305 -0
  155. package/src/longevity/constants.ts +15 -0
  156. package/src/longevity/factors.ts +125 -0
  157. package/src/longevity/model.ts +31 -0
  158. package/src/longevity/persistedGuard.ts +129 -0
  159. package/src/longevity/storage.ts +40 -0
  160. package/src/mc/messages.ts +118 -0
  161. package/src/mc/monteCarlo.worker.ts +44 -0
  162. package/src/mc/pool.ts +267 -0
  163. package/src/mc/runRequest.ts +125 -0
  164. package/src/optimize/messages.ts +84 -0
  165. package/src/optimize/optimize.worker.ts +29 -0
  166. package/src/optimize/runOptimize.ts +92 -0
  167. package/src/optimize/runSpendingSolve.ts +47 -0
  168. package/src/optimize/runner.ts +21 -0
  169. package/src/optimize/spendingMessages.ts +44 -0
  170. package/src/optimize/spendingRunner.ts +21 -0
  171. package/src/optimize/spendingSolve.worker.ts +18 -0
  172. package/src/planner/AssumptionsCardPage.tsx +136 -0
  173. package/src/planner/BucketLensCard.tsx +114 -0
  174. package/src/planner/ComparePlansPage.tsx +219 -0
  175. package/src/planner/DisclaimerPage.tsx +88 -0
  176. package/src/planner/HowTestedPage.tsx +159 -0
  177. package/src/planner/LiveStatus.tsx +15 -0
  178. package/src/planner/LongevityModal.tsx +55 -0
  179. package/src/planner/Modal.tsx +97 -0
  180. package/src/planner/MonteCarloPage.tsx +907 -0
  181. package/src/planner/OptimizePage.tsx +611 -0
  182. package/src/planner/PlanContext.tsx +198 -0
  183. package/src/planner/PlanPickerPage.tsx +124 -0
  184. package/src/planner/PlanWorkspace.tsx +290 -0
  185. package/src/planner/ProvenancePanel.tsx +45 -0
  186. package/src/planner/RelocationComparePage.tsx +485 -0
  187. package/src/planner/ReportPage.tsx +375 -0
  188. package/src/planner/ResultsPage.tsx +817 -0
  189. package/src/planner/ScenariosPage.tsx +285 -0
  190. package/src/planner/SocialSecuritySection.tsx +556 -0
  191. package/src/planner/SpendingSolverPage.tsx +512 -0
  192. package/src/planner/SsAnalysisPage.tsx +1134 -0
  193. package/src/planner/SurvivalPercentileModal.tsx +161 -0
  194. package/src/planner/SurvivorTransitionPage.tsx +286 -0
  195. package/src/planner/assumptionsExport.ts +371 -0
  196. package/src/planner/bucketLens.ts +89 -0
  197. package/src/planner/chartFrame.ts +8 -0
  198. package/src/planner/chartStyle.ts +11 -0
  199. package/src/planner/dialogViews.tsx +184 -0
  200. package/src/planner/dialogs.tsx +133 -0
  201. package/src/planner/examples/ExampleLibrary.tsx +189 -0
  202. package/src/planner/examples/ExamplePreviewBanner.tsx +55 -0
  203. package/src/planner/examples/ExamplesPage.tsx +25 -0
  204. package/src/planner/examples/OpenExampleButton.tsx +61 -0
  205. package/src/planner/examples/buildAggressiveSaver.ts +102 -0
  206. package/src/planner/examples/buildAnnuityEstate.ts +137 -0
  207. package/src/planner/examples/buildBaristaFire.ts +115 -0
  208. package/src/planner/examples/buildBracketFillRoth.ts +65 -0
  209. package/src/planner/examples/buildBridgeEarlyRetirement.ts +94 -0
  210. package/src/planner/examples/buildBrokerageNoHsa.ts +109 -0
  211. package/src/planner/examples/buildCoastFire.ts +88 -0
  212. package/src/planner/examples/buildContext.ts +20 -0
  213. package/src/planner/examples/buildEarlyCareerMatch.ts +93 -0
  214. package/src/planner/examples/buildEarlyRetireeAca.ts +61 -0
  215. package/src/planner/examples/buildExampleCouple.ts +103 -0
  216. package/src/planner/examples/buildFixedTargetSpending.ts +74 -0
  217. package/src/planner/examples/buildGlidepathAllocation.ts +131 -0
  218. package/src/planner/examples/buildGuardrailsFlex.ts +120 -0
  219. package/src/planner/examples/buildHsaPropertyDepth.ts +109 -0
  220. package/src/planner/examples/buildHsaStealthRetirement.ts +97 -0
  221. package/src/planner/examples/buildLeanFatFire.ts +109 -0
  222. package/src/planner/examples/buildLtcShock.ts +62 -0
  223. package/src/planner/examples/buildMovingStateTax.ts +53 -0
  224. package/src/planner/examples/buildNoAnnuityBrokerage.ts +92 -0
  225. package/src/planner/examples/buildRmdIrmaa.ts +55 -0
  226. package/src/planner/examples/buildSalaryGrowthEscalation.ts +96 -0
  227. package/src/planner/examples/buildStaticAllocationControl.ts +96 -0
  228. package/src/planner/examples/buildSurvivorYears.ts +62 -0
  229. package/src/planner/examples/buildUnderSavedSingle.ts +51 -0
  230. package/src/planner/examples/exampleCopy.ts +23 -0
  231. package/src/planner/examples/loadExample.ts +90 -0
  232. package/src/planner/examples/registry.ts +313 -0
  233. package/src/planner/explainPanels.tsx +233 -0
  234. package/src/planner/fields.tsx +381 -0
  235. package/src/planner/format.ts +33 -0
  236. package/src/planner/home/DataAndPrivacyCard.tsx +56 -0
  237. package/src/planner/home/GettingStartedPaths.tsx +46 -0
  238. package/src/planner/home/GettingStartedReopener.tsx +32 -0
  239. package/src/planner/home/StartHereLinks.tsx +22 -0
  240. package/src/planner/home/WelcomeHero.tsx +39 -0
  241. package/src/planner/home/YourPlans.tsx +72 -0
  242. package/src/planner/home/importErrorMessage.ts +22 -0
  243. package/src/planner/home/startHereSlugs.ts +7 -0
  244. package/src/planner/home/useHomeData.ts +190 -0
  245. package/src/planner/home/useHomeMode.ts +47 -0
  246. package/src/planner/householdActions.ts +22 -0
  247. package/src/planner/insights/InsightCardView.tsx +340 -0
  248. package/src/planner/insights/InsightsPage.tsx +204 -0
  249. package/src/planner/insights/categoryLabels.ts +11 -0
  250. package/src/planner/learnLinks.ts +85 -0
  251. package/src/planner/marketModelPicker.ts +172 -0
  252. package/src/planner/optimizePageChart.ts +40 -0
  253. package/src/planner/optimizePageClaim.ts +64 -0
  254. package/src/planner/planCompleteness.ts +27 -0
  255. package/src/planner/planContextCore.ts +26 -0
  256. package/src/planner/planner.css +2304 -0
  257. package/src/planner/provenanceLinks.ts +25 -0
  258. package/src/planner/sections/AccountFields.tsx +872 -0
  259. package/src/planner/sections/AccountsSection.tsx +89 -0
  260. package/src/planner/sections/AllocationPanel.tsx +261 -0
  261. package/src/planner/sections/AssumptionsSection.tsx +256 -0
  262. package/src/planner/sections/HouseholdSection.tsx +243 -0
  263. package/src/planner/sections/IncomeFloorSection.tsx +418 -0
  264. package/src/planner/sections/IncomeSection.tsx +170 -0
  265. package/src/planner/sections/InsuranceSection.tsx +362 -0
  266. package/src/planner/sections/SpendingSection.tsx +904 -0
  267. package/src/planner/sections/StrategySection.tsx +349 -0
  268. package/src/planner/sections/UpdateBalancesPanel.tsx +182 -0
  269. package/src/planner/sections/sectionHelpers.ts +48 -0
  270. package/src/planner/sections/shared.tsx +15 -0
  271. package/src/planner/sections.tsx +15 -0
  272. package/src/planner/ssAnalysis.ts +325 -0
  273. package/src/planner/successBand.ts +20 -0
  274. package/src/planner/survivorAnalysis.ts +277 -0
  275. package/src/planner/usStates.ts +19 -0
  276. package/src/planner/useMcSuccessRate.ts +77 -0
  277. package/src/planner/useProjection.ts +63 -0
  278. package/src/relocation/messages.ts +21 -0
  279. package/src/relocation/relocation.worker.ts +18 -0
  280. package/src/relocation/runRelocation.ts +17 -0
  281. package/src/relocation/runner.ts +22 -0
  282. package/src/report/brandingContext.ts +15 -0
  283. package/src/report/downloadReport.ts +34 -0
  284. package/src/report/reportHtml.ts +547 -0
  285. package/src/routes/LearnRoutes.tsx +46 -0
  286. package/src/routes/PlanRoutes.tsx +55 -0
  287. package/src/routes/RouteFallback.tsx +9 -0
  288. package/src/socialSecurity/breakEven.ts +107 -0
  289. package/src/socialSecurity/expectedPv.ts +164 -0
  290. package/src/socialSecurity/explain.ts +92 -0
  291. package/src/socialSecurity/ficaReturn.ts +81 -0
  292. package/src/socialSecurity/persistedSsGuard.ts +138 -0
  293. package/src/socialSecurity/ssFormUtils.ts +48 -0
  294. package/src/socialSecurity/ssaStatementXml.ts +156 -0
  295. package/src/socialSecurity/storage.ts +69 -0
  296. package/src/socialSecurity/survivorSwitching.ts +153 -0
  297. package/src/testSupport/samplePlan.ts +2 -0
  298. 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
+ }