@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,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, '&amp;')
151
+ .replace(/</g, '&lt;')
152
+ .replace(/>/g, '&gt;')
153
+ .replace(/"/g, '&quot;')
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
+ }