diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md
index a35c0cf..f818496 100644
--- a/RELEASE_NOTES.md
+++ b/RELEASE_NOTES.md
@@ -6,23 +6,26 @@
### Features
-- New theme preset options available to choose from in App Settings
-- Added undo and redo support across planning workflows, plus an audit history overlay so you can review and restore prior changes.
-- Added plan-wide search actions that can jump directly to settings, sections, and modals, including common add/edit/delete/pause tasks.
-- Added automated reallocation support when remaining spending is below target, with safe-source rules and a clear summary of what changed.
-- Added app-wide Lucide icon support with account icon selection, replacing legacy emoji-based iconography.
+- No major features for this release
### Improvements
-- Improved gross-to-net clarity with explicit pre-tax and post-tax deduction visibility in key breakdown views.
-- Expanded tax modeling so tax lines can be configured using either percentage rates or fixed amounts.
-- Expanded view mode flexibility with more cadence options, paycheck-cadence defaults, and better selector guidance.
-- Overhauled Appearance and Accessibility settings with curated light/dark preset pairs, manual dark-mode overrides, and a dedicated high-contrast mode.
-- Added app-level display scaling controls (zoom/font size) in Settings and View, including keyboard shortcuts for zoom in, zoom out, and reset.
+- Added the tab icon next to each view title so the active workspace has clearer visual context.
+- Simplified retirement setup by removing employer match handling from retirement elections to reduce complexity, as it's not an amount taken out of your paycheck generally
+- Improved Key Metrics view accuracy
+ - Remaining-for-spending totals now match between Key Metrics and Pay Breakdown in yearly mode.
+ - The Bills metric was reworked as Recurring Expenses and now reflects the total of custom allocations, bills, deductions, and loan payments.
+- Improved account deletion behavior so linked items are handled more intelligently across all supported item types.
+- Reduced icon width to look cleaner, and replaced a few icons to make more sense in context
+- Reduced built application size by around 100MB
### Bug Fixes
-- Fixed cross-mode rounding and persistence behavior so values remain stable after editing, saving, and reopening.
-- Fixed several search interaction issues, including navigation/scroll behavior and action responsiveness for pause/resume controls.
\ No newline at end of file
+- Fixed deduction amount rounding/display inconsistencies (for example 9.30 no longer drifting to 9.31).
+- Fixed edit-form amount formatting so trailing zeros are preserved more consistently when editing existing bill and deduction values.
+- Added tighter decimal precision handling in amount entry fields to match what the UI can reliably display.
+- Fixed history overlay behavior for legacy entries so edits no longer appear as misleading empty-to-value changes.
+- Fixed a history overlay deletion bug where deleting an Initial tracked state row could delete the wrong item in stacked history.
+- Fixed tax settings history to actually break out the line item changes done
\ No newline at end of file
diff --git a/app_updates/v0.4.0-icon-migration-plan.md b/app_updates/v0.4.0-icon-migration-plan.md
deleted file mode 100644
index e2e15d0..0000000
--- a/app_updates/v0.4.0-icon-migration-plan.md
+++ /dev/null
@@ -1,351 +0,0 @@
-# v0.4.0 Icon Migration Plan — Emoji → Lucide React
-
-## License
-
-**Lucide React** is published under the **ISC License** (a permissive open-source license functionally equivalent to MIT).
-- No attribution required in the app UI
-- No copyleft — can be used in commercial and closed-source products
-- Copyright notice is satisfied by the license file present in `node_modules/lucide-react` (included in any Electron build)
-- Zero legal risk for distribution on Mac App Store, Windows, or Linux
-
----
-
-## Approach
-
-The migration is split into three tiers by complexity:
-
-| Tier | Scope | Complexity |
-|------|-------|------------|
-| **1 — App chrome** | Buttons, headings, modal headers, option cards, empty states, tab icons, metric card icons | Straightforward swap |
-| **2 — Search module icons** | `categoryIcon` fields in all `searchModules/*.ts` — currently typed as `string` | Requires a type change from `string` to a Lucide icon type; then per-module substitution |
-| **3 — Account icon field** | User-entered emoji in the AccountsEditor icon text input; defaults in `accountDefaults.ts` | User data — recommend deferring or a separate purpose-built picker |
-
----
-
-## License Requirement Checklist
-
-- [x] ISC license verified from `npm info lucide-react` and GitHub source
-- [x] No UI attribution required
-- [x] Commercial distribution permitted
-- [x] Electron packaging: license file will be present in `node_modules/` inside the app bundle — satisfies the "include notice in copies" clause automatically
-
----
-
-## Tier 1 — App Chrome (Button Labels, Headings, Empty States, Cards)
-
-### A. Icon-Only Buttons (`✕` and `⚙`)
-
-The `✕` character is used as the entire visible content of six distinct icon-only buttons.
-The bare `⚙` (no variation selector) is used in the view mode settings button.
-
-| File | Current | Proposed Lucide Icon | Notes |
-|------|---------|----------------------|-------|
-| `components/_shared/layout/Modal/Modal.tsx` | `✕` (close modal) | `X` | `aria-label="Close modal"` already present |
-| `components/views/WelcomeScreen/WelcomeScreen.tsx` | `✕` (dismiss error banner) | `X` | |
-| `components/views/WelcomeScreen/WelcomeScreen.tsx` | `✕` (remove recent file) | `X` | `aria-label` present |
-| `components/tabViews/PayBreakdown/PayBreakdown.tsx` | `✕` (remove allocation category) | `X` | |
-| `components/tabViews/LoansManager/LoansManager.tsx` | `✕` (remove payment line) | `X` | |
-| `components/PlanSearchOverlay/PlanSearchOverlay.tsx` | `✕` (clear search) | `X` | |
-| `components/_shared/layout/ViewModeSelector/ViewModeSelector.tsx` | `⚙` (view mode settings) | `Settings` | aria-label present |
-
----
-
-### B. PlanDashboard Header Buttons
-
-| Current | Proposed Lucide Icon | Rationale |
-|---------|----------------------|-----------|
-| `🏦 Accounts` | `Building2` + "Accounts" | Bank/institution shape matches the tab |
-| `📋 Copy Plan` | `Copy` + "Copy Plan" | Direct semantic match |
-| `💾 Save` | `Save` + "Save" | Standard |
-| `🔐 Encryption Key Setup` | `LockKeyhole` + text | Keyhole variant = key-based lock |
-| `🔐 Disable Encryption` | `LockOpen` + text | Open lock for "disable" state |
-| `🔐 Manage/Enable Encryption` | `LockKeyhole` + text | Consistent with Setup |
-
----
-
-### C. PlanDashboard Status Pills and Banners
-
-| Current | Proposed Lucide Icon | Notes |
-|---------|----------------------|-------|
-| `🔒 Encrypted` (status pill) | `Lock` (icon) + "Encrypted" | 16px, inline before text |
-| `📄 Unencrypted` (status pill) | `LockOpen` (icon) + "Unencrypted" | |
-| `📺 View-Only: …` (mode banner) | `Eye` (icon) + "View-Only: …" | `Tv2` is too playful; `Eye` is cleaner for read-only context |
-
----
-
-### D. Toast Messages
-
-Toast emojis are currently baked into message strings (`'✏️ Plan updated'`). The right long-term fix is:
-- Strip emojis from all toast message strings
-- Use the existing Toast `variant` or `type` prop to render a Lucide icon inside the Toast component itself
-
-| Current emoji in toast | Recommended Toast variant / icon |
-|------------------------|----------------------------------|
-| `✏️` (rename/update success) | success variant → `Check` or `Pencil` |
-| `📋` (tab order updated) | success variant → `Check` |
-| `⚠️` (warning/error) | warning/error variant → `AlertTriangle` |
-| `🔒` (encryption enabled) | success variant → `Lock` |
-| `📄` (encryption disabled) | info variant → `LockOpen` |
-
-**Note:** This requires a small refactor of the Toast component to render an icon based on variant rather than embedding it in the string — flagged as part of this milestone's implementation.
-
----
-
-### E. Pay Breakdown Page
-
-| Current | Proposed Lucide Icon | Notes |
-|---------|----------------------|-------|
-| `⚙️ Pay Settings` button | `Settings` + "Pay Settings" | |
-| `ℹ️` (inline info indicator) | `Info` | `size={14}` to match inline weight |
-| `🧾` (Paycheck Deductions section header) | `ReceiptText` | |
-
----
-
-### F. WelcomeScreen Header Buttons
-
-| Current | Proposed Lucide Icon | Notes |
-|---------|----------------------|-------|
-| `✨ Try Demo` | `Sparkles` + "Try Demo" | Lucide has `Sparkles` |
-| `⚙️ Settings` | `Settings` + "Settings" | |
-| `📂` (Open Existing Plan button icon) | `FolderOpen` | Icon-only span inside the button |
-
----
-
-### G. Settings Modal — Theme Toggle
-
-| Current | Proposed Lucide Icon |
-|---------|----------------------|
-| `☀️ Light` | `Sun` + "Light" |
-| `🌙 Dark` | `Moon` + "Dark" |
-| `💻 System` | `Monitor` + "System" |
-| `✓ Backed up` / `Back Up First` | `Check` (conditionally shown) + text |
-
----
-
-### H. Encryption Config Panel (Option Cards)
-
-| Use | Current | Proposed Lucide Icon | Notes |
-|-----|---------|----------------------|-------|
-| Enable Encryption card | `🔒` | `ShieldCheck` | Shield with check = security enabled |
-| Change Key card | `🔑` | `KeyRound` | Rounded key icon |
-| Disable Encryption card | `📄` | `ShieldOff` | Shield-off = security disabled |
-| Setup — Enable | `🔒` | `ShieldCheck` | |
-| Setup — No Encryption | `📄` | `ShieldOff` | |
-| `✓ Copied!` feedback text | `✓` | `Check` (icon, inline) | Small `size={12}` |
-
----
-
-### I. Encryption Setup Page and Setup Wizard
-
-| File | Current | Proposed |
-|------|---------|----------|
-| `EncryptionSetup.tsx` page heading | `🔐 Encryption Key Setup` / `🔐 Security Setup` | `ShieldCheck` icon before heading text (icon outside `
`) |
-| `SetupWizard.tsx` section heading | `🔐 Security Setup` | Same — `ShieldCheck` icon beside heading |
-| `SetupWizard.tsx` Complete Setup button | `Complete Setup ✓` | `Complete Setup` + trailing `Check` icon |
-
----
-
-### J. BillsManager Empty States and Section Header
-
-| Current | Proposed Lucide Icon | Notes |
-|---------|----------------------|-------|
-| `🏦` empty-state (no accounts) | `Building2` | 48px, styled as empty-state illustration |
-| `📋` empty-state (no bills) | `ClipboardList` | 48px |
-| `🧾` Paycheck Deductions section header | `ReceiptText` | 16–18px inline |
-
----
-
-### K. LoansManager Empty States
-
-| Current | Proposed Lucide Icon |
-|---------|----------------------|
-| `🏦` empty-state | `Building2` |
-| `💸` empty-state | `Banknote` |
-
----
-
-### L. SavingsManager Empty States
-
-| Current | Proposed Lucide Icon | Notes |
-|---------|----------------------|-------|
-| `💰` empty-state | `PiggyBank` | For Savings contributions section |
-| `🏦` empty-state | `ChartNoAxesCombined` | For Retirement section |
-
----
-
-### M. PlanSearchOverlay — Search Input Icon
-
-| Current | Proposed Lucide Icon | Notes |
-|---------|----------------------|-------|
-| `🔍` (decorative input prefix) | `Search` | `aria-hidden="true"` stays |
-
----
-
-### N. PlanTabs — Manage Tabs Button Icon
-
-| Current | Proposed Lucide Icon |
-|---------|----------------------|
-| `✏️` tab icon inside "Manage Tabs" button | `LayoutList` or `SlidersHorizontal` | `LayoutList` = manage/reorder list of tabs |
-
----
-
-### O. TabManagementModal Header
-
-| Current | Proposed Lucide Icon |
-|---------|----------------------|
-| `📋 Manage Tabs` (modal header prop) | Icon moved into modal header slot — `LayoutList`, text stays "Manage Tabs" |
-
----
-
-### P. Alert Component
-
-| Current | Proposed |
-|---------|----------|
-| `✓` (success status badge) | `Check` icon | `AlertTriangle` for warning, `Info` for info, `X` for error — consistent set |
-
----
-
-### Q. About Modal — Feature Icons
-
-| Feature | Current | Proposed Lucide Icon |
-|---------|---------|----------------------|
-| Smart Budgeting | `💰` | `PiggyBank` |
-| Analytics | `📊` | `BarChart2` |
-| Security | `🔒` | `ShieldCheck` |
-| Multi-currency | `🌍` | `Globe` |
-| Accounts | `🏦` | `Building2` |
-| Pay periods | `📅` | `CalendarClock` |
-
----
-
-## Tier 1B — Navigation Tab Icons (`utils/tabManagement.ts`)
-
-Tab icons are currently emoji strings in `TabConfig`. These render in the tab strip.
-Lucide icons are React components, not strings — so `TabConfig.icon` will need to change from `string` to `React.ReactNode` or `LucideIcon`. This is a small type change with a predictable cascade.
-
-| Tab | Current | Proposed Lucide Icon |
-|-----|---------|----------------------|
-| Key Metrics | `📊` | `ChartPie` |
-| Pay Breakdown | `💵` | `Banknote` |
-| Bills | `📋` | `ClipboardList` |
-| Savings | `💰` | `PiggyBank` |
-| Loans | `🏦` | `Landmark` |
-| Taxes | `🏛️` | `Scale` |
-
-> `Landmark` for Loans (bank/lender) vs `Scale` for Taxes keeps the two visually distinct even though both had `🏛️`.
-
----
-
-## Tier 1C — Key Metrics Cards (`KeyMetrics.tsx`)
-
-`MetricCard` takes an `icon` string prop. Same type-change needed as tabs — change to `React.ReactNode`.
-
-| Metric | Current | Proposed Lucide Icon |
-|--------|---------|----------------------|
-| Total Income | `💰` | `BanknoteArrowDown` |
-| Total Taxes | `🏛️` | `Scale` |
-| Total Take Home Pay | `✅` | `HandCoins` |
-| Total Bills | `📋` | `ClipboardList` |
-| Savings Rate | `🏦` | `PiggyBank` |
-| Remaining for Spending | `💵` | `Wallet` |
-
----
-
-## Tier 2 — Search Module `categoryIcon` Fields
-
-**Architecture consideration:** All search modules export a `categoryIcon: string` field that renders the emoji inline in search results. Lucide icons are React components — to replace these, `categoryIcon` needs to change from `string` to `LucideIcon` (Lucide's exported component type), and the search result renderer needs to render it as ` `.
-
-This is a well-contained change but touches 9 files + the search result renderer.
-
-### Proposed mappings by module
-
-| Module | Current | Proposed Lucide Icon |
-|--------|---------|----------------------|
-| **accountsSearchModule** | `🏛️` | `Building2` |
-| **billsSearchModule** — bills | `🧾` | `ReceiptText` |
-| **billsSearchModule** — health | `🏥` | `HeartPulse` |
-| **keyMetricsSearchModule** — trending up | `📈` | `TrendingUp` |
-| **keyMetricsSearchModule** — taxes | `🏛️` | `Scale` |
-| **keyMetricsSearchModule** — bills | `📋` | `ClipboardList` |
-| **keyMetricsSearchModule** — savings rate | `🏦` | `PiggyBank` |
-| **keyMetricsSearchModule** — take home | `✅` | `HandCoins` |
-| **keyMetricsSearchModule** — remaining | `💵` | `Wallet` |
-| **keyMetricsSearchModule** — analytics | `📊` | `ChartPie` |
-| **loansSearchModule** | `💳` | `CreditCard` |
-| **payBreakdownSearchModule** — pay | `💸` | `Banknote` |
-| **payBreakdownSearchModule** — taxes | `🏛️` | `Scale` |
-| **payBreakdownSearchModule** — calculator | `🧮` | `Calculator` |
-| **payBreakdownSearchModule** — trending down | `📉` | `TrendingDown` |
-| **payBreakdownSearchModule** — pinned | `📌` | `Pin` |
-| **payBreakdownSearchModule** — take home | `✅` | `HandCoins` |
-| **payBreakdownSearchModule** — remaining | `💵` | `Wallet` |
-| **payBreakdownSearchModule** — receipts | `🧾` | `ReceiptText` |
-| **paySettingsSearchModule** — salary | `💰` | `BanknoteArrowUp` |
-| **paySettingsSearchModule** — schedule | `📅` | `CalendarClock` |
-| **paySettingsSearchModule** — hours | `⏱️` | `Clock` |
-| **preTaxDeductionsSearchModule** | `📉` | `TrendingDown` |
-| **quickActionsSearchModule** — all add actions | `➕` (×5) | `Plus` |
-| **quickActionsSearchModule** — settings | `⚙️` | `Settings` |
-| **savingsSearchModule** — savings | `🏦` | `PiggyBank` |
-| **savingsSearchModule** — vacation/goal | `🏖️` | `Umbrella` |
-| **settingsSearchModule** — palette | `🎨` | `Palette` |
-| **settingsSearchModule** — paintbrush | `🖌️` | `Paintbrush` |
-| **settingsSearchModule** — light theme | `☀️` | `Sun` |
-| **settingsSearchModule** — dark theme | `🌙` | `Moon` |
-| **settingsSearchModule** — system theme | `💻` | `Monitor` |
-| **settingsSearchModule** — font | `🔤` | `Type` |
-| **settingsSearchModule** — grid/density | `🔲` | `LayoutGrid` |
-| **settingsSearchModule** — accessibility | `♿` | `PersonStanding` |
-| **settingsSearchModule** — glossary | `📖` | `LibraryBig` |
-| **settingsSearchModule** — feedback | `💬` | `MessageSquareReply` |
-| **settingsSearchModule** — analytics | `📊` | `ChartPie` |
-| **settingsSearchModule** — date/view (×3) | `📅` | `CalendarClock` |
-| **settingsSearchModule** — save/backup | `💾` | `Save` |
-| **settingsSearchModule** — reset | `🔄` | `RefreshCw` |
-| **taxesSearchModule** (×3) | `🏛️` | `Scale` |
-
----
-
-## Tier 3 — Account Icon Field (Defer / Separate Task)
-
-The account icon in `AccountsEditor` is a **user-editable text input** where the user types an emoji character themselves. The defaults in `accountDefaults.ts` (`💳`, `💰`, `📈`, `💵`) are just pre-filled starting values — they're user data, not UI chrome.
-
-**Recommendation:** Do not migrate this in the icon library pass. Replacing it with a Lucide icon picker would require:
-- Changing the `Account.icon` data type from `string` (user emoji) to something structured
-- Building or integrating an icon picker component
-- A migration path for existing plan files that have emoji strings stored
-
-This is a separate UX task (e.g., v0.4.1 "Account Icon Picker"). For now, the `💰` placeholder text in the input can remain as a hint, and `accountDefaults.ts` defaults can stay as-is.
-
----
-
-## Test File Impact
-
-Four test files assert against emoji strings that will change as part of this migration. These need updates alongside the implementation — not separately.
-
-| Test file | Affected assertions |
-|-----------|---------------------|
-| `utils/accountDefaults.test.ts` | `.toBe('💳')`, `.toBe('💰')`, `.toBe('📈')`, `.toBe('💵')` — **keep as-is** if account tier is deferred |
-| `utils/tabManagement.test.ts` | Tab `icon` field assertions — update to Lucide component references |
-| `utils/planSearch.test.ts` | Account fixture `icon: '🏦'` — keep as-is (user data field) |
-| `utils/searchRegistry.test.ts` | `categoryIcon` fixture values `'📦'`, `'🎯'` — update when Tier 2 lands |
-| `test/accessibility.test.tsx` | Tests that render `✕` buttons — update to expect rendered `X` icon output |
-
----
-
-## Suggested Implementation Order
-
-1. **Install** `lucide-react` via npm
-2. **Tier 1 — App chrome** (all buttons, headings, empty states, option cards, about modal): no type changes needed, pure JSX swaps
-3. **Alert component** icon variants (replaces `✓` with `Check`, adds `AlertTriangle`, `Info`, `X`)
-4. **Toast message strings** — strip emojis, move icon rendering into the Toast component variant system
-5. **Tab icons** — change `TabConfig.icon: string` → `React.ReactNode`, update tab strip renderer, update `tabManagement.ts`
-6. **MetricCard icon prop** — same type change, update `KeyMetrics.tsx`
-7. **Tier 2 — Search modules** — update `categoryIcon` type in search registry, update search result renderer, update all 9 modules + their tests
-8. **Tier 3 — Account icon field** — defer to v0.4.1
-
----
-
-## Nothing to Defer (Risk: Zero)
-
-The ISC license requires zero action. No NOTICE file, no UI attribution badge, no license screen addition. The license file that ships inside the Electron app bundle inside `node_modules/lucide-react/LICENSE` fully satisfies the requirement.
diff --git a/app_updates/v0.4.0-list.md b/app_updates/v0.4.0-list.md
deleted file mode 100644
index 6ed79d3..0000000
--- a/app_updates/v0.4.0-list.md
+++ /dev/null
@@ -1,144 +0,0 @@
-# v0.4.0 Work Plan
-
-Status keys:
-- `[ ]` Planned
-- `[-]` In Progress
-- `[x]` Done
-
-Release notes discipline:
-- [ ] Add a user-facing RELEASE_NOTES bullet when each parent item reaches Done.
-- [ ] Keep release-note wording focused on outcomes, not implementation details.
-
-## 1. Gross-to-Net Breakdown Clarity
-Parent status: `[x] Done`
-
-- [x] Add explicit Pre-Tax Deductions line items in Gross-to-Net views.
-- [x] Add explicit Post-Tax Deductions line items in Gross-to-Net views.
-- [x] Validate labels/tooltips so users can clearly tell what is included.
-- [x] Add/update tests for displayed values and category separation.
-
-Done definition:
-- [x] A user can identify pre-tax vs post-tax deductions at a glance, with values matching calculations.
-
-## 2. Automated Reallocation to Target Remaining
-Parent status: `[x] Done`
-
-- [x] Add an action when Remaining is below target (for "All that remains for spending").
-- [x] Define and enforce "safe" reallocation sources:
- - [x] Savings contributions
- - [x] Investment contributions
- - [x] Retirement elections
- - [x] Bills/Deductions marked as discretionary
-- [x] Allow user to review and revise automated re-allocations as they see fit.
-- [x] Add discretionary tagging on Bills/Deductions create/edit flows.
-- [x] For items that are able to be enabled/disabled, prefer that over removing it completely
-- [x] Implement allocation priority/order rules and caps.
-- [x] Show a post-action summary of every changed amount.
-- [x] Add the "discretionary" definition to the glossary so users know what it means.
-- [x] Add tests for success path, no-op path, and guardrails.
-
-Done definition:
-- [x] Reallocation reaches or improves target safely and always provides a clear change summary.
-
-## 3. Taxation Updates and Accuracy
-Parent status: `[x] Done`
-
-- [x] Allow fixed-amount tax entry in addition to percentage tax lines.
- - [x] Each should update based on how the other was updated
-- [x] Support separate treatment for distinct tax lines (example: SUI vs Federal).
-- [x] Add tests covering mixed percentage + fixed tax configurations.
-
-Done definition:
-- [x] Users can model taxes to match pay-stub reality with clear line-item behavior.
-
-## 4. Theme and Accessibility Expansion
-Parent status: `[x] Done`
-
-- See `v0.4.0-theme-accessibility-plan.md` for reference of complete implementation
-- Progress tracking note: update `v0.4.0-theme-accessibility-plan.md` after each delivered increment.
-- Active scope: `Phase 1 foundation only` until explicitly advanced.
-- [x] Design and build a theme engine that will enable fluid and simple color scheme changes
-- [x] Add more curated light/dark theme pair presets.
-
-- [x] Allow manual dark-mode overrides after auto-generation if user prefers.
-- [x] Add high-contrast mode
-- [x] Add font-size/zoom controls in app settings and View menu.
-- [x] Add keyboard shortcuts for zoom: Cmd/Ctrl `+`, Cmd/Ctrl `-`, Cmd/Ctrl `0`.
- - [x] Make sure to add to the keyboard shortcuts modal
-
-Done definition:
-- [x] Users can apply preset/custom themes and accessibility scaling with stable contrast/readability.
-
-## 5. Cross-Mode Rounding and Persistence Stability
-Parent status: `[x] Done`
-
-- [x] Confirm and document conversion source-of-truth strategy.
-- [x] Decide whether yearly values are canonical storage (or another canonical model).
- - Canonical storage remains domain-specific rather than forcing everything into yearly values.
- - Manual account allocation categories use normalized per-paycheck storage because they are compared directly against paycheck net pay and reallocation math.
- - Bills, loans, savings, and other frequency-based items keep their existing native/monthly storage models.
-- [x] Ensure edits in paycheck/monthly/yearly remain stable after save/reopen.
-- [x] Add targeted tests for mode conversion + save/blur regression cases.
-
-Additions after completion:
-- [x] There's still rounding issues due to calculations. I think we need to find a stable source of truth, and use that to do calculations off of. I propose using the user's estimated annual pay since that is the largest unit and easiest to then split out smaller. They will be putting this number in when going through setup, and if they choose hourly as their wage it's easy to calculate that out to yearly, then divide down as well.
-
-Done definition:
-- [x] Re-entering the app does not introduce drift or unexpected value jumps across modes.
-
-## 6. View Mode Selector Enhancements
-Parent status: `[x] Done`
-
-- [x] Expand selector options from smallest to largest unit:
- - [x] Weekly
- - [x] Bi-weekly
- - [x] Semi-monthly
- - [x] Monthly
- - [x] Quarterly
- - [x] Yearly
-- [x] Default selector to the user paycheck cadence view mode.
-- [x] Add subtle helper text indicating which option matches paycheck cadence.
-- [x] Keep paycheck-cadence option visually identifiable even when not selected.
-- [x] Allow user to edit what options they want to always see in the view mode selector, so that it's not overly long (unless they want it to be).
-- [x] Add tests for initial/default mode and cadence indicator behavior.
-
-Done definition:
-- [x] Users can easily switch units and always find the paycheck-cadence mode quickly.
-
-## 7. Undo/Redo Functionality
-- Detailed implementation plan: `app_updates/v0.4.0-undo-redo-audit-plan.md`
-- [x] Analysis first on complexity of implementing an undo/redo feature
-- [x] When users make a change, allow them to undo it to put the Plan back to the prior state.
-- [x] Also allow a redo, if they change their mind and still want that.
-- [x] Add the options to Edit in menubar, and also as the usual keyboard shortcuts for undo and redo, depending on the OS
-- [x] Add the details of the shortcuts to the keyboard shortcuts modal
-- [x] Add an audit log for several key editable areas of the app so users can roll back if needed, and see what changes were made throughout the year
-
-## 8. Plan-wide Search Functionality
-- Most of this is completed, but some pending items:
-- [x] If a pay setting is queried, it shows in the search but clicking it does not open the pay settings modal and navigate to that setting yet
-- [x] Doesn't seem to be any search available for anything on the Key Metrics or Pay Breakdown views at all yet
-- [x] The search for specific components is working, but the scroll to view is still not working quite properly
-- [x] Enable buttons throughout the app to be searchable and jumped to, with their Modals opened as well. For example, search `Add bill` and it appear in search to jump to and start adding a new bill immediately.
-- [x] Allow user to take actions on certain things from the Search, such as
- - [x] For SectionItemCard items, easily pause, edit, or delete
-- [x] Possible to rework Search to be more extensible going forward? So it's very simple to add new components, settings, modals, etc. to the Search actions, without too much additional logic added every time.
-- [x] Few more fixes needed for search:
- - When clicking Pause/Resume there's a brief delay where button is stuck at old width and wording is wrong
-
-## 9. Add and Implement icon library
-Parent status: `[x] Done`
-- [x] Check and see what icon library would be best for the project
- - https://react-icons.github.io/react-icons/
- - https://iconoir.com
-- [x] Implement icons across the app in spots that make sense, replacing emoji counterparts
-
-Done definition:
-- [x] No more emojis being used in the app
-
-
-## Final v0.4.0 Exit Checklist
-- [x] All parent items above are marked `[x] Done` or explicitly deferred.
-- [x] RELEASE_NOTES updated with completed v0.4.0 user-facing items.
-- [x] Lint, typecheck, tests, and build pass for merged changes.
\ No newline at end of file
diff --git a/app_updates/v0.4.0-theme-accessibility-plan.md b/app_updates/v0.4.0-theme-accessibility-plan.md
deleted file mode 100644
index 80ffd88..0000000
--- a/app_updates/v0.4.0-theme-accessibility-plan.md
+++ /dev/null
@@ -1,308 +0,0 @@
-# v0.4.0 Theme and Accessibility Plan
-
-Status keys:
-- `[ ]` Planned
-- `[-]` In Progress
-- `[x]` Done
-
-Release notes discipline:
-- [ ] Add a user-facing RELEASE_NOTES.md bullet when each parent item reaches Done.
-- [ ] Keep release-note wording focused on outcomes, not implementation details.
-
-Current execution scope:
-- `Phase 4 active`.
-
-Phase order (rearranged for implementation):
-- `Phase 1 (Foundation)`: Sections 1, 4, and foundation parts of 6 + 7.
-- `Phase 2 (Presets)`: Section 2 + corresponding validation in 7.
-- `Phase 3 (Custom Themes)`: Section 3 + corresponding validation in 7.
-- `Phase 4 (Zoom and Final Hardening)`: Sections 5 + 8 + final rollout checks in 7.
-
----
-
-## 1. Appearance Architecture Foundation (Phase 1)
-Parent status: `[x] Done`
-
-- [x] Extend app-level settings to support appearance and accessibility preferences.
- - Keep these preferences in app settings, not plan files.
- - Include a clear model for theme mode, preset selection, custom appearance seed values, high contrast, and font scaling.
-- [x] Refactor theme application so the app resolves appearance from a single source of truth.
- - Avoid scattered `localStorage` theme reads outside the shared settings/theme flow.
-- [x] Define the semantic token contract for themeable UI.
- - Background, surface, text, border, accent, status, focus, overlay, and elevation tokens should be centrally owned.
- - All semantic tokens defined in `src/index.css` with light/dark/high-contrast overrides; consumed throughout UI via `var(--token-name)`.
-- [x] Audit existing raw or one-off styling patterns that would block stable theming.
- - Completed 4 passes of comprehensive tokenization:
- - Pass 1: Button, PillToggle, Toast, AccountsEditor focus ring, TabManagementModal — replaced raw rgba shadows/backgrounds with semantic tokens.
- - Pass 2: Added metric color tokens (posttax, bills, shortfall) with light/dark variants; replaced KeyMetrics hardcoded hex values and fileStorage encryption dialog inline colors with token resolution.
- - Pass 3: Tab/manager surface cleanup — TabPositionHandle, AccountsDeleteModal, LoansManager, PayBreakdown — unified to semantic tokens.
- - Pass 4: Account palette centralization — consolidated duplicate inline color/icon maps into `src/constants/accountPalette.ts`; refactored all callsites (accountDefaults.ts, demoDataGenerator.ts, fileStorage.ts) to import and use shared helpers.
- - Final sweep confirmed: 0 raw rgba/hex in active component files; all colors properly tokenized in index.css.
-- [x] Add/update tests for settings persistence and theme resolution behavior.
- - Updated accountDefaults.test.ts to assert against ACCOUNT_TYPE_COLORS constant; all 169 tests passing.
-
-Done definition:
-- [x] Theme and accessibility preferences are persisted safely, applied consistently, and resolved from one predictable settings model.
-
----
-
-## 2. Curated Theme Presets (Phase 2)
-Parent status: `[x] Done`
-
-- [x] Add curated preset theme families with paired light and dark variants.
- - Start with a small, opinionated set that feels polished rather than exhaustive.
-- [x] Separate theme mode from theme family.
- - Users choose Light, Dark, or System independently from the selected preset family.
-- [x] Ensure all major surfaces and shared controls render correctly across presets.
- - Include buttons, tabs, cards, modals, alerts, form fields, badges, and focus states.
- - Verify primary/secondary/destructive button contrast in both themes (including Edit/Delete on dark surfaces).
- - Verify tab label contrast on dark backgrounds (including selected/active state colors).
- - Completed representative preset QA sweep across modal, alert, tab-like selector, and destructive button surfaces in both light and dark modes for all curated presets via `presetSurfaceQa.test.tsx`.
- - Tightened readable accent text usage by switching shared text-bearing surfaces (PlanTabs active label, TabManagementModal badges/actions, ViewModeSelector active state and cadence pill, InfoBox headings) from `--accent-primary` to `--text-accent`.
- - Fixed default light theme readable accent token by making `--text-accent` and `--link-color` use a darker accessible indigo (`#5568d3`) instead of the decorative accent fill value (`#667eea`).
-- [x] Add a lightweight preview pattern in Settings so users can understand the preset before leaving the modal.
-- [x] Add/update tests where necessary.
- - Added persisted `appearancePreset` setting with normalization/import-export support and document-level `data-theme-preset` application in `ThemeContext`.
- - Introduced curated preset families: `Default`, `Spreadsheet Core`, `Ocean`, `Forest`, `Sunset`, and `Pretty in Pink`, each with paired light/dark accent, link, and header treatments.
- - Added Settings preset preview cards so users can compare families before leaving the modal.
- - Added `SettingsModal.test.tsx` to verify preset selection persistence and restored preview state across reopen.
- - Added `presetSurfaceQa.test.tsx` to smoke-test representative preset surfaces across light/dark families.
- - Extended appearance normalization tests, app-settings persistence tests, and contrast tests to cover preset families and their accessibility-sensitive accent/text combinations.
-
-Done definition:
-- [x] Users can switch among a curated set of polished preset themes and get consistent results in both light and dark modes.
-
----
-
-
-
-
-
----
-
-## 4. Accessibility Controls (Phase 1)
-Parent status: `[x] Done`
-
-- [x] Add a high-contrast mode.
- - Treat this as a real token layer, not just a minor dark-mode tweak.
- - Include stronger separators/dividers and clearer differentiation for adjacent surfaces and line items.
-- [x] Add font-size or UI scale controls in Settings.
- - Apply scaling through shared/root sizing variables rather than ad hoc per-component overrides.
- - As is best, want to make sure any measurement that makes sense to be is in rem units, so that things scale naturally based on the user's preferred font size
-- [x] Audit shared components for scaling behavior.
- - Buttons, inputs, pills, headers, cards, modal content, and table-like layouts should remain usable.
-- [x] Identify and clean up hard-coded font sizes or spacing that break accessibility scaling.
-- [x] Add non-color affordances for interactive help/info patterns.
- - Do not rely on dotted underline alone for discoverability, especially on smaller/mobile layouts.
- - Add a consistent info icon trigger (`i` or `?`) where glossary/help content is available.
-- [x] Ensure non-text contrast and component-state contrast meet WCAG targets.
- - Validate low-contrast combinations called out in UX review (light gray on white, dark gray on black, purple text on black).
- - Validate contrast for borders, row separators, control outlines, and disabled/inactive states.
-- [x] Consider reduced-motion support if the app's motion patterns justify it during implementation.
-
-Done definition:
-- [x] Users can increase readability and contrast without the app becoming visually broken or inconsistent.
-
-- Added small `ⓘ` (circled-i) superscript icon inside each `GlossaryTerm` button as a persistent non-color affordance. The icon is accent-colored, subtly dimmed at rest, and fully visible on hover/focus — making glossary terms discoverable without relying on the dotted underline alone.
-- Strengthened `GlossaryTerm` focus-visible outline from a semi-transparent `color-mix(...50%...)` value to a solid `var(--accent-primary)` 2px outline, which meets WCAG 2.4.11 Focus Appearance.
-- Fixed `--text-secondary` in light theme: `#6b7280` (Tailwind gray-500, 4.36:1 on white — fails WCAG AA for normal text) → `#4b5563` (Tailwind gray-600, ~6.7:1 ✓ passes AA).
-- Added `--text-accent` semantic token to separate accent-for-text from accent-for-backgrounds:
- - Light mode: `var(--accent-primary)` (#667eea, unchanged)
- - Dark mode: `#c084fc` (accent-secondary, 6.06:1 on dark bg ✓ vs. the old #a855f7 which was 4.06:1 — borderline fail for normal-sized text).
-- Residual contrast gap noted (not fixed in this release): light-theme `--border-color` (#e5e7eb) on white is ~1.2:1 — WCAG 1.4.11 non-text contrast for form control outlines requires 3:1. Full compliance would require a border of approximately Tailwind gray-500 (#6b7280), which is too dark for the current design aesthetic. High-contrast mode addresses this with `--border-color: #9ca3af`. `App.css` `body { font-size: 1rem }` so inherited text scales with the root (ThemeContext-controlled) font-size rather than always being locked to a hardcoded px value. This makes all text-level components (ViewModeSelector, labels, card content, etc.) scale correctly with Zoom/font-scale settings.
-- Converted `Toggle` switch and knob dimensions to `rem` units so the switch scales proportionally at large font sizes.
-- Converted `AccountsEditor` icon button dimensions to `rem`.
-- Converted `DateInput` calendar trigger button width to `rem`.
-- Fixed `ExportModal` checkbox label and error banner `font-size` from `14px` → `0.875rem`.
-- Added global `@media (prefers-reduced-motion: reduce)` rule in `index.css` to suppress animations and transitions for users who opt in at the OS level.
-- Implemented persisted accessibility settings in app settings model (`highContrastMode`, `fontScale`).
-- Implemented global appearance application via `ThemeContext` using `data-theme`, `data-contrast`, and root font-size.
-- Implemented Settings controls for High Contrast and UI Font Scale.
-- Added initial high-contrast token overrides for both light and dark themes.
-- Added shared appearance normalization utilities and wired them into settings read/save/import paths.
-- Added targeted tests for appearance normalization utility and app settings persistence/import normalization.
-- Converted left/right sidebar tab widths to scale-friendly sizing (`rem` + fit-content constraints) so labels remain readable at larger font scales.
-- Added a shared styled Select control with a custom caret indicator and adopted it in key accessibility-sensitive flows (Settings, Setup Wizard currency, Feedback category, and Plan year edit modal).
-- Renamed the shared Select control to `Dropdown` and migrated all direct JSX `` usages to the shared `Dropdown` component across the app.
-
----
-
-## 5. Zoom Controls and Shortcut Support (Phase 4)
-Parent status: `[x] Done`
-
-- [x] Add app zoom in, zoom out, and reset zoom actions.
- - Cmd/Ctrl `+`
- - Cmd/Ctrl `-`
- - Cmd/Ctrl `0`
-- [x] Add the same actions to the Electron View or Settings-related menu structure.
-- [x] Preserve the current shortcut reliability model.
- - Prefer Electron/app-level handling for global reliability, with renderer support only where needed.
-- [x] Make the difference between zoom and font scaling clear in the UI.
- - Zoom affects the whole app viewport.
- - Font scaling is a user preference for readability.
-- [x] Add the shortcuts to the keyboard shortcuts modal.
-- [x] Add/update tests where necessary.
- - Added `KeyboardShortcutsModal.test.tsx` assertions for zoom shortcuts and explicit zoom-vs-font-scale guidance.
- - Focused validation run: `KeyboardShortcutsModal` tests passing (2/2) and `npx tsc -b` clean.
-
-Done definition:
-- [x] Users can reliably zoom the app with standard shortcuts and understand how zoom differs from font scaling.
-
----
-
-## 6. Settings Modal Information Architecture (Phases 1-3)
-Parent status: `[x] Done`
-
-- [x] Reorganize Settings sections to better support the expanded feature set.
- - Appearance
- - Accessibility
- - Glossary
- - App Data / Reset
-- [x] Foundation layout updates in Settings to support accessibility controls.
- - Added distinct Accessibility section with high-contrast and font-scale controls.
-- [x] Ensure appearance and accessibility controls feel previewable, understandable, and reversible.
-- [x] Add concise helper copy where distinctions may be confusing.
- - Theme mode vs preset family
- - Preset theme vs custom theme
- - Font scale vs zoom
- - Added explicit helper copy in Settings clarifying:
- - Theme Mode controls light/dark/system behavior.
- - Preset controls color family.
- - Custom theme editing remains intentionally hidden in this release.
- - Zoom (viewport scale) vs Font Scale (readability) distinction.
-- [x] Clarify currency and regional presentation behavior in settings/setup flows.
- - Avoid depending on emoji-only country flags; ensure a readable text-first fallback label is always shown.
- - Verify dropdown chevron/icon spacing and right-side padding in select inputs.
- - Updated Setup Wizard currency options to text-first labels (`Symbol - Name (CODE)`) with flag emoji removed entirely.
- - Shared `Dropdown` caret spacing/right padding already verified and used by Settings + Setup Wizard.
-- [x] Keep the control surface clean and opinionated.
- - Avoid an “expert mode” UI in this release.
-- [x] Add/update tests where necessary.
- - Updated `SettingsModal.test.tsx` for merged `App Data and Reset` section behavior.
- - Added helper-copy coverage for Theme Mode vs Preset distinction.
- - Validation run: `SettingsModal` tests passing (9/9) and `npx tsc -b` clean.
-
-Done definition:
-- [x] The Settings modal can support the new appearance and accessibility controls without becoming cluttered or confusing.
-
----
-
-## 7. Quality, Validation, and Rollout Safety (Cross-Phase)
-Parent status: `[-] In Progress`
-
-- [-] Validate contrast and readability for preset and custom themes across shared UI states.
- - Default, hover, active, disabled, selected, focus, warning, error, success
- - Added preset-surface QA coverage for representative shared UI.
- - Tuned semantic alert/toast state tokens in `src/index.css` so success/warning/error/info blend with the active preset rather than staying preset-agnostic.
- - Added preset-specific contrast regression coverage for blended alert/toast tokens in `colorContrast.test.ts` across Default, Ocean, Forest, Sunset, Pink, and Spreadsheet Core in both light and dark modes.
-- [ ] Validate color-vision accessibility behavior for all supported colorblind modes.
- - Ensure warning/error/success/informational states remain distinguishable without relying on hue differences alone.
- - Verify iconography, labels, border styles, and emphasis patterns remain visible in each mode.
-- [ ] Add a layout/polish QA pass for spacing and alignment consistency.
- - Standardize button sizing, vertical centering, and padding between Bills/Loans and other shared action rows.
- - Verify action button alignment in editable list rows when inline amount fields are present.
- - Verify select control icon alignment/padding and baseline alignment in forms.
-- [ ] Add explicit form validation affordances for required inline fields.
- - Required line-item name should block save with visible inline error copy and focus guidance.
- - Confirm keyboard and screen-reader users receive the same validation feedback.
-- [ ] Verify import/export/reset behavior for the new app settings fields.
-- [ ] Confirm system-theme mode still behaves correctly when OS theme changes.
-- [ ] Confirm appearance settings survive app restart and restore accurately.
-- [-] Add targeted regression coverage for theme persistence, accessibility scaling, and zoom behaviors.
- - Added targeted coverage for theme preset persistence, accessibility setting normalization/persistence, zoom shortcut documentation, and preset-aware alert/toast contrast behavior.
-- [x] Run lint, typecheck, and tests after each delivered increment.
- - Phase 1 complete: 32 test files, 169 tests passing; 0 lint errors.
- - Phase 2 preset increment: lint ✓, tests ✓, typecheck ✓, build ✓.
- - Phase 2 QA + Phase 3 groundwork increment: lint ✓, tests ✓, typecheck ✓, build ✓ (53 test files / 495 tests passing).
- - Phase 3 custom mode slice: lint ✓, tests ✓, typecheck ✓, build ✓ (55 test files / 509 tests passing).
- - Phase 4 theming hardening increment: `colorContrast.test.ts` expanded to 112 passing assertions, including preset-aware alert/toast contrast checks; `npx tsc -b` clean.
-- [x] Run lint, typecheck, tests, and build before marking parent items done.
- - Validated: lint ✓, tests ✓ (169 passing)
-
-Done definition:
-- [ ] Theme and accessibility changes feel stable, reversible, and safe across restart, reset, and normal daily use.
-
----
-
-## 8. Colorblind Accessibility Modes (Phase 4)
-Parent status: `[-] In Progress`
-
-- [x] Add a persisted color-vision setting in app settings (app-level, not plan-level).
- - Suggested mode values: `normal`, `protanopia`, `deuteranopia`, `tritanopia`.
- - Apply via a root attribute (for example `data-color-vision`) so styling remains token-driven.
- - Added `colorVisionMode` to the app settings model, normalization, persistence/import-export flow, and `ThemeContext` root attribute wiring.
-- [x] Define color-vision-safe semantic token overrides in `src/index.css`.
- - Do not remap component-level colors ad hoc; keep behavior centralized via semantic tokens.
- - Re-tune status tokens (success/warning/error/info) and adjacent border/surface tokens per mode.
-- [-] Ensure critical state communication is not color-only.
- - Alerts, toasts, badges, and validation rows must include at least one non-color cue: icon, text label, pattern, or emphasis treatment.
- - Positive/neutral/negative financial outcomes should retain clear textual semantics even when hue cues collapse.
- - Shared `Alert` now renders a built-in severity label and icon (`Info`, `Success`, `Warning`, `Error`) so alerts no longer rely on color alone.
- - Key Metrics cards now render visible semantic badges (`Incoming`, `Withheld`, `Take-home`, `Committed`, `Saved`, `Flexible`/`Shortfall`) so the card role is not conveyed by accent color alone.
-- [x] Add targeted UI hardening for high-risk surfaces.
- - Pay Breakdown warning/error regions and remaining-balance feedback.
- - Destructive actions, confirmation dialogs, and disabled/inactive controls.
- - Key metrics/status chips and tab-level state cues where color is currently a primary indicator.
- - Completed first pass for Key Metrics with card-level semantic badges.
- - Completed first pass for Pay Breakdown warnings/errors with text-first state prefixes (`Overallocation:`, `Below target:`, `Allocation error:`, `Allocation warning:`) to preserve meaning when hue cues collapse.
- - Completed first pass for destructive confirmation dialogs via explicit semantic cue labels (`Confirmation required`, `Destructive action`) in shared `ConfirmDialog`.
- - Completed first pass for disabled/inactive control affordances in shared `Button` with explicit disabled-state metadata and a patterned/dashed visual treatment that does not depend on hue.
- - Residual state-only color cues in lower-priority surfaces remain.
-- [x] Add Settings controls and helper copy.
- - Place under Accessibility with concise explanation of what changes and what does not.
- - Clarify coexistence with high-contrast mode and preset theme selection.
- - Added `Enhanced State Cues` control with `Enhanced` and `Minimal` options so users can hide additional icon/label/pattern state chrome when desired.
-- [x] Add automated test coverage for color-vision modes.
- - Extend token-level contrast/value assertions in `colorContrast.test.ts` for each supported mode.
- - Add component assertions that non-color cues exist for warning/error/success states (for example icon/text presence tests in shared feedback components).
- - Expand representative fixture tests (similar to `presetSurfaceQa.test.tsx`) to render key surfaces under each color-vision mode.
- - Keep existing structural accessibility coverage (`accessibility.test.tsx` with `jest-axe`) as a baseline; use targeted assertions for color-vision behavior since jsdom/axe does not fully validate rendered color perception.
- - Added foundational regression coverage for color-vision setting normalization, persistence, ThemeContext root-attribute application, and Settings modal persistence.
- - Added explicit color-vision contrast coverage for light/dark semantic tokens, derived alert/toast tokens, and direct status-color readability across all supported modes.
- - Added shared `Alert` regression coverage and verified the general accessibility suite remains green.
- - Added focused `KeyMetrics.test.tsx` coverage for visible semantic badges and the dynamic `Shortfall` cue when bills exceed available net pay.
- - Added focused `PayBreakdown.test.tsx` assertions for text-first warning/error prefixes in below-target and over-allocation states.
- - Added focused `ConfirmDialog.test.tsx` assertions for explicit confirmation/destructive context labels independent of button color.
- - Added `colorVisionSurfaceQa.test.tsx` representative fixture coverage across all supported color-vision modes, light/dark themes, and normal/high contrast combinations.
- - Extended `Button.test.tsx` to assert explicit disabled-state hooks used by the non-color inactive control treatment.
- - Added settings/persistence/theme-root coverage for the new state cue mode (`enhanced`/`minimal`) toggle.
-- [ ] Add a manual QA checklist per mode.
- - Validate Light, Dark, and High Contrast combinations with each color-vision mode.
- - Validate keyboard and screen-reader feedback remains equivalent when visual color cues are reduced.
-
-Done definition:
-- [ ] Users can enable a colorblind mode and reliably distinguish app states (warning/error/success/info) through non-color cues plus accessible token contrast across light/dark/high-contrast themes.
-
----
-
-## Final v0.4.0 Exit Checklist
-- [ ] All parent items above are marked `[x] Done` or explicitly deferred.
-- [ ] RELEASE_NOTES.md updated with completed v0.4.0 user-facing items.
-- [ ] Lint, typecheck, tests, and build pass for merged changes.
-- [x] Lint, typecheck, tests, and build pass for merged changes.
- - Latest validation run: `npm run lint` ✓, `npm run test:run` ✓ (59 files / 586 tests), `npm run build` ✓.
\ No newline at end of file
diff --git a/app_updates/v0.4.0-undo-redo-audit-plan.md b/app_updates/v0.4.0-undo-redo-audit-plan.md
deleted file mode 100644
index a7e99fd..0000000
--- a/app_updates/v0.4.0-undo-redo-audit-plan.md
+++ /dev/null
@@ -1,224 +0,0 @@
-# v0.4.0 Undo/Redo and Audit History Plan
-
-Status keys:
-- `[ ]` Planned
-- `[-]` In Progress
-- `[x]` Done
-
-Release notes discipline:
-- [ ] Add a user-facing RELEASE_NOTES.md bullet when each parent item reaches Done.
-- [ ] Keep release-note wording focused on outcomes, not implementation details.
-
----
-
-## Implementation Sizing
-
-- Undo/Redo engine (app-level): Medium-High (about 1.5 to 3 dev days with tests)
-- Audit history capture and viewer: High (about 2 to 4 dev days incremental)
-- Roll back to any history point safely: High (about 1.5 to 3 dev days incremental)
-- Retention controls + compression strategy: Medium-High (about 1 to 2 dev days incremental)
-
-Total if fully implemented in one pass: High complexity, best delivered in phases.
-
----
-
-## 1. App-Level Undo/Redo Foundation
-Parent status: `[x] Done`
-
-- [x] Add history stacks to the budget state layer.
- - [x] Add undo and redo stacks in BudgetContext.
- - [x] Add a max history depth guard to control memory growth.
-- [x] Introduce one shared mutation helper for tracked updates.
- - [x] Ensure user-initiated changes push prior snapshot to undo stack.
- - [x] Ensure new edits clear redo stack.
-- [x] Add explicit support for non-tracked updates.
- - [x] System updates (load, relink metadata, session sync, etc.) should be excluded from history by default.
-- [x] Add context API surface for `undo`, `redo`, `canUndo`, `canRedo`.
-- [x] Add/update tests for stack behavior and state restoration.
-
-Done definition:
-- [x] A user can make edits, undo them, and redo them with deterministic data restoration across core plan entities.
-
----
-
-## 2. Command Wiring and Shortcut Reliability
-Parent status: `[x] Done`
-
-- [x] Add explicit app undo/redo menu events.
- - [x] Replace generic menu roles for undo/redo with app-specific event dispatch to renderer.
-- [x] Keep two-tier shortcut behavior.
- - [x] Electron/global shortcut path for reliability.
- - [x] Renderer capture-phase fallback where needed.
-- [x] Preserve native text-field undo in active input contexts.
- - [x] Do not hijack standard text editing undo for focused text inputs.
-- [x] Add Undo/Redo to keyboard shortcuts modal.
-- [x] Add/update tests for menu-event wiring and keyboard behavior.
-- [x] Add visual indicator when undo/redo action succeeds (HUD overlay matching zoom indicator position).
-
-Done definition:
-- [x] Cmd/Ctrl+Z and Shift+Cmd/Ctrl+Z (or Ctrl+Y if adopted) work reliably for app data edits without breaking native text editing expectations.
-
----
-
-## 3. Mutation Coverage and History Grouping
-Parent status: `[x] Done`
-
-- [x] Audit all mutation entry points and route them through tracked updates.
- - Pay settings, deductions, taxes, accounts, bills, loans, savings, retirement, tab/settings changes that are user-editable.
-- [x] Define operation grouping rules.
- - Treat multi-field modal save as one undo step, not many. (All modals call a single mutation function — already one step by design.)
- - Treat scripted bulk updates (reallocation apply) as one undo step. (Uses `updateBudgetData` once — already one step.)
- - Added `beginBatch()`/`commitBatch()`/`discardBatch()` API to `HistoryEngine` and `BudgetContext` for compound operations.
- - SetupWizard compound mutations (updateBudgetSettings + updatePaySettings + updateTaxSettings + updateBudgetData) wrapped in a batch → single undo step.
-- [x] Define undo boundaries for setup and plan lifecycle operations.
- - New plan, open plan, close plan, copy plan clear history on start.
- - SetupWizard completion produces a single batched undo step.
-- [x] Add/update tests for grouped operations and boundary behaviors.
-
-Done definition:
-- [x] Undo/redo steps feel intuitive, granular enough for user control, and do not explode into noisy per-keystroke history entries.
-
----
-
-## 4. Audit History Data Model (Object-Level)
-Parent status: `[x] Done`
-
-- [x] Define an audit log schema for object-level snapshots.
- - Includes timestamp, entity type, entity id, object snapshot, source action, and optional note.
- - Entity coverage includes bills, deductions, savings contributions, retirement elections, loans, benefits, accounts, allocation items, and core settings objects.
- - Schema is centralized in `src/types/audit.ts` (`AuditEntry`, `BudgetMetadata`).
-- [x] Capture audit entries from the same tracked mutation helper used by undo/redo.
- - Implemented in `applyBudgetMutation` via `buildAuditEntries(...)` from `src/utils/auditHistory.ts`.
- - No per-component logging duplication.
-- [x] Decide where audit history is stored.
- - Stored in plan file metadata (`BudgetData.metadata.auditHistory`) so history travels with the plan file.
-- [x] Add safeguards to avoid logging sensitive material unnecessarily.
- - Sensitive keys such as `encryptionKey` are stripped from audit snapshots before persistence.
-- [x] Add/update tests for audit-entry creation and schema stability.
- - Added `src/utils/auditHistory.test.ts` for schema and diff behavior.
- - Added `src/contexts/BudgetContext.test.tsx` coverage for tracked vs non-tracked audit capture.
-
-Done definition:
-- [x] Object state changes for bills, deductions, savings, retirement, and allocations are captured as inspectable snapshot history.
-
----
-
-## 5. Audit History Viewer UX
-Parent status: `[x] Done`
-
-- [x] Add per-object History entry points in item cards and edit surfaces.
- - [x] Add a `View History` button on `SectionItemCard` objects for Bills tab entities (bills + deductions), routed through a shared PlanDashboard overlay.
- - [x] Expand the same entry point pattern to savings, retirement, and loan cards.
- - [x] Pay Settings: "View History" button in modal footer.
- - [x] Tax Settings: "View History" button in PageHeader.
- - [x] Allocation Items: "View History" button at account level (next to Edit).
- - [ ] Other surfaces: Gross-to-net breakdown, settings export, advanced features (deferred to v0.4.1).
-- [x] Support filtering and search.
- - [x] Object-local timeline with date range filtering (From/To).
-- [x] Render object timeline using familiar card layouts.
- - [x] Field-level diffs with old/new value comparison.
- - [x] Strikethrough on old values, arrow to new values.
- - [x] Summary fields displayed inline (label, name, amount, rate, etc).
- - [x] Create/delete operations show snapshot summary.
- - [x] Newest entries first, scrollable timeline.
-- [x] Show date/time metadata on every history entry.
- - [x] Timestamp in local format (toLocaleString).
- - [x] Source action label.
-- [x] Allow per-object audit-entry deletion.
- - [x] Delete button per entry with confirmation dialog.
- - [x] Deletion itself not audited (per requirements).
-- [x] Add/update tests for viewer and utility.
- - [x] historyDiff utility tests (19 tests, 100% coverage).
- - [x] Entry point integration validated in component tests.
- - [x] Filter logic verified via existing audit tests.
- - [ ] Dedicated PlanHistoryOverlay component tests (optional, lower priority).
-
-Done definition:
-- [x] Users can open an object's history, scroll through its past states with field-level diffs, timestamps, and optionally remove entries they no longer want to retain.
-- [x] App Settings (budget-settings) changes are NOT tracked in history viewer (excluded from UI).
-- [x] All business data objects (bills, deductions, loans, savings, retirement, pay-settings, tax-settings, allocation-items) have accessible history entry points.
-
----
-
-
-
-
-
----
-
-
-
-## 7. History Retention Controls and File Size Management
-Parent status: `[~] Deferred — not needed at realistic usage scale (see benchmark)`
-
-- [~] Add user controls for history retention depth and/or age.
- - Deferred: benchmark shows ~567 B/entry; 200 entries/year ≈ 120 KB total plan file.
-- [~] Add optional audit-history compression for persisted data.
- - Deferred: file sizes are not a concern for this app's usage pattern.
-- [~] Add clear UX about storage tradeoffs.
- - Deferred with the above.
-- [~] Add pruning policy execution points.
- - Deferred with the above.
-- [x] Add/update tests for retention enforcement and load/save round-trips.
- - Done: `src/test/auditHistoryBenchmark.test.ts` validates size at 50–10,000 entries.
-
-Done definition:
-- [~] Deferred — plan files remain manageable without explicit controls at any realistic usage depth.
-
----
-
-## 8. Quality and Rollout Safety
-Parent status: `[x] Done`
-
-- [x] Add dedicated test coverage for undo/redo and audit features.
- - Unit coverage for stack transitions: `historyEngine.test.ts` (14 tests).
- - Audit entry creation: `auditHistory.test.ts` (4 tests).
- - Integration coverage: `BudgetContext.test.tsx` (tracked mutations, batch ops, undo/redo, audit history).
- - historyDiff utility: 19 tests with 100% coverage.
-- [x] Validate performance under heavy edit sequences.
- - `auditHistoryBenchmark.test.ts`: 500 snapshot round-trips in ~2.6ms (50ms budget). No degradation risk.
-- [x] Verify encrypted-plan compatibility.
- - `fileStorage.test.ts`: encrypt/decrypt roundtrip covered. `migrateBudgetData` runs on all 4 load paths (encrypted v1, plain, legacy encrypted) before the context sees the data.
-- [x] Add migration strategy for older plan files without audit metadata.
- - `migrateBudgetData` in `fileStorage.ts` initializes `metadata.auditHistory: []` for all old plans.
- - `BudgetContext.loadBudget` handles additional legacy field migrations (loans, lastSavedAt, benefits, retirement format).
- - Five new migration tests in `fileStorage.test.ts` cover: missing metadata, corrupted auditHistory, preserved history, legacy taxSettings format, missing optional arrays.
-- [x] Run lint, typecheck, tests, and build before marking parent items done.
- - 737 tests / 78 test files pass. `tsc --noEmit` + `eslint --max-warnings=0` clean.
-
-Done definition:
-- [x] Undo/redo and audit history ship with predictable behavior, acceptable performance, and no regressions in existing save/load flows.
-
----
-
-## Suggested Delivery Phases
-
-- Phase 1 (MVP): Items 1-3 (global undo/redo only)
-- Phase 2: Items 4-5 (audit capture + viewer)
-- Phase 3: Item 7 (retention/compression)
-- Phase 4: Item 8 hardening and release validation
-
----
-
-## Final v0.4.0 Exit Checklist
-- [x] All parent items above are marked `[x] Done` or explicitly deferred.
- - Items 1–5 Done. Item 6 deferred (commented out). Item 7 deferred (file size not a concern — see benchmark). Item 8 Done.
-- [x] Lint, typecheck, tests, and build pass for merged changes.
- - 737 tests / 78 files. `tsc --noEmit` and `eslint --max-warnings=0` both clean.
diff --git a/app_updates/v0.4.1-fixes.md b/app_updates/v0.4.1-fixes.md
new file mode 100644
index 0000000..faa4477
--- /dev/null
+++ b/app_updates/v0.4.1-fixes.md
@@ -0,0 +1,28 @@
+Few things needing fixing/updating across the app:
+
+- Key Metrics
+ - [x] Employer contributions are being counted towards "Your savings rate" on Key Metrics screen
+ - [x] Final "remaining for spending" amount is not aligning between Key Metrics and final Pay Breakdown "All that remains for spending" amount (Yearly view mode)
+ - [x] Need to rebrand "Bills" metric card as "Recurring Expenses", and then also include benefits Deductions and Loan payments into it, since right now it's only showing total "bill" items
+ - [x] Icons for metric cards not aligned with text
+- Account editor
+ - [x] When deleting an account with items associated with it, the app isn't picking up Savings contributions or Loans to be migrated to another account.
+ - [x] Deleting an account should be more dynamic in picking up the associated items with the account, checking all items linked with that "account id" instead of using if/else statements to determine the items to delete
+- Bills view
+ - [x] Some rounding issues when setting up a Deduction. Tried adding one for "9.30" but it kept showing as "9.31" once saved. When going to edit screen, it is showing "9.3".
+ - [x] In Bill items, amount saved as "9.30" is working fine and shows as "9.30", but when going back to edit it appears as "9.3" (missing final 0 in formatting).
+ - [x] Need to limit amount entry decimal spaces to maybe 3, as that's the max that shows in the UI anyway when item is saved.
+- Savings view
+ - [x] Get rid of the employer match logic from Retirement plans, since it doesn't add much and I think makes it confusing. Employer match doesn't count against your pay, so it shouldn't really be dealt with here.
+- All views
+ - [x] Add the tab Icon into the Tab View next to the view title header
+ - [x] If a user has a plan from before the log was available, then editing existing items shows weird data in the first entry since the app thinks the item is 'new' perhaps
+- App optimizations
+ - Want to try and optimize the code so that the builds aren't so large (currently over 200MB)
+ - Chunking warnings in the build
+ ```
+ (!) Some chunks are larger than 500 kB after minification. Consider:
+ - Using dynamic import() to code-split the application
+ - Use build.rollupOptions.output.manualChunks to improve chunking: https://rollupjs.org/configuration-options/#output-manualchunks
+ - Adjust chunk size limit for this warning via build.chunkSizeWarningLimit.
+ ```
\ No newline at end of file
diff --git a/package-lock.json b/package-lock.json
index 519eb8b..87a1c8b 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,25 +1,16 @@
{
"name": "paycheck-planner",
- "version": "0.4.0",
+ "version": "0.4.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paycheck-planner",
- "version": "0.4.0",
+ "version": "0.4.1",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
- "crypto-js": "^4.2.0",
- "d3-sankey": "^0.12.3",
- "jspdf": "^4.2.0",
- "jspdf-autotable": "^5.0.7",
- "keytar": "^7.9.0",
- "lucide-react": "^0.577.0",
- "pdf-lib": "^1.17.1",
- "react": "^19.2.0",
- "react-dom": "^19.2.0",
- "react-router-dom": "^7.13.1"
+ "keytar": "^7.9.0"
},
"devDependencies": {
"@electron/notarize": "^3.1.1",
@@ -29,13 +20,13 @@
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/crypto-js": "^4.2.2",
- "@types/d3-sankey": "^0.12.5",
"@types/jest-axe": "^3.5.9",
"@types/node": "^24.10.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"concurrently": "^9.2.1",
+ "crypto-js": "^4.2.0",
"electron": "^41.0.2",
"electron-builder": "^26.8.1",
"eslint": "^9.39.1",
@@ -44,7 +35,12 @@
"globals": "^16.5.0",
"jest-axe": "^10.0.0",
"jsdom": "^29.0.0",
+ "jspdf": "^4.2.0",
+ "jspdf-autotable": "^5.0.7",
+ "lucide-react": "^0.577.0",
"png2icons": "^2.0.1",
+ "react": "^19.2.0",
+ "react-dom": "^19.2.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.48.0",
"vite": "^7.3.1",
@@ -360,6 +356,7 @@
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
"integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
+ "dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -2071,36 +2068,6 @@
"node": ">=10"
}
},
- "node_modules/@pdf-lib/standard-fonts": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz",
- "integrity": "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==",
- "license": "MIT",
- "dependencies": {
- "pako": "^1.0.6"
- }
- },
- "node_modules/@pdf-lib/standard-fonts/node_modules/pako": {
- "version": "1.0.11",
- "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
- "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
- "license": "(MIT AND Zlib)"
- },
- "node_modules/@pdf-lib/upng": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/@pdf-lib/upng/-/upng-1.0.1.tgz",
- "integrity": "sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==",
- "license": "MIT",
- "dependencies": {
- "pako": "^1.0.10"
- }
- },
- "node_modules/@pdf-lib/upng/node_modules/pako": {
- "version": "1.0.11",
- "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
- "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
- "license": "(MIT AND Zlib)"
- },
"node_modules/@pkgjs/parseargs": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
@@ -2683,33 +2650,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/@types/d3-path": {
- "version": "1.0.11",
- "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-1.0.11.tgz",
- "integrity": "sha512-4pQMp8ldf7UaB/gR8Fvvy69psNHkTpD/pVw3vmEi8iZAB9EPMBruB1JvHO4BIq9QkUUd2lV1F5YXpMNj7JPBpw==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/@types/d3-sankey": {
- "version": "0.12.5",
- "resolved": "https://registry.npmjs.org/@types/d3-sankey/-/d3-sankey-0.12.5.tgz",
- "integrity": "sha512-/3RZSew0cLAtzGQ+C89hq/Rp3H20QJuVRSqFy6RKLe7E0B8kd2iOS1oBsodrgds4PcNVpqWhdUEng/SHvBcJ6Q==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@types/d3-shape": "^1"
- }
- },
- "node_modules/@types/d3-shape": {
- "version": "1.3.12",
- "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-1.3.12.tgz",
- "integrity": "sha512-8oMzcd4+poSLGgV0R1Q1rOlx/xdmozS4Xab7np0eamFFUYq71AU9pOCJEFnkXW2aI/oXdVYJzw6pssbSut7Z9Q==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@types/d3-path": "^1"
- }
- },
"node_modules/@types/debug": {
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
@@ -2903,6 +2843,7 @@
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz",
"integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==",
+ "dev": true,
"license": "MIT"
},
"node_modules/@types/plist": {
@@ -2921,6 +2862,7 @@
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz",
"integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==",
+ "dev": true,
"license": "MIT",
"optional": true
},
@@ -2965,6 +2907,7 @@
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
+ "dev": true,
"license": "MIT",
"optional": true
},
@@ -3954,6 +3897,7 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
"integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
+ "dev": true,
"license": "MIT",
"optional": true,
"engines": {
@@ -4343,6 +4287,7 @@
"version": "3.0.11",
"resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz",
"integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==",
+ "dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -4609,23 +4554,11 @@
"dev": true,
"license": "MIT"
},
- "node_modules/cookie": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
- "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
- "license": "MIT",
- "engines": {
- "node": ">=18"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/express"
- }
- },
"node_modules/core-js": {
"version": "3.48.0",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.48.0.tgz",
"integrity": "sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ==",
+ "dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
@@ -4681,12 +4614,14 @@
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz",
"integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==",
+ "dev": true,
"license": "MIT"
},
"node_modules/css-line-break": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz",
"integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
+ "dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -4721,40 +4656,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/d3-array": {
- "version": "2.12.1",
- "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz",
- "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==",
- "license": "BSD-3-Clause",
- "dependencies": {
- "internmap": "^1.0.0"
- }
- },
- "node_modules/d3-path": {
- "version": "1.0.9",
- "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz",
- "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==",
- "license": "BSD-3-Clause"
- },
- "node_modules/d3-sankey": {
- "version": "0.12.3",
- "resolved": "https://registry.npmjs.org/d3-sankey/-/d3-sankey-0.12.3.tgz",
- "integrity": "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==",
- "license": "BSD-3-Clause",
- "dependencies": {
- "d3-array": "1 - 2",
- "d3-shape": "^1.2.0"
- }
- },
- "node_modules/d3-shape": {
- "version": "1.3.7",
- "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz",
- "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==",
- "license": "BSD-3-Clause",
- "dependencies": {
- "d3-path": "1"
- }
- },
"node_modules/data-urls": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
@@ -5050,6 +4951,7 @@
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.2.tgz",
"integrity": "sha512-6obghkliLdmKa56xdbLOpUZ43pAR6xFy1uOrxBaIDjT+yaRuuybLjGS9eVBoSR/UPU5fq3OXClEHLJNGvbxKpQ==",
+ "dev": true,
"license": "(MPL-2.0 OR Apache-2.0)",
"optional": true,
"engines": {
@@ -5890,6 +5792,7 @@
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/fast-png/-/fast-png-6.4.0.tgz",
"integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"@types/pako": "^2.0.3",
@@ -5929,6 +5832,7 @@
"version": "0.8.2",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
"integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
+ "dev": true,
"license": "MIT"
},
"node_modules/file-entry-cache": {
@@ -6508,6 +6412,7 @@
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
"integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
+ "dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -6689,16 +6594,11 @@
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"license": "ISC"
},
- "node_modules/internmap": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz",
- "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==",
- "license": "ISC"
- },
"node_modules/iobuffer": {
"version": "5.4.0",
"resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz",
"integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==",
+ "dev": true,
"license": "MIT"
},
"node_modules/ip-address": {
@@ -7244,6 +7144,7 @@
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.2.1.tgz",
"integrity": "sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.28.6",
@@ -7261,6 +7162,7 @@
"version": "5.0.7",
"resolved": "https://registry.npmjs.org/jspdf-autotable/-/jspdf-autotable-5.0.7.tgz",
"integrity": "sha512-2wr7H6liNDBYNwt25hMQwXkEWFOEopgKIvR1Eukuw6Zmprm/ZcnmLTQEjW7Xx3FCbD3v7pflLcnMAv/h1jFDQw==",
+ "dev": true,
"license": "MIT",
"peerDependencies": {
"jspdf": "^2 || ^3 || ^4"
@@ -7385,6 +7287,7 @@
"version": "0.577.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.577.0.tgz",
"integrity": "sha512-4LjoFv2eEPwYDPg/CUdBJQSDfPyzXCRrVW1X7jrx/trgxnxkHFjnVZINbzvzxjN70dxychOfg+FTYwBiS3pQ5A==",
+ "dev": true,
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
@@ -8087,6 +7990,7 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz",
"integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==",
+ "dev": true,
"license": "(MIT AND Zlib)"
},
"node_modules/parent-module": {
@@ -8176,30 +8080,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/pdf-lib": {
- "version": "1.17.1",
- "resolved": "https://registry.npmjs.org/pdf-lib/-/pdf-lib-1.17.1.tgz",
- "integrity": "sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==",
- "license": "MIT",
- "dependencies": {
- "@pdf-lib/standard-fonts": "^1.0.0",
- "@pdf-lib/upng": "^1.0.1",
- "pako": "^1.0.11",
- "tslib": "^1.11.1"
- }
- },
- "node_modules/pdf-lib/node_modules/pako": {
- "version": "1.0.11",
- "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
- "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
- "license": "(MIT AND Zlib)"
- },
- "node_modules/pdf-lib/node_modules/tslib": {
- "version": "1.14.1",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
- "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
- "license": "0BSD"
- },
"node_modules/pe-library": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/pe-library/-/pe-library-0.4.1.tgz",
@@ -8226,6 +8106,7 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
"integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==",
+ "dev": true,
"license": "MIT",
"optional": true
},
@@ -8514,6 +8395,7 @@
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz",
"integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==",
+ "dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -8548,6 +8430,7 @@
"version": "19.2.4",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
+ "dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -8557,6 +8440,7 @@
"version": "19.2.4",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"scheduler": "^0.27.0"
@@ -8583,44 +8467,6 @@
"node": ">=0.10.0"
}
},
- "node_modules/react-router": {
- "version": "7.13.1",
- "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.1.tgz",
- "integrity": "sha512-td+xP4X2/6BJvZoX6xw++A2DdEi++YypA69bJUV5oVvqf6/9/9nNlD70YO1e9d3MyamJEBQFEzk6mbfDYbqrSA==",
- "license": "MIT",
- "dependencies": {
- "cookie": "^1.0.1",
- "set-cookie-parser": "^2.6.0"
- },
- "engines": {
- "node": ">=20.0.0"
- },
- "peerDependencies": {
- "react": ">=18",
- "react-dom": ">=18"
- },
- "peerDependenciesMeta": {
- "react-dom": {
- "optional": true
- }
- }
- },
- "node_modules/react-router-dom": {
- "version": "7.13.1",
- "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.1.tgz",
- "integrity": "sha512-UJnV3Rxc5TgUPJt2KJpo1Jpy0OKQr0AjgbZzBFjaPJcFOb2Y8jA5H3LT8HUJAiRLlWrEXWHbF1Z4SCZaQjWDHw==",
- "license": "MIT",
- "dependencies": {
- "react-router": "7.13.1"
- },
- "engines": {
- "node": ">=20.0.0"
- },
- "peerDependencies": {
- "react": ">=18",
- "react-dom": ">=18"
- }
- },
"node_modules/read-binary-file-arch": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/read-binary-file-arch/-/read-binary-file-arch-1.0.6.tgz",
@@ -8666,6 +8512,7 @@
"version": "0.13.11",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
+ "dev": true,
"license": "MIT",
"optional": true
},
@@ -8765,6 +8612,7 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz",
"integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==",
+ "dev": true,
"license": "MIT OR SEE LICENSE IN FEEL-FREE.md",
"optional": true,
"engines": {
@@ -8924,6 +8772,7 @@
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
+ "dev": true,
"license": "MIT"
},
"node_modules/semver": {
@@ -8961,12 +8810,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
- "node_modules/set-cookie-parser": {
- "version": "2.7.2",
- "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
- "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
- "license": "MIT"
- },
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -9241,6 +9084,7 @@
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz",
"integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==",
+ "dev": true,
"license": "MIT",
"optional": true,
"engines": {
@@ -9387,6 +9231,7 @@
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz",
"integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==",
+ "dev": true,
"license": "MIT",
"optional": true,
"engines": {
@@ -9529,6 +9374,7 @@
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
"integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
+ "dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -9893,6 +9739,7 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz",
"integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
+ "dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
diff --git a/package.json b/package.json
index f4e87e9..3322264 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "paycheck-planner",
"private": true,
- "version": "0.4.0",
+ "version": "0.4.1",
"description": "Desktop paycheck planning and budget management application.",
"author": {
"name": "Paycheck Planner",
@@ -93,16 +93,7 @@
}
},
"dependencies": {
- "crypto-js": "^4.2.0",
- "d3-sankey": "^0.12.3",
- "jspdf": "^4.2.0",
- "jspdf-autotable": "^5.0.7",
- "keytar": "^7.9.0",
- "lucide-react": "^0.577.0",
- "pdf-lib": "^1.17.1",
- "react": "^19.2.0",
- "react-dom": "^19.2.0",
- "react-router-dom": "^7.13.1"
+ "keytar": "^7.9.0"
},
"devDependencies": {
"@electron/notarize": "^3.1.1",
@@ -112,13 +103,13 @@
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/crypto-js": "^4.2.2",
- "@types/d3-sankey": "^0.12.5",
"@types/jest-axe": "^3.5.9",
"@types/node": "^24.10.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"concurrently": "^9.2.1",
+ "crypto-js": "^4.2.0",
"electron": "^41.0.2",
"electron-builder": "^26.8.1",
"eslint": "^9.39.1",
@@ -126,7 +117,12 @@
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"jest-axe": "^10.0.0",
+ "jspdf": "^4.2.0",
+ "jspdf-autotable": "^5.0.7",
"jsdom": "^29.0.0",
+ "lucide-react": "^0.577.0",
+ "react": "^19.2.0",
+ "react-dom": "^19.2.0",
"png2icons": "^2.0.1",
"typescript": "~5.9.3",
"typescript-eslint": "^8.48.0",
diff --git a/src/App.tsx b/src/App.tsx
index c4cf44b..c4f6519 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,5 +1,5 @@
// Main App component - decides whether to show setup, welcome screen, or dashboard
-import { useState, useEffect, useRef, useCallback } from 'react'
+import { useState, useEffect, useRef, useCallback, lazy, Suspense } from 'react'
import { APP_CUSTOM_EVENTS, MENU_EVENTS } from './constants/events'
import { useBudget } from './contexts/BudgetContext'
import { useGlobalKeyboardShortcuts } from './hooks'
@@ -8,11 +8,12 @@ import EncryptionSetup from './components/views/EncryptionSetup'
import WelcomeScreen from './components/views/WelcomeScreen'
import PlanDashboard from './components/PlanDashboard'
import SettingsModal from './components/modals/SettingsModal'
-import AboutModal from './components/modals/AboutModal'
-import GlossaryModal from './components/modals/GlossaryModal'
-import KeyboardShortcutsModal from './components/modals/KeyboardShortcutsModal'
import './App.css'
+const AboutModal = lazy(() => import('./components/modals/AboutModal'))
+const GlossaryModal = lazy(() => import('./components/modals/GlossaryModal'))
+const KeyboardShortcutsModal = lazy(() => import('./components/modals/KeyboardShortcutsModal'))
+
function App() {
if (import.meta.env.DEV) console.debug('[APP] App component rendering...');
@@ -283,19 +284,25 @@ function App() {
setShowSettings(false)} />
>
)}
- setShowAbout(false)} />
- {
- setShowGlossary(false)
- setInitialGlossaryTermId(null)
- }}
- />
- setShowKeyboardShortcuts(false)}
- />
+
+ setShowAbout(false)} />
+
+
+ {
+ setShowGlossary(false)
+ setInitialGlossaryTermId(null)
+ }}
+ />
+
+
+ setShowKeyboardShortcuts(false)}
+ />
+
{zoomIndicatorMessage && (
import('../modals/ExportModal'));
+const FeedbackModal = lazy(() => import('../modals/FeedbackModal'));
+const PlanSearchOverlay = lazy(() => import('./PlanSearchOverlay'));
+const PlanHistoryOverlay = lazy(() => import('./PlanHistoryOverlay'));
+
interface PlanHistoryState {
kind: 'plan-tab';
budgetHistoryId: string;
@@ -114,6 +115,19 @@ const isEditableTarget = (target: EventTarget | null): boolean => {
);
};
+const areAuditEntriesEquivalent = (a: AuditEntry, b: AuditEntry): boolean => {
+ if (a === b) return true;
+ return (
+ a.id === b.id &&
+ a.timestamp === b.timestamp &&
+ a.entityType === b.entityType &&
+ a.entityId === b.entityId &&
+ a.changeType === b.changeType &&
+ a.sourceAction === b.sourceAction &&
+ JSON.stringify(a.snapshot) === JSON.stringify(b.snapshot)
+ );
+};
+
const PlanDashboard: React.FC
= ({ onResetSetup, viewMode, onUndoRedoSuccess }) => {
const {
budgetData,
@@ -1204,7 +1218,7 @@ const PlanDashboard: React.FC = ({ onResetSetup, viewMode, o
}, []);
const handleDeleteHistoryEntry = useCallback(
- (entryId: string) => {
+ (entryToDelete: AuditEntry, originalIndex: number) => {
if (!budgetData) return;
openConfirmDialog({
@@ -1213,7 +1227,31 @@ const PlanDashboard: React.FC = ({ onResetSetup, viewMode, o
confirmLabel: 'Delete Entry',
confirmVariant: 'danger',
onConfirm: () => {
- const nextAudit = (budgetData.metadata?.auditHistory || []).filter((entry) => entry.id !== entryId);
+ const currentAudit = budgetData.metadata?.auditHistory || [];
+
+ let deleteIndex = -1;
+
+ if (
+ originalIndex >= 0 &&
+ originalIndex < currentAudit.length &&
+ areAuditEntriesEquivalent(currentAudit[originalIndex], entryToDelete)
+ ) {
+ deleteIndex = originalIndex;
+ }
+
+ if (deleteIndex === -1) {
+ deleteIndex = currentAudit.findIndex((entry) => areAuditEntriesEquivalent(entry, entryToDelete));
+ }
+
+ if (deleteIndex === -1 && typeof entryToDelete.id === 'string') {
+ deleteIndex = currentAudit.findIndex((entry) => entry.id === entryToDelete.id);
+ }
+
+ if (deleteIndex === -1) {
+ return;
+ }
+
+ const nextAudit = currentAudit.filter((_, idx) => idx !== deleteIndex);
updateBudgetData(
{
metadata: {
@@ -1952,23 +1990,25 @@ const PlanDashboard: React.FC = ({ onResetSetup, viewMode, o
- setShowFeedbackModal(false)}
- context={{
- appVersion: '1.0.0',
- activeTab,
- planYear: budgetData.year,
- planName: budgetData.name,
- currentFilePath: budgetData.settings.filePath,
- }}
- onSubmitted={({ success, message }: { success: boolean; message: string }) => {
- setStatusToast({
- type: success ? 'success' : 'error',
- message,
- });
- }}
- />
+
+ setShowFeedbackModal(false)}
+ context={{
+ appVersion: '1.0.0',
+ activeTab,
+ planYear: budgetData.year,
+ planName: budgetData.name,
+ currentFilePath: budgetData.settings.filePath,
+ }}
+ onSubmitted={({ success, message }: { success: boolean; message: string }) => {
+ setStatusToast({
+ type: success ? 'success' : 'error',
+ message,
+ });
+ }}
+ />
+
= ({ onResetSetup, viewMode, o
/>
{/* Export Modal */}
- setShowExportModal(false)}
- />
+
+ setShowExportModal(false)}
+ />
+
{/* Plan-wide Search Overlay */}
- setShowSearch(false)}
- budgetData={budgetData}
- onNavigate={handleSearchNavigate}
- />
-
- [a.id, a.name]))}
- onRestoreEntries={handleRestoreHistoryEntries}
- onClose={handleCloseObjectHistory}
- onDeleteEntry={handleDeleteHistoryEntry}
- />
+
+ setShowSearch(false)}
+ budgetData={budgetData}
+ onNavigate={handleSearchNavigate}
+ />
+
+
+
+ [a.id, a.name]))}
+ onRestoreEntries={handleRestoreHistoryEntries}
+ onClose={handleCloseObjectHistory}
+ onDeleteEntry={handleDeleteHistoryEntry}
+ />
+
{
+ it('deletes the clicked legacy "Initial tracked state" entry, not the item above it', () => {
+ const newerEntry: AuditEntry = {
+ id: 'legacy-duplicate-id',
+ timestamp: '2026-02-01T12:00:00.000Z',
+ entityType: 'bill',
+ entityId: 'bill-1',
+ changeType: 'update',
+ sourceAction: 'Edit bill',
+ snapshot: {
+ id: 'bill-1',
+ name: 'Internet',
+ amount: 85,
+ enabled: true,
+ },
+ };
+
+ const legacyInitialTrackedEntry: AuditEntry = {
+ id: 'legacy-duplicate-id',
+ timestamp: '2026-01-01T12:00:00.000Z',
+ entityType: 'bill',
+ entityId: 'bill-1',
+ changeType: 'update',
+ sourceAction: 'Edit bill',
+ snapshot: {
+ id: 'bill-1',
+ name: 'Internet',
+ amount: 80,
+ enabled: true,
+ },
+ };
+
+ const onDeleteEntry = vi.fn();
+
+ render(
+ ,
+ );
+
+ const initialTrackedStateLabel = screen.getByText('Initial tracked state');
+ const initialTrackedStateRow = initialTrackedStateLabel.closest('.plan-history-timeline-item');
+
+ expect(initialTrackedStateRow).not.toBeNull();
+
+ const deleteButton = within(initialTrackedStateRow as HTMLElement).getByRole('button', { name: 'Delete' });
+ fireEvent.click(deleteButton);
+
+ expect(onDeleteEntry).toHaveBeenCalledTimes(1);
+ expect(onDeleteEntry).toHaveBeenCalledWith(legacyInitialTrackedEntry, 1);
+ });
+});
diff --git a/src/components/PlanDashboard/PlanHistoryOverlay/PlanHistoryOverlay.tsx b/src/components/PlanDashboard/PlanHistoryOverlay/PlanHistoryOverlay.tsx
index f0e1143..2bb0dbf 100644
--- a/src/components/PlanDashboard/PlanHistoryOverlay/PlanHistoryOverlay.tsx
+++ b/src/components/PlanDashboard/PlanHistoryOverlay/PlanHistoryOverlay.tsx
@@ -21,12 +21,13 @@ interface PlanHistoryOverlayProps {
entityNames?: Record;
onRestoreEntries: (entryIds: string[]) => void;
onClose: () => void;
- onDeleteEntry: (entryId: string) => void;
+ onDeleteEntry: (entry: AuditEntry, originalIndex: number) => void;
}
interface TimelineEntryView {
entry: AuditEntry;
isCardType: boolean;
+ isLegacyBaselineMissing: boolean;
diffs: ReturnType;
cardDiffs: ReturnType;
summary: string[];
@@ -54,6 +55,17 @@ const PlanHistoryOverlay: React.FC = ({
const [dateFrom] = useState('');
const [dateTo] = useState('');
+ const getEntryRenderKey = (entry: AuditEntry, idx: number): string => {
+ const idPart = typeof entry.id === 'string' && entry.id.length > 0 ? entry.id : 'missing-id';
+ return `${idPart}-${entry.timestamp}-${entry.entityType}-${entry.entityId}-${idx}`;
+ };
+
+ const handleDeleteEntry = (entry: AuditEntry): void => {
+ // Use the exact source index from the full audit array as a stable fallback
+ // for legacy history rows that may not have unique IDs.
+ onDeleteEntry(entry, auditHistory.indexOf(entry));
+ };
+
// The most recent audit timestamp for this target entity (across all history, pre-filter).
// Used to hide the action button on the entry that represents the current state.
const latestAuditTimestamp = useMemo(() => {
@@ -131,12 +143,13 @@ const PlanHistoryOverlay: React.FC = ({
const buildEntryView = (entry: AuditEntry, idx: number): TimelineEntryView | null => {
const isCardType = CARD_ENTITY_TYPES.has(entry.entityType);
+ const isUpdateLike = entry.changeType === 'update' || entry.changeType === 'restore';
const sameEntityInFiltered = filteredEntries
.slice(idx + 1)
.find((candidate) => candidate.entityId === entry.entityId) ?? null;
const fallbackPrevEntry =
- !sameEntityInFiltered && (entry.changeType === 'update' || entry.changeType === 'restore')
+ !sameEntityInFiltered && isUpdateLike
? auditHistory
.filter((candidate) => {
if (candidate.entityType !== entry.entityType) return false;
@@ -147,8 +160,9 @@ const PlanHistoryOverlay: React.FC = ({
: null;
const baselineEntry = sameEntityInFiltered ?? fallbackPrevEntry;
+ const isLegacyBaselineMissing = isUpdateLike && !baselineEntry;
const allDiffs =
- (entry.changeType === 'update' || entry.changeType === 'restore')
+ isUpdateLike && baselineEntry
? extractFieldDiffs(baselineEntry?.snapshot ?? {}, entry.snapshot)
: [];
const diffs =
@@ -157,7 +171,7 @@ const PlanHistoryOverlay: React.FC = ({
: allDiffs;
// Do not render no-op update rows.
- if ((entry.changeType === 'update' || entry.changeType === 'restore') && diffs.length === 0) {
+ if (isUpdateLike && !isLegacyBaselineMissing && diffs.length === 0) {
return null;
}
@@ -199,6 +213,7 @@ const PlanHistoryOverlay: React.FC = ({
return {
entry,
isCardType,
+ isLegacyBaselineMissing,
diffs,
cardDiffs,
summary,
@@ -238,7 +253,7 @@ const PlanHistoryOverlay: React.FC = ({
*/}
{filteredEntries.length === 0 ? (
- No history entries match the selected filters.
+ There are no historic entries yet for this item.
) : (
{filteredEntries.map((entry, idx) => {
@@ -263,7 +278,7 @@ const PlanHistoryOverlay: React.FC
= ({
const first = batchViews[0];
return (
-
+
{first.changeLabel}
@@ -284,7 +299,7 @@ const PlanHistoryOverlay: React.FC
= ({
onDeleteEntry(first.entry.id)}
+ onClick={() => handleDeleteEntry(first.entry)}
>
Delete
@@ -301,16 +316,22 @@ const PlanHistoryOverlay: React.FC = ({
{(allocationView.entry.changeType === 'update' || allocationView.entry.changeType === 'restore') ? (
-
- {allocationView.diffs.map((diff) => (
-
- {formatFieldName(diff.key)}:
- {resolveValue(diff.key, diff.oldValue)}
- →
- {resolveValue(diff.key, diff.newValue)}
-
- ))}
-
+ allocationView.isLegacyBaselineMissing ? (
+
+ Initial tracked state for a legacy allocation item.
+
+ ) : (
+
+ {allocationView.diffs.map((diff) => (
+
+ {formatFieldName(diff.key)}:
+ {resolveValue(diff.key, diff.oldValue)}
+ →
+ {resolveValue(diff.key, diff.newValue)}
+
+ ))}
+
+ )
) : (
{toAllocationSummaryText(allocationView.entry.snapshot)}
@@ -324,7 +345,7 @@ const PlanHistoryOverlay: React.FC
= ({
}
return (
-
+
{view.changeLabel}
@@ -350,7 +371,7 @@ const PlanHistoryOverlay: React.FC
= ({
onDeleteEntry(entry.id)}
+ onClick={() => handleDeleteEntry(entry)}
>
Delete
@@ -376,6 +397,16 @@ const PlanHistoryOverlay: React.FC = ({
))}
)}
+ {view.isLegacyBaselineMissing && (
+
+
Initial tracked state
+
+
+ As this is the first tracked edit of this item, no earlier comparison exists yet to show.
+
+
+
+ )}
>
) : (
<>
@@ -412,7 +443,7 @@ const PlanHistoryOverlay: React.FC
= ({
{view.summary.length > 0 && (
{view.summary.map((line, i) => (
-
+
{line}
))}
@@ -420,6 +451,22 @@ const PlanHistoryOverlay: React.FC
= ({
)}
)}
+
+ {view.isLegacyBaselineMissing && (
+
+
Initial tracked state
+
+
+ As this is the first tracked edit of this item, no earlier comparison exists yet to show.
+
+ {view.summary.map((line, i) => (
+
+ {line}
+
+ ))}
+
+
+ )}
>
)}
diff --git a/src/components/PlanDashboard/PlanTabs/PlanTabs.css b/src/components/PlanDashboard/PlanTabs/PlanTabs.css
index 4b4df1e..a6ac2e8 100644
--- a/src/components/PlanDashboard/PlanTabs/PlanTabs.css
+++ b/src/components/PlanDashboard/PlanTabs/PlanTabs.css
@@ -22,7 +22,7 @@
display: flex;
align-items: center;
justify-content: center;
- gap: 0.5rem;
+ gap: 0.7rem;
white-space: nowrap;
outline: none !important;
}
diff --git a/src/components/_shared/layout/PageHeader/PageHeader.css b/src/components/_shared/layout/PageHeader/PageHeader.css
index b084e62..5eec5da 100644
--- a/src/components/_shared/layout/PageHeader/PageHeader.css
+++ b/src/components/_shared/layout/PageHeader/PageHeader.css
@@ -41,14 +41,22 @@
.page-header-content {
display: flex;
- flex-direction: column;
+ flex-direction: row;
+ align-items: center;
+ gap: 0.75rem;
+}
+
+.page-header-content .page-header-icon {
+ display: flex;
+ color: var(--icon-color-accent);
+ font-size: 2.5rem;
}
.page-header h1 {
font-size: 1.4rem;
font-weight: 600;
margin: 0 0 0.25rem 0;
- color: var(--text-primary);
+ color: var(--text-accent);
}
.page-header p {
diff --git a/src/components/_shared/layout/PageHeader/PageHeader.tsx b/src/components/_shared/layout/PageHeader/PageHeader.tsx
index bd6fedf..540e611 100644
--- a/src/components/_shared/layout/PageHeader/PageHeader.tsx
+++ b/src/components/_shared/layout/PageHeader/PageHeader.tsx
@@ -1,19 +1,26 @@
import React from 'react';
-import type { ReactNode } from 'react';
import './PageHeader.css';
interface PageHeaderProps {
title: string;
subtitle?: string;
- actions?: ReactNode;
+ actions?: React.ReactNode;
+ icon?: React.ReactNode;
}
-const PageHeader: React.FC = ({ title, subtitle, actions }) => {
+const PageHeader: React.FC = ({ title, subtitle, actions, icon }) => {
return (
-
{title}
- {subtitle &&
{subtitle}
}
+ {icon && (
+
+ {icon}
+
+ )}
+
+
{title}
+ {subtitle &&
{subtitle}
}
+
{actions &&
{actions}
}
diff --git a/src/components/modals/AccountsModal/AccountsModal.tsx b/src/components/modals/AccountsModal/AccountsModal.tsx
index e353d24..11b04b1 100644
--- a/src/components/modals/AccountsModal/AccountsModal.tsx
+++ b/src/components/modals/AccountsModal/AccountsModal.tsx
@@ -1,6 +1,7 @@
import React, { useState } from 'react';
import { useBudget } from '../../../contexts/BudgetContext';
import type { Account } from '../../../types/accounts';
+import type { BudgetData } from '../../../types/budget';
import { Modal, Button, FormGroup, AccountsEditor, Dropdown } from '../../_shared';
import './AccountsModal.css';
import './AccountsDeleteModal.css';
@@ -9,14 +10,116 @@ interface AccountsModalProps {
onClose: () => void;
}
+type LinkedDataKey = 'bills' | 'benefits' | 'retirement' | 'savingsContributions' | 'loans';
+
+type LinkedSummary = {
+ key: LinkedDataKey;
+ label: string;
+ count: number;
+};
+
+type LinkedCollectionConfig = {
+ key: LinkedDataKey;
+ label: string;
+ getItems: (data: BudgetData) => Array;
+ isLinkedToAccount: (item: unknown, accountId: string) => boolean;
+ reassignAccount: (item: unknown, targetAccountId: string) => unknown;
+};
+
+const LINKED_ACCOUNT_COLLECTIONS: LinkedCollectionConfig[] = [
+ {
+ key: 'bills',
+ label: 'bill(s)',
+ getItems: (data) => data.bills,
+ isLinkedToAccount: (item, accountId) => (item as BudgetData['bills'][number]).accountId === accountId,
+ reassignAccount: (item, targetAccountId) => ({
+ ...(item as BudgetData['bills'][number]),
+ accountId: targetAccountId,
+ }),
+ },
+ {
+ key: 'benefits',
+ label: 'deduction(s)',
+ getItems: (data) => data.benefits,
+ isLinkedToAccount: (item, accountId) => (item as BudgetData['benefits'][number]).sourceAccountId === accountId,
+ reassignAccount: (item, targetAccountId) => ({
+ ...(item as BudgetData['benefits'][number]),
+ deductionSource: 'account' as const,
+ sourceAccountId: targetAccountId,
+ }),
+ },
+ {
+ key: 'retirement',
+ label: 'retirement election(s)',
+ getItems: (data) => data.retirement,
+ isLinkedToAccount: (item, accountId) => (item as BudgetData['retirement'][number]).sourceAccountId === accountId,
+ reassignAccount: (item, targetAccountId) => ({
+ ...(item as BudgetData['retirement'][number]),
+ deductionSource: 'account' as const,
+ sourceAccountId: targetAccountId,
+ }),
+ },
+ {
+ key: 'savingsContributions',
+ label: 'savings contribution(s)',
+ getItems: (data) => data.savingsContributions ?? [],
+ isLinkedToAccount: (item, accountId) => (item as NonNullable[number]).accountId === accountId,
+ reassignAccount: (item, targetAccountId) => ({
+ ...(item as NonNullable[number]),
+ accountId: targetAccountId,
+ }),
+ },
+ {
+ key: 'loans',
+ label: 'loan(s)',
+ getItems: (data) => data.loans,
+ isLinkedToAccount: (item, accountId) => (item as BudgetData['loans'][number]).accountId === accountId,
+ reassignAccount: (item, targetAccountId) => ({
+ ...(item as BudgetData['loans'][number]),
+ accountId: targetAccountId,
+ }),
+ },
+];
+
+const getLinkedSummaries = (data: BudgetData, accountId: string): LinkedSummary[] => {
+ return LINKED_ACCOUNT_COLLECTIONS.map((config) => {
+ const count = config.getItems(data).filter((item) => config.isLinkedToAccount(item, accountId)).length;
+ return {
+ key: config.key,
+ label: config.label,
+ count,
+ };
+ });
+};
+
+const buildLinkedCollectionUpdates = (
+ data: BudgetData,
+ accountId: string,
+ mode: 'reallocate' | 'delete-all' | 'delete-account',
+ targetAccountId?: string,
+): Partial> => {
+ return LINKED_ACCOUNT_COLLECTIONS.reduce((updates, config) => {
+ const nextCollection = config.getItems(data).map((item) => {
+ if (!config.isLinkedToAccount(item, accountId)) {
+ return item;
+ }
+ if (mode !== 'reallocate' || !targetAccountId) {
+ return null;
+ }
+ return config.reassignAccount(item, targetAccountId);
+ }).filter((item): item is NonNullable => item !== null);
+
+ (updates as Record>)[config.key] = nextCollection;
+ return updates;
+ }, {} as Partial>);
+};
+
const AccountsModal: React.FC = ({ onClose }) => {
const { budgetData, addAccount, updateAccount, updateBudgetData } = useBudget();
const [deleteTargetAccountId, setDeleteTargetAccountId] = useState('');
const [deleteDialogState, setDeleteDialogState] = useState<{
account: Account;
- linkedBills: number;
- linkedBenefits: number;
- linkedRetirement: number;
+ linkedSummaries: LinkedSummary[];
} | null>(null);
const accounts = budgetData?.accounts ?? [];
@@ -48,20 +151,12 @@ const AccountsModal: React.FC = ({ onClose }) => {
const accountToDelete = budgetData.accounts.find((account) => account.id === id);
if (!accountToDelete) return;
- const linkedBills = budgetData.bills.filter((bill) => bill.accountId === id).length;
- const linkedBenefits = budgetData.benefits.filter(
- (benefit) => benefit.sourceAccountId === id
- ).length;
- const linkedRetirement = budgetData.retirement.filter(
- (election) => election.sourceAccountId === id
- ).length;
+ const linkedSummaries = getLinkedSummaries(budgetData, id);
const fallbackAccount = accounts.find((account) => account.id !== id);
setDeleteTargetAccountId(fallbackAccount?.id || '');
setDeleteDialogState({
account: accountToDelete,
- linkedBills,
- linkedBenefits,
- linkedRetirement,
+ linkedSummaries,
});
};
@@ -80,44 +175,30 @@ const AccountsModal: React.FC = ({ onClose }) => {
if (!deleteTargetAccountId || deleteTargetAccountId === accountId) {
return;
}
-
- const updatedBills = budgetData.bills.map((bill) =>
- bill.accountId === accountId ? { ...bill, accountId: deleteTargetAccountId } : bill
- );
- const updatedBenefits = budgetData.benefits.map((benefit) =>
- benefit.sourceAccountId === accountId
- ? { ...benefit, deductionSource: 'account' as const, sourceAccountId: deleteTargetAccountId }
- : benefit
- );
- const updatedRetirement = budgetData.retirement.map((election) =>
- election.sourceAccountId === accountId
- ? { ...election, deductionSource: 'account' as const, sourceAccountId: deleteTargetAccountId }
- : election
+ const linkedCollectionUpdates = buildLinkedCollectionUpdates(
+ budgetData,
+ accountId,
+ mode,
+ deleteTargetAccountId,
);
updateBudgetData({
accounts: updatedAccounts,
- bills: updatedBills,
- benefits: updatedBenefits,
- retirement: updatedRetirement,
+ ...linkedCollectionUpdates,
});
handleCloseDeleteDialog();
return;
}
- const updatedBills = budgetData.bills.filter((bill) => bill.accountId !== accountId);
- const updatedBenefits = budgetData.benefits.filter(
- (benefit) => benefit.sourceAccountId !== accountId
- );
- const updatedRetirement = budgetData.retirement.filter(
- (election) => election.sourceAccountId !== accountId
+ const linkedCollectionUpdates = buildLinkedCollectionUpdates(
+ budgetData,
+ accountId,
+ mode,
);
updateBudgetData({
accounts: updatedAccounts,
- bills: updatedBills,
- benefits: updatedBenefits,
- retirement: updatedRetirement,
+ ...linkedCollectionUpdates,
});
handleCloseDeleteDialog();
};
@@ -125,7 +206,7 @@ const AccountsModal: React.FC = ({ onClose }) => {
if (!budgetData) return null;
const totalLinkedItems = deleteDialogState
- ? deleteDialogState.linkedBills + deleteDialogState.linkedBenefits + deleteDialogState.linkedRetirement
+ ? deleteDialogState.linkedSummaries.reduce((sum, summary) => sum + summary.count, 0)
: 0;
const hasLinkedItems = totalLinkedItems > 0;
@@ -196,9 +277,9 @@ const AccountsModal: React.FC = ({ onClose }) => {
This account is linked to existing data. Choose whether to move linked items to another account or delete them.
- {deleteDialogState.linkedBills} bill(s)
- {deleteDialogState.linkedBenefits} benefit(s)
- {deleteDialogState.linkedRetirement} retirement election(s)
+ {deleteDialogState.linkedSummaries.map((summary) => (
+ {summary.count} {summary.label}
+ ))}
@@ -218,7 +299,7 @@ const AccountsModal: React.FC = ({ onClose }) => {
>
) : (
- This account has no linked bills, benefits, or retirement elections. Deleting it will only remove the account.
+ This account has no linked items. Deleting it will only remove the account.
)}
diff --git a/src/components/modals/ExportModal/ExportModal.tsx b/src/components/modals/ExportModal/ExportModal.tsx
index 743d856..480d36e 100644
--- a/src/components/modals/ExportModal/ExportModal.tsx
+++ b/src/components/modals/ExportModal/ExportModal.tsx
@@ -1,6 +1,6 @@
import React, { useState } from 'react';
import { useBudget } from '../../../contexts/BudgetContext';
-import { exportToPDF, type PDFExportOptions } from '../../../services/pdfExport';
+import type { PDFExportOptions } from '../../../services/pdfExport';
import { Modal, Button, FormGroup } from '../../_shared';
import './ExportModal.css';
@@ -55,6 +55,8 @@ const ExportModal: React.FC
= ({ isOpen, onClose }) => {
includeTaxes,
};
+ const { exportToPDF } = await import('../../../services/pdfExport');
+
// Generate PDF
const pdfData = await exportToPDF(budgetData, options);
@@ -141,7 +143,7 @@ const ExportModal: React.FC = ({ isOpen, onClose }) => {
checked={includeBenefits}
onChange={(e) => setIncludeBenefits(e.target.checked)}
/>
- Benefits
+ Deductions
{
+ const factor = 10 ** decimals;
+ return Math.round((value + Number.EPSILON) * factor) / factor;
+};
+
+const sanitizeDecimalInput = (rawValue: string, maxDecimals: number): string | null => {
+ const value = rawValue.replace(/,/g, '');
+
+ if (value === '') return '';
+ if (!/^\d*\.?\d*$/.test(value)) return null;
+
+ const [wholePart, decimalPart] = value.split('.');
+ if (decimalPart == null) return wholePart;
+
+ return `${wholePart}.${decimalPart.slice(0, maxDecimals)}`;
+};
+
+const formatAmountForInput = (amount: number, minDecimals: number, maxDecimals: number): string => {
+ if (!Number.isFinite(amount)) return '';
+
+ const rounded = roundToScale(amount, maxDecimals);
+ let formatted = rounded.toFixed(maxDecimals).replace(/0+$/, '').replace(/\.$/, '');
+
+ if (!formatted.includes('.') && minDecimals > 0) {
+ formatted = `${formatted}.${'0'.repeat(minDecimals)}`;
+ } else if (formatted.includes('.')) {
+ const fractionLength = formatted.split('.')[1].length;
+ if (fractionLength < minDecimals) {
+ formatted = `${formatted}${'0'.repeat(minDecimals - fractionLength)}`;
+ }
+ }
+
+ return formatted;
+};
+
const BillsManager: React.FC = ({
scrollToAccountId,
searchActionRequestKey,
@@ -126,7 +163,7 @@ const BillsManager: React.FC = ({
const bill = budgetData.bills.find((item) => item.id === searchActionTargetId);
if (bill) {
setBillName(bill.name);
- setBillAmount(bill.amount.toString());
+ setBillAmount(formatAmountForInput(bill.amount, 2, MAX_AMOUNT_DECIMALS));
setBillFrequency(bill.frequency);
setBillAccountId(bill.accountId);
setBillNotes(bill.notes || '');
@@ -163,7 +200,7 @@ const BillsManager: React.FC = ({
const benefit = budgetData.benefits.find((item) => item.id === searchActionTargetId);
if (benefit) {
setBenefitName(benefit.name);
- setBenefitAmount(benefit.amount.toString());
+ setBenefitAmount(formatAmountForInput(benefit.amount, benefit.isPercentage ? 0 : 2, MAX_AMOUNT_DECIMALS));
setBenefitIsPercentage(benefit.isPercentage || false);
setBenefitSource(benefit.deductionSource || 'paycheck');
setBenefitSourceAccountId(benefit.sourceAccountId || '');
@@ -243,7 +280,7 @@ const BillsManager: React.FC = ({
if (benefit.isPercentage) {
return roundUpToCent((grossPayPerPaycheck * benefit.amount) / 100);
}
- return roundUpToCent(benefit.amount);
+ return roundToCent(benefit.amount);
};
const getBenefitMonthly = (benefit: Benefit): number => {
@@ -313,7 +350,7 @@ const BillsManager: React.FC = ({
const handleEditBill = (bill: Bill) => {
setBillName(bill.name);
- setBillAmount(bill.amount.toString());
+ setBillAmount(formatAmountForInput(bill.amount, 2, MAX_AMOUNT_DECIMALS));
setBillFrequency(bill.frequency);
setBillAccountId(bill.accountId);
setBillNotes(bill.notes || '');
@@ -325,12 +362,13 @@ const BillsManager: React.FC = ({
const handleSaveBill = () => {
const trimmedBillName = billName.trim();
const parsedBillAmount = parseFloat(billAmount);
+ const normalizedBillAmount = roundToScale(parsedBillAmount, MAX_AMOUNT_DECIMALS);
const errors: BillFieldErrors = {};
if (!trimmedBillName) {
errors.name = 'Bill name is required.';
}
- if (!Number.isFinite(parsedBillAmount) || parsedBillAmount <= 0) {
+ if (!Number.isFinite(parsedBillAmount) || normalizedBillAmount <= 0) {
errors.amount = 'Please enter a valid amount greater than zero.';
}
if (!billAccountId) {
@@ -344,7 +382,7 @@ const BillsManager: React.FC = ({
const billData = {
name: trimmedBillName,
- amount: parsedBillAmount,
+ amount: normalizedBillAmount,
frequency: billFrequency,
accountId: billAccountId,
enabled: editingBill ? editingBill.enabled !== false : true,
@@ -389,7 +427,7 @@ const BillsManager: React.FC = ({
const handleEditBenefit = (benefit: Benefit) => {
setBenefitName(benefit.name);
- setBenefitAmount(benefit.amount.toString());
+ setBenefitAmount(formatAmountForInput(benefit.amount, benefit.isPercentage ? 0 : 2, MAX_AMOUNT_DECIMALS));
setBenefitIsPercentage(benefit.isPercentage || false);
setBenefitSource(benefit.deductionSource || 'paycheck');
setBenefitSourceAccountId(benefit.sourceAccountId || '');
@@ -402,13 +440,14 @@ const BillsManager: React.FC = ({
const handleSaveBenefit = () => {
const name = benefitName.trim();
const parsedAmount = parseFloat(benefitAmount);
+ const normalizedAmount = roundToScale(parsedAmount, MAX_AMOUNT_DECIMALS);
const isAccountSource = benefitSource === 'account';
const errors: BenefitFieldErrors = {};
if (!name) {
errors.name = 'Deduction name is required.';
}
- if (!Number.isFinite(parsedAmount) || parsedAmount < 0) {
+ if (!Number.isFinite(parsedAmount) || normalizedAmount < 0) {
errors.amount = 'Please enter a valid deduction amount.';
}
if (isAccountSource && !benefitSourceAccountId) {
@@ -422,7 +461,7 @@ const BillsManager: React.FC = ({
const payload = {
name,
- amount: parsedAmount,
+ amount: normalizedAmount,
enabled: editingBenefit ? editingBenefit.enabled !== false : true,
discretionary: benefitIsDiscretionary,
isTaxable: isAccountSource ? true : benefitIsTaxable,
@@ -469,6 +508,7 @@ const BillsManager: React.FC = ({
}
actions={
@@ -687,15 +727,17 @@ const BillsManager: React.FC = ({
{
- setBillAmount(e.target.value);
+ const nextValue = sanitizeDecimalInput(e.target.value, MAX_AMOUNT_DECIMALS);
+ if (nextValue === null) return;
+ setBillAmount(nextValue);
billErrors.clearFieldError('amount');
}}
placeholder="0.00"
- step="0.01"
- min="0"
+ pattern="^\\d*\\.?\\d{0,3}$"
className={billFieldErrors.amount ? 'field-error' : ''}
required
/>
@@ -809,16 +851,18 @@ const BillsManager: React.FC = ({
{
- setBenefitAmount(e.target.value);
+ const nextValue = sanitizeDecimalInput(e.target.value, MAX_AMOUNT_DECIMALS);
+ if (nextValue === null) return;
+ setBenefitAmount(nextValue);
benefitErrors.clearFieldError('amount');
}}
placeholder={benefitIsPercentage ? '0' : '0.00'}
- step={benefitIsPercentage ? '0.1' : '0.01'}
- min="0"
+ pattern="^\\d*\\.?\\d{0,3}$"
required
/>
diff --git a/src/components/tabViews/KeyMetrics/KeyMetrics.css b/src/components/tabViews/KeyMetrics/KeyMetrics.css
index 2b49429..38a5822 100644
--- a/src/components/tabViews/KeyMetrics/KeyMetrics.css
+++ b/src/components/tabViews/KeyMetrics/KeyMetrics.css
@@ -84,6 +84,7 @@
}
.metric-icon {
+ display: flex;
font-size: 2.2rem;
}
diff --git a/src/components/tabViews/KeyMetrics/KeyMetrics.test.tsx b/src/components/tabViews/KeyMetrics/KeyMetrics.test.tsx
index ce1bbfb..8c0c92b 100644
--- a/src/components/tabViews/KeyMetrics/KeyMetrics.test.tsx
+++ b/src/components/tabViews/KeyMetrics/KeyMetrics.test.tsx
@@ -22,7 +22,16 @@ const {
annualSalary: 60000,
minLeftover: 0,
},
- accounts: [],
+ accounts: [
+ {
+ id: 'acc-1',
+ name: 'Checking',
+ type: 'checking',
+ color: '#4f46e5',
+ icon: 'wallet',
+ allocationCategories: [] as Array<{ id: string; name: string; amount: number }>,
+ },
+ ],
bills: [
{
id: 'bill-1',
@@ -32,9 +41,28 @@ const {
enabled: true,
},
],
- benefits: [],
+ benefits: [] as Array<{
+ id: string;
+ name: string;
+ amount: number;
+ isTaxable: boolean;
+ isPercentage?: boolean;
+ enabled?: boolean;
+ deductionSource?: 'paycheck' | 'account';
+ }>,
retirement: [],
- loans: [],
+ loans: [] as Array<{
+ id: string;
+ name: string;
+ type: 'mortgage' | 'auto' | 'student' | 'personal' | 'credit-card' | 'other';
+ principal: number;
+ currentBalance: number;
+ interestRate: number;
+ monthlyPayment: number;
+ accountId: string;
+ startDate: string;
+ enabled?: boolean;
+ }>,
savingsContributions: [],
preTaxDeductions: [],
taxSettings: {
@@ -119,6 +147,9 @@ describe('KeyMetrics semantic badges', () => {
calculatePaycheckBreakdownMock.mockClear();
calculateRetirementContributionsMock.mockClear();
updateBudgetSettingsMock.mockClear();
+ mockBudgetData.accounts[0].allocationCategories = [];
+ mockBudgetData.benefits = [];
+ mockBudgetData.loans = [];
});
it('renders visible semantic badges for the metric cards', () => {
@@ -132,13 +163,75 @@ describe('KeyMetrics semantic badges', () => {
expect(screen.getByText('Flexible')).toBeTruthy();
});
+ it('shows recurring expenses card totals including bills, deductions, and loans', () => {
+ mockBudgetData.benefits = [
+ {
+ id: 'benefit-1',
+ name: 'Health Deduction',
+ amount: 200,
+ isTaxable: true,
+ isPercentage: false,
+ enabled: true,
+ deductionSource: 'paycheck',
+ },
+ ];
+ mockBudgetData.loans = [
+ {
+ id: 'loan-1',
+ name: 'Auto Loan',
+ type: 'auto',
+ principal: 20000,
+ currentBalance: 15000,
+ interestRate: 4,
+ monthlyPayment: 300,
+ accountId: 'acc-1',
+ startDate: '2026-01-01',
+ enabled: true,
+ },
+ ];
+
+ render( );
+
+ expect(screen.getByText('Recurring Expenses')).toBeTruthy();
+ // 12k bills + (200*12) deductions + (300*12) loans = 18k yearly.
+ expect(screen.getByText('$18,000')).toBeTruthy();
+ expect(screen.getByText('3 items')).toBeTruthy();
+ });
+
+ it('includes custom allocation line items in recurring expenses totals', () => {
+ mockBudgetData.accounts[0].allocationCategories = [
+ { id: 'custom-1', name: 'Subscriptions', amount: 150 },
+ { id: '__bills_auto-1', name: 'Auto Bills', amount: 400 },
+ ];
+
+ render( );
+
+ // 12k bills + (150*12) custom allocations = 13.8k yearly.
+ // Auto allocation categories should not be double counted here.
+ expect(screen.getByText('$13,800')).toBeTruthy();
+ expect(screen.getByText('2 items')).toBeTruthy();
+ });
+
it('switches the remaining card badge to shortfall when bills exceed net pay', () => {
- annualizedSummary.annualNet = 6000;
- annualizedSummary.monthlyNet = 500;
+ mockBudgetData.accounts[0].allocationCategories = [
+ { id: 'cat-1', name: 'Allocated', amount: 3600 },
+ ];
render( );
expect(screen.getByText('Shortfall')).toBeTruthy();
expect(screen.queryByText('Flexible')).toBeNull();
});
+
+ it('uses allocation leftover math for remaining yearly amount', () => {
+ // Net is 3,500 per paycheck, with 1,000 allocated => 2,500 remaining per paycheck.
+ // Monthly pay frequency means yearly remaining should be 30,000.
+ mockBudgetData.accounts[0].allocationCategories = [
+ { id: 'cat-2', name: 'Planned spending', amount: 1000 },
+ ];
+
+ render( );
+
+ expect(screen.getByText('$30,000')).toBeTruthy();
+ });
});
\ No newline at end of file
diff --git a/src/components/tabViews/KeyMetrics/KeyMetrics.tsx b/src/components/tabViews/KeyMetrics/KeyMetrics.tsx
index 84c225f..c7a8248 100644
--- a/src/components/tabViews/KeyMetrics/KeyMetrics.tsx
+++ b/src/components/tabViews/KeyMetrics/KeyMetrics.tsx
@@ -1,12 +1,12 @@
import React from 'react';
-import { BanknoteArrowDown, ClipboardList, HandCoins, PiggyBank, Scale, Wallet } from 'lucide-react';
+import { BanknoteArrowDown, ClipboardList, HandCoins, LayoutGrid, PiggyBank, Scale, Wallet } from 'lucide-react';
import { useBudget } from '../../../contexts/BudgetContext';
import { calculateAnnualizedPayBreakdown, calculateAnnualizedPaySummary } from '../../../services/budgetCalculations';
import { formatWithSymbol } from '../../../utils/currency';
-import { roundUpToCent } from '../../../utils/money';
+import { roundToCent, roundUpToCent } from '../../../utils/money';
import { getPaychecksPerYear } from '../../../utils/payPeriod';
import { convertBillToYearly } from '../../../utils/billFrequency';
-import { getSavingsFrequencyOccurrencesPerYear } from '../../../utils/frequency';
+import { getBillFrequencyOccurrencesPerYear, getSavingsFrequencyOccurrencesPerYear } from '../../../utils/frequency';
import { buildKeyMetricsSegments } from '../../../utils/keyMetricsSegments';
import type { KeyMetricsBreakdownView } from '../../../types/settings';
import { PageHeader, ViewModeSelector } from '../../_shared';
@@ -36,6 +36,88 @@ interface MetricCardProps {
type BreakdownView = 'bars' | 'stacked' | 'pie';
+const AUTO_ALLOCATION_PREFIXES = ['__bills_', '__benefits_', '__retirement_', '__loans_', '__savings_'];
+
+const isAutoAllocationCategoryId = (categoryId: string): boolean => {
+ return AUTO_ALLOCATION_PREFIXES.some((prefix) => categoryId.startsWith(prefix));
+};
+
+const calculateBillPerPaycheck = (amount: number, frequency: string, paychecksPerYear: number): number => {
+ const billsPerYear = getBillFrequencyOccurrencesPerYear(frequency);
+ return roundUpToCent((amount * billsPerYear) / paychecksPerYear);
+};
+
+const calculateRemainingForSpendingPerPaycheck = (
+ budgetData: NonNullable['budgetData']>,
+ grossPayPerPaycheck: number,
+ netPayPerPaycheck: number,
+ paychecksPerYear: number,
+): number => {
+ const totalAllocated = budgetData.accounts.reduce((accountSum, account) => {
+ const userCategories = (account.allocationCategories || []).filter(
+ (category) => !isAutoAllocationCategoryId(category.id),
+ );
+ const userTotal = userCategories.reduce((sum, category) => sum + Math.max(0, category.amount || 0), 0);
+
+ const accountBills = budgetData.bills.filter(
+ (bill) => bill.enabled !== false && bill.accountId === account.id,
+ );
+ const accountBenefits = budgetData.benefits.filter(
+ (benefit) => benefit.enabled !== false && benefit.deductionSource === 'account' && benefit.sourceAccountId === account.id,
+ );
+ const accountRetirement = budgetData.retirement.filter(
+ (election) => election.enabled !== false && election.deductionSource === 'account' && election.sourceAccountId === account.id,
+ );
+ const accountLoans = (budgetData.loans || []).filter(
+ (loan) => loan.enabled !== false && loan.accountId === account.id,
+ );
+ const accountSavings = (budgetData.savingsContributions || []).filter(
+ (item) => item.enabled !== false && item.accountId === account.id,
+ );
+
+ const billsPerPaycheck = accountBills.reduce((sum, bill) => {
+ return sum + calculateBillPerPaycheck(bill.amount, bill.frequency, paychecksPerYear);
+ }, 0);
+
+ const accountDeductionsPerPaycheck = accountBenefits.reduce((sum, benefit) => {
+ const amountPerPaycheck = benefit.isPercentage
+ ? roundUpToCent((grossPayPerPaycheck * benefit.amount) / 100)
+ : roundUpToCent(benefit.amount);
+ return sum + amountPerPaycheck;
+ }, 0);
+
+ const accountRetirementPerPaycheck = accountRetirement.reduce((sum, election) => {
+ const employeePerPaycheck = election.employeeContributionIsPercentage
+ ? Math.round((((grossPayPerPaycheck * election.employeeContribution) / 100) + Number.EPSILON) * 100) / 100
+ : Math.round((election.employeeContribution + Number.EPSILON) * 100) / 100;
+ return sum + employeePerPaycheck;
+ }, 0);
+
+ const accountLoansPerPaycheck = accountLoans.reduce((sum, loan) => {
+ return sum + (loan.monthlyPayment * 12) / paychecksPerYear;
+ }, 0);
+
+ const accountSavingsPerPaycheck = accountSavings.reduce((sum, item) => {
+ const occurrencesPerYear = getSavingsFrequencyOccurrencesPerYear(item.frequency);
+ const perPaycheck = occurrencesPerYear === paychecksPerYear
+ ? roundUpToCent(item.amount)
+ : roundUpToCent((item.amount * occurrencesPerYear) / paychecksPerYear);
+ return sum + perPaycheck;
+ }, 0);
+
+ const autoBillsAndDeductions = accountBills.length > 0 || accountBenefits.length > 0
+ ? roundUpToCent(billsPerPaycheck + accountDeductionsPerPaycheck)
+ : 0;
+ const autoRetirement = accountRetirement.length > 0 ? roundUpToCent(accountRetirementPerPaycheck) : 0;
+ const autoLoans = accountLoans.length > 0 ? roundUpToCent(accountLoansPerPaycheck) : 0;
+ const autoSavings = accountSavings.length > 0 ? roundUpToCent(accountSavingsPerPaycheck) : 0;
+
+ return accountSum + userTotal + autoBillsAndDeductions + autoRetirement + autoLoans + autoSavings;
+ }, 0);
+
+ return netPayPerPaycheck - totalAllocated;
+};
+
const MetricCard: React.FC = ({
id,
className,
@@ -124,11 +206,50 @@ const KeyMetrics: React.FC = ({
return sum + convertBillToYearly(bill.amount, bill.frequency);
}, 0));
- // Calculate monthly averages
- const monthlyBills = roundUpToCent(annualBills / 12);
+ const annualRecurringDeductions = roundUpToCent((budgetData.benefits || []).reduce((sum, benefit) => {
+ if (benefit.enabled === false) return sum;
+
+ const perPaycheck = benefit.isPercentage
+ ? roundToCent((breakdown.grossPay * benefit.amount) / 100)
+ : roundToCent(benefit.amount);
+
+ return sum + (perPaycheck * paychecksPerYear);
+ }, 0));
+
+ const annualLoanPayments = roundUpToCent((budgetData.loans || []).reduce((sum, loan) => {
+ if (loan.enabled === false) return sum;
+ return sum + (loan.monthlyPayment * 12);
+ }, 0));
+
+ const customAllocationItems = (budgetData.accounts || []).flatMap((account) =>
+ (account.allocationCategories || []).filter((category) => !isAutoAllocationCategoryId(category.id)),
+ );
- // Calculate remaining/free money (before savings; used for bar sub-component math)
- const annualRemaining = roundUpToCent(annualNet - annualBills);
+ const annualCustomAllocationItems = roundUpToCent(customAllocationItems.reduce((sum, category) => {
+ return sum + (Math.max(0, category.amount || 0) * paychecksPerYear);
+ }, 0));
+
+ const annualRecurringExpenses = roundUpToCent(
+ annualBills + annualRecurringDeductions + annualLoanPayments + annualCustomAllocationItems,
+ );
+ const monthlyRecurringExpenses = roundUpToCent(annualRecurringExpenses / 12);
+ const recurringExpenseCount =
+ budgetData.bills.length +
+ budgetData.benefits.length +
+ (budgetData.loans || []).length +
+ customAllocationItems.length;
+
+ // Keep historical yearly segment math for the summary chart.
+ const annualRemainingBeforeSavings = roundUpToCent(annualNet - annualBills);
+
+ // Align this metric with Pay Breakdown's "All that remains for spending" logic.
+ const remainingPerPaycheck = calculateRemainingForSpendingPerPaycheck(
+ budgetData,
+ breakdown.grossPay,
+ breakdown.netPay,
+ paychecksPerYear,
+ );
+ const annualRemainingForSpending = roundUpToCent(remainingPerPaycheck * paychecksPerYear);
// Calculate savings rate from savings accounts, savings contributions, and retirement elections.
const savingsAccounts = budgetData.accounts.filter(a => a.type === 'savings');
@@ -183,12 +304,12 @@ const KeyMetrics: React.FC = ({
// Break net into its real sub-components so every dollar of gross is accounted for in the bar.
const annualBillsCoveredByNet = roundUpToCent(Math.min(Math.max(annualBills, 0), Math.max(annualNet, 0)));
- const annualRemainingPositive = roundUpToCent(Math.max(annualRemaining, 0));
- const annualShortfall = roundUpToCent(Math.max(-annualRemaining, 0));
+ const annualRemainingPositive = roundUpToCent(Math.max(annualRemainingBeforeSavings, 0));
+ const annualShortfall = roundUpToCent(Math.max(-annualRemainingBeforeSavings, 0));
const annualSavingsInBar = roundUpToCent(Math.min(annualSavings, annualRemainingPositive));
const annualFlexibleRemaining = roundUpToCent(Math.max(annualRemainingPositive - annualSavingsInBar, 0));
- const remainingContextLabel = annualShortfall > 0 ? 'Shortfall' : 'Flexible';
- const remainingContextTone = annualShortfall > 0 ? 'negative' : 'accent';
+ const remainingContextLabel = annualRemainingForSpending < 0 ? 'Shortfall' : 'Flexible';
+ const remainingContextTone = annualRemainingForSpending < 0 ? 'negative' : 'accent';
// Ensure tiny non-zero amounts are still visibly represented in UI bars.
const MIN_VISIBLE_STACKED_BAR_PX = 4;
@@ -266,7 +387,8 @@ const KeyMetrics: React.FC = ({
}
/>
@@ -344,12 +466,12 @@ const KeyMetrics: React.FC = ({
{formatWithSymbol(breakdown.netPay, currency, { maximumFractionDigits: 2 })}
- {/* Bills Card */}
+ {/* Recurring Expenses Card */}
}
- title="Total Bills"
+ title="Recurring Expenses"
contextLabel="Committed"
contextTone="warning"
ariaLabel="Open bills tab"
@@ -357,15 +479,15 @@ const KeyMetrics: React.FC
= ({
>
Yearly
- {formatWithSymbol(annualBills, currency, { maximumFractionDigits: 0 })}
+ {formatWithSymbol(annualRecurringExpenses, currency, { maximumFractionDigits: 0 })}
Monthly
- {formatWithSymbol(monthlyBills, currency, { maximumFractionDigits: 0 })}
+ {formatWithSymbol(monthlyRecurringExpenses, currency, { maximumFractionDigits: 0 })}
Count
- {budgetData.bills.length} bills
+ {recurringExpenseCount} items
@@ -407,15 +529,15 @@ const KeyMetrics: React.FC = ({
>
Yearly
- {formatWithSymbol(annualFlexibleRemaining, currency, { maximumFractionDigits: 0 })}
+ {formatWithSymbol(annualRemainingForSpending, currency, { maximumFractionDigits: 0 })}
Monthly
- {formatWithSymbol(annualFlexibleRemaining / 12, currency, { maximumFractionDigits: 0 })}
+ {formatWithSymbol(annualRemainingForSpending / 12, currency, { maximumFractionDigits: 0 })}
% of Net
- {(annualNet > 0 ? (annualFlexibleRemaining / annualNet) * 100 : 0).toFixed(1)}%
+ {(annualNet > 0 ? (annualRemainingForSpending / annualNet) * 100 : 0).toFixed(1)}%
diff --git a/src/components/tabViews/LoansManager/LoansManager.tsx b/src/components/tabViews/LoansManager/LoansManager.tsx
index 652b190..865059d 100644
--- a/src/components/tabViews/LoansManager/LoansManager.tsx
+++ b/src/components/tabViews/LoansManager/LoansManager.tsx
@@ -1,5 +1,5 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
-import { Banknote, Building2, Plus, X } from 'lucide-react';
+import { Banknote, Building2, Landmark, Plus, X } from 'lucide-react';
import { useBudget } from '../../../contexts/BudgetContext';
import { useAppDialogs, useFieldErrors, useModalEntityEditor } from '../../../hooks';
import type { AuditHistoryTarget } from '../../../types/audit';
@@ -472,6 +472,7 @@ const LoansManager: React.FC
= ({
}
actions={
<>
diff --git a/src/components/tabViews/PayBreakdown/PayBreakdown.test.tsx b/src/components/tabViews/PayBreakdown/PayBreakdown.test.tsx
index 3b86f17..7a8f152 100644
--- a/src/components/tabViews/PayBreakdown/PayBreakdown.test.tsx
+++ b/src/components/tabViews/PayBreakdown/PayBreakdown.test.tsx
@@ -157,7 +157,7 @@ describe('PayBreakdown custom allocation validation', () => {
);
await user.click(screen.getByRole('button', { name: 'Edit' }));
- await user.click(screen.getByRole('button', { name: 'Add Item' }));
+ await user.click(screen.getByRole('button', { name: 'Add Custom Item' }));
}
it('shows an error and blocks save when a custom allocation amount has no name', async () => {
diff --git a/src/components/tabViews/PayBreakdown/PayBreakdown.tsx b/src/components/tabViews/PayBreakdown/PayBreakdown.tsx
index 10121a1..fb52b97 100644
--- a/src/components/tabViews/PayBreakdown/PayBreakdown.tsx
+++ b/src/components/tabViews/PayBreakdown/PayBreakdown.tsx
@@ -1,5 +1,5 @@
import React, { useEffect, useMemo, useState, useRef } from 'react';
-import { Info, Plus, Settings, X } from 'lucide-react';
+import { Wallet, Info, Plus, Settings, X } from 'lucide-react';
import { useBudget } from '../../../contexts/BudgetContext';
import { useAppDialogs } from '../../../hooks';
import { calculateAnnualizedPayBreakdown, calculateDisplayPayBreakdown } from '../../../services/budgetCalculations';
@@ -727,6 +727,7 @@ const PayBreakdown: React.FC = ({
}
actions={
<>
setShowPaySettingsModal(true)}>
@@ -971,7 +972,7 @@ const PayBreakdown: React.FC = ({
addCategory(displayAccount.id)}>
- Add Item
+ Add Custom Item
cancelAccountEdit(displayAccount.id)}>Cancel
diff --git a/src/components/tabViews/SavingsManager/SavingsManager.tsx b/src/components/tabViews/SavingsManager/SavingsManager.tsx
index 95ea4e6..4b0ce03 100644
--- a/src/components/tabViews/SavingsManager/SavingsManager.tsx
+++ b/src/components/tabViews/SavingsManager/SavingsManager.tsx
@@ -46,7 +46,6 @@ type SavingsFieldErrors = {
type RetirementFieldErrors = {
employeeAmount?: string;
sourceAccountId?: string;
- employerMatchCap?: string;
yearlyLimit?: string;
customLabel?: string;
};
@@ -91,9 +90,6 @@ const SavingsManager: React.FC
= ({
const [retirementSource, setRetirementSource] = useState<'paycheck' | 'account'>('paycheck');
const [retirementSourceAccountId, setRetirementSourceAccountId] = useState('');
const [retirementIsPreTax, setRetirementIsPreTax] = useState(true);
- const [employerMatchOption, setEmployerMatchOption] = useState<'no-match' | 'has-match'>('no-match');
- const [employerMatchCap, setEmployerMatchCap] = useState('');
- const [employerMatchCapIsPercentage, setEmployerMatchCapIsPercentage] = useState(true);
const [yearlyLimit, setYearlyLimit] = useState('');
const [retirementFieldErrors, setRetirementFieldErrors] = useState({});
const [retirementFormMessage, setRetirementFormMessage] = useState<{ type: 'warning' | 'error'; message: string } | null>(null);
@@ -199,9 +195,6 @@ const SavingsManager: React.FC = ({
setRetirementSource(election.deductionSource || 'paycheck');
setRetirementSourceAccountId(election.sourceAccountId || '');
setRetirementIsPreTax(election.isPreTax !== false);
- setEmployerMatchOption(election.hasEmployerMatch ? 'has-match' : 'no-match');
- setEmployerMatchCap((election.employerMatchCap || 0).toString());
- setEmployerMatchCapIsPercentage(election.employerMatchCapIsPercentage);
setYearlyLimit((election.yearlyLimit || '').toString());
setRetirementFieldErrors({});
setRetirementFormMessage(null);
@@ -219,9 +212,6 @@ const SavingsManager: React.FC = ({
setRetirementSource('paycheck');
setRetirementSourceAccountId('');
setRetirementIsPreTax(true);
- setEmployerMatchOption('no-match');
- setEmployerMatchCap('');
- setEmployerMatchCapIsPercentage(true);
setYearlyLimit('');
setRetirementFieldErrors({});
setRetirementFormMessage(null);
@@ -280,23 +270,9 @@ const SavingsManager: React.FC = ({
? (grossPayPerPaycheck * election.employeeContribution) / 100
: election.employeeContribution;
- let employerAmountPerPaycheck = 0;
- if (election.hasEmployerMatch) {
- const employeePercentage = election.employeeContributionIsPercentage
- ? election.employeeContribution
- : (employeeAmountPerPaycheck / grossPayPerPaycheck) * 100;
-
- if (election.employerMatchCapIsPercentage) {
- const matchPercentage = Math.min(employeePercentage, election.employerMatchCap);
- employerAmountPerPaycheck = (grossPayPerPaycheck * matchPercentage) / 100;
- } else {
- employerAmountPerPaycheck = Math.min(employeeAmountPerPaycheck, election.employerMatchCap);
- }
- }
-
return {
employeeAmount: roundToCent(employeeAmountPerPaycheck),
- employerAmount: roundToCent(employerAmountPerPaycheck),
+ employerAmount: 0,
};
};
@@ -319,16 +295,14 @@ const SavingsManager: React.FC = ({
const bEnabled = b.enabled !== false;
if (aEnabled !== bEnabled) return aEnabled ? -1 : 1;
- const aTotal = calculateRetirementContributions(a).employeeAmount + calculateRetirementContributions(a).employerAmount;
- const bTotal = calculateRetirementContributions(b).employeeAmount + calculateRetirementContributions(b).employerAmount;
- return bTotal - aTotal;
+ return calculateRetirementContributions(b).employeeAmount - calculateRetirementContributions(a).employeeAmount;
});
const retirementTotalPerPaycheck = sortedRetirement.reduce((sum, election) => {
if (election.enabled === false) return sum;
- const { employeeAmount: employeePerPaycheck, employerAmount } = calculateRetirementContributions(election);
- return sum + employeePerPaycheck + employerAmount;
+ const { employeeAmount: employeePerPaycheck } = calculateRetirementContributions(election);
+ return sum + employeePerPaycheck;
}, 0);
const totalSavingsAndRetirementPerPaycheck = roundToCent(
@@ -475,9 +449,6 @@ const SavingsManager: React.FC = ({
setRetirementSource('paycheck');
setRetirementSourceAccountId('');
setRetirementIsPreTax(true);
- setEmployerMatchOption('no-match');
- setEmployerMatchCap('');
- setEmployerMatchCapIsPercentage(true);
setYearlyLimit('');
setRetirementFieldErrors({});
setRetirementFormMessage(null);
@@ -493,9 +464,6 @@ const SavingsManager: React.FC = ({
setRetirementSource(election.deductionSource || 'paycheck');
setRetirementSourceAccountId(election.sourceAccountId || '');
setRetirementIsPreTax(election.isPreTax !== false);
- setEmployerMatchOption(election.hasEmployerMatch ? 'has-match' : 'no-match');
- setEmployerMatchCap((election.employerMatchCap || 0).toString());
- setEmployerMatchCapIsPercentage(election.employerMatchCapIsPercentage);
setYearlyLimit((election.yearlyLimit || '').toString());
setRetirementFieldErrors({});
setRetirementFormMessage(null);
@@ -503,9 +471,7 @@ const SavingsManager: React.FC = ({
};
const handleSaveRetirement = () => {
- const hasEmployerMatch = employerMatchOption === 'has-match';
const parsedEmployeeContribution = parseFloat(employeeAmount);
- const parsedMatchCap = parseFloat(employerMatchCap);
const parsedYearlyLimit = yearlyLimit ? parseFloat(yearlyLimit) : undefined;
const isAccountSource = retirementSource === 'account';
const errors: RetirementFieldErrors = {};
@@ -522,10 +488,6 @@ const SavingsManager: React.FC = ({
errors.sourceAccountId = 'Please select an account for this retirement deduction source.';
}
- if (hasEmployerMatch && (!Number.isFinite(parsedMatchCap) || parsedMatchCap < 0)) {
- errors.employerMatchCap = 'Please enter a valid employer match cap.';
- }
-
if (yearlyLimit && (!Number.isFinite(parsedYearlyLimit) || (parsedYearlyLimit ?? 0) <= 0)) {
errors.yearlyLimit = 'Please enter a valid yearly limit greater than zero.';
}
@@ -561,9 +523,9 @@ const SavingsManager: React.FC = ({
isPreTax: isAccountSource ? false : retirementIsPreTax,
deductionSource: retirementSource,
sourceAccountId: isAccountSource ? retirementSourceAccountId : undefined,
- hasEmployerMatch,
- employerMatchCap: hasEmployerMatch ? (Number.isNaN(parsedMatchCap) ? 0 : parsedMatchCap) : 0,
- employerMatchCapIsPercentage: hasEmployerMatch ? employerMatchCapIsPercentage : false,
+ hasEmployerMatch: false,
+ employerMatchCap: 0,
+ employerMatchCapIsPercentage: false,
yearlyLimit: parsedYearlyLimit,
};
@@ -610,6 +572,7 @@ const SavingsManager: React.FC = ({
}
/>
= ({
) : (
{sortedRetirement.map((retirement) => {
- const { employeeAmount: employeePerPaycheck, employerAmount } = getRetirementContributionPreview(retirement);
- const totalPerPaycheck = employeePerPaycheck + employerAmount;
+ const { employeeAmount: employeePerPaycheck } = getRetirementContributionPreview(retirement);
+ const totalPerPaycheck = employeePerPaycheck;
const totalInDisplayMode = toDisplayAmount(totalPerPaycheck, paychecksPerYear, displayMode);
const isEnabled = retirement.enabled !== false;
const isPreTaxRetirement = retirement.isPreTax !== false;
@@ -754,15 +717,6 @@ const SavingsManager: React.FC = ({
{retirement.employeeContributionIsPercentage && ` (${retirement.employeeContribution}%)`}
- {retirement.hasEmployerMatch && (
-
- Employer Match :
-
- {formatWithSymbol(employerAmount || 0, currency, { minimumFractionDigits: 2 })} per paycheck
- {' '}(up to {retirement.employerMatchCapIsPercentage ? `${retirement.employerMatchCap || 0}%` : formatWithSymbol(retirement.employerMatchCap || 0, currency, { minimumFractionDigits: 2 })})
-
-
- )}
{displayMode !== 'paycheck' && (
Total {getDisplayModeLabel(displayMode)}:
@@ -1039,51 +993,6 @@ const SavingsManager: React.FC = ({
-
-
Employer Match (Optional)
-
Employer Match Availability >}>
- setEmployerMatchOption(value as 'no-match' | 'has-match')}
- layout="column"
- options={[
- { value: 'no-match', label: 'Employer does not offer match' },
- { value: 'has-match', label: 'Employer offers match' },
- ]}
- />
-
-
- {employerMatchOption === 'has-match' && (
-
- Match Cap >} required error={retirementFieldErrors.employerMatchCap}>
- {
- setEmployerMatchCap(e.target.value);
- if (retirementFieldErrors.employerMatchCap) {
- setRetirementFieldErrors((prev) => ({ ...prev, employerMatchCap: undefined }));
- }
- }}
- placeholder={employerMatchCapIsPercentage ? '6' : '0.00'}
- step={employerMatchCapIsPercentage ? '0.1' : '0.01'}
- min="0"
- required
- />
-
- Cap Type >}>
- setEmployerMatchCapIsPercentage(e.target.value === 'percentage')}>
- % of Gross Pay
- Fixed Amount
-
-
-
- )}
-
= ({ searchOpenSettingsRequestKe
}
actions={
<>
{onViewHistory && (
diff --git a/src/contexts/BudgetContext.tsx b/src/contexts/BudgetContext.tsx
index d06bfda..40f8db0 100644
--- a/src/contexts/BudgetContext.tsx
+++ b/src/contexts/BudgetContext.tsx
@@ -1072,8 +1072,8 @@ export const BudgetProvider: React.FC = ({ children }) => {
}, [budgetData]);
/**
- * Calculate retirement contribution amounts for display
- * Returns estimated employee and employer contributions per paycheck
+ * Calculate retirement contribution amounts for display.
+ * Employer match is intentionally excluded from in-app retirement math.
*/
const calculateRetirementContributions = useCallback((election: RetirementElection) => {
if (!budgetData || election.enabled === false) {
@@ -1104,25 +1104,7 @@ export const BudgetProvider: React.FC = ({ children }) => {
employeeAmount = roundToCent(election.employeeContribution);
}
- // Calculate employer match (if enabled)
- let employerAmount = 0;
- if (election.hasEmployerMatch) {
- // Convert employee contribution to percentage of gross for comparison
- const employeePercentage = election.employeeContributionIsPercentage
- ? election.employeeContribution
- : (employeeAmount / grossPay) * 100;
-
- if (election.employerMatchCapIsPercentage) {
- // Cap is a percentage - employer matches up to that percentage
- const matchPercentage = Math.min(employeePercentage, election.employerMatchCap);
- employerAmount = roundToCent((grossPay * matchPercentage) / 100);
- } else {
- // Cap is a dollar amount - employer matches up to that amount
- employerAmount = Math.min(employeeAmount, roundToCent(election.employerMatchCap));
- }
- }
-
- return { employeeAmount, employerAmount };
+ return { employeeAmount, employerAmount: 0 };
}, [budgetData]);
/**
diff --git a/src/data/glossary.ts b/src/data/glossary.ts
index e532d2f..8b0e95c 100644
--- a/src/data/glossary.ts
+++ b/src/data/glossary.ts
@@ -3,7 +3,6 @@ export type GlossaryCategory =
| 'taxes'
| 'deductions'
| 'allocations'
- | 'benefits'
| 'retirement'
| 'accounts'
| 'loans';
@@ -26,7 +25,7 @@ export const glossaryTerms: GlossaryTerm[] = [
category: 'pay',
shortDefinition: 'Total earnings before taxes and deductions.',
fullDefinition:
- 'Gross Pay is your total pay before any taxes, benefits, retirement contributions, or other deductions are subtracted.',
+ 'Gross Pay is your total pay before any taxes, deductions, retirement contributions, or other withholdings are subtracted.',
aliases: ['gross income'],
tags: ['salary', 'hourly', 'paycheck'],
relatedTermIds: ['net-pay', 'deduction', 'withholding'],
@@ -58,7 +57,7 @@ export const glossaryTerms: GlossaryTerm[] = [
category: 'deductions',
shortDefinition: 'Subtracted before taxes are calculated.',
fullDefinition:
- 'Pre-tax deductions reduce taxable income before tax calculations. Common examples include certain benefits and retirement contributions.',
+ 'Pre-tax deductions reduce taxable income before tax calculations. Common examples include insurance deductions and retirement contributions.',
tags: ['taxable income'],
relatedTermIds: ['deduction', 'taxable-income', 'retirement-contribution'],
},
@@ -173,12 +172,12 @@ export const glossaryTerms: GlossaryTerm[] = [
},
{
id: 'benefit',
- term: 'Benefit',
- category: 'benefits',
- shortDefinition: 'Amount deducted from pay or an account for benefits, such as insurance.',
+ term: 'Insurance Deduction',
+ category: 'deductions',
+ shortDefinition: 'Amount deducted from pay or an account for insurance and similar programs.',
fullDefinition:
- 'Benefits can include insurance plans and other programs. They might be employer-sponsored and can be deducted pre-tax or post-tax depending on the specific benefit and plan, or paid out of pocket and tracked as an allocation.',
- tags: ['insurance', 'employer', 'benefit'],
+ 'Recurring deductions can include insurance plans and other employer programs. They can be deducted pre-tax or post-tax depending on the deduction type, or paid out of pocket and tracked as an allocation.',
+ tags: ['insurance', 'employer', 'deduction'],
relatedTermIds: ['pre-tax-deduction', 'post-tax-deduction', 'allocation'],
},
{
@@ -189,16 +188,7 @@ export const glossaryTerms: GlossaryTerm[] = [
fullDefinition:
'A retirement contribution is money you set aside toward retirement accounts such as a 401(k), 403(b), IRA, or pension plans. It is generally deducted from your paycheck before taxes, but may vary depending on the plan.',
tags: ['US-only'],
- relatedTermIds: ['employer-match', 'pre-tax-deduction', 'annual-contribution-limit'],
- },
- {
- id: 'employer-match',
- term: 'Employer Match',
- category: 'retirement',
- shortDefinition: 'Employer contribution based on your own contribution.',
- fullDefinition:
- 'Employer match is additional money your employer contributes to your retirement plan when you contribute, often up to a defined limit.',
- relatedTermIds: ['retirement-contribution', 'annual-contribution-limit'],
+ relatedTermIds: ['pre-tax-deduction', 'annual-contribution-limit'],
},
{
id: 'annual-contribution-limit',
@@ -207,7 +197,7 @@ export const glossaryTerms: GlossaryTerm[] = [
shortDefinition: 'Maximum allowed yearly retirement contribution.',
fullDefinition:
'The annual contribution limit is the maximum amount you can contribute to specific retirement plans in a calendar year, based on applicable rules.',
- relatedTermIds: ['retirement-contribution', 'employer-match'],
+ relatedTermIds: ['retirement-contribution'],
},
{
id: 'account',
@@ -292,7 +282,6 @@ export const glossaryCategoryLabels: Record = {
taxes: 'Taxes',
deductions: 'Deductions',
allocations: 'Allocations',
- benefits: 'Benefits',
retirement: 'Retirement',
accounts: 'Accounts',
loans: 'Loans',
diff --git a/src/index.css b/src/index.css
index 19e140a..91158c7 100644
--- a/src/index.css
+++ b/src/index.css
@@ -4,7 +4,7 @@
--icon-color-default: currentColor;
--icon-color-muted: var(--text-secondary);
--icon-color-accent: var(--accent-primary);
- --icon-stroke-width: 1.9;
+ --icon-stroke-width: 1.3;
line-height: 1.5;
font-weight: 400;
diff --git a/src/services/pdfExport.ts b/src/services/pdfExport.ts
index 1182179..0e2ab8a 100644
--- a/src/services/pdfExport.ts
+++ b/src/services/pdfExport.ts
@@ -197,15 +197,15 @@ export async function exportToPDF(
yPosition = getNextYPosition(doc, yPosition + 15);
}
- // Benefits Section
+ // Deductions Section
if (includeBenefits && budgetData.benefits.length > 0) {
checkPageBreak(60);
doc.setFontSize(16);
doc.setTextColor(40, 40, 40);
- doc.text('Benefits', 20, yPosition);
+ doc.text('Deductions', 20, yPosition);
yPosition += 10;
- const benefitsData = budgetData.benefits.map(benefit => {
+ const deductionsData = budgetData.benefits.map(benefit => {
const amount = benefit.isPercentage
? `${benefit.amount}% (${formatWithSymbol((paycheckAmount * benefit.amount) / 100, currency, { minimumFractionDigits: 2, maximumFractionDigits: 2 })})`
: formatWithSymbol(benefit.amount, currency, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
@@ -216,8 +216,8 @@ export async function exportToPDF(
autoTable(doc, {
startY: yPosition,
- head: [['Benefit', 'Amount', 'Tax Type', 'Source']],
- body: benefitsData,
+ head: [['Deduction', 'Amount', 'Tax Type', 'Source']],
+ body: deductionsData,
theme: 'striped',
headStyles: { fillColor: [70, 130, 180] },
margin: { left: 20, right: 20 },
diff --git a/src/utils/historyDiff.test.ts b/src/utils/historyDiff.test.ts
index e1ecc7a..d8b0f53 100644
--- a/src/utils/historyDiff.test.ts
+++ b/src/utils/historyDiff.test.ts
@@ -102,6 +102,19 @@ describe('historyDiff utilities', () => {
it('should fallback to generic formatting for unknown keys', () => {
expect(formatDiffValueForField('amount', 1000)).toBe('1,000');
});
+
+ it('should format tax line arrays with line-level detail', () => {
+ const lines = [
+ { id: 'federal', label: 'Federal Tax', rate: 22, calculationType: 'percentage' },
+ { id: 'local', label: 'Local Tax', amount: 45, calculationType: 'fixed' },
+ ];
+
+ expect(formatDiffValueForField('taxLines', lines)).toBe('Federal Tax: 22% | Local Tax: 45 fixed');
+ });
+
+ it('should format empty tax lines arrays as (empty)', () => {
+ expect(formatDiffValueForField('taxLines', [])).toBe('(empty)');
+ });
});
describe('summarizePaymentBreakdownDiff', () => {
@@ -158,6 +171,10 @@ describe('historyDiff utilities', () => {
it('should handle already formatted fields', () => {
expect(formatFieldName('Name')).toBe('Name');
});
+
+ it('should use display override for tax lines', () => {
+ expect(formatFieldName('taxLines')).toBe('Tax Lines');
+ });
});
describe('getSummaryFields', () => {
diff --git a/src/utils/historyDiff.ts b/src/utils/historyDiff.ts
index 4abcecf..fdfc8d6 100644
--- a/src/utils/historyDiff.ts
+++ b/src/utils/historyDiff.ts
@@ -110,6 +110,34 @@ const formatPaymentBreakdown = (value: unknown): string => {
return lines.join(' | ');
};
+const formatTaxLines = (value: unknown): string => {
+ if (!Array.isArray(value)) return formatDiffValue(value);
+ if (value.length === 0) return '(empty)';
+
+ const lines = value
+ .filter((line) => line && typeof line === 'object')
+ .map((line) => {
+ const item = line as Record;
+ const label = typeof item.label === 'string' && item.label.length > 0 ? item.label : 'Tax line';
+ const calculationType = item.calculationType === 'fixed' ? 'fixed' : 'percentage';
+
+ if (calculationType === 'fixed') {
+ const amount = typeof item.amount === 'number'
+ ? item.amount.toLocaleString('en-US', { maximumFractionDigits: 2 })
+ : formatDiffValue(item.amount);
+ return `${label}: ${amount} fixed`;
+ }
+
+ const rate = typeof item.rate === 'number'
+ ? item.rate.toLocaleString('en-US', { maximumFractionDigits: 2 })
+ : formatDiffValue(item.rate);
+ return `${label}: ${rate}%`;
+ });
+
+ if (lines.length === 0) return '[Array]';
+ return lines.join(' | ');
+};
+
const toPaymentLineKey = (line: Record): string => {
if (typeof line.id === 'string' && line.id.length > 0) return line.id;
const label = typeof line.label === 'string' ? line.label : 'line';
@@ -197,6 +225,9 @@ export const formatDiffValueForField = (key: string, value: unknown): string =>
if (key === 'paymentBreakdown') {
return formatPaymentBreakdown(value);
}
+ if (key === 'taxLines') {
+ return formatTaxLines(value);
+ }
return formatDiffValue(value);
};
@@ -216,6 +247,7 @@ const FIELD_DISPLAY_NAMES: Record = {
interestRate: 'Interest Rate',
paymentFrequency: 'Payment Frequency',
deductionSource: 'Deduction Source',
+ taxLines: 'Tax Lines',
};
/**
diff --git a/src/utils/searchModules/billsSearchModule.ts b/src/utils/searchModules/billsSearchModule.ts
index a23b5ab..53a1573 100644
--- a/src/utils/searchModules/billsSearchModule.ts
+++ b/src/utils/searchModules/billsSearchModule.ts
@@ -1,7 +1,7 @@
/**
* Bills Search Module
*
- * Contributes search results for Bills and Benefits to the search registry.
+ * Contributes search results for Bills and Deductions to the search registry.
* Also provides action handlers for bill/benefit actions (toggle, edit, delete).
*/
@@ -12,7 +12,7 @@ import type { SearchModule, SearchActionContext } from '../searchRegistry';
import { createTypedActionHandler, formatSearchCurrency, incrementRequestKey } from './moduleUtils';
/**
- * Builds search results for Bills and Benefits.
+ * Builds search results for Bills and Deductions.
*/
function buildBillsResults(budgetData: BudgetData): SearchResult[] {
const results: SearchResult[] = [];
@@ -58,7 +58,7 @@ function buildBillsResults(budgetData: BudgetData): SearchResult[] {
});
}
- // ── Benefits ──────────────────────────────────────────────────────────────
+ // ── Deductions ────────────────────────────────────────────────────────────
for (const benefit of budgetData.benefits ?? []) {
const paused = benefit.enabled === false;
results.push({
@@ -67,7 +67,7 @@ function buildBillsResults(budgetData: BudgetData): SearchResult[] {
subtitle: benefit.isPercentage
? `${benefit.amount}% — ${benefit.isTaxable ? 'taxable' : 'non-taxable'}`
: `${formatSearchCurrency(benefit.amount, currency)} — ${benefit.isTaxable ? 'taxable' : 'non-taxable'}`,
- category: 'Benefits',
+ category: 'Deductions',
categoryIcon: HeartPulse,
badge: paused ? 'Paused' : undefined,
inlineActions: [
@@ -120,7 +120,7 @@ const handleBillsAction = createTypedActionHandler('open-bills-action', (billsAc
/**
* The Bills search module.
- * Exports bills and benefits as searchable results and provides action handling.
+ * Exports bills and deductions as searchable results and provides action handling.
*/
export const billsSearchModule: SearchModule = {
id: 'bills',
diff --git a/src/utils/searchModules/savingsSearchModule.ts b/src/utils/searchModules/savingsSearchModule.ts
index 1e52220..2802023 100644
--- a/src/utils/searchModules/savingsSearchModule.ts
+++ b/src/utils/searchModules/savingsSearchModule.ts
@@ -59,7 +59,7 @@ function buildSavingsResults(budgetData: BudgetData): SearchResult[] {
results.push({
id: `retirement-${election.id}`,
title: label,
- subtitle: `Employee contribution: ${contribLabel}${election.hasEmployerMatch ? ' · Employer match' : ''}`,
+ subtitle: `Your contribution: ${contribLabel}`,
category: 'Retirement',
categoryIcon: Umbrella,
badge: paused ? 'Paused' : undefined,
diff --git a/src/utils/tabManagement.ts b/src/utils/tabManagement.ts
index bed99ed..2bb7286 100644
--- a/src/utils/tabManagement.ts
+++ b/src/utils/tabManagement.ts
@@ -1,5 +1,5 @@
// Utility functions for managing dashboard tabs
-import { Banknote, ChartPie, ClipboardList, Landmark, PiggyBank, Scale } from 'lucide-react';
+import { Wallet, LayoutGrid, ClipboardList, Landmark, PiggyBank, Scale } from 'lucide-react';
import type { TabConfig } from '../types/tabs';
export type TabId = 'metrics' | 'breakdown' | 'bills' | 'loans' | 'savings' | 'taxes';
@@ -22,7 +22,7 @@ export function getDefaultTabConfigs(): TabConfig[] {
{
id: 'metrics',
label: 'Key Metrics',
- icon: ChartPie,
+ icon: LayoutGrid,
visible: true,
order: 0,
pinned: false,
@@ -30,7 +30,7 @@ export function getDefaultTabConfigs(): TabConfig[] {
{
id: 'breakdown',
label: 'Pay Breakdown',
- icon: Banknote,
+ icon: Wallet,
visible: true,
order: 1,
pinned: false,
diff --git a/version b/version
index 1d0ba9e..267577d 100644
--- a/version
+++ b/version
@@ -1 +1 @@
-0.4.0
+0.4.1
diff --git a/vite.config.ts b/vite.config.ts
index 976b218..5e02eca 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -31,4 +31,42 @@ export default defineConfig({
'@': path.resolve(__dirname, './src'),
},
},
+ build: {
+ rollupOptions: {
+ output: {
+ manualChunks(id) {
+ if (!id.includes('node_modules')) return;
+
+ if (id.includes('/node_modules/react/') || id.includes('/node_modules/react-dom/')) {
+ return 'react-vendor';
+ }
+
+ if (id.includes('/node_modules/lucide-react/')) {
+ return 'icons-vendor';
+ }
+
+ if (
+ id.includes('/node_modules/jspdf/') ||
+ id.includes('/node_modules/jspdf-autotable/')
+ ) {
+ return 'jspdf-vendor';
+ }
+
+ if (id.includes('/node_modules/pdf-lib/') || id.includes('/node_modules/@pdf-lib/')) {
+ return 'pdf-lib-vendor';
+ }
+
+ if (
+ id.includes('/node_modules/html2canvas/') ||
+ id.includes('/node_modules/dompurify/') ||
+ id.includes('/node_modules/canvg/')
+ ) {
+ return 'canvas-vendor';
+ }
+
+ return undefined;
+ },
+ },
+ },
+ },
})