@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,547 @@
|
|
|
1
|
+
import { objectivePolicies } from '@retiregolden/engine/decisions'
|
|
2
|
+
import type { Account, IncomeStream, Plan } from '@retiregolden/engine/model/plan'
|
|
3
|
+
import {
|
|
4
|
+
LATEST_PACK_YEAR,
|
|
5
|
+
PARAMETER_DATA_AS_OF,
|
|
6
|
+
PARAMETER_DATA_BASIS,
|
|
7
|
+
PARAMETER_PROVENANCE,
|
|
8
|
+
} from '@retiregolden/engine/params'
|
|
9
|
+
import { LATEST_STATE_PACK_YEAR } from '@retiregolden/engine/params/state'
|
|
10
|
+
import type { ProjectionSummary } from '@retiregolden/engine/projection/compare'
|
|
11
|
+
import type { ProjectionResult, YearResult } from '@retiregolden/engine/projection/types'
|
|
12
|
+
import type { ExactLedgerTournament, ExactLedgerValidation } from '@retiregolden/engine/projection/optimizePlan'
|
|
13
|
+
import type { OptimizeResult } from '../optimize/messages'
|
|
14
|
+
import { fmtMoney } from '../planner/format'
|
|
15
|
+
|
|
16
|
+
export interface ReportDecisionCandidateRow {
|
|
17
|
+
afterTaxEstateDelta: number
|
|
18
|
+
candidateId: string
|
|
19
|
+
label: string
|
|
20
|
+
lifetimeTaxDelta: number
|
|
21
|
+
lossReason: string
|
|
22
|
+
moneyLastsYearsDelta: number
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ReportClaimAgeEvidence {
|
|
26
|
+
combinationsEvaluated: number
|
|
27
|
+
/** Null when the current claim ages won the joint grid. */
|
|
28
|
+
winningClaimLabel: string | null
|
|
29
|
+
jointExactEstate: number
|
|
30
|
+
currentClaimExactEstate: number
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface ReportRecommendationEvidence {
|
|
34
|
+
objectiveId: string
|
|
35
|
+
objectiveLabel: string
|
|
36
|
+
recommendationState: string
|
|
37
|
+
winnerLabel: string
|
|
38
|
+
winnerSource: string
|
|
39
|
+
validation: ExactLedgerValidation | null
|
|
40
|
+
candidates: ReportDecisionCandidateRow[]
|
|
41
|
+
/** Non-null only when claim-age co-optimization ran (Step 5). */
|
|
42
|
+
claimAge: ReportClaimAgeEvidence | null
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Host-supplied identity for downloaded reports — a generic composition hook
|
|
47
|
+
* (any host can pass any brand); omitting every field reproduces today's
|
|
48
|
+
* RetireGolden report byte for byte. Values are sanitized, not trusted: the
|
|
49
|
+
* report's no-script guarantee must hold whatever the host passes.
|
|
50
|
+
*/
|
|
51
|
+
export interface ReportBranding {
|
|
52
|
+
/** Name used in the report <title>, the "prepared" line, and the download filename. Default: "RetireGolden". */
|
|
53
|
+
productName?: string
|
|
54
|
+
/** Letterhead logo. Must be a data:image/... URI so the report stays self-contained; anything else is ignored. */
|
|
55
|
+
logoDataUri?: string
|
|
56
|
+
/** Alt text for the logo; defaults to the product name. */
|
|
57
|
+
logoAlt?: string
|
|
58
|
+
/**
|
|
59
|
+
* Color for the letterhead rule under the header — hex, rgb()/hsl(), or a
|
|
60
|
+
* CSS named color; anything that doesn't parse as one falls back to the
|
|
61
|
+
* default RetireGolden gold.
|
|
62
|
+
*/
|
|
63
|
+
accentColor?: string
|
|
64
|
+
/** Extra line rendered at the end of the report (e.g. a firm disclosure). Plain text; rendered escaped. */
|
|
65
|
+
footerNote?: string
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface StandaloneReportInput {
|
|
69
|
+
plan: Plan
|
|
70
|
+
result: ProjectionResult
|
|
71
|
+
summary: ProjectionSummary
|
|
72
|
+
startYear: number
|
|
73
|
+
preparedAtIso?: string
|
|
74
|
+
recommendationEvidence?: ReportRecommendationEvidence | null
|
|
75
|
+
branding?: ReportBranding | null
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const DEFAULT_PRODUCT_NAME = 'RetireGolden'
|
|
79
|
+
const DEFAULT_ACCENT_COLOR = '#B8860B'
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The accent is interpolated into a CSS context where escapeHtml doesn't
|
|
83
|
+
* apply, so it must positively parse as a color — a charset allowlist alone
|
|
84
|
+
* would still admit safe-charset non-colors (`not-a-color`, `calc(1px)`,
|
|
85
|
+
* IE-era `expression(...)`), which the browser drops as invalid
|
|
86
|
+
* declarations, silently losing the letterhead rule instead of falling back.
|
|
87
|
+
* Recognized: hex, rgb()/rgba()/hsl()/hsla() (comma or space syntax), and
|
|
88
|
+
* CSS named colors. Anything else falls back to the default.
|
|
89
|
+
*/
|
|
90
|
+
const HEX_COLOR = /^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/
|
|
91
|
+
/** One rgb()/hsl() argument: a number with an optional %/angle unit, or CSS4 `none`. */
|
|
92
|
+
const COLOR_FUNCTION_ARG = /^(?:none|-?(?:\d+\.?\d*|\.\d+)(?:e-?\d+)?(?:%|deg|grad|rad|turn)?)$/i
|
|
93
|
+
// prettier-ignore
|
|
94
|
+
const NAMED_COLORS = new Set(('aliceblue,antiquewhite,aqua,aquamarine,azure,beige,bisque,black,blanchedalmond,blue,' +
|
|
95
|
+
'blueviolet,brown,burlywood,cadetblue,chartreuse,chocolate,coral,cornflowerblue,cornsilk,crimson,cyan,darkblue,' +
|
|
96
|
+
'darkcyan,darkgoldenrod,darkgray,darkgreen,darkgrey,darkkhaki,darkmagenta,darkolivegreen,darkorange,darkorchid,' +
|
|
97
|
+
'darkred,darksalmon,darkseagreen,darkslateblue,darkslategray,darkslategrey,darkturquoise,darkviolet,deeppink,' +
|
|
98
|
+
'deepskyblue,dimgray,dimgrey,dodgerblue,firebrick,floralwhite,forestgreen,fuchsia,gainsboro,ghostwhite,gold,' +
|
|
99
|
+
'goldenrod,gray,green,greenyellow,grey,honeydew,hotpink,indianred,indigo,ivory,khaki,lavender,lavenderblush,' +
|
|
100
|
+
'lawngreen,lemonchiffon,lightblue,lightcoral,lightcyan,lightgoldenrodyellow,lightgray,lightgreen,lightgrey,' +
|
|
101
|
+
'lightpink,lightsalmon,lightseagreen,lightskyblue,lightslategray,lightslategrey,lightsteelblue,lightyellow,lime,' +
|
|
102
|
+
'limegreen,linen,magenta,maroon,mediumaquamarine,mediumblue,mediumorchid,mediumpurple,mediumseagreen,' +
|
|
103
|
+
'mediumslateblue,mediumspringgreen,mediumturquoise,mediumvioletred,midnightblue,mintcream,mistyrose,moccasin,' +
|
|
104
|
+
'navajowhite,navy,oldlace,olive,olivedrab,orange,orangered,orchid,palegoldenrod,palegreen,paleturquoise,' +
|
|
105
|
+
'palevioletred,papayawhip,peachpuff,peru,pink,plum,powderblue,purple,rebeccapurple,red,rosybrown,royalblue,' +
|
|
106
|
+
'saddlebrown,salmon,sandybrown,seagreen,seashell,sienna,silver,skyblue,slateblue,slategray,slategrey,snow,' +
|
|
107
|
+
'springgreen,steelblue,tan,teal,thistle,tomato,transparent,turquoise,violet,wheat,white,whitesmoke,yellow,' +
|
|
108
|
+
'yellowgreen').split(','))
|
|
109
|
+
|
|
110
|
+
function isCssColor(value: string): boolean {
|
|
111
|
+
if (HEX_COLOR.test(value)) return true
|
|
112
|
+
const colorFunction = /^(?:rgb|rgba|hsl|hsla)\((.*)\)$/i.exec(value)
|
|
113
|
+
if (colorFunction) {
|
|
114
|
+
const args = colorFunction[1].trim().split(/\s*[,/]\s*|\s+/)
|
|
115
|
+
return args.length >= 3 && args.length <= 4 && args.every((arg) => COLOR_FUNCTION_ARG.test(arg))
|
|
116
|
+
}
|
|
117
|
+
return NAMED_COLORS.has(value.toLowerCase())
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function safeCssColor(value: string | undefined): string {
|
|
121
|
+
const trimmed = value?.trim()
|
|
122
|
+
if (!trimmed) return DEFAULT_ACCENT_COLOR
|
|
123
|
+
return trimmed.length <= 64 && isCssColor(trimmed) ? trimmed : DEFAULT_ACCENT_COLOR
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Only self-contained raster/svg data URIs; <img> never executes SVG scripts. */
|
|
127
|
+
function safeLogoDataUri(value: string | undefined): string | null {
|
|
128
|
+
const trimmed = value?.trim()
|
|
129
|
+
if (!trimmed) return null
|
|
130
|
+
return /^data:image\/(png|jpeg|gif|webp|svg\+xml|avif);base64,[a-zA-Z0-9+/=]+$/.test(trimmed) ? trimmed : null
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const CATEGORIES = ['cash', 'taxable', 'equityComp', 'traditional', 'roth', 'hsa'] as const
|
|
134
|
+
|
|
135
|
+
const ACCOUNT_LABEL: Record<Account['type'], string> = {
|
|
136
|
+
annuity: 'Annuity',
|
|
137
|
+
cash: 'Cash',
|
|
138
|
+
debt: 'Debt',
|
|
139
|
+
equityComp: 'Equity comp',
|
|
140
|
+
hsa: 'HSA',
|
|
141
|
+
pension: 'Pension',
|
|
142
|
+
property: 'Property',
|
|
143
|
+
roth: 'Roth',
|
|
144
|
+
taxable: 'Brokerage',
|
|
145
|
+
traditional: 'Traditional',
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function escapeHtml(value: unknown): string {
|
|
149
|
+
return String(value)
|
|
150
|
+
.replace(/&/g, '&')
|
|
151
|
+
.replace(/</g, '<')
|
|
152
|
+
.replace(/>/g, '>')
|
|
153
|
+
.replace(/"/g, '"')
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function fmtSignedMoney(value: number): string {
|
|
157
|
+
return `${value > 0 ? '+' : ''}${fmtMoney(value)}`
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function fmtPct(value: number, digits = 1): string {
|
|
161
|
+
return `${value.toFixed(digits)}%`
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function dateLabel(iso: string): string {
|
|
165
|
+
return new Date(iso).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function ownerName(plan: Plan, ownerPersonId: string | null): string {
|
|
169
|
+
if (ownerPersonId === null) return 'Joint'
|
|
170
|
+
return plan.household.people.find((p) => p.id === ownerPersonId)?.name ?? '-'
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function accountBalance(a: Account): number {
|
|
174
|
+
if ('balance' in a) return a.balance
|
|
175
|
+
if (a.type === 'property') return a.value
|
|
176
|
+
return 0
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function incomeLabel(plan: Plan, s: IncomeStream): string {
|
|
180
|
+
if (s.type === 'wages') return `Wages - ${ownerName(plan, s.personId)}`
|
|
181
|
+
if (s.type === 'socialSecurity') return `Social Security - ${ownerName(plan, s.personId)}`
|
|
182
|
+
return s.label
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function incomeDetail(s: IncomeStream): string {
|
|
186
|
+
switch (s.type) {
|
|
187
|
+
case 'wages':
|
|
188
|
+
return `${fmtMoney(s.annualGross)}/yr until ${s.endAge ?? 'retirement'}`
|
|
189
|
+
case 'socialSecurity':
|
|
190
|
+
return `${s.piaMonthly !== null ? `${fmtMoney(s.piaMonthly)}/mo PIA` : 'from earnings record'}, claim ${s.claimAge.years}y${s.claimAge.months ? ` ${s.claimAge.months}m` : ''}`
|
|
191
|
+
case 'recurring':
|
|
192
|
+
return `${fmtMoney(s.annualAmount)}/yr${s.inflationAdjusted ? ', inflation-adjusted' : ''}`
|
|
193
|
+
case 'oneTime':
|
|
194
|
+
return `${fmtMoney(s.amount)} in ${s.year}`
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function conversionSummary(plan: Plan): string {
|
|
199
|
+
const rc = plan.strategies.rothConversion
|
|
200
|
+
if (rc.mode === 'none') return 'None'
|
|
201
|
+
if (rc.mode === 'manual') return `Manual - ${rc.conversions.length} year(s)`
|
|
202
|
+
if (rc.mode === 'optimized') return `Optimized - ${rc.conversions.length} year(s)`
|
|
203
|
+
return `Fill to ${rc.target}${rc.targetValue !== null ? ` (${rc.targetValue})` : ''}, ${rc.startYear}-${rc.endYear}`
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function withdrawalSummary(plan: Plan): string {
|
|
207
|
+
const w = plan.strategies.withdrawalOrder
|
|
208
|
+
if (w.mode === 'sequential') return 'Sequential: cash, taxable, equity comp, traditional, Roth, HSA'
|
|
209
|
+
if (w.mode === 'proportional') return 'Proportional across accounts'
|
|
210
|
+
return `Bracket-targeted to ${w.bracketPct}%`
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function spendingPolicySummary(plan: Plan): string {
|
|
214
|
+
const policy = plan.expenses.spendingPolicy
|
|
215
|
+
if (!policy || policy.mode === 'fixedTarget') return 'Fixed target (no guardrails)'
|
|
216
|
+
if (policy.mode === 'withdrawalRateGuardrails') {
|
|
217
|
+
return `Withdrawal-rate guardrails: cut above ${policy.upperGuardrailPct ?? 120}% / restore below ${policy.lowerGuardrailPct ?? 80}% of the starting rate, ${policy.adjustmentPct ?? 10}% steps`
|
|
218
|
+
}
|
|
219
|
+
if (policy.mode === 'abw') {
|
|
220
|
+
const source =
|
|
221
|
+
policy.abw?.returnSource === 'cape'
|
|
222
|
+
? `CAPE ${policy.abw?.startingCape ?? 25} earnings yield`
|
|
223
|
+
: policy.abw?.returnSource === 'tips'
|
|
224
|
+
? `TIPS real yield ${policy.abw?.bondRealYieldPct ?? 2}%`
|
|
225
|
+
: `fixed ${policy.abw?.fixedRealReturnPct ?? 3.8}% real`
|
|
226
|
+
const horizon =
|
|
227
|
+
policy.abw?.horizon === 'survival25'
|
|
228
|
+
? '25% survival age'
|
|
229
|
+
: policy.abw?.horizon === 'survival10'
|
|
230
|
+
? '10% survival age'
|
|
231
|
+
: 'planning age'
|
|
232
|
+
return `Amortized spending (ABW): ${source}, to ${horizon}, tilt ${policy.abw?.tiltPct ?? 0}%/yr`
|
|
233
|
+
}
|
|
234
|
+
const lower =
|
|
235
|
+
policy.lowerBalanceThresholdPct !== undefined
|
|
236
|
+
? `cut below ${policy.lowerBalanceThresholdPct.toFixed(0)}%`
|
|
237
|
+
: 'no cut threshold'
|
|
238
|
+
const upper =
|
|
239
|
+
policy.upperBalanceThresholdPct !== undefined
|
|
240
|
+
? `raise above ${policy.upperBalanceThresholdPct.toFixed(0)}%`
|
|
241
|
+
: 'no raise threshold'
|
|
242
|
+
return `Risk-based guardrails (${policy.targetSuccessLowerPct ?? 70}-${policy.targetSuccessUpperPct ?? 95}% success band): ${lower} / ${upper} of the starting portfolio, ${policy.adjustmentPct ?? 10}% steps`
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function table(headers: string[], rows: string[][]): string {
|
|
246
|
+
return [
|
|
247
|
+
'<table>',
|
|
248
|
+
`<thead><tr>${headers.map((h) => `<th>${escapeHtml(h)}</th>`).join('')}</tr></thead>`,
|
|
249
|
+
`<tbody>${rows.map((row) => `<tr>${row.map((cell) => `<td>${cell}</td>`).join('')}</tr>`).join('')}</tbody>`,
|
|
250
|
+
'</table>',
|
|
251
|
+
].join('')
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function headlineSection(summary: ProjectionSummary, result: ProjectionResult): string {
|
|
255
|
+
const lasts = summary.depletionYear === null ? `Full plan through ${result.endYear}` : `Depletes in ${summary.depletionYear}`
|
|
256
|
+
const rows = [
|
|
257
|
+
['Ending net worth', fmtMoney(summary.endingNetWorth), `in ${result.endYear}`],
|
|
258
|
+
['Ending after-tax estate', fmtMoney(summary.endingAfterTaxEstate), 'net of heir tax on pre-tax balances'],
|
|
259
|
+
['Money lasts', escapeHtml(lasts), 'deterministic exact-ledger run'],
|
|
260
|
+
['Lifetime tax + penalties', fmtMoney(summary.lifetimeTaxesAndPenalties), 'federal + state + penalties'],
|
|
261
|
+
['Lifetime Roth conversions', fmtMoney(summary.lifetimeRothConversions), 'executed by the ledger'],
|
|
262
|
+
['FI target', fmtMoney(summary.fiNumber), 'today-dollar portfolio target'],
|
|
263
|
+
]
|
|
264
|
+
return `<section><h2>Headline results</h2>${table(['Metric', 'Value', 'Notes'], rows)}</section>`
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function householdSection(plan: Plan): string {
|
|
268
|
+
return `<section><h2>Household</h2>${table(
|
|
269
|
+
['Person', 'Date of birth', 'Retirement age', 'Planning age'],
|
|
270
|
+
plan.household.people.map((p) => [
|
|
271
|
+
escapeHtml(p.name),
|
|
272
|
+
escapeHtml(p.dob),
|
|
273
|
+
escapeHtml(p.retirementAge ?? '-'),
|
|
274
|
+
escapeHtml(p.longevity.planningAge),
|
|
275
|
+
]),
|
|
276
|
+
)}</section>`
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function accountsSection(plan: Plan): string {
|
|
280
|
+
const rows = plan.accounts.map((a) => [
|
|
281
|
+
escapeHtml(a.name),
|
|
282
|
+
escapeHtml(ACCOUNT_LABEL[a.type]),
|
|
283
|
+
escapeHtml(ownerName(plan, a.ownerPersonId)),
|
|
284
|
+
fmtMoney(accountBalance(a)),
|
|
285
|
+
escapeHtml('annualReturnPct' in a && a.annualReturnPct !== null ? `${a.annualReturnPct}%` : 'default'),
|
|
286
|
+
])
|
|
287
|
+
return `<section><h2>Accounts</h2>${table(['Account', 'Type', 'Owner', 'Balance', 'Return'], rows)}</section>`
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function incomeSection(plan: Plan): string {
|
|
291
|
+
return `<section><h2>Income</h2>${table(
|
|
292
|
+
['Source', 'Detail'],
|
|
293
|
+
plan.incomes.map((stream) => [escapeHtml(incomeLabel(plan, stream)), escapeHtml(incomeDetail(stream))]),
|
|
294
|
+
)}</section>`
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function assumptionsSection(plan: Plan): string {
|
|
298
|
+
const rows = [
|
|
299
|
+
['General inflation', fmtPct(plan.assumptions.inflationPct)],
|
|
300
|
+
['Healthcare extra inflation', fmtPct(plan.assumptions.healthcareExtraInflationPct)],
|
|
301
|
+
['Default return', fmtPct(plan.assumptions.defaultReturnPct)],
|
|
302
|
+
['Safe withdrawal rate', fmtPct(plan.assumptions.safeWithdrawalRatePct ?? 4)],
|
|
303
|
+
['State effective tax override', fmtPct(plan.assumptions.stateEffectiveTaxPct)],
|
|
304
|
+
['Local income tax', fmtPct(plan.assumptions.localIncomeTaxPct)],
|
|
305
|
+
['Heir tax rate', fmtPct(plan.assumptions.heirTaxRatePct, 0)],
|
|
306
|
+
['Roth conversions', escapeHtml(conversionSummary(plan))],
|
|
307
|
+
['Withdrawal order', escapeHtml(withdrawalSummary(plan))],
|
|
308
|
+
['Spending policy', escapeHtml(spendingPolicySummary(plan))],
|
|
309
|
+
['Federal parameter pack', `${LATEST_PACK_YEAR}`],
|
|
310
|
+
['State parameter pack', `${LATEST_STATE_PACK_YEAR}`],
|
|
311
|
+
['Parameter data as of', escapeHtml(PARAMETER_DATA_AS_OF)],
|
|
312
|
+
['Parameter data basis', escapeHtml(PARAMETER_DATA_BASIS)],
|
|
313
|
+
]
|
|
314
|
+
return `<section><h2>Assumptions and provenance</h2>${table(['Assumption', 'Value'], rows)}</section>`
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function warningsSection(result: ProjectionResult): string {
|
|
318
|
+
if (result.warnings.length === 0) return ''
|
|
319
|
+
return `<section><h2>Modeling notes</h2><ul>${result.warnings.map((w) => `<li>${escapeHtml(w)}</li>`).join('')}</ul></section>`
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function provenanceSection(): string {
|
|
323
|
+
return `<section><h2>Parameter source appendix</h2>${table(
|
|
324
|
+
['Group', 'Figures', 'Publisher', 'Source'],
|
|
325
|
+
PARAMETER_PROVENANCE.map((source) => [
|
|
326
|
+
escapeHtml(source.label),
|
|
327
|
+
escapeHtml(source.figures),
|
|
328
|
+
escapeHtml(source.publisher),
|
|
329
|
+
`<a href="${escapeHtml(source.url)}">${escapeHtml(source.url)}</a>`,
|
|
330
|
+
]),
|
|
331
|
+
)}</section>`
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function recommendationSection(evidence: ReportRecommendationEvidence | null | undefined): string {
|
|
335
|
+
if (!evidence) return ''
|
|
336
|
+
const validation = evidence.validation
|
|
337
|
+
const summaryRows = [
|
|
338
|
+
['Objective', escapeHtml(evidence.objectiveLabel)],
|
|
339
|
+
['Winner source', escapeHtml(evidence.winnerSource)],
|
|
340
|
+
['Winner', escapeHtml(evidence.winnerLabel)],
|
|
341
|
+
['Recommendation state', escapeHtml(evidence.recommendationState)],
|
|
342
|
+
]
|
|
343
|
+
if (validation) {
|
|
344
|
+
summaryRows.push(
|
|
345
|
+
['Baseline after-tax estate', fmtMoney(validation.baseline.endingAfterTaxEstate)],
|
|
346
|
+
['Candidate after-tax estate', fmtMoney(validation.candidate.endingAfterTaxEstate)],
|
|
347
|
+
['After-tax estate delta', fmtSignedMoney(validation.afterTaxEstateDelta)],
|
|
348
|
+
['Lifetime tax delta', fmtSignedMoney(validation.lifetimeTaxDelta)],
|
|
349
|
+
['Money-lasts delta', `${validation.moneyLastsYearsDelta > 0 ? '+' : ''}${validation.moneyLastsYearsDelta} year(s)`],
|
|
350
|
+
['Requested conversions', fmtMoney(validation.requestedConversionTotal)],
|
|
351
|
+
['Executed conversions', `${fmtMoney(validation.executedConversionTotal)} (${Math.round(validation.executedConversionRatio * 100)}%)`],
|
|
352
|
+
)
|
|
353
|
+
if (validation.firstMateriallyUnexecutedYear !== null) {
|
|
354
|
+
summaryRows.push(['First material execution shortfall', `${validation.firstMateriallyUnexecutedYear}`])
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
if (evidence.claimAge) {
|
|
358
|
+
const claim = evidence.claimAge
|
|
359
|
+
summaryRows.push(
|
|
360
|
+
['SS claim combinations optimized', `${claim.combinationsEvaluated}`],
|
|
361
|
+
[
|
|
362
|
+
'Recommended Social Security claim change',
|
|
363
|
+
escapeHtml(claim.winningClaimLabel ?? 'None - current claim ages held'),
|
|
364
|
+
],
|
|
365
|
+
)
|
|
366
|
+
if (claim.winningClaimLabel !== null) {
|
|
367
|
+
summaryRows.push(
|
|
368
|
+
['Joint (claim + conversions) after-tax estate', fmtMoney(claim.jointExactEstate)],
|
|
369
|
+
['Best current-claim after-tax estate', fmtMoney(claim.currentClaimExactEstate)],
|
|
370
|
+
['Claim-change estate gain', fmtSignedMoney(claim.jointExactEstate - claim.currentClaimExactEstate)],
|
|
371
|
+
)
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
const candidateRows = evidence.candidates.map((candidate) => [
|
|
375
|
+
escapeHtml(candidate.label),
|
|
376
|
+
fmtSignedMoney(candidate.afterTaxEstateDelta),
|
|
377
|
+
fmtSignedMoney(candidate.lifetimeTaxDelta),
|
|
378
|
+
`${candidate.moneyLastsYearsDelta > 0 ? '+' : ''}${candidate.moneyLastsYearsDelta}`,
|
|
379
|
+
escapeHtml(candidate.lossReason),
|
|
380
|
+
])
|
|
381
|
+
return `<section><h2>Recommendation evidence</h2>${table(['Field', 'Value'], summaryRows)}${
|
|
382
|
+
candidateRows.length > 0
|
|
383
|
+
? `<h3>Candidate loss reasons</h3>${table(['Candidate', 'Estate delta', 'Tax delta', 'Years delta', 'Reason'], candidateRows)}`
|
|
384
|
+
: ''
|
|
385
|
+
}</section>`
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function appendixSection(result: ProjectionResult): string {
|
|
389
|
+
const rowFor = (y: YearResult) => [
|
|
390
|
+
`${y.year}`,
|
|
391
|
+
fmtMoney(y.incomes.total),
|
|
392
|
+
fmtMoney(y.expenses.total),
|
|
393
|
+
fmtMoney(y.contributions),
|
|
394
|
+
fmtMoney(y.rmd),
|
|
395
|
+
fmtMoney(y.rothConversion),
|
|
396
|
+
fmtMoney(y.tax + y.penalties),
|
|
397
|
+
fmtMoney(y.magi),
|
|
398
|
+
fmtMoney(y.withdrawals.total),
|
|
399
|
+
fmtMoney(y.investableTotal),
|
|
400
|
+
fmtMoney(y.netWorth),
|
|
401
|
+
]
|
|
402
|
+
return `<section><h2>Year-by-year ledger appendix</h2>${table(
|
|
403
|
+
['Year', 'Income', 'Expenses', 'Contrib.', 'RMD', 'Roth conv.', 'Tax + penalties', 'MAGI', 'Withdrawals', 'Investable', 'Net worth'],
|
|
404
|
+
result.years.map(rowFor),
|
|
405
|
+
)}</section>`
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function balanceChartData(plan: Plan, result: ProjectionResult): string {
|
|
409
|
+
const rows = result.years.map((year) => {
|
|
410
|
+
const categories = Object.fromEntries(CATEGORIES.map((category) => [category, 0])) as Record<(typeof CATEGORIES)[number], number>
|
|
411
|
+
for (const account of plan.accounts) {
|
|
412
|
+
if ((CATEGORIES as readonly string[]).includes(account.type)) {
|
|
413
|
+
categories[account.type as (typeof CATEGORIES)[number]] += year.balances[account.id] ?? 0
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
return [
|
|
417
|
+
year.year,
|
|
418
|
+
...CATEGORIES.map((category) => Math.round(categories[category])),
|
|
419
|
+
Math.round(year.incomes.total),
|
|
420
|
+
Math.round(year.expenses.total + year.tax + year.penalties),
|
|
421
|
+
].join(',')
|
|
422
|
+
})
|
|
423
|
+
return [
|
|
424
|
+
['year', ...CATEGORIES, 'income', 'spendingPlusTax'].join(','),
|
|
425
|
+
...rows,
|
|
426
|
+
].join('\n')
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
export function buildStandaloneReportHtml(input: StandaloneReportInput): string {
|
|
430
|
+
const preparedAtIso = input.preparedAtIso ?? new Date().toISOString()
|
|
431
|
+
const csvData = escapeHtml(balanceChartData(input.plan, input.result))
|
|
432
|
+
const branding = input.branding ?? {}
|
|
433
|
+
const productName = branding.productName?.trim() || DEFAULT_PRODUCT_NAME
|
|
434
|
+
const accentColor = safeCssColor(branding.accentColor)
|
|
435
|
+
const logoDataUri = safeLogoDataUri(branding.logoDataUri)
|
|
436
|
+
const logoHtml = logoDataUri
|
|
437
|
+
? `<img class="report-logo" src="${escapeHtml(logoDataUri)}" alt="${escapeHtml(branding.logoAlt?.trim() || productName)}">\n`
|
|
438
|
+
: ''
|
|
439
|
+
const logoCss = logoDataUri ? '.report-logo { display: block; max-height: 56px; max-width: 280px; margin-bottom: 12px; }\n' : ''
|
|
440
|
+
const footerNote = branding.footerNote?.trim()
|
|
441
|
+
const footerNoteHtml = footerNote ? `\n<p class="muted">${escapeHtml(footerNote)}</p>` : ''
|
|
442
|
+
return `<!doctype html>
|
|
443
|
+
<html lang="en">
|
|
444
|
+
<head>
|
|
445
|
+
<meta charset="utf-8">
|
|
446
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
447
|
+
<title>${escapeHtml(input.plan.name)} - ${escapeHtml(productName)} report</title>
|
|
448
|
+
<style>
|
|
449
|
+
:root { color-scheme: light; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: #12342b; background: #f7fbf8; }
|
|
450
|
+
body { margin: 0; }
|
|
451
|
+
main { max-width: 1040px; margin: 0 auto; padding: 32px 20px 56px; }
|
|
452
|
+
header { border-bottom: 3px solid ${accentColor}; margin-bottom: 24px; padding-bottom: 18px; }
|
|
453
|
+
${logoCss}h1 { font-size: 2rem; margin: 0 0 6px; }
|
|
454
|
+
h2 { border-bottom: 1px solid #cfe2d8; font-size: 1.25rem; margin: 32px 0 12px; padding-bottom: 6px; }
|
|
455
|
+
h3 { font-size: 1rem; margin: 18px 0 8px; }
|
|
456
|
+
p, li { line-height: 1.5; }
|
|
457
|
+
.muted { color: #577167; }
|
|
458
|
+
.disclaimer { background: #eef8f1; border: 1px solid #cfe2d8; border-radius: 8px; padding: 12px 14px; }
|
|
459
|
+
table { border-collapse: collapse; font-size: 0.9rem; margin: 10px 0 18px; width: 100%; }
|
|
460
|
+
th, td { border-bottom: 1px solid #dceae3; padding: 7px 8px; text-align: left; vertical-align: top; }
|
|
461
|
+
th { background: #eaf5ef; font-weight: 700; }
|
|
462
|
+
a { color: #087452; }
|
|
463
|
+
details { margin-top: 16px; }
|
|
464
|
+
pre { background: #f0f5f2; border: 1px solid #dceae3; border-radius: 8px; max-height: 220px; overflow: auto; padding: 10px; }
|
|
465
|
+
@media print { body { background: #fff; } main { padding: 0; } a { color: inherit; } }
|
|
466
|
+
</style>
|
|
467
|
+
</head>
|
|
468
|
+
<body>
|
|
469
|
+
<main>
|
|
470
|
+
<header>
|
|
471
|
+
${logoHtml}<h1>${escapeHtml(input.plan.name)}</h1>
|
|
472
|
+
<p class="muted">${escapeHtml(productName)} self-contained HTML report prepared ${escapeHtml(dateLabel(preparedAtIso))}. Projection start year: ${input.startYear}.</p>
|
|
473
|
+
</header>
|
|
474
|
+
<p class="disclaimer">Educational illustration only - not tax, legal, financial, or medical advice. Figures are projections based on the assumptions below and will differ from actual results.</p>
|
|
475
|
+
${headlineSection(input.summary, input.result)}
|
|
476
|
+
${recommendationSection(input.recommendationEvidence)}
|
|
477
|
+
${householdSection(input.plan)}
|
|
478
|
+
${accountsSection(input.plan)}
|
|
479
|
+
${incomeSection(input.plan)}
|
|
480
|
+
${assumptionsSection(input.plan)}
|
|
481
|
+
${warningsSection(input.result)}
|
|
482
|
+
${appendixSection(input.result)}
|
|
483
|
+
${provenanceSection()}
|
|
484
|
+
<details>
|
|
485
|
+
<summary>Embedded chart/automation data CSV</summary>
|
|
486
|
+
<pre>${csvData}</pre>
|
|
487
|
+
</details>${footerNoteHtml}
|
|
488
|
+
</main>
|
|
489
|
+
</body>
|
|
490
|
+
</html>`
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function lossReasonForCandidate(
|
|
494
|
+
tournament: ExactLedgerTournament,
|
|
495
|
+
validation: ExactLedgerValidation | null,
|
|
496
|
+
candidate: ExactLedgerTournament['candidates'][number],
|
|
497
|
+
): string {
|
|
498
|
+
if (tournament.winnerCandidateId === candidate.id) return 'Selected exact-ledger winner.'
|
|
499
|
+
if (candidate.afterTaxEstateDelta <= 1) return 'Did not improve after-tax estate over the current plan.'
|
|
500
|
+
const benchmark = validation?.afterTaxEstateDelta ?? Math.max(0, ...tournament.candidates.map((row) => row.afterTaxEstateDelta))
|
|
501
|
+
if (benchmark > candidate.afterTaxEstateDelta) return `Trailed the selected recommendation by ${fmtMoney(benchmark - candidate.afterTaxEstateDelta)}.`
|
|
502
|
+
if (tournament.winnerSource === 'incumbent') return 'The current conversion strategy remained the best result found.'
|
|
503
|
+
if (tournament.winnerSource === 'none') return 'No candidate cleared the recommendation threshold.'
|
|
504
|
+
return 'Not selected under the active objective and guardrails.'
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
export function reportEvidenceFromOptimizeResult(result: OptimizeResult): ReportRecommendationEvidence {
|
|
508
|
+
const tournament = result.tournament
|
|
509
|
+
// Only the winning source's validation belongs on the report. winnerValidation is
|
|
510
|
+
// set for 'milp'/'candidate' winners and intentionally null for 'incumbent'/'none';
|
|
511
|
+
// do not fall back to the solver's cleanedValidation, which describes a schedule that
|
|
512
|
+
// did not win and would mislabel a "no change" / incumbent result.
|
|
513
|
+
const validation = tournament.winnerValidation ?? null
|
|
514
|
+
const policy = objectivePolicies[tournament.policyId]
|
|
515
|
+
const recommendationState = validation?.recommendationState ?? (tournament.winnerSource === 'incumbent' ? 'neutral' : 'none')
|
|
516
|
+
const winnerLabel =
|
|
517
|
+
tournament.winnerLabel ??
|
|
518
|
+
(tournament.winnerSource === 'milp'
|
|
519
|
+
? "the solver's cleaned schedule"
|
|
520
|
+
: tournament.winnerSource === 'incumbent'
|
|
521
|
+
? 'current plan strategy'
|
|
522
|
+
: 'none')
|
|
523
|
+
return {
|
|
524
|
+
objectiveId: tournament.policyId,
|
|
525
|
+
objectiveLabel: policy.label,
|
|
526
|
+
recommendationState,
|
|
527
|
+
winnerLabel,
|
|
528
|
+
winnerSource: tournament.winnerSource,
|
|
529
|
+
validation,
|
|
530
|
+
candidates: tournament.candidates.map((candidate) => ({
|
|
531
|
+
afterTaxEstateDelta: candidate.afterTaxEstateDelta,
|
|
532
|
+
candidateId: candidate.id,
|
|
533
|
+
label: candidate.label,
|
|
534
|
+
lifetimeTaxDelta: candidate.lifetimeTaxDelta,
|
|
535
|
+
lossReason: lossReasonForCandidate(tournament, validation, candidate),
|
|
536
|
+
moneyLastsYearsDelta: candidate.moneyLastsYearsDelta,
|
|
537
|
+
})),
|
|
538
|
+
claimAge: result.claimAge?.enabled
|
|
539
|
+
? {
|
|
540
|
+
combinationsEvaluated: result.claimAge.combinationsEvaluated,
|
|
541
|
+
winningClaimLabel: result.claimAge.winningClaimLabel,
|
|
542
|
+
jointExactEstate: result.claimAge.jointExactEstate,
|
|
543
|
+
currentClaimExactEstate: result.claimAge.currentClaimExactEstate,
|
|
544
|
+
}
|
|
545
|
+
: null,
|
|
546
|
+
}
|
|
547
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { useEffect } from 'react'
|
|
2
|
+
import { Route, Routes, useLocation } from 'react-router-dom'
|
|
3
|
+
import { LearningCenterPage } from '../learn/LearningCenterPage'
|
|
4
|
+
import { ArticlePage } from '../learn/ArticlePage'
|
|
5
|
+
import { GlossaryPage } from '../learn/GlossaryPage'
|
|
6
|
+
import { SourcesPage } from '../learn/SourcesPage'
|
|
7
|
+
import '../learn/learn.css'
|
|
8
|
+
|
|
9
|
+
function ScrollToLearnTarget() {
|
|
10
|
+
const { hash, pathname } = useLocation()
|
|
11
|
+
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
if (hash) {
|
|
14
|
+
let targetId = hash.slice(1)
|
|
15
|
+
try {
|
|
16
|
+
targetId = decodeURIComponent(targetId)
|
|
17
|
+
} catch {
|
|
18
|
+
// Malformed external hashes should still land at a useful reading position.
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const target = document.getElementById(targetId)
|
|
22
|
+
if (target) {
|
|
23
|
+
target.scrollIntoView()
|
|
24
|
+
return
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
window.scrollTo({ top: 0, left: 0, behavior: 'auto' })
|
|
29
|
+
}, [hash, pathname])
|
|
30
|
+
|
|
31
|
+
return null
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export default function LearnRoutes() {
|
|
35
|
+
return (
|
|
36
|
+
<>
|
|
37
|
+
<ScrollToLearnTarget />
|
|
38
|
+
<Routes>
|
|
39
|
+
<Route index element={<LearningCenterPage />} />
|
|
40
|
+
<Route path="glossary" element={<GlossaryPage />} />
|
|
41
|
+
<Route path="sources" element={<SourcesPage />} />
|
|
42
|
+
<Route path=":slug" element={<ArticlePage />} />
|
|
43
|
+
</Routes>
|
|
44
|
+
</>
|
|
45
|
+
)
|
|
46
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { Navigate, Route, Routes } from 'react-router-dom'
|
|
2
|
+
import { PlanWorkspace } from '../planner/PlanWorkspace'
|
|
3
|
+
import { ReportPage } from '../planner/ReportPage'
|
|
4
|
+
import {
|
|
5
|
+
AccountsSection,
|
|
6
|
+
AssumptionsSection,
|
|
7
|
+
HouseholdSection,
|
|
8
|
+
IncomeFloorSection,
|
|
9
|
+
IncomeSection,
|
|
10
|
+
InsuranceSection,
|
|
11
|
+
SpendingSection,
|
|
12
|
+
StrategySection,
|
|
13
|
+
} from '../planner/sections'
|
|
14
|
+
import { AssumptionsCardPage } from '../planner/AssumptionsCardPage'
|
|
15
|
+
import { SocialSecuritySection } from '../planner/SocialSecuritySection'
|
|
16
|
+
import { SsAnalysisPage } from '../planner/SsAnalysisPage'
|
|
17
|
+
import { ResultsPage } from '../planner/ResultsPage'
|
|
18
|
+
import { MonteCarloPage } from '../planner/MonteCarloPage'
|
|
19
|
+
import { RelocationComparePage } from '../planner/RelocationComparePage'
|
|
20
|
+
import { ScenariosPage } from '../planner/ScenariosPage'
|
|
21
|
+
import { OptimizePage } from '../planner/OptimizePage'
|
|
22
|
+
import { SpendingSolverPage } from '../planner/SpendingSolverPage'
|
|
23
|
+
import { SurvivorTransitionPage } from '../planner/SurvivorTransitionPage'
|
|
24
|
+
import { InsightsPage } from '../planner/insights/InsightsPage'
|
|
25
|
+
import '../planner/planner.css'
|
|
26
|
+
|
|
27
|
+
export default function PlanRoutes() {
|
|
28
|
+
return (
|
|
29
|
+
<Routes>
|
|
30
|
+
<Route path=":planId" element={<PlanWorkspace />}>
|
|
31
|
+
<Route index element={<Navigate to="household" replace />} />
|
|
32
|
+
<Route path="household" element={<HouseholdSection />} />
|
|
33
|
+
<Route path="social-security" element={<SocialSecuritySection />} />
|
|
34
|
+
<Route path="accounts" element={<AccountsSection />} />
|
|
35
|
+
<Route path="insurance" element={<InsuranceSection />} />
|
|
36
|
+
<Route path="income" element={<IncomeSection />} />
|
|
37
|
+
<Route path="income-floor" element={<IncomeFloorSection />} />
|
|
38
|
+
<Route path="spending" element={<SpendingSection />} />
|
|
39
|
+
<Route path="strategy" element={<StrategySection />} />
|
|
40
|
+
<Route path="assumptions" element={<AssumptionsSection />} />
|
|
41
|
+
<Route path="assumptions-card" element={<AssumptionsCardPage />} />
|
|
42
|
+
<Route path="social-security-analysis" element={<SsAnalysisPage />} />
|
|
43
|
+
<Route path="results" element={<ResultsPage />} />
|
|
44
|
+
<Route path="monte-carlo" element={<MonteCarloPage />} />
|
|
45
|
+
<Route path="scenarios" element={<ScenariosPage />} />
|
|
46
|
+
<Route path="survivor" element={<SurvivorTransitionPage />} />
|
|
47
|
+
<Route path="relocation" element={<RelocationComparePage />} />
|
|
48
|
+
<Route path="optimize" element={<OptimizePage />} />
|
|
49
|
+
<Route path="spending-solver" element={<SpendingSolverPage />} />
|
|
50
|
+
<Route path="insights" element={<InsightsPage />} />
|
|
51
|
+
</Route>
|
|
52
|
+
<Route path=":planId/report" element={<ReportPage />} />
|
|
53
|
+
</Routes>
|
|
54
|
+
)
|
|
55
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Shown while a lazy route chunk loads — the shared skeleton vocabulary, not bare text. */
|
|
2
|
+
export function RouteFallback() {
|
|
3
|
+
return (
|
|
4
|
+
<div className="page" role="status" aria-live="polite" aria-label="Loading">
|
|
5
|
+
<div className="skeleton" style={{ height: '2rem', marginBottom: '1rem' }} aria-hidden="true" />
|
|
6
|
+
<div className="skeleton" style={{ height: '14rem' }} aria-hidden="true" />
|
|
7
|
+
</div>
|
|
8
|
+
)
|
|
9
|
+
}
|