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