@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,485 @@
1
+ /**
2
+ * Relocation Compare (Explore rail): "where should I retire?" priced on the
3
+ * user's actual plan. Picks up to MAX_RELOCATION_CANDIDATES candidate states
4
+ * (each optionally with a move year, a local-rate knob, and a flat spending
5
+ * delta), runs the real plan once per candidate in a Web Worker — shared
6
+ * market paths for the success rate — and ranks the rows with a per-state
7
+ * driver drill-down. Income tax only; the page names what's out of scope.
8
+ */
9
+
10
+ import { useMemo, useRef, useState } from 'react'
11
+ import { useNavigate } from 'react-router-dom'
12
+
13
+ import type { StateRetirementExclusion } from '@retiregolden/engine/params/state'
14
+ import {
15
+ MAX_RELOCATION_CANDIDATES,
16
+ relocationScenarioPatch,
17
+ type RelocationCandidate,
18
+ type RelocationCandidateRow,
19
+ type RelocationComparison,
20
+ } from '@retiregolden/engine/projection/relocation'
21
+ import type { Plan } from '@retiregolden/engine/model/plan'
22
+ import { runRelocationCompare } from '../relocation/runner'
23
+ import { LearnAboutScreen } from '../learn/LearnAboutScreen'
24
+ import { LearnLink } from '../learn/LearnLink'
25
+ import { HelpTip, NumberField, PercentField, SelectField } from './fields'
26
+ import { fmtMoney, fmtPct } from './format'
27
+ import { LEARN } from './learnLinks'
28
+ import { buildModel } from './marketModelPicker'
29
+ import { usePlan } from './planContextCore'
30
+ import { currentStartYear, seedFromPlanId } from './useProjection'
31
+ import { US_STATES } from './usStates'
32
+
33
+ const RELOCATION_MC_PATHS = 500
34
+
35
+ type RankMetric = 'lifetimeTax' | 'estate' | 'success'
36
+
37
+ function makeScenarioId(): string {
38
+ return typeof crypto !== 'undefined' && 'randomUUID' in crypto
39
+ ? `scenario-${crypto.randomUUID()}`
40
+ : `scenario-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
41
+ }
42
+
43
+ function exclusionLabel(rule: StateRetirementExclusion): string {
44
+ if (rule.kind === 'full') return rule.minAge !== undefined ? `fully excluded from age ${rule.minAge}` : 'fully excluded'
45
+ if (rule.kind === 'capped') {
46
+ const cap = fmtMoney(rule.capPerPerson ?? 0)
47
+ return rule.minAge !== undefined ? `up to ${cap}/person from age ${rule.minAge}` : `up to ${cap}/person`
48
+ }
49
+ return 'taxed like other income'
50
+ }
51
+
52
+ function DriverRow({ label, value, note }: { label: string; value: string; note?: string }) {
53
+ return (
54
+ <tr>
55
+ <td>{label}</td>
56
+ <td style={{ textAlign: 'right' }}>{value}</td>
57
+ <td className="muted">{note ?? ''}</td>
58
+ </tr>
59
+ )
60
+ }
61
+
62
+ function DriverDetails({ row }: { row: RelocationCandidateRow }) {
63
+ const d = row.drivers
64
+ if (!d) return null
65
+ if (!d.facts) {
66
+ return (
67
+ <p className="field-hint">
68
+ {row.modeled
69
+ ? 'Driver attribution is unavailable for this row.'
70
+ : 'This row was priced without a modeled state pack (flat override or unmodeled state), so per-driver attribution is unavailable.'}
71
+ </p>
72
+ )
73
+ }
74
+ const f = d.facts
75
+ if (!f.hasIncomeTax) {
76
+ // A structural $0 only holds when the whole residency path was untaxed;
77
+ // a move-year candidate still pays the origin state before the move.
78
+ return row.lifetimeStateLocalTax === 0 ? (
79
+ <p className="field-hint">
80
+ {f.stateName} levies no broad income tax — the whole state+local line is $0 by construction. Property, sales,
81
+ and other taxes are outside this model.
82
+ </p>
83
+ ) : (
84
+ <p className="field-hint">
85
+ {f.stateName} levies no broad income tax, so the years lived there contribute $0 — the{' '}
86
+ {fmtMoney(row.lifetimeStateLocalTax)} lifetime state+local line comes from the years before the move (or other
87
+ residences on this row&apos;s path). Property, sales, and other taxes are outside this model.
88
+ </p>
89
+ )
90
+ }
91
+ return (
92
+ <div className="table-scroll">
93
+ <table>
94
+ <thead>
95
+ <tr>
96
+ <th style={{ textAlign: 'left' }}>Driver ({f.stateName})</th>
97
+ <th style={{ textAlign: 'right' }}>Lifetime state tax it saves</th>
98
+ <th style={{ textAlign: 'left' }}>Rule</th>
99
+ </tr>
100
+ </thead>
101
+ <tbody>
102
+ <DriverRow
103
+ label="Social Security treatment"
104
+ value={f.taxesSocialSecurity ? '—' : fmtMoney(d.ssTreatmentSavings)}
105
+ note={
106
+ f.taxesSocialSecurity
107
+ ? 'Taxes the federally taxable part of Social Security'
108
+ : 'Leaves Social Security out of the state base'
109
+ }
110
+ />
111
+ <DriverRow
112
+ label={f.retirementRuleShared ? 'Retirement-income exclusion (one shared rule)' : 'Retirement-income exclusions (both buckets)'}
113
+ value={fmtMoney(d.retirementExclusionSavings)}
114
+ note={
115
+ f.retirementRuleShared
116
+ ? `All retirement income: ${exclusionLabel(f.retirementPrivate)}`
117
+ : `Private: ${exclusionLabel(f.retirementPrivate)}; public: ${exclusionLabel(f.retirementPublic)}`
118
+ }
119
+ />
120
+ {!f.retirementRuleShared ? (
121
+ <DriverRow
122
+ label="— of which the separate public-pension rule"
123
+ value={fmtMoney(d.publicPensionExclusionSavings)}
124
+ note={exclusionLabel(f.retirementPublic)}
125
+ />
126
+ ) : null}
127
+ <DriverRow
128
+ label="Capital-gain treatment"
129
+ value={fmtMoney(d.capitalGainsTreatmentSavings)}
130
+ note={
131
+ f.capitalGainsTaxablePct >= 100
132
+ ? f.capitalLossCarryforwardConformity === 'currentYearOnly'
133
+ ? 'Gains fully taxed; prior-year loss carryforwards not honored'
134
+ : 'Net gains taxed as ordinary income'
135
+ : `${f.capitalGainsTaxablePct}% of net gains in the state base`
136
+ }
137
+ />
138
+ </tbody>
139
+ </table>
140
+ <p className="field-hint" style={{ marginTop: '0.5rem' }}>
141
+ Each figure re-prices every ledger year with that one rule neutralized, so the figures explain — but need not
142
+ sum to — the {fmtMoney(d.totalStateLocalTax)} lifetime state+local tax this row actually paid (top marginal
143
+ rate {f.topRatePct}%). A negative figure means the rule costs more than the benchmark.
144
+ </p>
145
+ </div>
146
+ )
147
+ }
148
+
149
+ interface CandidateDraft {
150
+ state: string
151
+ moveYear: number | null
152
+ localRatePct: number
153
+ spendingDeltaPct: number
154
+ }
155
+
156
+ function draftToCandidate(draft: CandidateDraft): RelocationCandidate {
157
+ return {
158
+ state: draft.state,
159
+ moveYear: draft.moveYear ?? undefined,
160
+ localRatePct: draft.localRatePct > 0 ? draft.localRatePct : 0,
161
+ spendingDeltaPct: draft.spendingDeltaPct !== 0 ? draft.spendingDeltaPct : undefined,
162
+ }
163
+ }
164
+
165
+ export function RelocationComparePage() {
166
+ const { plan, update } = usePlan()
167
+ const navigate = useNavigate()
168
+ const startYear = currentStartYear()
169
+
170
+ const [drafts, setDrafts] = useState<CandidateDraft[]>([
171
+ { state: 'FL', moveYear: null, localRatePct: 0, spendingDeltaPct: 0 },
172
+ ])
173
+ const [includeMc, setIncludeMc] = useState(true)
174
+ const [rankBy, setRankBy] = useState<RankMetric>('lifetimeTax')
175
+ const [running, setRunning] = useState(false)
176
+ const runToken = useRef(0)
177
+ const [compareState, setCompareState] = useState<{
178
+ forPlan: Plan
179
+ forDrafts: CandidateDraft[]
180
+ result: RelocationComparison | null
181
+ error: string | null
182
+ } | null>(null)
183
+
184
+ // Results are keyed to the plan object AND the candidate drafts they ran
185
+ // on: any plan or candidate edit makes them disappear instead of showing
186
+ // rows that no longer match the form.
187
+ const fresh = compareState !== null && compareState.forPlan === plan && compareState.forDrafts === drafts
188
+ const result = fresh ? compareState.result : null
189
+ const error = fresh ? compareState.error : null
190
+
191
+ const overrideActive = plan.assumptions.stateEffectiveTaxPct > 0
192
+
193
+ const run = () => {
194
+ // Only the latest run may commit results, so a slow worker completion can
195
+ // never present rows for a form the user has since edited.
196
+ const token = ++runToken.current
197
+ const forPlan = plan
198
+ const forDrafts = drafts
199
+ setRunning(true)
200
+ setCompareState(null)
201
+ const monteCarlo = includeMc
202
+ ? {
203
+ model: buildModel('lognormal', plan.assumptions.inflationPct, 12, 60, plan),
204
+ pathCount: RELOCATION_MC_PATHS,
205
+ seed: seedFromPlanId(plan.id),
206
+ }
207
+ : null
208
+ runRelocationCompare({ plan: forPlan, candidates: drafts.map(draftToCandidate), startYear, monteCarlo })
209
+ .then((r) => {
210
+ if (token === runToken.current) setCompareState({ forPlan, forDrafts, result: r, error: null })
211
+ })
212
+ .catch((e: unknown) => {
213
+ if (token === runToken.current) {
214
+ setCompareState({ forPlan, forDrafts, result: null, error: e instanceof Error ? e.message : String(e) })
215
+ }
216
+ })
217
+ .finally(() => {
218
+ if (token === runToken.current) setRunning(false)
219
+ })
220
+ }
221
+
222
+ const addScenario = (row: RelocationCandidateRow) => {
223
+ if (!row.candidate) return
224
+ const candidate = row.candidate
225
+ const baseName = candidate.moveYear !== undefined
226
+ ? `Relocate to ${candidate.state} in ${candidate.moveYear}`
227
+ : `Relocate to ${candidate.state}`
228
+ const names = new Set(plan.scenarios.map((s) => s.name))
229
+ let name = baseName
230
+ let suffix = 2
231
+ while (names.has(name)) name = `${baseName} (${suffix++})`
232
+ const patch = relocationScenarioPatch(plan, candidate, startYear)
233
+ update((d) => {
234
+ d.scenarios.push({ id: makeScenarioId(), name, patch })
235
+ })
236
+ navigate(`/plan/${plan.id}/scenarios`)
237
+ }
238
+
239
+ const baseline = result?.rows.find((r) => r.id === 'baseline') ?? null
240
+ // "Success rate" is only a real ordering when the last run priced one.
241
+ const effectiveRankBy: RankMetric = rankBy === 'success' && !result?.monteCarlo ? 'lifetimeTax' : rankBy
242
+ const rankedCandidates = useMemo(() => {
243
+ if (!result) return []
244
+ const rows = result.rows.filter((r) => r.id !== 'baseline')
245
+ const key = (r: RelocationCandidateRow): number => {
246
+ if (r.error) return Number.POSITIVE_INFINITY
247
+ if (effectiveRankBy === 'estate') return -r.endingAfterTaxEstate
248
+ if (effectiveRankBy === 'success') return -(r.successRate ?? -1)
249
+ return r.lifetimeTaxesAndPenalties
250
+ }
251
+ return [...rows].sort((a, b) => key(a) - key(b) || a.destinationState.localeCompare(b.destinationState))
252
+ }, [result, effectiveRankBy])
253
+
254
+ const deflateEnd = (row: RelocationCandidateRow, amount: number) =>
255
+ amount / Math.pow(1 + plan.assumptions.inflationPct / 100, row.endYear - startYear)
256
+
257
+ return (
258
+ <section>
259
+ <div className="card">
260
+ <h2>Relocation Compare</h2>
261
+ <p className="card-hint">
262
+ &quot;Which state should I retire in?&quot; — priced on <em>your</em> plan, not a generic ranking. Each
263
+ candidate state re-runs your full year-by-year projection (federal + modeled state tax, retirement-income
264
+ exclusions, Social Security treatment, capital-gain rules, split-year moves), so the rows differ only by
265
+ where you live. <LearnLink {...LEARN.stateRelocation} />
266
+ </p>
267
+ <div className="callout callout--info">
268
+ <p className="card-hint">
269
+ <strong>Income tax is one relocation factor, not the decision.</strong> Property and sales taxes, housing
270
+ and living costs, healthcare access, and being near people you love are all outside this model. The
271
+ optional local-rate and spending-adjustment knobs are blunt approximations you control — nothing here
272
+ recommends a &quot;best state.&quot; <LearnLink {...LEARN.stateRelocation} />
273
+ </p>
274
+ </div>
275
+ {overrideActive ? (
276
+ <div className="callout callout--warn">
277
+ <p className="card-hint">
278
+ Your plan sets a flat state-tax override ({plan.assumptions.stateEffectiveTaxPct}% under Assumptions),
279
+ which replaces the modeled per-state rules. The baseline row keeps it; candidate rows clear it so the
280
+ destination&apos;s modeled rules can apply — so part of any difference below comes from dropping the
281
+ override itself.
282
+ </p>
283
+ </div>
284
+ ) : null}
285
+ </div>
286
+
287
+ <div className="card">
288
+ <h2>Candidate states</h2>
289
+ <p className="card-hint">
290
+ Up to {MAX_RELOCATION_CANDIDATES} states. Leave &quot;Move year&quot; blank to price the whole plan as a
291
+ resident there; set a year to model a split-year move (July, like the Household screen). Nothing here
292
+ changes your plan — use &quot;Add as scenario&quot; on a result row to keep one.
293
+ </p>
294
+ {drafts.map((draft, i) => (
295
+ <div className="item-row" key={i} style={{ marginTop: i === 0 ? 0 : '0.75rem' }}>
296
+ <div className="item-row-head">
297
+ <span className="item-row-title">
298
+ <span className="type-chip">Candidate</span>
299
+ {US_STATES.find((s) => s.value === draft.state)?.label ?? draft.state}
300
+ </span>
301
+ <button
302
+ type="button"
303
+ className="btn-ghost btn-ghost-danger"
304
+ onClick={() => setDrafts((ds) => ds.filter((_, j) => j !== i))}
305
+ >
306
+ Remove
307
+ </button>
308
+ </div>
309
+ <div className="form-grid">
310
+ <SelectField
311
+ label="State"
312
+ value={draft.state}
313
+ options={US_STATES}
314
+ onCommit={(v) => setDrafts((ds) => ds.map((d, j) => (j === i ? { ...d, state: v } : d)))}
315
+ />
316
+ <NumberField
317
+ label="Move year (optional)"
318
+ help="Blank prices the whole plan as a resident of the candidate state (your planned moves are replaced). A year models staying put until a split-year July move — the move-year is taxed part-year in each state."
319
+ value={draft.moveYear}
320
+ allowNull
321
+ min={startYear}
322
+ max={startYear + 60}
323
+ onCommit={(v) =>
324
+ setDrafts((ds) => ds.map((d, j) => (j === i ? { ...d, moveYear: v === null ? null : Math.round(v) } : d)))
325
+ }
326
+ />
327
+ <PercentField
328
+ label="Local income tax (optional)"
329
+ help="Flat local/city rate on state taxable income in the destination (e.g. ~3.9% in NYC). Replaces the plan's local rate for this candidate, across the whole plan — exact for a from-the-start candidate, an approximation across a mid-plan move."
330
+ value={draft.localRatePct}
331
+ min={0}
332
+ max={10}
333
+ onCommit={(v) => setDrafts((ds) => ds.map((d, j) => (j === i ? { ...d, localRatePct: v ?? 0 } : d)))}
334
+ />
335
+ <PercentField
336
+ label="Spending adjustment (optional)"
337
+ help="Blunt cost-of-living knob: changes baseline lifestyle spending by this percent for the candidate (e.g. -10 for a cheaper metro, +15 for a pricier one). Applies to the whole plan, not just after the move."
338
+ value={draft.spendingDeltaPct}
339
+ min={-50}
340
+ max={50}
341
+ onCommit={(v) => setDrafts((ds) => ds.map((d, j) => (j === i ? { ...d, spendingDeltaPct: v ?? 0 } : d)))}
342
+ />
343
+ </div>
344
+ </div>
345
+ ))}
346
+ <div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap', alignItems: 'center', marginTop: '0.75rem' }}>
347
+ <button
348
+ type="button"
349
+ className="btn btn-secondary btn-small"
350
+ disabled={drafts.length >= MAX_RELOCATION_CANDIDATES}
351
+ onClick={() => setDrafts((ds) => [...ds, { state: 'TX', moveYear: null, localRatePct: 0, spendingDeltaPct: 0 }])}
352
+ >
353
+ Add state
354
+ </button>
355
+ <label style={{ display: 'inline-flex', alignItems: 'center', gap: '0.4rem' }}>
356
+ <input type="checkbox" checked={includeMc} onChange={(e) => setIncludeMc(e.target.checked)} />
357
+ <span className="field-label" style={{ margin: 0 }}>
358
+ Success rate ({RELOCATION_MC_PATHS} shared market paths per state)
359
+ </span>
360
+ <HelpTip text="Runs a Monte Carlo per row with the same seed and market paths, so path N is the identical market history for every state — differences are the state's doing, not luck. Uses the smooth-randomness model; the Monte Carlo page has the full model catalog." />
361
+ </label>
362
+ <button type="button" className="btn btn-primary btn-small" disabled={running || drafts.length === 0} onClick={run}>
363
+ {running ? 'Comparing…' : 'Run compare'}
364
+ </button>
365
+ </div>
366
+ {running ? <div className="skeleton" style={{ height: '2rem', marginTop: '0.75rem' }} aria-label="Comparing states" /> : null}
367
+ {error ? <p style={{ color: 'var(--bad)' }}>Compare error: {error}</p> : null}
368
+ </div>
369
+
370
+ {result && baseline && !running ? (
371
+ <div className="card">
372
+ <h2>Ranked results</h2>
373
+ <p className="card-hint">
374
+ Every row is your full plan, identical except for residence (and any knobs you set on the candidate).
375
+ Deltas are vs. staying in {baseline.destinationState}. Dollar columns are nominal lifetime sums; the
376
+ estate column is deflated to today&apos;s dollars.
377
+ </p>
378
+ <div className="form-grid">
379
+ <SelectField
380
+ label="Rank by"
381
+ value={effectiveRankBy}
382
+ options={[
383
+ { value: 'lifetimeTax', label: 'Lifetime taxes & penalties (low to high)' },
384
+ { value: 'estate', label: 'Ending after-tax estate (high to low)' },
385
+ // Only a real ordering when this run priced a success rate.
386
+ ...(result.monteCarlo ? [{ value: 'success' as const, label: 'Success rate (high to low)' }] : []),
387
+ ]}
388
+ onCommit={setRankBy}
389
+ />
390
+ </div>
391
+ <div className="table-scroll">
392
+ <table>
393
+ <thead>
394
+ <tr>
395
+ <th style={{ textAlign: 'left' }}>Residence</th>
396
+ <th style={{ textAlign: 'right' }}>
397
+ Lifetime state+local tax <HelpTip text="Sum of the ledger's per-year state and local income-tax lines over the whole projection (nominal). The drill-down explains which state rules drive it." />
398
+ </th>
399
+ <th style={{ textAlign: 'right' }}>
400
+ Lifetime taxes & penalties <HelpTip text="Federal + state + local + penalties over the whole projection (nominal) — the ranking default, since a state change also moves federal interactions like deduction and bracket timing." />
401
+ </th>
402
+ <th style={{ textAlign: 'right' }}>Δ vs staying</th>
403
+ <th style={{ textAlign: 'right' }}>Ending after-tax estate (today&apos;s $)</th>
404
+ {result.monteCarlo ? <th style={{ textAlign: 'right' }}>Success rate</th> : null}
405
+ </tr>
406
+ </thead>
407
+ <tbody>
408
+ {[baseline, ...rankedCandidates].map((row) => {
409
+ const delta = row.error ? null : row.lifetimeTaxesAndPenalties - baseline.lifetimeTaxesAndPenalties
410
+ return (
411
+ <tr key={row.id}>
412
+ <td>
413
+ {row.id === 'baseline' ? <strong>{row.label}</strong> : row.label}
414
+ {!row.error && !row.modeled ? (
415
+ <>
416
+ {' '}
417
+ <HelpTip text="Part of this row was priced without a modeled state pack (unmodeled state, or the flat override) — treat its state-tax line as approximate." />
418
+ </>
419
+ ) : null}
420
+ </td>
421
+ {row.error ? (
422
+ <td colSpan={result.monteCarlo ? 5 : 4} style={{ color: 'var(--bad)' }}>
423
+ {row.error}
424
+ </td>
425
+ ) : (
426
+ <>
427
+ <td style={{ textAlign: 'right' }}>{fmtMoney(row.lifetimeStateLocalTax)}</td>
428
+ <td style={{ textAlign: 'right' }}>{fmtMoney(row.lifetimeTaxesAndPenalties)}</td>
429
+ <td style={{ textAlign: 'right', color: delta !== null && delta < 0 ? 'var(--good)' : undefined }}>
430
+ {row.id === 'baseline' || delta === null ? '—' : `${delta > 0 ? '+' : ''}${fmtMoney(delta)}`}
431
+ </td>
432
+ <td style={{ textAlign: 'right' }}>{fmtMoney(deflateEnd(row, row.endingAfterTaxEstate))}</td>
433
+ </>
434
+ )}
435
+ {result.monteCarlo && !row.error ? (
436
+ <td style={{ textAlign: 'right' }}>{row.successRate !== null ? fmtPct(row.successRate, 1) : '—'}</td>
437
+ ) : null}
438
+ </tr>
439
+ )
440
+ })}
441
+ </tbody>
442
+ </table>
443
+ </div>
444
+ {[baseline, ...rankedCandidates]
445
+ .filter((row) => !row.error)
446
+ .map((row) => (
447
+ <details className="ss-explainer" key={row.id}>
448
+ <summary>What drives &quot;{row.label}&quot;?</summary>
449
+ <DriverDetails row={row} />
450
+ {row.candidate ? (
451
+ <div style={{ marginTop: '0.5rem' }}>
452
+ <button type="button" className="btn btn-secondary btn-small" onClick={() => addScenario(row)}>
453
+ Add as scenario
454
+ </button>
455
+ <p className="field-hint" style={{ marginTop: '0.5rem' }}>
456
+ Creates a side-by-side scenario under Scenarios using the planner&apos;s existing state-move
457
+ fields — your plan itself is not changed. The scenario reruns to exactly this row.
458
+ </p>
459
+ </div>
460
+ ) : null}
461
+ {row.depletionYear !== null ? (
462
+ <p className="field-hint" style={{ color: 'var(--bad)' }}>
463
+ This row&apos;s deterministic projection runs short of money in {row.depletionYear}.
464
+ </p>
465
+ ) : null}
466
+ {row.warnings.map((warning) => (
467
+ <p className="field-hint" key={warning}>
468
+ ⚠ {warning}
469
+ </p>
470
+ ))}
471
+ </details>
472
+ ))}
473
+ <p className="field-hint" style={{ marginTop: '0.75rem' }}>
474
+ Rows use the same start year, market assumptions, and (when enabled) the same {result.monteCarlo?.pathCount}{' '}
475
+ seeded market paths, so differences are attributable to residence, not simulation noise. State rules come
476
+ from the modeled per-state packs (all 50 states + DC); property, sales, and estate/inheritance taxes are
477
+ not modeled.
478
+ </p>
479
+ </div>
480
+ ) : null}
481
+
482
+ <LearnAboutScreen route="/plan/:planId/relocation" />
483
+ </section>
484
+ )
485
+ }