@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,224 @@
1
+ /**
2
+ * Generic spreadsheet / RPM CSV import (onboarding-import-and-migration step 4).
3
+ *
4
+ * For everything without a recognized export: the Bogleheads Retiree Portfolio
5
+ * Model saved as CSV, a homegrown net-worth spreadsheet, another tool's ad-hoc
6
+ * dump. Two-phase: `analyzeGenericCsv` finds the header row and guesses a role
7
+ * for each column; the wizard lets the user correct the roles; then
8
+ * `draftPlanFromGenericCsv` maps rows to accounts with the shared review
9
+ * checklist. Reuses the hardened CSV core from step 2.
10
+ */
11
+
12
+ import type { Account, Plan } from '@retiregolden/engine/model/plan'
13
+ import { createEmptyPlan, parsePlan } from '@retiregolden/engine/model/plan'
14
+ import { parseCsv, parseMoney } from './csv'
15
+ import { mapProjectionLabAccountType } from './projectionLab'
16
+ import type { ImportReviewItem } from './reviewChecklist'
17
+
18
+ export type ColumnRole = 'name' | 'type' | 'balance' | 'costBasis' | 'contribution' | 'ignore'
19
+
20
+ export const COLUMN_ROLE_LABEL: Record<ColumnRole, string> = {
21
+ name: 'Account name',
22
+ type: 'Account type',
23
+ balance: 'Balance / value',
24
+ costBasis: 'Cost basis',
25
+ contribution: 'Annual contribution',
26
+ ignore: 'Ignore',
27
+ }
28
+
29
+ export interface GenericCsvAnalysis {
30
+ header: string[]
31
+ dataRows: string[][]
32
+ /** One guessed role per header column; the wizard lets the user override. */
33
+ guessedRoles: ColumnRole[]
34
+ }
35
+
36
+ export type GenericCsvAnalysisResult = { ok: true; analysis: GenericCsvAnalysis } | { ok: false; message: string }
37
+
38
+ /** Guess a column's role from its header text. */
39
+ export function guessColumnRole(headerCell: string): ColumnRole {
40
+ const h = headerCell.trim().toLowerCase()
41
+ if (h === '') return 'ignore'
42
+ if (/basis|cost/.test(h)) return 'costBasis'
43
+ if (/contribut|saving|deposit/.test(h)) return 'contribution'
44
+ if (/balance|value|amount|total|\$/.test(h)) return 'balance'
45
+ if (/type|class|category|tax treatment/.test(h)) return 'type'
46
+ if (/account|name|description|label|holding|asset/.test(h)) return 'name'
47
+ return 'ignore'
48
+ }
49
+
50
+ /** True when the cell reads as data (money) rather than a label. */
51
+ function isMoneyish(cell: string): boolean {
52
+ return parseMoney(cell) !== null
53
+ }
54
+
55
+ /**
56
+ * Find the header row (the first row that is mostly non-numeric labels and is
57
+ * followed by at least one row with a money-ish cell) and guess column roles.
58
+ */
59
+ export function analyzeGenericCsv(text: string): GenericCsvAnalysisResult {
60
+ const parsed = parseCsv(text)
61
+ if (!parsed.ok) return { ok: false, message: parsed.message }
62
+ const rows = parsed.rows
63
+
64
+ for (let r = 0; r < Math.min(rows.length, 30); r++) {
65
+ const cells = rows[r]!
66
+ const nonEmpty = cells.filter((c) => c.trim() !== '')
67
+ if (nonEmpty.length < 2) continue
68
+ if (nonEmpty.some(isMoneyish)) continue // data row, not a header
69
+ const dataRows = rows.slice(r + 1).filter((row) => row.some(isMoneyish))
70
+ if (dataRows.length === 0) continue
71
+ const guessedRoles = cells.map(guessColumnRole)
72
+ // A usable table needs at least a name-ish and a money-ish column somewhere;
73
+ // the user can still fix the guesses by hand.
74
+ return { ok: true, analysis: { header: cells, dataRows, guessedRoles } }
75
+ }
76
+ return {
77
+ ok: false,
78
+ message:
79
+ 'No header row followed by rows with dollar values was found. Save the sheet as CSV with one row per account and a header row (e.g. "Account, Type, Balance").',
80
+ }
81
+ }
82
+
83
+ export interface GenericCsvDraft {
84
+ plan: Plan
85
+ review: ImportReviewItem[]
86
+ }
87
+
88
+ export type GenericCsvDraftResult = { ok: true } & GenericCsvDraft | { ok: false; message: string }
89
+
90
+ /**
91
+ * Map data rows onto a draft plan using the (possibly user-corrected) column
92
+ * roles. Rows without a readable balance are reported and skipped; account
93
+ * types come from the type column when present, else from the name, else
94
+ * default to taxable with a review item.
95
+ */
96
+ export function draftPlanFromGenericCsv(
97
+ analysis: GenericCsvAnalysis,
98
+ roles: ColumnRole[],
99
+ newId: () => string = () => crypto.randomUUID(),
100
+ ): GenericCsvDraftResult {
101
+ const nameCol = roles.indexOf('name')
102
+ const balanceCol = roles.indexOf('balance')
103
+ if (balanceCol === -1) {
104
+ return { ok: false, message: 'Assign one column as the balance/value before importing.' }
105
+ }
106
+ const typeCol = roles.indexOf('type')
107
+ const basisCol = roles.indexOf('costBasis')
108
+ const contributionCol = roles.indexOf('contribution')
109
+
110
+ const review: ImportReviewItem[] = []
111
+ const plan = createEmptyPlan({ newId, name: 'Imported from spreadsheet' })
112
+ const ownerId = plan.household.people[0]!.id
113
+
114
+ // Spreadsheet footers that re-state sums import as phantom accounts if kept.
115
+ const totalRowRe = /^(sub)?total\b|grand total/i
116
+ const loanLikeRe = /\bloan\b|debt|mortgage|heloc|liabilit|credit/i
117
+
118
+ for (let r = 0; r < analysis.dataRows.length; r++) {
119
+ const cells = analysis.dataRows[r]!
120
+ const name = (nameCol === -1 ? '' : (cells[nameCol] ?? '').trim()) || `Row ${r + 1}`
121
+ const typeText = typeCol === -1 ? '' : (cells[typeCol] ?? '').trim()
122
+ if (totalRowRe.test(name) || totalRowRe.test(typeText)) {
123
+ review.push({ status: 'skipped', source: name, detail: 'Summary/total row — counting it would double the money above it.' })
124
+ continue
125
+ }
126
+ const balance = parseMoney(cells[balanceCol])
127
+ // A negative balance on a loan-looking row is a liability written with a
128
+ // sign convention — import it as debt instead of dropping the row.
129
+ const negativeLiability = balance !== null && balance < 0 && loanLikeRe.test(`${name} ${typeText}`)
130
+ if ((balance === null || balance < 0) && !negativeLiability) {
131
+ review.push({ status: 'skipped', source: name, detail: 'Row had no readable non-negative balance.' })
132
+ continue
133
+ }
134
+ const guessSource = typeText !== '' ? typeText : name
135
+ const typeGuess = negativeLiability ? 'debt' : mapProjectionLabAccountType(typeText, name)
136
+ const mapped = typeGuess ?? 'taxable'
137
+ const amount = Math.abs(balance!)
138
+
139
+ const base = { id: newId(), name, annualReturnPct: null }
140
+ const contribution = contributionCol === -1 ? null : parseMoney(cells[contributionCol])
141
+ const annualContribution = contribution !== null && contribution > 0 ? contribution : 0
142
+
143
+ let account: Account
144
+ switch (mapped) {
145
+ case 'taxable': {
146
+ const basisRaw = basisCol === -1 ? null : parseMoney(cells[basisCol])
147
+ // A negative basis cell (adjustment lines, sign conventions) must not
148
+ // sink the whole import at validation — treat it like a missing basis.
149
+ const basis = basisRaw !== null && basisRaw >= 0 ? basisRaw : null
150
+ account = { ...base, type: 'taxable', ownerPersonId: null, balance: amount, costBasis: basis ?? amount, annualContribution }
151
+ if (basis === null) {
152
+ review.push({
153
+ status: 'defaulted',
154
+ source: name,
155
+ detail:
156
+ (basisRaw !== null && basisRaw < 0 ? 'The cost basis cell was negative, so it was ignored — ' : 'No cost basis column/value — ') +
157
+ 'basis was set equal to the balance (no unrealized gain). Correct it on the Accounts screen.',
158
+ })
159
+ }
160
+ break
161
+ }
162
+ case 'traditional':
163
+ case 'roth':
164
+ account = {
165
+ ...base,
166
+ type: mapped,
167
+ ownerPersonId: ownerId,
168
+ kind: /401|403|457|tsp/i.test(guessSource) ? 'employer' : 'ira',
169
+ balance: amount,
170
+ annualContribution,
171
+ }
172
+ break
173
+ case 'hsa':
174
+ account = { ...base, type: 'hsa', ownerPersonId: ownerId, balance: amount, annualContribution }
175
+ break
176
+ case 'cash':
177
+ account = { ...base, type: 'cash', ownerPersonId: null, balance: amount, annualContribution }
178
+ break
179
+ case 'property':
180
+ account = { ...base, type: 'property', ownerPersonId: null, value: amount, plannedSaleYear: null, expectedNetProceeds: null }
181
+ break
182
+ case 'debt':
183
+ account = { ...base, type: 'debt', ownerPersonId: null, balance: amount, interestPct: 5, monthlyPayment: 0 }
184
+ if (negativeLiability) {
185
+ review.push({
186
+ status: 'defaulted',
187
+ source: name,
188
+ detail: `The negative balance looked like a liability sign convention — imported as a $${amount.toLocaleString('en-US', { maximumFractionDigits: 0 })} debt.`,
189
+ })
190
+ }
191
+ review.push({
192
+ status: 'defaulted',
193
+ source: name,
194
+ detail: 'Debts need an interest rate and monthly payment — defaults of 5% and $0/mo were used; set the real terms on the Accounts screen.',
195
+ })
196
+ break
197
+ }
198
+ plan.accounts.push(account)
199
+ review.push({
200
+ status: typeGuess === null ? 'defaulted' : 'mapped',
201
+ source: `${name}${typeText !== '' ? ` (${typeText})` : ''}`,
202
+ detail:
203
+ typeGuess === null
204
+ ? `No recognizable account type — imported as a taxable account with a $${amount.toLocaleString('en-US', { maximumFractionDigits: 0 })} balance. Change the type on the Accounts screen if that is wrong.`
205
+ : `Imported as a ${mapped} account with a $${amount.toLocaleString('en-US', { maximumFractionDigits: 0 })} balance.`,
206
+ })
207
+ }
208
+
209
+ if (plan.accounts.length === 0) {
210
+ return { ok: false, message: 'No rows with a readable balance were found with the current column assignment.' }
211
+ }
212
+
213
+ review.push({
214
+ status: 'unmapped',
215
+ source: 'Everything except accounts',
216
+ detail: 'Spreadsheet rows import as account balances only — enter household, income, spending, and Social Security in the planner sections.',
217
+ })
218
+
219
+ const parsed = parsePlan(plan)
220
+ if (!parsed.ok) {
221
+ return { ok: false, message: `The mapped plan failed validation: ${parsed.issues.join('; ')}` }
222
+ }
223
+ return { ok: true, plan: parsed.plan, review }
224
+ }
@@ -0,0 +1,350 @@
1
+ /**
2
+ * ProjectionLab data-export mapper (onboarding-import-and-migration step 3).
3
+ *
4
+ * ProjectionLab lets users export their data as JSON. This maps that file's
5
+ * documented, user-visible substance — current-finances accounts, income
6
+ * sources, expenses, and the retirement milestone — onto a draft RetireGolden
7
+ * plan through the normal validated plan route. The export is version-sniffed
8
+ * by shape (`currentFinances.accounts`); anything else is refused with a
9
+ * helpful message rather than guessed at, and every mapped, assumed, or
10
+ * unmappable item lands on the review checklist. No scraping, no network —
11
+ * the user's own exported file, parsed in the browser.
12
+ */
13
+
14
+ import type { Account, Plan } from '@retiregolden/engine/model/plan'
15
+ import { createEmptyPlan, parsePlan } from '@retiregolden/engine/model/plan'
16
+ import { MAX_REASONABLE_DOLLARS, parseMoney } from './csv'
17
+ import type { ImportReviewItem } from './reviewChecklist'
18
+
19
+ export const MAX_IMPORT_JSON_CHARS = 10_000_000
20
+
21
+ export type ProjectionLabImportResult =
22
+ | { ok: true; plan: Plan; review: ImportReviewItem[] }
23
+ | { ok: false; message: string }
24
+
25
+ const UNRECOGNIZED_MESSAGE =
26
+ 'This file does not match a ProjectionLab data export we recognize (expected a "currentFinances" section with accounts). ' +
27
+ 'ProjectionLab may have changed its format — you can still bring balances over with the broker CSV or spreadsheet import.'
28
+
29
+ function asRecord(v: unknown): Record<string, unknown> | null {
30
+ return typeof v === 'object' && v !== null && !Array.isArray(v) ? (v as Record<string, unknown>) : null
31
+ }
32
+
33
+ function asArray(v: unknown): unknown[] | null {
34
+ return Array.isArray(v) ? v : null
35
+ }
36
+
37
+ /**
38
+ * A finite, sane, non-negative dollar amount — anything else is null. Numeric
39
+ * strings ("350000", "$4,500") are accepted through the strict money parser,
40
+ * since JSON exports sometimes stringify amounts; junk still fails.
41
+ */
42
+ function asDollars(v: unknown): number | null {
43
+ if (typeof v === 'string') {
44
+ const parsed = parseMoney(v)
45
+ return parsed !== null && parsed >= 0 ? parsed : null
46
+ }
47
+ if (typeof v !== 'number' || !Number.isFinite(v)) return null
48
+ if (v < 0 || v > MAX_REASONABLE_DOLLARS) return null
49
+ return v
50
+ }
51
+
52
+ function asString(v: unknown): string | null {
53
+ return typeof v === 'string' && v.trim() !== '' ? v.trim() : null
54
+ }
55
+
56
+ function firstString(rec: Record<string, unknown>, ...keys: string[]): string | null {
57
+ for (const k of keys) {
58
+ const s = asString(rec[k])
59
+ if (s !== null) return s
60
+ }
61
+ return null
62
+ }
63
+
64
+ function firstDollars(rec: Record<string, unknown>, ...keys: string[]): number | null {
65
+ for (const k of keys) {
66
+ const n = asDollars(rec[k])
67
+ if (n !== null) return n
68
+ }
69
+ return null
70
+ }
71
+
72
+ type MappedType = 'cash' | 'taxable' | 'traditional' | 'roth' | 'hsa' | 'property' | 'debt'
73
+
74
+ /**
75
+ * Keyword-match a ProjectionLab account type/name onto a RetireGolden account
76
+ * class. Loan/debt keywords are checked first so "401k loan" or "IRA loan" is
77
+ * a liability, never a retirement asset.
78
+ */
79
+ export function mapProjectionLabAccountType(type: string, name: string): MappedType | null {
80
+ const t = `${type} ${name}`.toLowerCase()
81
+ if (/mortgage|debt|\bloan\b|liabilit|heloc/.test(t)) return 'debt'
82
+ if (/\broth\b/.test(t)) return 'roth'
83
+ if (/\bhsa\b|health savings/.test(t)) return 'hsa'
84
+ if (/401|403|457\b|\bira\b|pretax|pre-tax|traditional|\btsp\b|rollover|sep\b|simple\b/.test(t)) return 'traditional'
85
+ if (/real.?estate|property|\bhome\b|house/.test(t)) return 'property'
86
+ if (/taxable|brokerage|investment/.test(t)) return 'taxable'
87
+ if (/cash|checking|savings|money market|emergency/.test(t)) return 'cash'
88
+ return null
89
+ }
90
+
91
+ export function mapProjectionLabExport(
92
+ json: string,
93
+ newId: () => string = () => crypto.randomUUID(),
94
+ ): ProjectionLabImportResult {
95
+ if (json.length > MAX_IMPORT_JSON_CHARS) {
96
+ return { ok: false, message: 'File is too large to be a ProjectionLab export.' }
97
+ }
98
+ let raw: unknown
99
+ try {
100
+ raw = JSON.parse(json)
101
+ } catch {
102
+ return { ok: false, message: 'This file is not valid JSON.' }
103
+ }
104
+ const root = asRecord(raw)
105
+ if (!root) return { ok: false, message: UNRECOGNIZED_MESSAGE }
106
+
107
+ const currentFinances = asRecord(root['currentFinances'])
108
+ const rawAccounts = currentFinances ? asArray(currentFinances['accounts']) : null
109
+ if (!currentFinances || !rawAccounts) return { ok: false, message: UNRECOGNIZED_MESSAGE }
110
+
111
+ const review: ImportReviewItem[] = []
112
+ const plan = createEmptyPlan({ newId, name: 'Imported from ProjectionLab' })
113
+ const person = plan.household.people[0]!
114
+
115
+ // --- Household ------------------------------------------------------------
116
+ const user = asRecord(root['user']) ?? asRecord(root['profile'])
117
+ const birthYearRaw = user?.['birthYear']
118
+ const birthYear =
119
+ typeof birthYearRaw === 'number' && Number.isInteger(birthYearRaw) && birthYearRaw > 1900 && birthYearRaw < 2100
120
+ ? birthYearRaw
121
+ : null
122
+ if (birthYear !== null) {
123
+ person.dob = `${birthYear}-07-01`
124
+ review.push({
125
+ status: 'defaulted',
126
+ source: `Birth year ${birthYear}`,
127
+ detail: 'Date of birth set to July 1 of your ProjectionLab birth year — set the exact date on the Household screen.',
128
+ })
129
+ } else {
130
+ review.push({
131
+ status: 'unmapped',
132
+ source: 'Date of birth',
133
+ detail: 'The export carried no readable birth year — set your date of birth on the Household screen.',
134
+ })
135
+ }
136
+ review.push({
137
+ status: 'defaulted',
138
+ source: 'Filing status & state',
139
+ detail: `Filing status defaulted to single and state to ${plan.household.state} — ProjectionLab exports do not carry them. Set both on the Household screen.`,
140
+ })
141
+
142
+ // Retirement milestone age, when present on the first plan.
143
+ const firstPlan = asArray(root['plans'])?.map(asRecord).find((p) => p !== null) ?? null
144
+ const milestones = firstPlan ? (asArray(firstPlan['milestones']) ?? []) : []
145
+ for (const m of milestones) {
146
+ const rec = asRecord(m)
147
+ if (!rec) continue
148
+ const name = (firstString(rec, 'name', 'label') ?? '').toLowerCase()
149
+ const age = rec['age']
150
+ if (name.includes('retire') && typeof age === 'number' && age >= 30 && age <= 80) {
151
+ person.retirementAge = age
152
+ review.push({ status: 'mapped', source: `Milestone "${firstString(rec, 'name', 'label')!}"`, detail: `Retirement age set to ${age}.` })
153
+ break
154
+ }
155
+ }
156
+
157
+ // --- Accounts ---------------------------------------------------------------
158
+ for (const entry of rawAccounts) {
159
+ const rec = asRecord(entry)
160
+ if (!rec) continue
161
+ const name = firstString(rec, 'name', 'label') ?? 'Imported account'
162
+ const typeStr = firstString(rec, 'type', 'accountType', 'category') ?? ''
163
+ const balance = firstDollars(rec, 'balance', 'value', 'currentBalance', 'amount')
164
+ if (balance === null) {
165
+ review.push({ status: 'skipped', source: name, detail: 'Account had no readable balance.' })
166
+ continue
167
+ }
168
+ const mapped = mapProjectionLabAccountType(typeStr, name)
169
+ if (mapped === null) {
170
+ review.push({
171
+ status: 'unmapped',
172
+ source: `${name} (${typeStr || 'unknown type'}, $${balance.toLocaleString('en-US', { maximumFractionDigits: 0 })})`,
173
+ detail: 'Account type has no RetireGolden equivalent mapping — add it by hand on the Accounts screen.',
174
+ })
175
+ continue
176
+ }
177
+
178
+ const base = { id: newId(), name, annualReturnPct: null }
179
+ let account: Account
180
+ switch (mapped) {
181
+ case 'taxable': {
182
+ const costBasis = firstDollars(rec, 'costBasis', 'basis')
183
+ account = {
184
+ ...base,
185
+ type: 'taxable',
186
+ ownerPersonId: null,
187
+ balance,
188
+ costBasis: costBasis ?? balance,
189
+ annualContribution: 0,
190
+ }
191
+ if (costBasis === null) {
192
+ review.push({
193
+ status: 'defaulted',
194
+ source: name,
195
+ detail: 'No cost basis in the export — basis was set equal to the balance (no unrealized gain). Correct it on the Accounts screen.',
196
+ })
197
+ }
198
+ break
199
+ }
200
+ case 'traditional':
201
+ account = {
202
+ ...base,
203
+ type: 'traditional',
204
+ ownerPersonId: person.id,
205
+ kind: /401|403|457|tsp/i.test(`${typeStr} ${name}`) ? 'employer' : 'ira',
206
+ balance,
207
+ annualContribution: 0,
208
+ }
209
+ break
210
+ case 'roth':
211
+ account = {
212
+ ...base,
213
+ type: 'roth',
214
+ ownerPersonId: person.id,
215
+ kind: /401|403|457|tsp/i.test(`${typeStr} ${name}`) ? 'employer' : 'ira',
216
+ balance,
217
+ annualContribution: 0,
218
+ }
219
+ break
220
+ case 'hsa':
221
+ account = { ...base, type: 'hsa', ownerPersonId: person.id, balance, annualContribution: 0 }
222
+ break
223
+ case 'cash':
224
+ account = { ...base, type: 'cash', ownerPersonId: null, balance, annualContribution: 0 }
225
+ break
226
+ case 'property':
227
+ account = { ...base, type: 'property', ownerPersonId: null, value: balance, plannedSaleYear: null, expectedNetProceeds: null }
228
+ break
229
+ case 'debt': {
230
+ const interestRaw = rec['interestRate']
231
+ const payment = firstDollars(rec, 'payment', 'monthlyPayment')
232
+ // Nothing imports silently: an unreadable rate falls back to 5% with a
233
+ // review item, and a fraction-looking rate (0.035) is scaled with one.
234
+ let interestPct = 5
235
+ let interestNote: string | null = 'No readable interest rate in the export — 5% was assumed. Set the real rate on the Accounts screen.'
236
+ if (typeof interestRaw === 'number' && Number.isFinite(interestRaw) && interestRaw >= 0 && interestRaw < 100) {
237
+ if (interestRaw > 0 && interestRaw < 1) {
238
+ interestPct = Math.round(interestRaw * 100 * 10000) / 10000
239
+ interestNote = `The export's interest rate of ${interestRaw} looked like a fraction — imported as ${interestPct}%. Check it on the Accounts screen.`
240
+ } else {
241
+ interestPct = interestRaw
242
+ interestNote = null
243
+ }
244
+ }
245
+ account = {
246
+ ...base,
247
+ type: 'debt',
248
+ ownerPersonId: null,
249
+ balance,
250
+ interestPct,
251
+ monthlyPayment: payment ?? 0,
252
+ }
253
+ if (interestNote !== null) {
254
+ review.push({ status: 'defaulted', source: name, detail: interestNote })
255
+ }
256
+ if (payment === null) {
257
+ review.push({
258
+ status: 'defaulted',
259
+ source: name,
260
+ detail: 'No monthly payment in the export — set the real payment on the Accounts screen.',
261
+ })
262
+ }
263
+ break
264
+ }
265
+ }
266
+ plan.accounts.push(account)
267
+ review.push({
268
+ status: 'mapped',
269
+ source: `${name} (${typeStr || 'type from name'})`,
270
+ detail: `Imported as a ${mapped} account with a $${balance.toLocaleString('en-US', { maximumFractionDigits: 0 })} balance.`,
271
+ })
272
+ }
273
+
274
+ // --- Income sources ---------------------------------------------------------
275
+ const rawIncomes = asArray(currentFinances['incomeSources']) ?? asArray(currentFinances['incomes']) ?? []
276
+ for (const entry of rawIncomes) {
277
+ const rec = asRecord(entry)
278
+ if (!rec) continue
279
+ const name = firstString(rec, 'name', 'label') ?? 'Income'
280
+ const annual = firstDollars(rec, 'annualAmount', 'annual', 'amount')
281
+ if (annual === null) {
282
+ review.push({ status: 'skipped', source: name, detail: 'Income source had no readable annual amount.' })
283
+ continue
284
+ }
285
+ const typeStr = (firstString(rec, 'type', 'category') ?? '').toLowerCase()
286
+ const looksLikeWages = /employment|salary|wage|job|work/.test(`${typeStr} ${name.toLowerCase()}`)
287
+ if (looksLikeWages) {
288
+ plan.incomes.push({ type: 'wages', id: newId(), personId: person.id, annualGross: annual, endAge: null, realGrowthPct: 0 })
289
+ review.push({ status: 'mapped', source: name, detail: `Imported as wages of $${annual.toLocaleString('en-US')} /yr until retirement.` })
290
+ } else if (/social security|\bss\b|ssa/.test(`${typeStr} ${name.toLowerCase()}`)) {
291
+ review.push({
292
+ status: 'unmapped',
293
+ source: name,
294
+ detail: 'Social Security needs a claim age and benefit basis — set it up on the Social Security screen (you can import your SSA statement there).',
295
+ })
296
+ } else {
297
+ plan.incomes.push({
298
+ type: 'recurring',
299
+ id: newId(),
300
+ label: name,
301
+ annualAmount: annual,
302
+ startYear: null,
303
+ endYear: null,
304
+ inflationAdjusted: true,
305
+ taxTreatment: 'ordinary',
306
+ })
307
+ review.push({
308
+ status: 'defaulted',
309
+ source: name,
310
+ detail: `Imported as recurring ordinary income of $${annual.toLocaleString('en-US')} /yr with no end year — set dates and tax treatment on the Income screen.`,
311
+ })
312
+ }
313
+ }
314
+
315
+ // --- Expenses ----------------------------------------------------------------
316
+ const rawExpenses = asArray(currentFinances['expenses']) ?? []
317
+ let expenseTotal = 0
318
+ const expenseNames: string[] = []
319
+ for (const entry of rawExpenses) {
320
+ const rec = asRecord(entry)
321
+ if (!rec) continue
322
+ const annual = firstDollars(rec, 'annualAmount', 'annual', 'amount')
323
+ if (annual === null) continue
324
+ expenseTotal += annual
325
+ const name = firstString(rec, 'name', 'label')
326
+ if (name) expenseNames.push(name)
327
+ }
328
+ if (expenseTotal > 0) {
329
+ plan.expenses.baseAnnual = expenseTotal
330
+ review.push({
331
+ status: 'defaulted',
332
+ source: expenseNames.length > 0 ? expenseNames.join(', ') : 'Expenses',
333
+ detail: `Baseline annual spending set to the $${expenseTotal.toLocaleString('en-US')} sum of your ProjectionLab expenses — RetireGolden models one baseline plus phases/goals, so re-shape it on the Spending screen (healthcare is modeled separately).`,
334
+ })
335
+ } else {
336
+ review.push({ status: 'unmapped', source: 'Spending', detail: 'No readable expenses in the export — set baseline spending on the Spending screen.' })
337
+ }
338
+
339
+ review.push({
340
+ status: 'unmapped',
341
+ source: 'Strategies, assumptions & scenarios',
342
+ detail: 'Withdrawal strategy, Roth conversions, market assumptions, and scenarios do not transfer between tools — review the Strategy and Assumptions screens.',
343
+ })
344
+
345
+ const parsed = parsePlan(plan)
346
+ if (!parsed.ok) {
347
+ return { ok: false, message: `The mapped plan failed validation: ${parsed.issues.join('; ')}` }
348
+ }
349
+ return { ok: true, plan: parsed.plan, review }
350
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Shared review-checklist model for every import mapper
3
+ * (onboarding-import-and-migration). The rule that keeps mapping honest:
4
+ * nothing imports silently — every value that lands in a draft plan, every
5
+ * default the mapper had to invent, and every source item it could NOT map
6
+ * becomes a visible review item.
7
+ */
8
+
9
+ export type ImportItemStatus = 'mapped' | 'defaulted' | 'unmapped' | 'skipped'
10
+
11
+ export interface ImportReviewItem {
12
+ status: ImportItemStatus
13
+ /** Where in the source this came from (file row, export field, 1040 line). */
14
+ source: string
15
+ /** What happened in the draft plan — or why nothing did and what to do instead. */
16
+ detail: string
17
+ }
18
+
19
+ export const IMPORT_STATUS_LABEL: Record<ImportItemStatus, string> = {
20
+ mapped: 'Imported',
21
+ defaulted: 'Assumed — review',
22
+ unmapped: 'Not imported — add by hand',
23
+ skipped: 'Skipped',
24
+ }
25
+
26
+ /** Display order: what needs the user's eyes first. */
27
+ export const IMPORT_STATUS_ORDER: ImportItemStatus[] = ['unmapped', 'defaulted', 'mapped', 'skipped']
28
+
29
+ export function countByStatus(items: ImportReviewItem[]): Record<ImportItemStatus, number> {
30
+ const counts: Record<ImportItemStatus, number> = { mapped: 0, defaulted: 0, unmapped: 0, skipped: 0 }
31
+ for (const item of items) counts[item.status]++
32
+ return counts
33
+ }