@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,113 @@
1
+ /**
2
+ * FedInvest live TIPS prices — the IO half: the ONLY network touch in the
3
+ * app, and it is strictly opt-in. The pure parsing/date/matching half lives
4
+ * in the engine package (`@retiregolden/engine/ladder/fedInvest`); this
5
+ * module owns the fetch and the per-day cache, keeping the engine free of
6
+ * browser globals.
7
+ *
8
+ * Privacy posture:
9
+ * - Nothing runs without an explicit button press; there is no background
10
+ * refresh, no fetch on page load, and the embedded real-yield curve always
11
+ * remains the planning source of truth (offline-first).
12
+ * - The request goes directly to the U.S. Treasury's FedInvest service and
13
+ * carries only a price date — never any plan data.
14
+ * - Responses are cached per day in localStorage (`retiregolden.fedinvest.v1`),
15
+ * so repeat looks are served offline; "Clear all data" removes the cache.
16
+ * - FedInvest does not currently send CORS headers, so the direct fetch can
17
+ * be blocked by the browser. The UI degrades to a manual import of the
18
+ * same `securityprice.csv` the FedInvest site downloads — zero network
19
+ * from the app, same data, same cache.
20
+ */
21
+
22
+ import {
23
+ FEDINVEST_CSV_URL,
24
+ latestPriceDate,
25
+ latestPriceDateIso,
26
+ parseFedInvestCsv,
27
+ type FedInvestSnapshot,
28
+ } from '@retiregolden/engine/ladder/fedInvest'
29
+
30
+ import { STORAGE_KEYS, readLocal, writeLocal } from './localStore'
31
+
32
+ export function readFedInvestCache(): FedInvestSnapshot | null {
33
+ const raw = readLocal(STORAGE_KEYS.fedInvestCache)
34
+ if (!raw) return null
35
+ try {
36
+ const parsed = JSON.parse(raw) as FedInvestSnapshot
37
+ if (!Array.isArray(parsed.tips)) return null
38
+ if (typeof parsed.priceDateIso !== 'string' && parsed.priceDateIso !== null) return null
39
+ return parsed
40
+ } catch {
41
+ return null
42
+ }
43
+ }
44
+
45
+ function cacheSnapshot(snapshot: FedInvestSnapshot): void {
46
+ writeLocal(STORAGE_KEYS.fedInvestCache, JSON.stringify(snapshot))
47
+ }
48
+
49
+ /**
50
+ * Fetch (cache-first) the TIPS price list for the latest business day. Only
51
+ * ever called from an explicit user action. Throws a readable Error when the
52
+ * network, CORS policy, or parse fails — the caller shows it (offering the
53
+ * manual CSV import) and the app carries on with the embedded curve.
54
+ */
55
+ export async function fetchFedInvestTips(now = new Date()): Promise<FedInvestSnapshot> {
56
+ const date = latestPriceDate(now)
57
+ const priceDateIso = latestPriceDateIso(now)
58
+ const cached = readFedInvestCache()
59
+ // Only a same-day FETCHED snapshot satisfies a fetch request: an imported
60
+ // file has no known price date, so it must never suppress a real refresh.
61
+ if (cached && cached.source === 'fetch' && cached.priceDateIso === priceDateIso) return cached
62
+
63
+ const body = new URLSearchParams({
64
+ priceDateDay: String(date.getDate()),
65
+ priceDateMonth: String(date.getMonth() + 1),
66
+ priceDateYear: String(date.getFullYear()),
67
+ fileType: 'csv',
68
+ csv: 'CSV FORMAT',
69
+ })
70
+ let response: Response
71
+ try {
72
+ response = await fetch(FEDINVEST_CSV_URL, {
73
+ method: 'POST',
74
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
75
+ body: body.toString(),
76
+ })
77
+ } catch {
78
+ throw new Error('Could not reach the Treasury FedInvest service (blocked or offline).')
79
+ }
80
+ if (!response.ok) {
81
+ throw new Error(`The Treasury FedInvest service answered ${response.status}; try again later.`)
82
+ }
83
+ const tips = parseFedInvestCsv(await response.text())
84
+ if (tips.length === 0) {
85
+ throw new Error('FedInvest returned no TIPS rows for the latest business day (a holiday, or the format changed).')
86
+ }
87
+ const snapshot: FedInvestSnapshot = { priceDateIso, fetchedAtIso: new Date().toISOString(), source: 'fetch', tips }
88
+ cacheSnapshot(snapshot)
89
+ return snapshot
90
+ }
91
+
92
+ /**
93
+ * Manual fallback with zero network from the app: the user downloads
94
+ * `securityprice.csv` from the FedInvest site themselves and hands the file
95
+ * content here. The CSV carries no date, so the snapshot's priceDateIso stays
96
+ * null (unknown) rather than guessing — and an import never counts as "fresh"
97
+ * for the fetch cache. Throws a readable Error when the file isn't a
98
+ * FedInvest CSV.
99
+ */
100
+ export function importFedInvestCsv(text: string): FedInvestSnapshot {
101
+ const tips = parseFedInvestCsv(text)
102
+ if (tips.length === 0) {
103
+ throw new Error("That file doesn't look like a FedInvest securityprice.csv (no TIPS rows found).")
104
+ }
105
+ const snapshot: FedInvestSnapshot = {
106
+ priceDateIso: null,
107
+ fetchedAtIso: new Date().toISOString(),
108
+ source: 'import',
109
+ tips,
110
+ }
111
+ cacheSnapshot(snapshot)
112
+ return snapshot
113
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Guarded localStorage access plus the central registry of `retiregolden.*`
3
+ * keys. localStorage can throw (private browsing, quota, disabled storage);
4
+ * reads fall back to null and writes/removes fail silently — IndexedDB is the
5
+ * durable store, these are per-device conveniences. "Clear all data" removes
6
+ * every key with the `retiregolden.` prefix, so new keys must keep it.
7
+ */
8
+
9
+ export const STORAGE_KEYS = {
10
+ theme: 'retiregolden.theme',
11
+ homeWelcomeDismissed: 'retiregolden.home.welcomeDismissed',
12
+ insightsDismissed: 'retiregolden.insights.dismissed',
13
+ examplesExpanded: 'retiregolden.examples.expanded',
14
+ longevity: 'retiregolden.longevity.v1',
15
+ longevityPartner: 'retiregolden.longevity.partner.v1',
16
+ socialSecurityForm: 'retiregolden.ss.v1',
17
+ fedInvestCache: 'retiregolden.fedinvest.v1',
18
+ } as const
19
+
20
+ export function readLocal(key: string): string | null {
21
+ try {
22
+ return localStorage.getItem(key)
23
+ } catch {
24
+ return null
25
+ }
26
+ }
27
+
28
+ export function writeLocal(key: string, value: string): void {
29
+ try {
30
+ localStorage.setItem(key, value)
31
+ } catch {
32
+ /* storage unavailable — the visible state still applies this session */
33
+ }
34
+ }
35
+
36
+ export function removeLocal(key: string): void {
37
+ try {
38
+ localStorage.removeItem(key)
39
+ } catch {
40
+ /* storage unavailable */
41
+ }
42
+ }
@@ -0,0 +1,24 @@
1
+ /** Shared provenance helpers — raw IndexedDB records skip Zod defaults. */
2
+
3
+ export const EXAMPLE_PLAN_ID_PREFIX = 'example:'
4
+
5
+ export function isExamplePlanId(id: string): boolean {
6
+ return id.startsWith(EXAMPLE_PLAN_ID_PREFIX)
7
+ }
8
+
9
+ export function exampleStorageId(exampleId: string): string {
10
+ return `${EXAMPLE_PLAN_ID_PREFIX}${exampleId}`
11
+ }
12
+
13
+ /** Exclude only explicit demos; missing `origin` is treated as a user plan. */
14
+ export function isUserPlan(raw: Record<string, unknown>): boolean {
15
+ return raw['origin'] !== 'example'
16
+ }
17
+
18
+ export function isExamplePlan(raw: Record<string, unknown>): boolean {
19
+ return raw['origin'] === 'example'
20
+ }
21
+
22
+ export function planOriginFromRaw(raw: Record<string, unknown>): 'user' | 'example' {
23
+ return raw['origin'] === 'example' ? 'example' : 'user'
24
+ }
@@ -0,0 +1,165 @@
1
+ /**
2
+ * IndexedDB persistence for v2 plans. Replaces v1's localStorage approach —
3
+ * plans (earnings histories, scenarios, cached results later) outgrow the
4
+ * ~5 MB synchronous localStorage budget. Every read passes through schema
5
+ * migration + validation; corrupt records are surfaced, never silently used.
6
+ */
7
+
8
+ import { openDB, type IDBPDatabase } from 'idb'
9
+
10
+ import { isUserPlan, planOriginFromRaw } from './planOrigin'
11
+ import { migratePlanToCurrent, type MigrateResult } from '@retiregolden/engine/model/migrations'
12
+ import { parsePlan, type Plan } from '@retiregolden/engine/model/plan'
13
+
14
+ const DB_NAME = 'retiregolden.v2'
15
+ const DB_VERSION = 1
16
+ const PLANS_STORE = 'plans'
17
+
18
+ let dbPromise: Promise<IDBPDatabase> | null = null
19
+
20
+ function db(): Promise<IDBPDatabase> {
21
+ dbPromise ??= openDB(DB_NAME, DB_VERSION, {
22
+ upgrade(database) {
23
+ if (!database.objectStoreNames.contains(PLANS_STORE)) {
24
+ database.createObjectStore(PLANS_STORE, { keyPath: 'id' })
25
+ }
26
+ },
27
+ })
28
+ return dbPromise
29
+ }
30
+
31
+ /** Test hook: drop the cached connection so fake-indexeddb resets take effect. */
32
+ export function _resetPlanStoreForTests(): void {
33
+ dbPromise = null
34
+ }
35
+
36
+ export interface PlanSummary {
37
+ id: string
38
+ name: string
39
+ updatedAtIso: string
40
+ origin: 'user' | 'example'
41
+ }
42
+
43
+ function toSummary(raw: Record<string, unknown>): PlanSummary | null {
44
+ const id = String(raw['id'] ?? '')
45
+ if (id === '') return null
46
+ return {
47
+ id,
48
+ name: String(raw['name'] ?? '(unnamed)'),
49
+ updatedAtIso: String(raw['updatedAtIso'] ?? ''),
50
+ origin: planOriginFromRaw(raw),
51
+ }
52
+ }
53
+
54
+ async function summariesWhere(predicate: (raw: Record<string, unknown>) => boolean): Promise<PlanSummary[]> {
55
+ const all = (await (await db()).getAll(PLANS_STORE)) as Array<Record<string, unknown>>
56
+ return all
57
+ .filter(predicate)
58
+ .map(toSummary)
59
+ .filter((s): s is PlanSummary => s !== null)
60
+ .sort((a, b) => b.updatedAtIso.localeCompare(a.updatedAtIso))
61
+ }
62
+
63
+ /** All stored plans, including library demos. Prefer the filtered helpers for UI. */
64
+ export async function listPlanSummaries(): Promise<PlanSummary[]> {
65
+ return summariesWhere(() => true)
66
+ }
67
+
68
+ export async function listUserPlanSummaries(): Promise<PlanSummary[]> {
69
+ return summariesWhere(isUserPlan)
70
+ }
71
+
72
+ export async function listExampleSummaries(): Promise<PlanSummary[]> {
73
+ return summariesWhere((raw) => !isUserPlan(raw))
74
+ }
75
+
76
+ /** Loads, migrates, and validates a stored plan. */
77
+ export async function loadPlan(id: string): Promise<MigrateResult> {
78
+ const raw = (await (await db()).get(PLANS_STORE, id)) as unknown
79
+ if (raw === undefined) return { ok: false, reason: 'not_object' }
80
+ return migratePlanToCurrent(raw)
81
+ }
82
+
83
+ export type SavePlanResult = { ok: true; plan: Plan } | { ok: false; issues: string[] }
84
+
85
+ /** Validates and writes a plan, bumping `updatedAtIso`. */
86
+ export async function savePlan(plan: Plan, now: () => Date = () => new Date()): Promise<SavePlanResult> {
87
+ const stamped: Plan = { ...plan, updatedAtIso: now().toISOString() }
88
+ const checked = parsePlan(stamped)
89
+ if (!checked.ok) return { ok: false, issues: checked.issues }
90
+ await (await db()).put(PLANS_STORE, checked.plan)
91
+ return { ok: true, plan: checked.plan }
92
+ }
93
+
94
+ export interface DuplicatePlanOptions {
95
+ name?: string
96
+ newId?: () => string
97
+ now?: () => Date
98
+ /**
99
+ * Clone this in-memory plan instead of reloading the stored copy. The open
100
+ * workspace passes its live (possibly just-edited, not-yet-autosaved) plan so
101
+ * a duplicate doesn't silently drop unsaved changes.
102
+ */
103
+ source?: Plan
104
+ }
105
+
106
+ export async function duplicatePlan(id: string, opts: DuplicatePlanOptions = {}): Promise<SavePlanResult> {
107
+ let source = opts.source
108
+ if (!source) {
109
+ const loaded = await loadPlan(id)
110
+ if (!loaded.ok) return { ok: false, issues: [`Could not load source plan (${loaded.reason}).`] }
111
+ source = loaded.plan
112
+ }
113
+ const now = opts.now ?? (() => new Date())
114
+ const nowIso = now().toISOString()
115
+ const clone: Plan = structuredClone(source)
116
+ clone.id = (opts.newId ?? (() => crypto.randomUUID()))()
117
+ clone.name = opts.name?.trim() || `Copy of ${source.name}`
118
+ clone.origin = 'user'
119
+ clone.exampleSourceId = source.exampleSourceId
120
+ clone.createdAtIso = nowIso
121
+ clone.updatedAtIso = nowIso
122
+ return savePlan(clone, () => new Date(nowIso))
123
+ }
124
+
125
+ /**
126
+ * Atomically converts a library demo to a user plan: delete the reserved
127
+ * `example:*` record and put the converted plan under a fresh id.
128
+ */
129
+ export async function convertExampleToUserPlan(
130
+ plan: Plan,
131
+ opts: { newId?: () => string; now?: () => Date } = {},
132
+ ): Promise<SavePlanResult> {
133
+ const newId = (opts.newId ?? (() => crypto.randomUUID()))()
134
+ const now = opts.now ?? (() => new Date())
135
+ const nowIso = now().toISOString()
136
+ const converted: Plan = {
137
+ ...plan,
138
+ id: newId,
139
+ origin: 'user',
140
+ exampleSourceId: plan.exampleSourceId ?? plan.id.replace(/^example:/, ''),
141
+ updatedAtIso: nowIso,
142
+ }
143
+ const checked = parsePlan(converted)
144
+ if (!checked.ok) return { ok: false, issues: checked.issues }
145
+
146
+ const database = await db()
147
+ const tx = database.transaction(PLANS_STORE, 'readwrite')
148
+ await tx.store.delete(plan.id)
149
+ await tx.store.put(checked.plan)
150
+ await tx.done
151
+ return { ok: true, plan: checked.plan }
152
+ }
153
+
154
+ export async function deletePlan(id: string): Promise<void> {
155
+ await (await db()).delete(PLANS_STORE, id)
156
+ }
157
+
158
+ export async function clearAllPlans(): Promise<void> {
159
+ await (await db()).clear(PLANS_STORE)
160
+ }
161
+
162
+ /** Raw record count — includes library demos. */
163
+ export async function countStoredPlans(): Promise<number> {
164
+ return (await (await db()).count(PLANS_STORE))
165
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * v2 JSON backup envelope — ports the retired v1 pattern (versioned envelope,
3
+ * size cap, validate-before-replace) to multi-plan v2 data.
4
+ * Plans inside the envelope carry their own schemaVersion and are individually
5
+ * migrated on import, so old backups stay restorable forever.
6
+ */
7
+
8
+ import { migratePlanToCurrent } from '@retiregolden/engine/model/migrations'
9
+ import type { Plan } from '@retiregolden/engine/model/plan'
10
+ import { isExamplePlanId } from './planOrigin'
11
+ import { listPlanSummaries } from './planStore'
12
+
13
+ export const V2_BACKUP_KIND = 'retiregolden.v2.backup'
14
+ /** Legacy envelope kinds from before the RetireGolden rebrand — still accepted on import. */
15
+ export const LEGACY_V2_BACKUP_KIND = 'retirecalc.v2.backup'
16
+ export const RETIREMINT_V2_BACKUP_KIND = 'retiremint.v2.backup'
17
+ export const V2_BACKUP_VERSION = 1
18
+ /** Generous cap; primarily guards against importing the wrong (huge) file. */
19
+ export const MAX_BACKUP_JSON_CHARS = 10_000_000
20
+
21
+ export interface V2BackupEnvelope {
22
+ kind: typeof V2_BACKUP_KIND
23
+ backupVersion: typeof V2_BACKUP_VERSION
24
+ exportedAtIso: string
25
+ plans: unknown[]
26
+ }
27
+
28
+ export function serializeV2Backup(plans: Plan[], now: () => Date = () => new Date()): string {
29
+ const envelope: V2BackupEnvelope = {
30
+ kind: V2_BACKUP_KIND,
31
+ backupVersion: V2_BACKUP_VERSION,
32
+ exportedAtIso: now().toISOString(),
33
+ plans,
34
+ }
35
+ return JSON.stringify(envelope, null, 2)
36
+ }
37
+
38
+ export type ParseV2BackupResult =
39
+ | { ok: true; plans: Plan[]; warnings: string[] }
40
+ | { ok: false; reason: 'too_large' | 'not_json' | 'wrong_kind' | 'unsupported_version' | 'no_valid_plans' }
41
+
42
+ export function parseV2Backup(json: string): ParseV2BackupResult {
43
+ if (json.length > MAX_BACKUP_JSON_CHARS) return { ok: false, reason: 'too_large' }
44
+
45
+ let raw: unknown
46
+ try {
47
+ raw = JSON.parse(json)
48
+ } catch {
49
+ return { ok: false, reason: 'not_json' }
50
+ }
51
+ if (typeof raw !== 'object' || raw === null) return { ok: false, reason: 'wrong_kind' }
52
+ const env = raw as { kind?: string; backupVersion?: number; plans?: unknown }
53
+ if (
54
+ (env.kind !== V2_BACKUP_KIND &&
55
+ env.kind !== RETIREMINT_V2_BACKUP_KIND &&
56
+ env.kind !== LEGACY_V2_BACKUP_KIND) ||
57
+ !Array.isArray(env.plans)
58
+ ) {
59
+ return { ok: false, reason: 'wrong_kind' }
60
+ }
61
+ if (env.backupVersion !== V2_BACKUP_VERSION) return { ok: false, reason: 'unsupported_version' }
62
+
63
+ const plans: Plan[] = []
64
+ const warnings: string[] = []
65
+ env.plans.forEach((rawPlan, i) => {
66
+ const result = migratePlanToCurrent(rawPlan)
67
+ if (result.ok) {
68
+ plans.push(result.plan)
69
+ } else {
70
+ warnings.push(`plan ${i + 1}: skipped (${result.reason})`)
71
+ }
72
+ })
73
+ if (plans.length === 0) return { ok: false, reason: 'no_valid_plans' }
74
+ return { ok: true, plans, warnings }
75
+ }
76
+
77
+ /**
78
+ * On import, demos become user plans and reserved `example:*` ids are rekeyed
79
+ * so they cannot collide with library demo slots.
80
+ */
81
+ export async function normalizePlansForImport(plans: Plan[]): Promise<Plan[]> {
82
+ const existing = new Set((await listPlanSummaries()).map((s) => s.id))
83
+ const used = new Set(existing)
84
+ const normalized: Plan[] = []
85
+
86
+ for (const plan of plans) {
87
+ let id = plan.id
88
+ if (plan.origin === 'example' || isExamplePlanId(id) || used.has(id)) {
89
+ do {
90
+ id = crypto.randomUUID()
91
+ } while (used.has(id))
92
+ }
93
+ used.add(id)
94
+ normalized.push({
95
+ ...plan,
96
+ id,
97
+ origin: 'user',
98
+ })
99
+ }
100
+ return normalized
101
+ }