@retiregolden/planner-ui 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +661 -0
- package/README.md +181 -0
- package/package.json +77 -0
- package/src/App.tsx +246 -0
- package/src/RouteErrorBoundary.tsx +45 -0
- package/src/assets/hero.png +0 -0
- package/src/assets/react.svg +1 -0
- package/src/assets/vite.svg +1 -0
- package/src/data/fedInvestClient.ts +113 -0
- package/src/data/localStore.ts +42 -0
- package/src/data/planOrigin.ts +24 -0
- package/src/data/planStore.ts +165 -0
- package/src/data/v2Backup.ts +101 -0
- package/src/import/ImportPage.tsx +347 -0
- package/src/import/ReviewChecklistView.tsx +38 -0
- package/src/import/brokerCsv.ts +395 -0
- package/src/import/csv.ts +133 -0
- package/src/import/genericCsv.ts +224 -0
- package/src/import/projectionLab.ts +350 -0
- package/src/import/reviewChecklist.ts +33 -0
- package/src/import/tenForty.ts +275 -0
- package/src/index.css +630 -0
- package/src/index.ts +16 -0
- package/src/learn/ArticleBody.tsx +78 -0
- package/src/learn/ArticlePage.tsx +57 -0
- package/src/learn/GlossaryPage.tsx +33 -0
- package/src/learn/LearnAboutScreen.tsx +41 -0
- package/src/learn/LearnCards.tsx +41 -0
- package/src/learn/LearnLink.tsx +91 -0
- package/src/learn/LearningCenterPage.tsx +114 -0
- package/src/learn/SourcesPage.tsx +98 -0
- package/src/learn/components/ArticleFigure.tsx +34 -0
- package/src/learn/components/ArticleShell.tsx +86 -0
- package/src/learn/components/ComparisonTable.tsx +42 -0
- package/src/learn/components/FormulaBlock.tsx +34 -0
- package/src/learn/components/PurchasingPowerChart.tsx +41 -0
- package/src/learn/components/RelatedArticles.tsx +27 -0
- package/src/learn/components/ScenarioCard.tsx +24 -0
- package/src/learn/components/SourceList.tsx +23 -0
- package/src/learn/components/charts.tsx +21 -0
- package/src/learn/content/about-retiregolden.ts +100 -0
- package/src/learn/content/aca-premium-tax-credits-and-magi.ts +103 -0
- package/src/learn/content/account-types-overview.ts +106 -0
- package/src/learn/content/after-tax-estate.ts +111 -0
- package/src/learn/content/agi-magi-and-taxable-income.ts +112 -0
- package/src/learn/content/appealing-irmaa-ssa-44.ts +95 -0
- package/src/learn/content/assumption-general-inflation.ts +82 -0
- package/src/learn/content/assumption-healthcare-inflation.ts +85 -0
- package/src/learn/content/assumption-heir-tax-rate.ts +79 -0
- package/src/learn/content/assumption-investment-returns.ts +90 -0
- package/src/learn/content/assumption-longevity-planning-age.ts +78 -0
- package/src/learn/content/assumption-recent-magi.ts +83 -0
- package/src/learn/content/assumption-social-security-cola.ts +89 -0
- package/src/learn/content/assumption-social-security-trust-fund.ts +83 -0
- package/src/learn/content/assumption-state-tax-override.ts +79 -0
- package/src/learn/content/beneficiaries-and-account-titling.ts +99 -0
- package/src/learn/content/break-even-useful-lens.ts +94 -0
- package/src/learn/content/building-a-retirement-spending-budget.ts +100 -0
- package/src/learn/content/cola-and-inflation-protection.ts +102 -0
- package/src/learn/content/divorced-spousal-and-survivor-records.ts +104 -0
- package/src/learn/content/dynamic-spending-guardrails.ts +90 -0
- package/src/learn/content/earnings-test-before-fra.ts +100 -0
- package/src/learn/content/employer-match-and-contribution-order.ts +104 -0
- package/src/learn/content/examplePlanArticles.ts +525 -0
- package/src/learn/content/fees-expense-ratios-and-compounding-drag.ts +98 -0
- package/src/learn/content/fi-number-and-four-percent-rule.ts +64 -0
- package/src/learn/content/filling-a-tax-bracket-with-roth-conversions.ts +98 -0
- package/src/learn/content/funded-ratio.ts +70 -0
- package/src/learn/content/healthcare-after-65.ts +103 -0
- package/src/learn/content/healthcare-before-65.ts +104 -0
- package/src/learn/content/historical-vs-random-return-models.ts +101 -0
- package/src/learn/content/how-assumptions-change-the-answer.ts +105 -0
- package/src/learn/content/how-much-can-i-spend.ts +105 -0
- package/src/learn/content/how-social-security-is-taxed.ts +95 -0
- package/src/learn/content/how-the-optimizer-thinks.ts +102 -0
- package/src/learn/content/how-the-optimizer-values-after-tax-estate.ts +97 -0
- package/src/learn/content/how-to-model-accumulation.ts +67 -0
- package/src/learn/content/how-to-read-a-retirement-projection.ts +115 -0
- package/src/learn/content/hsas-and-qualified-medical-expenses.ts +108 -0
- package/src/learn/content/hsas-as-retirement-accounts.ts +101 -0
- package/src/learn/content/inflation-risk.ts +98 -0
- package/src/learn/content/inherited-ira-10-year-rule.ts +105 -0
- package/src/learn/content/insurance-in-your-retirement-plan.ts +103 -0
- package/src/learn/content/irmaa-two-year-lookback.ts +99 -0
- package/src/learn/content/long-term-care-costs-and-insurance.ts +103 -0
- package/src/learn/content/long-term-care-insurance-as-risk-transfer.ts +98 -0
- package/src/learn/content/longevity-risk.ts +99 -0
- package/src/learn/content/marginal-vs-effective-tax-rate.ts +98 -0
- package/src/learn/content/medicare-part-b-vs-part-d-irmaa.ts +102 -0
- package/src/learn/content/mortality-weighted-social-security.ts +113 -0
- package/src/learn/content/moving-to-retiregolden.ts +86 -0
- package/src/learn/content/niit-high-income-investment-tax.ts +98 -0
- package/src/learn/content/ordinary-income-vs-capital-gains.ts +103 -0
- package/src/learn/content/paying-conversion-taxes-taxable-vs-ira.ts +102 -0
- package/src/learn/content/pensions-and-annuities.ts +101 -0
- package/src/learn/content/permanent-life-insurance-in-a-plan.ts +106 -0
- package/src/learn/content/pia-aime-and-bend-points.ts +103 -0
- package/src/learn/content/planner-overview.ts +106 -0
- package/src/learn/content/planning-for-couples-and-survivor-years.ts +108 -0
- package/src/learn/content/privacy-what-stays-in-your-browser.ts +99 -0
- package/src/learn/content/qcds-qualified-charitable-distributions.ts +101 -0
- package/src/learn/content/reading-the-results-page.ts +96 -0
- package/src/learn/content/reading-the-social-security-analysis-page.ts +106 -0
- package/src/learn/content/real-estate-home-equity-and-debt.ts +100 -0
- package/src/learn/content/reports-csv-exports-and-sharing.ts +101 -0
- package/src/learn/content/risk-based-guardrails.ts +100 -0
- package/src/learn/content/rmds-required-minimum-distributions.ts +100 -0
- package/src/learn/content/roth-conversion-basics.ts +104 -0
- package/src/learn/content/rsus-and-espp.ts +101 -0
- package/src/learn/content/rule-of-55-and-72t.ts +107 -0
- package/src/learn/content/savings-rate-biggest-lever.ts +66 -0
- package/src/learn/content/seed-your-plan-from-your-tax-return.ts +93 -0
- package/src/learn/content/sensitivity-testing-what-changes-the-answer.ts +104 -0
- package/src/learn/content/sequence-of-returns-risk.ts +98 -0
- package/src/learn/content/social-security-bridge.ts +67 -0
- package/src/learn/content/social-security-claiming-age-basics.ts +113 -0
- package/src/learn/content/social-security-taxes-vs-benefits.ts +76 -0
- package/src/learn/content/spending-profiles-and-the-retirement-smile.ts +92 -0
- package/src/learn/content/spousal-and-survivor-benefits.ts +120 -0
- package/src/learn/content/ssdi-and-retirement-planning.ts +72 -0
- package/src/learn/content/standard-deduction-senior-deduction-and-itemizing.ts +97 -0
- package/src/learn/content/state-income-taxes-in-retirement.ts +97 -0
- package/src/learn/content/step-up-in-basis.ts +102 -0
- package/src/learn/content/survivor-planning-for-couples.ts +110 -0
- package/src/learn/content/survivor-spending-in-couple-plans.ts +98 -0
- package/src/learn/content/tax-cliffs-and-bracket-edges.ts +105 -0
- package/src/learn/content/tax-loss-and-gain-harvesting.ts +99 -0
- package/src/learn/content/taxable-brokerage-basis-and-capital-gains.ts +99 -0
- package/src/learn/content/three-big-questions-spending-time-risk.ts +103 -0
- package/src/learn/content/tips-ladders.ts +92 -0
- package/src/learn/content/todays-dollars-vs-future-dollars.ts +107 -0
- package/src/learn/content/traditional-vs-roth-contributions.ts +113 -0
- package/src/learn/content/troubleshooting-surprising-results.ts +105 -0
- package/src/learn/content/trust-fund-haircut-scenarios.ts +101 -0
- package/src/learn/content/understanding-monte-carlo-success-rate.ts +118 -0
- package/src/learn/content/understanding-your-plan-assumptions.ts +134 -0
- package/src/learn/content/using-assumptions-and-provenance.ts +98 -0
- package/src/learn/content/using-scenarios-to-compare-choices.ts +99 -0
- package/src/learn/content/what-changes-when-you-move-states.ts +141 -0
- package/src/learn/content/what-is-fire.ts +65 -0
- package/src/learn/content/what-monte-carlo-proves.ts +98 -0
- package/src/learn/content/what-retiregolden-models.ts +103 -0
- package/src/learn/content/what-retirement-healthcare-really-costs.ts +117 -0
- package/src/learn/content/why-95-percent-is-not-a-guarantee.ts +98 -0
- package/src/learn/content/why-roth-conversions-raise-other-costs.ts +106 -0
- package/src/learn/content/why-small-tax-cliffs-can-matter.ts +109 -0
- package/src/learn/content/widows-penalty-and-survivor-brackets.ts +106 -0
- package/src/learn/content/withdrawal-order-basics.ts +105 -0
- package/src/learn/glossary.ts +191 -0
- package/src/learn/inlineMarkdown.tsx +54 -0
- package/src/learn/learn.css +537 -0
- package/src/learn/learningRegistry.ts +502 -0
- package/src/longevity/LongevityResults.tsx +85 -0
- package/src/longevity/LongevityWizard.tsx +305 -0
- package/src/longevity/constants.ts +15 -0
- package/src/longevity/factors.ts +125 -0
- package/src/longevity/model.ts +31 -0
- package/src/longevity/persistedGuard.ts +129 -0
- package/src/longevity/storage.ts +40 -0
- package/src/mc/messages.ts +118 -0
- package/src/mc/monteCarlo.worker.ts +44 -0
- package/src/mc/pool.ts +267 -0
- package/src/mc/runRequest.ts +125 -0
- package/src/optimize/messages.ts +84 -0
- package/src/optimize/optimize.worker.ts +29 -0
- package/src/optimize/runOptimize.ts +92 -0
- package/src/optimize/runSpendingSolve.ts +47 -0
- package/src/optimize/runner.ts +21 -0
- package/src/optimize/spendingMessages.ts +44 -0
- package/src/optimize/spendingRunner.ts +21 -0
- package/src/optimize/spendingSolve.worker.ts +18 -0
- package/src/planner/AssumptionsCardPage.tsx +136 -0
- package/src/planner/BucketLensCard.tsx +114 -0
- package/src/planner/ComparePlansPage.tsx +219 -0
- package/src/planner/DisclaimerPage.tsx +88 -0
- package/src/planner/HowTestedPage.tsx +159 -0
- package/src/planner/LiveStatus.tsx +15 -0
- package/src/planner/LongevityModal.tsx +55 -0
- package/src/planner/Modal.tsx +97 -0
- package/src/planner/MonteCarloPage.tsx +907 -0
- package/src/planner/OptimizePage.tsx +611 -0
- package/src/planner/PlanContext.tsx +198 -0
- package/src/planner/PlanPickerPage.tsx +124 -0
- package/src/planner/PlanWorkspace.tsx +290 -0
- package/src/planner/ProvenancePanel.tsx +45 -0
- package/src/planner/RelocationComparePage.tsx +485 -0
- package/src/planner/ReportPage.tsx +375 -0
- package/src/planner/ResultsPage.tsx +817 -0
- package/src/planner/ScenariosPage.tsx +285 -0
- package/src/planner/SocialSecuritySection.tsx +556 -0
- package/src/planner/SpendingSolverPage.tsx +512 -0
- package/src/planner/SsAnalysisPage.tsx +1134 -0
- package/src/planner/SurvivalPercentileModal.tsx +161 -0
- package/src/planner/SurvivorTransitionPage.tsx +286 -0
- package/src/planner/assumptionsExport.ts +371 -0
- package/src/planner/bucketLens.ts +89 -0
- package/src/planner/chartFrame.ts +8 -0
- package/src/planner/chartStyle.ts +11 -0
- package/src/planner/dialogViews.tsx +184 -0
- package/src/planner/dialogs.tsx +133 -0
- package/src/planner/examples/ExampleLibrary.tsx +189 -0
- package/src/planner/examples/ExamplePreviewBanner.tsx +55 -0
- package/src/planner/examples/ExamplesPage.tsx +25 -0
- package/src/planner/examples/OpenExampleButton.tsx +61 -0
- package/src/planner/examples/buildAggressiveSaver.ts +102 -0
- package/src/planner/examples/buildAnnuityEstate.ts +137 -0
- package/src/planner/examples/buildBaristaFire.ts +115 -0
- package/src/planner/examples/buildBracketFillRoth.ts +65 -0
- package/src/planner/examples/buildBridgeEarlyRetirement.ts +94 -0
- package/src/planner/examples/buildBrokerageNoHsa.ts +109 -0
- package/src/planner/examples/buildCoastFire.ts +88 -0
- package/src/planner/examples/buildContext.ts +20 -0
- package/src/planner/examples/buildEarlyCareerMatch.ts +93 -0
- package/src/planner/examples/buildEarlyRetireeAca.ts +61 -0
- package/src/planner/examples/buildExampleCouple.ts +103 -0
- package/src/planner/examples/buildFixedTargetSpending.ts +74 -0
- package/src/planner/examples/buildGlidepathAllocation.ts +131 -0
- package/src/planner/examples/buildGuardrailsFlex.ts +120 -0
- package/src/planner/examples/buildHsaPropertyDepth.ts +109 -0
- package/src/planner/examples/buildHsaStealthRetirement.ts +97 -0
- package/src/planner/examples/buildLeanFatFire.ts +109 -0
- package/src/planner/examples/buildLtcShock.ts +62 -0
- package/src/planner/examples/buildMovingStateTax.ts +53 -0
- package/src/planner/examples/buildNoAnnuityBrokerage.ts +92 -0
- package/src/planner/examples/buildRmdIrmaa.ts +55 -0
- package/src/planner/examples/buildSalaryGrowthEscalation.ts +96 -0
- package/src/planner/examples/buildStaticAllocationControl.ts +96 -0
- package/src/planner/examples/buildSurvivorYears.ts +62 -0
- package/src/planner/examples/buildUnderSavedSingle.ts +51 -0
- package/src/planner/examples/exampleCopy.ts +23 -0
- package/src/planner/examples/loadExample.ts +90 -0
- package/src/planner/examples/registry.ts +313 -0
- package/src/planner/explainPanels.tsx +233 -0
- package/src/planner/fields.tsx +381 -0
- package/src/planner/format.ts +33 -0
- package/src/planner/home/DataAndPrivacyCard.tsx +56 -0
- package/src/planner/home/GettingStartedPaths.tsx +46 -0
- package/src/planner/home/GettingStartedReopener.tsx +32 -0
- package/src/planner/home/StartHereLinks.tsx +22 -0
- package/src/planner/home/WelcomeHero.tsx +39 -0
- package/src/planner/home/YourPlans.tsx +72 -0
- package/src/planner/home/importErrorMessage.ts +22 -0
- package/src/planner/home/startHereSlugs.ts +7 -0
- package/src/planner/home/useHomeData.ts +190 -0
- package/src/planner/home/useHomeMode.ts +47 -0
- package/src/planner/householdActions.ts +22 -0
- package/src/planner/insights/InsightCardView.tsx +340 -0
- package/src/planner/insights/InsightsPage.tsx +204 -0
- package/src/planner/insights/categoryLabels.ts +11 -0
- package/src/planner/learnLinks.ts +85 -0
- package/src/planner/marketModelPicker.ts +172 -0
- package/src/planner/optimizePageChart.ts +40 -0
- package/src/planner/optimizePageClaim.ts +64 -0
- package/src/planner/planCompleteness.ts +27 -0
- package/src/planner/planContextCore.ts +26 -0
- package/src/planner/planner.css +2304 -0
- package/src/planner/provenanceLinks.ts +25 -0
- package/src/planner/sections/AccountFields.tsx +872 -0
- package/src/planner/sections/AccountsSection.tsx +89 -0
- package/src/planner/sections/AllocationPanel.tsx +261 -0
- package/src/planner/sections/AssumptionsSection.tsx +256 -0
- package/src/planner/sections/HouseholdSection.tsx +243 -0
- package/src/planner/sections/IncomeFloorSection.tsx +418 -0
- package/src/planner/sections/IncomeSection.tsx +170 -0
- package/src/planner/sections/InsuranceSection.tsx +362 -0
- package/src/planner/sections/SpendingSection.tsx +904 -0
- package/src/planner/sections/StrategySection.tsx +349 -0
- package/src/planner/sections/UpdateBalancesPanel.tsx +182 -0
- package/src/planner/sections/sectionHelpers.ts +48 -0
- package/src/planner/sections/shared.tsx +15 -0
- package/src/planner/sections.tsx +15 -0
- package/src/planner/ssAnalysis.ts +325 -0
- package/src/planner/successBand.ts +20 -0
- package/src/planner/survivorAnalysis.ts +277 -0
- package/src/planner/usStates.ts +19 -0
- package/src/planner/useMcSuccessRate.ts +77 -0
- package/src/planner/useProjection.ts +63 -0
- package/src/relocation/messages.ts +21 -0
- package/src/relocation/relocation.worker.ts +18 -0
- package/src/relocation/runRelocation.ts +17 -0
- package/src/relocation/runner.ts +22 -0
- package/src/report/brandingContext.ts +15 -0
- package/src/report/downloadReport.ts +34 -0
- package/src/report/reportHtml.ts +547 -0
- package/src/routes/LearnRoutes.tsx +46 -0
- package/src/routes/PlanRoutes.tsx +55 -0
- package/src/routes/RouteFallback.tsx +9 -0
- package/src/socialSecurity/breakEven.ts +107 -0
- package/src/socialSecurity/expectedPv.ts +164 -0
- package/src/socialSecurity/explain.ts +92 -0
- package/src/socialSecurity/ficaReturn.ts +81 -0
- package/src/socialSecurity/persistedSsGuard.ts +138 -0
- package/src/socialSecurity/ssFormUtils.ts +48 -0
- package/src/socialSecurity/ssaStatementXml.ts +156 -0
- package/src/socialSecurity/storage.ts +69 -0
- package/src/socialSecurity/survivorSwitching.ts +153 -0
- package/src/testSupport/samplePlan.ts +2 -0
- package/src/workers/run.ts +45 -0
|
@@ -0,0 +1,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
|
+
}
|