From c212493ec96f1f932154ae333aaf3b569d98f5ee Mon Sep 17 00:00:00 2001 From: Joseph Date: Tue, 24 Mar 2026 16:45:51 -0400 Subject: [PATCH 01/81] Delete v0.4.1-fixes.md --- app_updates/v0.4.1-fixes.md | 28 ---------------------------- 1 file changed, 28 deletions(-) delete mode 100644 app_updates/v0.4.1-fixes.md diff --git a/app_updates/v0.4.1-fixes.md b/app_updates/v0.4.1-fixes.md deleted file mode 100644 index faa4477..0000000 --- a/app_updates/v0.4.1-fixes.md +++ /dev/null @@ -1,28 +0,0 @@ -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 From 009761c317c0c7bc91e1cd3407d1a1e331f47240 Mon Sep 17 00:00:00 2001 From: Joseph Date: Tue, 24 Mar 2026 16:45:53 -0400 Subject: [PATCH 02/81] Create v0.4.2-fixes.md --- app_updates/v0.4.2-fixes.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 app_updates/v0.4.2-fixes.md diff --git a/app_updates/v0.4.2-fixes.md b/app_updates/v0.4.2-fixes.md new file mode 100644 index 0000000..c68ac8c --- /dev/null +++ b/app_updates/v0.4.2-fixes.md @@ -0,0 +1,5 @@ +Things needing fixing or updating: + +- [x] When Loans section is empty, "add loan" button is not centered +- [x] When generating demo data for the demo, the amounts are being overallocated and immediately going into the negative +- [x] When first setting up a new plan, the view mode selector only shows 2 view modes when it should be three, and neither of which include the user's pay cadence they chose in setup \ No newline at end of file From 84b5b2f22116cc48bacd7672a7096fb9a7e55e9b Mon Sep 17 00:00:00 2001 From: Joseph Date: Tue, 24 Mar 2026 16:46:00 -0400 Subject: [PATCH 03/81] update version --- package.json | 2 +- version | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 3322264..6151ec7 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "paycheck-planner", "private": true, - "version": "0.4.1", + "version": "0.4.2", "description": "Desktop paycheck planning and budget management application.", "author": { "name": "Paycheck Planner", diff --git a/version b/version index 267577d..2b7c5ae 100644 --- a/version +++ b/version @@ -1 +1 @@ -0.4.1 +0.4.2 From 3aec2d45fb9d63d42af9d77e328364bc48e93465 Mon Sep 17 00:00:00 2001 From: Joseph Date: Tue, 24 Mar 2026 16:46:20 -0400 Subject: [PATCH 04/81] Added more demo variety; fixed amounts going negative --- src/utils/demoDataGenerator.test.ts | 32 ++++++++++++++++++++ src/utils/demoDataGenerator.ts | 46 ++++++++++++++++++++++------- 2 files changed, 67 insertions(+), 11 deletions(-) diff --git a/src/utils/demoDataGenerator.test.ts b/src/utils/demoDataGenerator.test.ts index 1439577..f4b3ae6 100644 --- a/src/utils/demoDataGenerator.test.ts +++ b/src/utils/demoDataGenerator.test.ts @@ -29,4 +29,36 @@ describe('demoDataGenerator utilities', () => { expect(Number.isNaN(Date.parse(data.createdAt))).toBe(false); expect(Number.isNaN(Date.parse(data.updatedAt))).toBe(false); }); + + it('never generates loan payments that push total fixed expenses above 92% of estimated net', () => { + // Run many seeds to catch worst-case over-allocation + const seeds = [0, 0.1, 0.25, 0.5, 0.75, 0.9, 0.99]; + for (const seed of seeds) { + vi.spyOn(Math, 'random').mockReturnValue(seed); + const data = generateDemoBudgetData(2026); + + const annualBills = data.bills.reduce((sum, b) => sum + b.amount * 12, 0); + const annualLoans = data.loans.reduce((sum, l) => sum + (l.monthlyPayment ?? 0) * 12, 0); + const totalFixed = annualBills + annualLoans; + + // Derive gross from paySettings the same way the generator does + let grossPerYear = 0; + if (data.paySettings.payType === 'salary') { + grossPerYear = data.paySettings.annualSalary ?? 0; + } else { + const { hourlyRate = 0, hoursPerPayPeriod = 0, payFrequency } = data.paySettings; + const ppy = payFrequency === 'weekly' ? 52 : payFrequency === 'bi-weekly' ? 26 : payFrequency === 'semi-monthly' ? 24 : 12; + grossPerYear = hourlyRate * hoursPerPayPeriod * ppy; + } + + // After the safety cap, total fixed expenses must be well under gross. + // Minimum take-home is ~62% of gross (heavy-tax scenario). The generator + // caps at 92% of estimated net → at most ~0.92 * 0.62 * gross ≈ 57% of gross. + // Using 80% of gross as a generous sanity bound ensures the test catches + // any regression without being fragile to rounding or estimation error. + expect(totalFixed).toBeLessThanOrEqual(grossPerYear * 0.8); + + vi.restoreAllMocks(); + } + }); }); diff --git a/src/utils/demoDataGenerator.ts b/src/utils/demoDataGenerator.ts index 9e9c53d..ef2d9f1 100644 --- a/src/utils/demoDataGenerator.ts +++ b/src/utils/demoDataGenerator.ts @@ -34,14 +34,17 @@ export function generateDemoBudgetData(year: number, currency: string = 'USD'): annualGrossPay = hourlyRate * weeklyHours * 52; } else { - const salaryOptions = [32000, 38000, 45000, 52000, 60000, 70000, 82000, 92000, 98000]; + const salaryOptions = [32000, 38000, 45000, 52000, 60000, 70000, 82000, 92000, 98000, 110000, 130000, 210000]; + const variance = randomBetween(0.88, 1.12); annualSalary = salaryOptions[Math.floor(Math.random() * salaryOptions.length)]; - annualGrossPay = annualSalary; + annualGrossPay = annualSalary * variance; } let federalTaxRate = 10; if (annualGrossPay > 50000) federalTaxRate = 12; if (annualGrossPay > 80000) federalTaxRate = 18; + if (annualGrossPay > 120000) federalTaxRate = 22; + if (annualGrossPay > 200000) federalTaxRate = 28; federalTaxRate += Math.random() * 2; const stateTaxRate = Math.random() < 0.25 ? 0 : 3 + Math.random() * 4; @@ -49,7 +52,7 @@ export function generateDemoBudgetData(year: number, currency: string = 'USD'): const accounts: Account[] = [ { id: checkingId, - name: 'My Checking', + name: 'Checking', type: 'checking', icon: getDefaultAccountIcon('checking'), color: getDefaultAccountColor('checking'), @@ -130,20 +133,23 @@ export function generateDemoBudgetData(year: number, currency: string = 'USD'): const monthlyGross = annualGrossPay / 12; - const housingPercent = randomBetween(0.30, 0.40); - const utilitiesPercent = randomBetween(0.05, 0.08); + const housingPercent = randomBetween(0.30, 0.45); + const utilitiesPercent = randomBetween(0.03, 0.06); const targetMonthlyBills = [ - { name: 'Rent', category: 'Housing', basePercent: housingPercent }, - { name: 'Utilities', category: 'Utilities', basePercent: utilitiesPercent }, - { name: 'Internet', category: 'Utilities', basePercent: 0.02 }, - { name: 'Insurance', category: 'Insurance', basePercent: 0.05 }, - { name: 'Streaming Service', category: 'Entertainment', basePercent: 0.01 }, + { name: 'Rent', category: 'Housing', basePercent: housingPercent, baseAmount: 0 }, + { name: 'Utilities', category: 'Utilities', basePercent: utilitiesPercent, baseAmount: 0 }, + { name: 'Internet', category: 'Utilities', basePercent: 0.02, baseAmount: 0 }, + { name: 'Insurance', category: 'Insurance', basePercent: 0.05, baseAmount: 0 }, + { name: 'Streaming Service', category: 'Entertainment', basePercent: 0, baseAmount: 10 }, + { name: 'Gym', category: 'Health', baseAmount: randomBetween(15, 60), basePercent: 0 }, ]; const bills: Bill[] = targetMonthlyBills.map((billTemplate) => { const variance = randomBetween(0.88, 1.12); - const monthlyAmount = roundToCents(monthlyGross * billTemplate.basePercent * variance); + const monthlyAmount = billTemplate.basePercent == 0 + ? roundToCents(billTemplate.baseAmount * variance) + : roundToCents(monthlyGross * billTemplate.basePercent * variance); return { id: crypto.randomUUID(), name: billTemplate.name, @@ -321,6 +327,24 @@ export function generateDemoBudgetData(year: number, currency: string = 'USD'): }); } + // Safety cap: total bills + loan payments must not exceed 92% of estimated annual net. + // Bills are already capped vs net, but loans are derived from gross percentages and can + // stack up dramatically when multiple loan types are generated simultaneously. + if (loans.length > 0) { + const annualBillTotal = bills.reduce((sum, bill) => sum + bill.amount * 12, 0); + const annualLoanTotal = loans.reduce((sum, loan) => sum + (loan.monthlyPayment ?? 0) * 12, 0); + const maxAnnualTotal = estimatedAnnualNet * 0.92; + if (annualBillTotal + annualLoanTotal > maxAnnualTotal) { + const maxLoanAnnual = Math.max(0, maxAnnualTotal - annualBillTotal); + const loanScale = annualLoanTotal > 0 ? maxLoanAnnual / annualLoanTotal : 1; + if (loanScale < 1) { + loans.forEach((loan) => { + loan.monthlyPayment = roundToCents(Math.max(10, (loan.monthlyPayment ?? 0) * loanScale)); + }); + } + } + } + return { id: crypto.randomUUID(), name: `${year} Demo Plan`, From 682d31130d9294c5cd0658b336ce2733394607df Mon Sep 17 00:00:00 2001 From: Joseph Date: Tue, 24 Mar 2026 16:46:49 -0400 Subject: [PATCH 05/81] Refactor view mode selector UI, raise favorites cap Restructure the ViewModeSelector markup and styles: remove the extra wrapper, move the settings button out of the option loop, ensure buttons use proper keys and className logic, and adjust CSS (selector height, reduced padding, font-size tweaks, and settings icon styling). Improve copy and formatting in ViewModeSettingsModal and tidy the checkbox disable expression. Increase MAX_VISIBLE_FAVORITE_VIEW_MODES from 3 to 6 to allow pinning more view modes. --- .../ViewModeSelector/ViewModeSelector.css | 20 +++------ .../ViewModeSelector/ViewModeSelector.tsx | 44 +++++++++---------- .../ViewModeSettingsModal.tsx | 8 ++-- src/utils/viewModePreferences.ts | 2 +- 4 files changed, 33 insertions(+), 41 deletions(-) diff --git a/src/components/_shared/layout/ViewModeSelector/ViewModeSelector.css b/src/components/_shared/layout/ViewModeSelector/ViewModeSelector.css index c2f6aee..84bcbda 100644 --- a/src/components/_shared/layout/ViewModeSelector/ViewModeSelector.css +++ b/src/components/_shared/layout/ViewModeSelector/ViewModeSelector.css @@ -1,21 +1,16 @@ -.view-mode-selector-wrap { - display: inline-flex; - align-items: flex-start; - gap: 0.5rem; - padding-bottom: 1.65rem; -} - .view-mode-selector { display: flex; + height: 2.3rem; gap: 0.5rem; background: var(--bg-tertiary); - padding: 0.25rem; + padding: 0.15rem; border-radius: 8px; overflow: visible; border: 2px solid var(--border-header-soft); } .view-mode-selector button { + font-size: 0.85rem; margin: 0; display: inline-flex; align-items: center; @@ -94,6 +89,10 @@ line-height: 1; transition: background 0.15s, border-color 0.15s, color 0.15s; } +.view-mode-settings-button .ui-icon { + font-size: 1rem; + stroke-width: 2px; +} .view-mode-settings-button:hover { background: var(--bg-primary); @@ -122,11 +121,6 @@ width: 100%; } - .view-mode-selector-wrap { - width: 100%; - padding-bottom: 1.8rem; - } - .view-mode-selector button { flex: 1; font-size: 0.85rem; diff --git a/src/components/_shared/layout/ViewModeSelector/ViewModeSelector.tsx b/src/components/_shared/layout/ViewModeSelector/ViewModeSelector.tsx index d3fe719..c007e50 100644 --- a/src/components/_shared/layout/ViewModeSelector/ViewModeSelector.tsx +++ b/src/components/_shared/layout/ViewModeSelector/ViewModeSelector.tsx @@ -65,32 +65,30 @@ const ViewModeSelector = ({ }, [payCadenceMode, options, favoritesProp, resolvedOptions]); return ( -
-
- {optionsWithCadence.map((option) => ( - - ))} - {!options && onOpenViewModeSettings && ( +
+ {optionsWithCadence.map((option) => ( - )} -
+ ))} + {!options && onOpenViewModeSettings && ( + + )}
); }; diff --git a/src/components/modals/ViewModeSettingsModal/ViewModeSettingsModal.tsx b/src/components/modals/ViewModeSettingsModal/ViewModeSettingsModal.tsx index 2faf476..726d4fc 100644 --- a/src/components/modals/ViewModeSettingsModal/ViewModeSettingsModal.tsx +++ b/src/components/modals/ViewModeSettingsModal/ViewModeSettingsModal.tsx @@ -38,7 +38,7 @@ const ViewModeSettingsModal: React.FC = ({ >
- + = ({ value: option.value, label: option.label, disabled: - (favorites.length === 1 && favorites.includes(option.value)) || - (favorites.length >= MAX_VISIBLE_FAVORITE_VIEW_MODES && !favorites.includes(option.value)), + (favorites.length === 1 && favorites.includes(option.value)) + || (favorites.length >= MAX_VISIBLE_FAVORITE_VIEW_MODES && !favorites.includes(option.value)), }))} />
- Applies to this plan only. Pin up to {MAX_VISIBLE_FAVORITE_VIEW_MODES} view modes; at least one must stay enabled. + Selected favorites apply to this plan only. You can pin up to {MAX_VISIBLE_FAVORITE_VIEW_MODES} view modes, and a minimum of 1 must stay enabled.
diff --git a/src/utils/viewModePreferences.ts b/src/utils/viewModePreferences.ts index 378a7d8..fbf90c6 100644 --- a/src/utils/viewModePreferences.ts +++ b/src/utils/viewModePreferences.ts @@ -10,7 +10,7 @@ export const SELECTABLE_VIEW_MODES: SelectableViewMode[] = [ 'yearly', ]; -export const MAX_VISIBLE_FAVORITE_VIEW_MODES = 3; +export const MAX_VISIBLE_FAVORITE_VIEW_MODES = 6; export const DEFAULT_FAVORITE_VIEW_MODES: SelectableViewMode[] = ['monthly', 'yearly']; export type ViewModeOption = { From e8aa2741d2ba2d9b5e0bbae75d8c384382053bdc Mon Sep 17 00:00:00 2001 From: Joseph Date: Tue, 24 Mar 2026 16:47:08 -0400 Subject: [PATCH 06/81] Fix add loan button being off center when no items added yet --- .../tabViews/LoansManager/LoansManager.tsx | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/components/tabViews/LoansManager/LoansManager.tsx b/src/components/tabViews/LoansManager/LoansManager.tsx index 865059d..362d9d5 100644 --- a/src/components/tabViews/LoansManager/LoansManager.tsx +++ b/src/components/tabViews/LoansManager/LoansManager.tsx @@ -477,7 +477,7 @@ const LoansManager: React.FC = ({ <> } @@ -490,7 +490,7 @@ const LoansManager: React.FC = ({
{budgetData.accounts.length === 0 ? ( -
+
@@ -498,13 +498,14 @@ const LoansManager: React.FC = ({

Accounts are created during setup. Add an account before assigning loan payments.

) : loansList.length === 0 ? ( -
+
-

No Loan Payments Yet

+

No Loan Payments Added Yet

Add your first recurring loan payment to track it across the app.

-
From d96e19b1d109b1b8ecec65dff82000556ad694d6 Mon Sep 17 00:00:00 2001 From: Joseph Date: Tue, 24 Mar 2026 16:47:18 -0400 Subject: [PATCH 07/81] minor updates --- .../tabViews/BillsManager/BillsManager.css | 10 ---------- .../tabViews/BillsManager/BillsManager.tsx | 16 +++++++++++----- 2 files changed, 11 insertions(+), 15 deletions(-) diff --git a/src/components/tabViews/BillsManager/BillsManager.css b/src/components/tabViews/BillsManager/BillsManager.css index 69c6a62..babc340 100644 --- a/src/components/tabViews/BillsManager/BillsManager.css +++ b/src/components/tabViews/BillsManager/BillsManager.css @@ -46,11 +46,6 @@ flex-wrap: wrap; } -.bills-manager .bills-header-actions .view-mode-selector-wrap { - flex: 1 1 34rem; - min-width: min(100%, 28rem); -} - .bills-manager .bills-manager-header { display: flex; align-items: center; @@ -78,11 +73,6 @@ align-items: stretch; } - .bills-manager .bills-header-actions .view-mode-selector-wrap { - flex: 1 1 auto; - min-width: 0; - } - .bills-manager .bills-header-buttons { margin-left: 0; width: 100%; diff --git a/src/components/tabViews/BillsManager/BillsManager.tsx b/src/components/tabViews/BillsManager/BillsManager.tsx index 231f8fc..6d87a04 100644 --- a/src/components/tabViews/BillsManager/BillsManager.tsx +++ b/src/components/tabViews/BillsManager/BillsManager.tsx @@ -529,7 +529,7 @@ const BillsManager: React.FC = ({ /> {budgetData.accounts.length === 0 ? ( -
+
@@ -537,15 +537,21 @@ const BillsManager: React.FC = ({

Accounts are created during the initial setup wizard. Run the setup wizard to create your first account.

) : !hasAnyItems ? ( -
+
-

No Bills or Deductions Yet

+

No Bills or Deductions Added Yet

Add recurring bills or paycheck/account deductions to get started

- - + +
) : ( From f2cfd62abc4588299a774e182251b512d48fb381 Mon Sep 17 00:00:00 2001 From: Joseph Date: Tue, 24 Mar 2026 16:47:34 -0400 Subject: [PATCH 08/81] Tweak header styles and guard viewMode favorites Adjust header layout and button sizing: align items in .header-btn-group, set .header-btn-secondary to height: fit-content, and remove redundant .view-mode-selector-wrap rules. Add a null/undefined guard in PlanDashboard.tsx so sanitizeFavoriteViewModes is only called when viewModeFavorites is present (pass undefined otherwise) to avoid potential runtime errors. --- src/components/PlanDashboard/PlanDashboard.css | 12 ++---------- src/components/PlanDashboard/PlanDashboard.tsx | 2 +- 2 files changed, 3 insertions(+), 11 deletions(-) diff --git a/src/components/PlanDashboard/PlanDashboard.css b/src/components/PlanDashboard/PlanDashboard.css index 698f5bb..6086fbe 100644 --- a/src/components/PlanDashboard/PlanDashboard.css +++ b/src/components/PlanDashboard/PlanDashboard.css @@ -296,20 +296,17 @@ .header-btn-group { display: flex; + align-items: center; gap: 0.5rem; } -.view-mode-selector-wrap { - padding-bottom: 0; - max-width: 100%; -} - /* Header button styles - these are on the gradient background */ .header-btn-secondary { background: var(--surface-header-tint); color: var(--text-inverse); border: 2px solid var(--border-header-soft); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--text-inverse) 8%, transparent); + height: fit-content; } .header-btn-secondary:hover:not(:disabled) { @@ -423,11 +420,6 @@ justify-content: stretch; } - .view-mode-selector-wrap { - order: 2; - width: 100%; - } - .header-btn-group > button { flex: 1; padding: 0.65rem 1rem; diff --git a/src/components/PlanDashboard/PlanDashboard.tsx b/src/components/PlanDashboard/PlanDashboard.tsx index 24fc414..6fdabcc 100644 --- a/src/components/PlanDashboard/PlanDashboard.tsx +++ b/src/components/PlanDashboard/PlanDashboard.tsx @@ -1690,7 +1690,7 @@ const PlanDashboard: React.FC = ({ onResetSetup, viewMode, o Date: Tue, 24 Mar 2026 16:47:35 -0400 Subject: [PATCH 09/81] Update SavingsManager.tsx --- src/components/tabViews/SavingsManager/SavingsManager.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/tabViews/SavingsManager/SavingsManager.tsx b/src/components/tabViews/SavingsManager/SavingsManager.tsx index 4b0ce03..92b2223 100644 --- a/src/components/tabViews/SavingsManager/SavingsManager.tsx +++ b/src/components/tabViews/SavingsManager/SavingsManager.tsx @@ -570,7 +570,7 @@ const SavingsManager: React.FC = ({ return (
From 5cfae2db27e700a42fef0a7d74d3e27a11ab7456 Mon Sep 17 00:00:00 2001 From: Joseph Date: Tue, 24 Mar 2026 20:51:41 -0400 Subject: [PATCH 38/81] Update LoansManager.tsx --- src/components/tabViews/LoansManager/LoansManager.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/tabViews/LoansManager/LoansManager.tsx b/src/components/tabViews/LoansManager/LoansManager.tsx index 362d9d5..f599206 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, Landmark, Plus, X } from 'lucide-react'; +import { Building2, Landmark, Plus, X } from 'lucide-react'; import { useBudget } from '../../../contexts/BudgetContext'; import { useAppDialogs, useFieldErrors, useModalEntityEditor } from '../../../hooks'; import type { AuditHistoryTarget } from '../../../types/audit'; From 5e1bb2f41f7ad4a81800e375161f4c7f2f947bb9 Mon Sep 17 00:00:00 2001 From: Joseph Date: Tue, 24 Mar 2026 21:25:54 -0400 Subject: [PATCH 39/81] Remove view mode settings modal and simplify selector Delete the ViewModeSettingsModal component, its CSS and index export; remove related tests. Simplify viewModePreferences to only expose SELECTABLE_VIEW_MODES and drop favorites, sync and builder helpers. Update ViewModeSelector to build options from SELECTABLE_VIEW_MODES and remove favorites/pay-cadence/settings UI and logic. Remove the viewModeFavoritesChanged custom event and the viewModeFavorites field from settings types. These changes streamline view mode handling by removing per-plan favorites, cadence sync, and the settings modal UI. --- .../ViewModeSelector.test.tsx | 38 ++-------- .../ViewModeSelector/ViewModeSelector.tsx | 63 ++------------- .../ViewModeSettingsModal.css | 10 --- .../ViewModeSettingsModal.tsx | 64 ---------------- .../modals/ViewModeSettingsModal/index.ts | 1 - src/constants/events.ts | 1 - src/types/settings.ts | 2 - src/utils/viewModePreferences.test.ts | 74 ------------------ src/utils/viewModePreferences.ts | 76 ------------------- 9 files changed, 11 insertions(+), 318 deletions(-) delete mode 100644 src/components/modals/ViewModeSettingsModal/ViewModeSettingsModal.css delete mode 100644 src/components/modals/ViewModeSettingsModal/ViewModeSettingsModal.tsx delete mode 100644 src/components/modals/ViewModeSettingsModal/index.ts delete mode 100644 src/utils/viewModePreferences.test.ts diff --git a/src/components/_shared/layout/ViewModeSelector/ViewModeSelector.test.tsx b/src/components/_shared/layout/ViewModeSelector/ViewModeSelector.test.tsx index b965ff2..8767fb3 100644 --- a/src/components/_shared/layout/ViewModeSelector/ViewModeSelector.test.tsx +++ b/src/components/_shared/layout/ViewModeSelector/ViewModeSelector.test.tsx @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import ViewModeSelector from './ViewModeSelector'; @@ -10,26 +10,13 @@ const DEFAULT_OPTIONS = [ ]; describe('ViewModeSelector', () => { - beforeEach(() => { - localStorage.clear(); - }); it('renders default options when none provided', () => { render(); + expect(screen.getByRole('button', { name: 'Weekly' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Bi-weekly' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Semi-monthly' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Monthly' })).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Yearly' })).toBeInTheDocument(); - }); - - it('injects pay cadence mode even when not favorited', () => { - render( - , - ); - - expect(screen.getByRole('button', { name: /Weekly\s*Pay Frequency/i })).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Monthly' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Quarterly' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Yearly' })).toBeInTheDocument(); }); @@ -55,19 +42,4 @@ describe('ViewModeSelector', () => { expect(screen.getByRole('button', { name: 'Option A' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Option B' })).toBeInTheDocument(); }); - - it('shows settings shortcut and triggers callback when provided', async () => { - const onOpenViewModeSettings = vi.fn(); - - render( - , - ); - - await userEvent.click(screen.getByRole('button', { name: 'Open view mode settings' })); - expect(onOpenViewModeSettings).toHaveBeenCalledTimes(1); - }); }); diff --git a/src/components/_shared/layout/ViewModeSelector/ViewModeSelector.tsx b/src/components/_shared/layout/ViewModeSelector/ViewModeSelector.tsx index 72861ab..e029381 100644 --- a/src/components/_shared/layout/ViewModeSelector/ViewModeSelector.tsx +++ b/src/components/_shared/layout/ViewModeSelector/ViewModeSelector.tsx @@ -1,8 +1,5 @@ -import { useMemo } from 'react'; -import { Settings } from 'lucide-react'; -import type { SelectableViewMode } from '../../../../types/viewMode'; import type { ViewMode } from '../../../../types/viewMode'; -import { buildViewModeSelectorOptions, sanitizeFavoriteViewModes } from '../../../../utils/viewModePreferences'; +import { SELECTABLE_VIEW_MODES } from '../../../../utils/viewModePreferences'; import { getDisplayModeLabel } from '../../../../utils/payPeriod'; import './ViewModeSelector.css'; @@ -15,11 +12,6 @@ interface ViewModeSelectorProps { mode: T; onChange: (mode: T) => void; options?: ViewModeOption[]; - /** Plan-specific favorites; when provided, skips internal app-settings reads. */ - favorites?: SelectableViewMode[]; - payCadenceMode?: T; - payCadenceLabel?: string; - onOpenViewModeSettings?: () => void; disabled?: boolean; } @@ -27,46 +19,16 @@ const ViewModeSelector = ({ mode, onChange, options, - favorites: favoritesProp, - payCadenceMode, - payCadenceLabel = 'Pay Frequency', - onOpenViewModeSettings, disabled = false, }: ViewModeSelectorProps) => { - const favoriteModes = favoritesProp ?? sanitizeFavoriteViewModes(undefined); - - const resolvedOptions = useMemo(() => { - if (options) { - return options as ViewModeOption[]; - } - - return buildViewModeSelectorOptions(favoriteModes, payCadenceMode as never) as ViewModeOption[]; - }, [options, favoriteModes, payCadenceMode]); - - const optionsWithCadence = useMemo(() => { - // Only auto-add cadence tab when the user has not explicitly configured - // favorites (favouritesProp absent). When plan-specific favorites are - // provided, the user's checkbox selection is the authoritative list. - if (!payCadenceMode || options || favoritesProp !== undefined) { - return resolvedOptions; - } - - if (resolvedOptions.some((option) => option.value === payCadenceMode)) { - return resolvedOptions; - } - - return [ - { - value: payCadenceMode, - label: getDisplayModeLabel(payCadenceMode as ViewMode), - } as ViewModeOption, - ...resolvedOptions, - ]; - }, [payCadenceMode, options, favoritesProp, resolvedOptions]); + const resolvedOptions = options ?? (SELECTABLE_VIEW_MODES.map((value) => ({ + value, + label: getDisplayModeLabel(value), + })) as ViewModeOption[]); return (
- {optionsWithCadence.map((option) => ( + {resolvedOptions.map((option) => ( ))} - {!options && onOpenViewModeSettings && ( - - )}
); }; diff --git a/src/components/modals/ViewModeSettingsModal/ViewModeSettingsModal.css b/src/components/modals/ViewModeSettingsModal/ViewModeSettingsModal.css deleted file mode 100644 index 3862671..0000000 --- a/src/components/modals/ViewModeSettingsModal/ViewModeSettingsModal.css +++ /dev/null @@ -1,10 +0,0 @@ -.modal-content.view-mode-settings-modal-content { - max-width: min(480px, 96vw); -} - -.view-mode-settings-modal-body { - display: flex; - flex-direction: column; - gap: 1.25rem; - padding: 0.25rem 0; -} diff --git a/src/components/modals/ViewModeSettingsModal/ViewModeSettingsModal.tsx b/src/components/modals/ViewModeSettingsModal/ViewModeSettingsModal.tsx deleted file mode 100644 index 726d4fc..0000000 --- a/src/components/modals/ViewModeSettingsModal/ViewModeSettingsModal.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import React from 'react'; -import type { SelectableViewMode } from '../../../types/viewMode'; -import { CheckboxGroup, InfoBox, Modal } from '../../_shared'; -import { MAX_VISIBLE_FAVORITE_VIEW_MODES, SELECTABLE_VIEW_MODES, sanitizeFavoriteViewModes } from '../../../utils/viewModePreferences'; -import './ViewModeSettingsModal.css'; - -const VIEW_MODE_OPTIONS = SELECTABLE_VIEW_MODES.map((mode) => { - let label = mode.charAt(0).toUpperCase() + mode.slice(1); - if (mode === 'bi-weekly') label = 'Bi-weekly'; - if (mode === 'semi-monthly') label = 'Semi-monthly'; - return { value: mode, label }; -}); - -interface ViewModeSettingsModalProps { - isOpen: boolean; - onClose: () => void; - favorites: SelectableViewMode[]; - onChange: (favorites: SelectableViewMode[]) => void; -} - -const ViewModeSettingsModal: React.FC = ({ - isOpen, - onClose, - favorites, - onChange, -}) => { - const handleChange = (values: string[]) => { - const next = sanitizeFavoriteViewModes(values).slice(0, MAX_VISIBLE_FAVORITE_VIEW_MODES) as SelectableViewMode[]; - onChange(next); - }; - - return ( - -
-
- - ({ - value: option.value, - label: option.label, - disabled: - (favorites.length === 1 && favorites.includes(option.value)) - || (favorites.length >= MAX_VISIBLE_FAVORITE_VIEW_MODES && !favorites.includes(option.value)), - }))} - /> -
- - - Selected favorites apply to this plan only. You can pin up to {MAX_VISIBLE_FAVORITE_VIEW_MODES} view modes, and a minimum of 1 must stay enabled. - -
-
- ); -}; - -export default ViewModeSettingsModal; diff --git a/src/components/modals/ViewModeSettingsModal/index.ts b/src/components/modals/ViewModeSettingsModal/index.ts deleted file mode 100644 index 4df815c..0000000 --- a/src/components/modals/ViewModeSettingsModal/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from './ViewModeSettingsModal'; diff --git a/src/constants/events.ts b/src/constants/events.ts index 6c012c7..4088bf5 100644 --- a/src/constants/events.ts +++ b/src/constants/events.ts @@ -29,7 +29,6 @@ export const APP_CUSTOM_EVENTS = { themeModeChanged: 'theme-mode-changed', appearanceSettingsChanged: 'appearance-settings-changed', glossaryTermsChanged: 'glossary-terms-changed', - viewModeFavoritesChanged: 'view-mode-favorites-changed', viewModeAutoSwitched: 'view-mode-auto-switched', undoRedoStatus: 'app:undo-redo-status', } as const; diff --git a/src/types/settings.ts b/src/types/settings.ts index 095eeb0..eb6d0fd 100644 --- a/src/types/settings.ts +++ b/src/types/settings.ts @@ -1,6 +1,5 @@ import type { TabConfig, TabDisplayMode, TabPosition } from './tabs'; import type { AppearanceMode, AppearancePreset, ColorVisionMode, CustomAppearanceSettings, StateCueMode, ThemeMode } from './appearance'; -import type { SelectableViewMode } from './viewMode'; import type { ViewMode } from './viewMode'; export type KeyMetricsBreakdownView = 'flow' | 'stacked' | 'pie'; @@ -24,7 +23,6 @@ export interface BudgetSettings { }; activeTab?: string; keyMetricsBreakdownView?: KeyMetricsBreakdownView; - viewModeFavorites?: SelectableViewMode[]; } export interface AppSettings { diff --git a/src/utils/viewModePreferences.test.ts b/src/utils/viewModePreferences.test.ts deleted file mode 100644 index c23dac6..0000000 --- a/src/utils/viewModePreferences.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { buildViewModeSelectorOptions, DEFAULT_FAVORITE_VIEW_MODES, sanitizeFavoriteViewModes, syncFavoritesForCadence } from './viewModePreferences'; - -describe('viewModePreferences utilities', () => { - it('falls back to default favorites when favorites are missing or invalid', () => { - expect(sanitizeFavoriteViewModes(undefined)).toEqual(DEFAULT_FAVORITE_VIEW_MODES); - expect(sanitizeFavoriteViewModes('weekly')).toEqual(DEFAULT_FAVORITE_VIEW_MODES); - expect(sanitizeFavoriteViewModes([])).toEqual(DEFAULT_FAVORITE_VIEW_MODES); - }); - - it('keeps only valid unique favorites in display order', () => { - const favorites = sanitizeFavoriteViewModes(['yearly', 'weekly', 'weekly', 'invalid']); - - expect(favorites).toEqual(['weekly', 'yearly']); - }); - - it('uses only favorited modes for visible selector options', () => { - const options = buildViewModeSelectorOptions(['monthly', 'yearly']); - - expect(options.map((option) => option.value)).toEqual(['monthly', 'yearly']); - }); - - it('caps visible favorites and can add a supplemental default for monthly cadence', () => { - const options = buildViewModeSelectorOptions(['monthly', 'yearly'], 'monthly'); - - expect(options.map((option) => option.value)).toEqual(['monthly', 'yearly']); - }); -}); - -describe('syncFavoritesForCadence', () => { - it('returns null when the cadence mode is already in favorites', () => { - expect(syncFavoritesForCadence(['bi-weekly', 'monthly', 'quarterly'], 'bi-weekly')).toBeNull(); - }); - - it('returns null when the cadence is already in favorites and no old cadence to remove', () => { - expect(syncFavoritesForCadence(['monthly', 'yearly'], 'monthly')).toBeNull(); - }); - - it('adds cadence at its canonical position when there is room', () => { - // monthly(3) + yearly(5) → room for bi-weekly(1) → [bi-weekly, monthly, yearly] - expect(syncFavoritesForCadence(['monthly', 'yearly'], 'bi-weekly')).toEqual(['bi-weekly', 'monthly', 'yearly']); - }); - - it('adds cadence without dropping when still under capacity', () => { - // 3 favorites, max=6 → room available, add bi-weekly → [bi-weekly, monthly, quarterly, yearly] - expect(syncFavoritesForCadence(['monthly', 'quarterly', 'yearly'], 'bi-weekly')).toEqual(['bi-weekly', 'monthly', 'quarterly', 'yearly']); - }); - - it('inserts cadence in canonical order when dropped item is not at position 0', () => { - // [monthly, yearly] at cap=3? No, only 2 — room available, add quarterly - expect(syncFavoritesForCadence(['monthly', 'yearly'], 'quarterly')).toEqual(['monthly', 'quarterly', 'yearly']); - }); - - it('inserts cadence in canonical order when there is room', () => { - // [bi-weekly, monthly, yearly] → room available, add semi-monthly → [bi-weekly, semi-monthly, monthly, yearly] - expect(syncFavoritesForCadence(['bi-weekly', 'monthly', 'yearly'], 'semi-monthly')).toEqual(['bi-weekly', 'semi-monthly', 'monthly', 'yearly']); - - }); - - it('removes old cadence and adds new one when frequency changes (non-default cadence)', () => { - // bi-weekly → monthly: remove bi-weekly (not a default), monthly already present → ['monthly','yearly'] - expect(syncFavoritesForCadence(['bi-weekly', 'monthly', 'yearly'], 'monthly', 'bi-weekly')).toEqual(['monthly', 'yearly']); - }); - - it('removes old cadence and adds new one when switching between two non-default cadences', () => { - // bi-weekly → semi-monthly: remove bi-weekly, add semi-monthly - expect(syncFavoritesForCadence(['bi-weekly', 'monthly', 'yearly'], 'semi-monthly', 'bi-weekly')).toEqual(['semi-monthly', 'monthly', 'yearly']); - }); - - it('does not remove old cadence when it is a permanent default', () => { - // monthly → bi-weekly: monthly is a DEFAULT so it stays; bi-weekly added - expect(syncFavoritesForCadence(['monthly', 'yearly'], 'bi-weekly', 'monthly')).toEqual(['bi-weekly', 'monthly', 'yearly']); - }); -}); diff --git a/src/utils/viewModePreferences.ts b/src/utils/viewModePreferences.ts index b692412..cbd2703 100644 --- a/src/utils/viewModePreferences.ts +++ b/src/utils/viewModePreferences.ts @@ -1,5 +1,4 @@ import type { SelectableViewMode } from '../types/viewMode'; -import { getDisplayModeLabel } from './payPeriod'; export const SELECTABLE_VIEW_MODES: SelectableViewMode[] = [ 'weekly', @@ -9,78 +8,3 @@ export const SELECTABLE_VIEW_MODES: SelectableViewMode[] = [ 'quarterly', 'yearly', ]; - -export const MAX_VISIBLE_FAVORITE_VIEW_MODES = 6; -export const DEFAULT_FAVORITE_VIEW_MODES: SelectableViewMode[] = ['monthly', 'yearly']; - -export type ViewModeOption = { - value: SelectableViewMode; - label: string; -}; - -export function sanitizeFavoriteViewModes(modes: unknown): SelectableViewMode[] { - if (!Array.isArray(modes)) { - return [...DEFAULT_FAVORITE_VIEW_MODES]; - } - - const unique = new Set(); - for (const mode of SELECTABLE_VIEW_MODES) { - if (modes.includes(mode)) { - unique.add(mode); - } - } - - return unique.size > 0 ? Array.from(unique) : [...DEFAULT_FAVORITE_VIEW_MODES]; -} - -/** - * When the pay frequency changes, keep the favorites list in sync: - * - Removes the previousCadenceMode from favorites when it is not a permanent - * default (monthly/yearly) and differs from the new cadence — prevents stale - * cadence tabs from lingering after a frequency change. - * - If the new cadence mode is already in the updated list, returns null (no update needed). - * - Otherwise inserts the new cadence at its canonical position and returns the - * updated list capped at MAX_VISIBLE_FAVORITE_VIEW_MODES. - */ -export function syncFavoritesForCadence( - currentFavorites: SelectableViewMode[], - cadenceMode: SelectableViewMode, - previousCadenceMode?: SelectableViewMode, -): SelectableViewMode[] | null { - // Remove the old cadence if it differs from the new one and is not a - // permanent default (monthly/yearly are always useful regardless of cadence). - let base = currentFavorites; - if ( - previousCadenceMode && - previousCadenceMode !== cadenceMode && - !DEFAULT_FAVORITE_VIEW_MODES.includes(previousCadenceMode) - ) { - base = currentFavorites.filter((f) => f !== previousCadenceMode); - } - - if (base.includes(cadenceMode)) { - // If we removed the old cadence the list changed — return the cleaned-up list. - return base.length !== currentFavorites.length - ? sanitizeFavoriteViewModes(base).slice(0, MAX_VISIBLE_FAVORITE_VIEW_MODES) - : null; - } - - const finalBase = - base.length >= MAX_VISIBLE_FAVORITE_VIEW_MODES ? base.slice(1) : base; - return sanitizeFavoriteViewModes([...finalBase, cadenceMode]).slice(0, MAX_VISIBLE_FAVORITE_VIEW_MODES); -} - -export function buildViewModeSelectorOptions( - favorites: unknown, - _payCadenceMode?: SelectableViewMode, - maxFavorites = MAX_VISIBLE_FAVORITE_VIEW_MODES, -): ViewModeOption[] { - const sanitizedFavorites = sanitizeFavoriteViewModes(favorites); - const cappedFavorites = sanitizedFavorites.slice(0, Math.max(maxFavorites, 1)); - return SELECTABLE_VIEW_MODES - .filter((mode) => cappedFavorites.includes(mode)) - .map((mode) => ({ - value: mode, - label: getDisplayModeLabel(mode), - })); -} From 9faf49b6ac2310adb7ec04f1bb61b428a0ec84af Mon Sep 17 00:00:00 2001 From: Joseph Date: Tue, 24 Mar 2026 21:26:44 -0400 Subject: [PATCH 40/81] Remove view favorite sync on pay frequency change Stop synchronizing view mode favorites when pay frequency changes. Removed imports of SelectableViewMode, sanitizeFavoriteViewModes, and syncFavoritesForCadence, and deleted the logic that computed and applied newFavorites. The code now only updates settings.displayMode to the new cadence mode, simplifying the pay frequency change flow and avoiding changes to viewModeFavorites. --- .../modals/PaySettingsModal/PaySettingsModal.tsx | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/src/components/modals/PaySettingsModal/PaySettingsModal.tsx b/src/components/modals/PaySettingsModal/PaySettingsModal.tsx index f95cc9f..41b67b9 100644 --- a/src/components/modals/PaySettingsModal/PaySettingsModal.tsx +++ b/src/components/modals/PaySettingsModal/PaySettingsModal.tsx @@ -3,14 +3,12 @@ import { useBudget } from '../../../contexts/BudgetContext'; import { useAppDialogs } from '../../../hooks'; import type { BudgetData } from '../../../types/budget'; import type { PayFrequency } from '../../../types/frequencies'; -import type { SelectableViewMode } from '../../../types/viewMode'; import type { PaySettings } from '../../../types/payroll'; import type { AuditHistoryTarget } from '../../../types/audit'; import { convertBudgetAmounts } from '../../../services/budgetCurrencyConversion'; import { CURRENCIES, getCurrencySymbol } from '../../../utils/currency'; import { getDisplayModeLabel, getPaychecksPerYear, getPayFrequencyViewMode } from '../../../utils/payPeriod'; import { normalizeStoredAllocationAmount } from '../../../utils/allocationEditor'; -import { sanitizeFavoriteViewModes, syncFavoritesForCadence } from '../../../utils/viewModePreferences'; import { APP_CUSTOM_EVENTS } from '../../../constants/events'; import { formatSuggestedLeftover, getSuggestedLeftoverPerPaycheck } from '../../../utils/paySuggestions'; import { Modal, Button, ErrorDialog, Dropdown, FormGroup, InputWithPrefix, FormattedNumberInput, RadioGroup } from '../../_shared'; @@ -251,19 +249,13 @@ const PaySettingsModal: React.FC = ({ isOpen, onClose, se }; } - // Sync view mode selector favorites when pay frequency changes so the new - // cadence tab appears at its canonical position in this plan's selector. if (editPayFrequency !== budgetData.paySettings.payFrequency) { - const oldCadenceMode = getPayFrequencyViewMode(budgetData.paySettings.payFrequency) as SelectableViewMode; - const cadenceMode = getPayFrequencyViewMode(editPayFrequency) as SelectableViewMode; - const existingFavorites = sanitizeFavoriteViewModes(updatedBudget.settings.viewModeFavorites); - const newFavorites = syncFavoritesForCadence(existingFavorites, cadenceMode, oldCadenceMode); + const cadenceMode = getPayFrequencyViewMode(editPayFrequency); updatedBudget = { ...updatedBudget, settings: { ...updatedBudget.settings, displayMode: cadenceMode, - viewModeFavorites: newFavorites ?? existingFavorites, }, }; From 24c6d71944aa73bc4c9bc8a8f216c671dd5f5bd9 Mon Sep 17 00:00:00 2001 From: Joseph Date: Tue, 24 Mar 2026 21:54:39 -0400 Subject: [PATCH 41/81] Added header icons to modals --- .../PlanDashboard/PlanTabs/TabManagementModal.tsx | 8 ++------ src/components/modals/AboutModal/AboutModal.tsx | 7 ++++--- src/components/modals/AccountsModal/AccountsModal.tsx | 2 ++ src/components/modals/FeedbackModal/FeedbackModal.tsx | 2 ++ src/components/modals/GlossaryModal/GlossaryModal.tsx | 2 ++ .../modals/PaySettingsModal/PaySettingsModal.tsx | 2 ++ 6 files changed, 14 insertions(+), 9 deletions(-) diff --git a/src/components/PlanDashboard/PlanTabs/TabManagementModal.tsx b/src/components/PlanDashboard/PlanTabs/TabManagementModal.tsx index 121f2d1..dc43823 100644 --- a/src/components/PlanDashboard/PlanTabs/TabManagementModal.tsx +++ b/src/components/PlanDashboard/PlanTabs/TabManagementModal.tsx @@ -39,12 +39,8 @@ const TabManagementModal: React.FC = ({ -
@@ -63,7 +64,7 @@ const AboutModal: React.FC = ({ isOpen, onClose }) => {

Account Management

-

Create and manage multiple accounts (checking, savings, investment) and associate them with your bills and allocations.

+

Create and manage multiple accounts (checking, savings, investment) and associate them with your bills and other allocations.

diff --git a/src/components/modals/AccountsModal/AccountsModal.tsx b/src/components/modals/AccountsModal/AccountsModal.tsx index 11b04b1..41cd1ec 100644 --- a/src/components/modals/AccountsModal/AccountsModal.tsx +++ b/src/components/modals/AccountsModal/AccountsModal.tsx @@ -5,6 +5,7 @@ import type { BudgetData } from '../../../types/budget'; import { Modal, Button, FormGroup, AccountsEditor, Dropdown } from '../../_shared'; import './AccountsModal.css'; import './AccountsDeleteModal.css'; +import { Sheet } from 'lucide-react'; interface AccountsModalProps { onClose: () => void; @@ -217,6 +218,7 @@ const AccountsModal: React.FC = ({ onClose }) => { onClose={onClose} contentClassName="accounts-modal" header="Manage Your Accounts" + headerIcon={
+ ); + } + + // ── Variants B, C, D: Chip + Floating Panel ─────────────────────────────── + const isFan = variant === 'fan'; + const showArrows = variant === 'floating-row' || variant === 'grid-popover'; + const panelLayout = variant === 'grid-popover' ? 'grid' : 'row'; + + return ( + + ); +}; + +export default CompactViewModeSelector; diff --git a/src/components/_shared/layout/CompactViewModeSelector/index.ts b/src/components/_shared/layout/CompactViewModeSelector/index.ts new file mode 100644 index 0000000..9604847 --- /dev/null +++ b/src/components/_shared/layout/CompactViewModeSelector/index.ts @@ -0,0 +1,2 @@ +export { default } from './CompactViewModeSelector'; +export type { CompactViewModeSelectorProps, CompactViewModeVariant, CompactSelectorOption } from './CompactViewModeSelector'; From b651e8ac5412327d4c4cb34d9b9db6def898c345 Mon Sep 17 00:00:00 2001 From: Joseph Date: Tue, 24 Mar 2026 22:05:55 -0400 Subject: [PATCH 43/81] Update AboutModal.tsx --- src/components/modals/AboutModal/AboutModal.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/components/modals/AboutModal/AboutModal.tsx b/src/components/modals/AboutModal/AboutModal.tsx index aff0cfb..3038c25 100644 --- a/src/components/modals/AboutModal/AboutModal.tsx +++ b/src/components/modals/AboutModal/AboutModal.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { BadgeQuestionMark, Building2, CalendarClock, ChartPie, Globe, PiggyBank, ShieldCheck } from 'lucide-react'; import { Button, Modal } from '../../_shared'; +import { APP_VERSION, APP_NAME } from '../../../constants/appMeta'; import './AboutModal.css'; interface AboutModalProps { @@ -13,7 +14,7 @@ const AboutModal: React.FC = ({ isOpen, onClose }) => { ); From e7dbbb828641ccac81984b4817dce3a9edf036ad Mon Sep 17 00:00:00 2001 From: Joseph Date: Tue, 24 Mar 2026 22:08:41 -0400 Subject: [PATCH 44/81] commented out checkbox --- src/components/modals/FeedbackModal/FeedbackModal.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/modals/FeedbackModal/FeedbackModal.tsx b/src/components/modals/FeedbackModal/FeedbackModal.tsx index 90334a3..0261bac 100644 --- a/src/components/modals/FeedbackModal/FeedbackModal.tsx +++ b/src/components/modals/FeedbackModal/FeedbackModal.tsx @@ -255,7 +255,7 @@ const FeedbackModal: React.FC = ({ isOpen, onClose, context,
-
+ {/*
-
+
*/} From 3fbda19ff3c7ecd2d56eaa08a2a0181c1986377e Mon Sep 17 00:00:00 2001 From: Joseph Date: Tue, 24 Mar 2026 22:08:46 -0400 Subject: [PATCH 45/81] Update index.ts --- src/components/_shared/index.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/components/_shared/index.ts b/src/components/_shared/index.ts index 8adc651..0c0d4a8 100644 --- a/src/components/_shared/index.ts +++ b/src/components/_shared/index.ts @@ -21,6 +21,8 @@ export { default as AccountsEditor } from './workflows/AccountsEditor'; export { default as TaxLinesEditor } from './workflows/TaxLinesEditor'; export { default as EncryptionConfigPanel } from './workflows/EncryptionConfigPanel'; export { default as ViewModeSelector } from './layout/ViewModeSelector'; +export { default as CompactViewModeSelector } from './layout/CompactViewModeSelector'; +export type { CompactViewModeVariant } from './layout/CompactViewModeSelector'; export { default as PageHeader } from './layout/PageHeader'; export { default as Banner } from './layout/Banner'; export { default as ProgressBar } from './feedback/ProgressBar'; From e313c653ff21f6bfb892a14283e3b26271968777 Mon Sep 17 00:00:00 2001 From: Joseph Date: Tue, 24 Mar 2026 22:08:57 -0400 Subject: [PATCH 46/81] adjusted theme preset grid --- .../modals/SettingsModal/SettingsModal.css | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/components/modals/SettingsModal/SettingsModal.css b/src/components/modals/SettingsModal/SettingsModal.css index 6a70d3a..05c3673 100644 --- a/src/components/modals/SettingsModal/SettingsModal.css +++ b/src/components/modals/SettingsModal/SettingsModal.css @@ -154,7 +154,7 @@ .settings-preset-grid { display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 0.85rem; } @@ -338,7 +338,7 @@ } .settings-preset-grid { - grid-template-columns: 1fr; + grid-template-columns: 1fr 1fr 1fr; } .shared-checkbox-group.settings-view-mode-grid { @@ -346,10 +346,20 @@ } } +@media (max-width: 768px) { + .settings-preset-grid { + grid-template-columns: 1fr 1fr; + } +} + @media (max-width: 640px) { .shared-checkbox-group.settings-view-mode-grid { grid-template-columns: 1fr; } + + .settings-preset-grid { + grid-template-columns: 1fr; + } } .settings-danger-zone { From c5d5aed5ba963b0f372e32cf340c42b58f32f060 Mon Sep 17 00:00:00 2001 From: Joseph Date: Tue, 24 Mar 2026 22:09:07 -0400 Subject: [PATCH 47/81] Update appearancePresets.ts --- src/constants/appearancePresets.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/constants/appearancePresets.ts b/src/constants/appearancePresets.ts index 9ff8145..3df39ae 100644 --- a/src/constants/appearancePresets.ts +++ b/src/constants/appearancePresets.ts @@ -18,7 +18,7 @@ export const APPEARANCE_PRESET_OPTIONS: AppearancePresetMeta[] = [ { value: 'default', label: 'Paycheck Planner Purple', - description: 'Indigo and violet with the existing Paycheck Planner look.', + description: 'Indigo and violet with the original Paycheck Planner look.', preview: { accent: '#667eea', accentAlt: '#764ba2', From 8fca62ce29ad51df8b2572d731071f13fc277595 Mon Sep 17 00:00:00 2001 From: Joseph Date: Tue, 24 Mar 2026 22:09:25 -0400 Subject: [PATCH 48/81] added meta constants --- src/constants/appMeta.ts | 2 ++ src/vite-env.d.ts | 3 +++ vite.config.ts | 9 +++++++++ 3 files changed, 14 insertions(+) create mode 100644 src/constants/appMeta.ts create mode 100644 src/vite-env.d.ts diff --git a/src/constants/appMeta.ts b/src/constants/appMeta.ts new file mode 100644 index 0000000..fbcb850 --- /dev/null +++ b/src/constants/appMeta.ts @@ -0,0 +1,2 @@ +export const APP_NAME = 'Paycheck Planner'; +export const APP_VERSION = __APP_VERSION__; diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts new file mode 100644 index 0000000..dbb4c62 --- /dev/null +++ b/src/vite-env.d.ts @@ -0,0 +1,3 @@ +/// + +declare const __APP_VERSION__: string; diff --git a/vite.config.ts b/vite.config.ts index 5e02eca..a4c95bc 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -2,9 +2,18 @@ import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' import electron from 'vite-plugin-electron/simple' import path from 'path' +import fs from 'fs' + +const packageJson = JSON.parse( + fs.readFileSync(path.resolve(__dirname, 'package.json'), 'utf-8'), +) as { version?: string } +const appVersion = packageJson.version ?? '0.0.0' // https://vite.dev/config/ export default defineConfig({ + define: { + __APP_VERSION__: JSON.stringify(appVersion), + }, plugins: [ react(), electron({ From e50d6afd314fa39256883438e112ba39dec0662e Mon Sep 17 00:00:00 2001 From: Joseph Date: Tue, 24 Mar 2026 22:21:41 -0400 Subject: [PATCH 49/81] removed screenshot attachment --- src/components/modals/FeedbackModal/FeedbackModal.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/modals/FeedbackModal/FeedbackModal.tsx b/src/components/modals/FeedbackModal/FeedbackModal.tsx index 0261bac..2e95ebf 100644 --- a/src/components/modals/FeedbackModal/FeedbackModal.tsx +++ b/src/components/modals/FeedbackModal/FeedbackModal.tsx @@ -266,11 +266,11 @@ const FeedbackModal: React.FC = ({ isOpen, onClose, context,
*/} - + {/* {screenshot && Attached: {screenshot.fileName}} {screenshotError && {screenshotError}} - + */} {fieldError &&

{fieldError}

} From c5f8ec5eb9d24b7f7c1b0691a29cd417315a5c54 Mon Sep 17 00:00:00 2001 From: Joseph Date: Tue, 24 Mar 2026 22:21:49 -0400 Subject: [PATCH 50/81] fixes for loading envs --- vite.config.ts | 119 ++++++++++++++++++++++++++----------------------- 1 file changed, 63 insertions(+), 56 deletions(-) diff --git a/vite.config.ts b/vite.config.ts index a4c95bc..29df567 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,4 +1,4 @@ -import { defineConfig } from 'vite' +import { defineConfig, loadEnv } from 'vite' import react from '@vitejs/plugin-react' import electron from 'vite-plugin-electron/simple' import path from 'path' @@ -10,72 +10,79 @@ const packageJson = JSON.parse( const appVersion = packageJson.version ?? '0.0.0' // https://vite.dev/config/ -export default defineConfig({ - define: { - __APP_VERSION__: JSON.stringify(appVersion), - }, - plugins: [ - react(), - electron({ - main: { - entry: 'electron/main.ts', - vite: { - define: { - 'process.env.FEEDBACK_FORM_URL': JSON.stringify(process.env.FEEDBACK_FORM_URL ?? ''), - 'process.env.FEEDBACK_FORM_ENTRY_EMAIL': JSON.stringify(process.env.FEEDBACK_FORM_ENTRY_EMAIL ?? ''), - 'process.env.FEEDBACK_FORM_ENTRY_CATEGORY': JSON.stringify(process.env.FEEDBACK_FORM_ENTRY_CATEGORY ?? ''), - 'process.env.FEEDBACK_FORM_ENTRY_SUBJECT': JSON.stringify(process.env.FEEDBACK_FORM_ENTRY_SUBJECT ?? ''), - 'process.env.FEEDBACK_FORM_ENTRY_DETAILS': JSON.stringify(process.env.FEEDBACK_FORM_ENTRY_DETAILS ?? ''), +export default defineConfig(({ mode }) => { + // loadEnv reads .env files (local dev); process.env is the fallback for CI + // where vars are injected directly into the environment by GitHub Actions. + const fileEnv = loadEnv(mode, process.cwd(), '') + const getEnv = (key: string): string => fileEnv[key] ?? process.env[key] ?? '' + + return { + define: { + __APP_VERSION__: JSON.stringify(appVersion), + }, + plugins: [ + react(), + electron({ + main: { + entry: 'electron/main.ts', + vite: { + define: { + 'process.env.FEEDBACK_FORM_URL': JSON.stringify(getEnv('FEEDBACK_FORM_URL')), + 'process.env.FEEDBACK_FORM_ENTRY_EMAIL': JSON.stringify(getEnv('FEEDBACK_FORM_ENTRY_EMAIL')), + 'process.env.FEEDBACK_FORM_ENTRY_CATEGORY': JSON.stringify(getEnv('FEEDBACK_FORM_ENTRY_CATEGORY')), + 'process.env.FEEDBACK_FORM_ENTRY_SUBJECT': JSON.stringify(getEnv('FEEDBACK_FORM_ENTRY_SUBJECT')), + 'process.env.FEEDBACK_FORM_ENTRY_DETAILS': JSON.stringify(getEnv('FEEDBACK_FORM_ENTRY_DETAILS')), + }, }, }, + preload: { + input: path.join(__dirname, 'electron/preload.ts'), + }, + renderer: {}, + }), + ], + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), }, - preload: { - input: path.join(__dirname, 'electron/preload.ts'), - }, - renderer: {}, - }), - ], - resolve: { - alias: { - '@': path.resolve(__dirname, './src'), }, - }, - build: { - rollupOptions: { - output: { - manualChunks(id) { - if (!id.includes('node_modules')) return; + 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/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/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/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/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'; - } + if ( + id.includes('/node_modules/html2canvas/') || + id.includes('/node_modules/dompurify/') || + id.includes('/node_modules/canvg/') + ) { + return 'canvas-vendor'; + } - return undefined; + return undefined; + }, }, }, }, - }, + } }) From d03a354f925663d9b6c47cea108421bdc84280a9 Mon Sep 17 00:00:00 2001 From: Joseph Date: Wed, 25 Mar 2026 08:35:28 -0400 Subject: [PATCH 51/81] Refactor CompactViewModeSelector UI and behavior Replace the old variant-based CompactViewModeSelector with a simpler, responsive implementation: consolidate styles, remove the CompactViewModeVariant export/prop, and switch from a floating "badge" to a subtle indicator dot. Add dynamic panel layout/alignment (row/column + left/center/right) computed via useLayoutEffect and measured placement logic to prevent snapping on open, plus a panel ref and adjusted open/close timing. Improve accessibility and ARIA (trigger label, option labels, keyboard handling) and simplify markup (single chip with arrows and unified panel option rendering). --- src/components/_shared/index.ts | 1 - .../CompactViewModeSelector.css | 223 ++++++-------- .../CompactViewModeSelector.tsx | 276 ++++++++++-------- .../layout/CompactViewModeSelector/index.ts | 2 +- 4 files changed, 238 insertions(+), 264 deletions(-) diff --git a/src/components/_shared/index.ts b/src/components/_shared/index.ts index 0c0d4a8..3d082b6 100644 --- a/src/components/_shared/index.ts +++ b/src/components/_shared/index.ts @@ -22,7 +22,6 @@ export { default as TaxLinesEditor } from './workflows/TaxLinesEditor'; export { default as EncryptionConfigPanel } from './workflows/EncryptionConfigPanel'; export { default as ViewModeSelector } from './layout/ViewModeSelector'; export { default as CompactViewModeSelector } from './layout/CompactViewModeSelector'; -export type { CompactViewModeVariant } from './layout/CompactViewModeSelector'; export { default as PageHeader } from './layout/PageHeader'; export { default as Banner } from './layout/Banner'; export { default as ProgressBar } from './feedback/ProgressBar'; diff --git a/src/components/_shared/layout/CompactViewModeSelector/CompactViewModeSelector.css b/src/components/_shared/layout/CompactViewModeSelector/CompactViewModeSelector.css index 68cda2a..2efa3aa 100644 --- a/src/components/_shared/layout/CompactViewModeSelector/CompactViewModeSelector.css +++ b/src/components/_shared/layout/CompactViewModeSelector/CompactViewModeSelector.css @@ -1,10 +1,6 @@ /* ============================================================================= CompactViewModeSelector (.cvms) - Supports four interaction variants: - spinner — Inline ← label → arrows, no expansion. - floating-row — Single chip; hover reveals a floating horizontal row. - grid-popover — Single chip; hover reveals a floating 2×3 grid card. - fan — Minimal label; hover reveals a floating strip with stagger. + Compact chip with left/right controls and a floating option panel. ============================================================================= */ /* ── Base wrapper ─────────────────────────────────────────────────────────── */ @@ -21,7 +17,7 @@ z-index: 5000; } -/* ── Arrow buttons (used in spinner, floating-row, grid-popover) ──────────── */ +/* ── Arrow buttons ─────────────────────────────────────────────────────────── */ .cvms__arrow { display: inline-flex; align-items: center; @@ -47,59 +43,37 @@ cursor: not-allowed; } -/* ── Cadence badge — floats below its parent button ─────────────────────── */ -.cvms__badge { - position: absolute; - top: calc(100% + 0.55rem); - left: 50%; - transform: translateX(-50%); - white-space: nowrap; - font-size: 0.6rem; - font-weight: 700; - letter-spacing: 0.02em; - padding: 0.1rem 0.35rem; - border-radius: 999px; - border: 1px solid var(--border-accent-soft); - color: var(--text-primary); - background: color-mix(in srgb, var(--bg-primary) 94%, var(--accent-primary) 6%); - box-shadow: var(--shadow-sm); - pointer-events: none; - z-index: 3; -} - -.cvms__badge--trigger { - top: calc(100% + 0.5rem); - z-index: 4; +/* ── Highlight indicator ─────────────────────────────────────────────────── */ +.cvms__indicator { + display: inline-flex; + align-items: center; + gap: 0.28rem; + min-width: 0; + color: var(--text-secondary); } -/* ═══════════════════════════════════════════════════════════════════════════ - Variant A — Spinner - ═══════════════════════════════════════════════════════════════════════════ */ -.cvms--spinner { - background: var(--bg-tertiary); - border: 2px solid var(--border-header-soft); - border-radius: 0.5rem; - padding: 0.2rem 0.25rem; - gap: 0.1rem; +.cvms__indicator-dot { + width: 0.45rem; + height: 0.45rem; + border-radius: 999px; + background: color-mix(in srgb, var(--accent-primary) 78%, var(--bg-primary) 22%); + box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent-primary) 22%, var(--border-color) 78%); + flex-shrink: 0; } -.cvms--spinner .cvms__spinner-label { - display: inline-flex; - align-items: center; - gap: 0.4rem; - padding: 0 0.5rem; - font-size: 0.85rem; - font-weight: 600; - color: var(--text-accent); +.cvms__indicator-text { + max-width: 0; + overflow: hidden; + opacity: 0; white-space: nowrap; - position: relative; - min-width: 7.5rem; - justify-content: center; + font-size: 0.64rem; + font-weight: 700; + letter-spacing: 0.02em; + color: var(--text-secondary); + transition: max-width 0.16s ease, opacity 0.12s ease; } -/* ═══════════════════════════════════════════════════════════════════════════ - Chip — shared shell for floating-row, grid-popover, fan - ═══════════════════════════════════════════════════════════════════════════ */ +/* ── Chip shell ───────────────────────────────────────────────────────────── */ .cvms__chip { display: inline-flex; align-items: center; @@ -136,9 +110,15 @@ background: color-mix(in srgb, var(--accent-primary) 8%, transparent); } -.cvms__trigger-label { - /* ensures consistent min width so chip doesn't jump between short/long labels */ +.cvms__trigger-content { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.35rem; min-width: 6rem; +} + +.cvms__trigger-label { text-align: center; } @@ -152,19 +132,16 @@ transform: rotate(180deg); } -/* ═══════════════════════════════════════════════════════════════════════════ - Floating panel — shared by floating-row, grid-popover, fan - ═══════════════════════════════════════════════════════════════════════════ */ +/* ── Floating panel ───────────────────────────────────────────────────────── */ .cvms__panel { position: absolute; top: calc(100% + 0.5rem); - left: 50%; - transform: translateX(-50%) translateY(-6px); z-index: 5010; background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 0.6rem; box-shadow: var(--shadow-lg), 0 0 0 1px color-mix(in srgb, var(--accent-primary) 8%, transparent); + max-width: calc(100vw - 1rem); overflow: visible; isolation: isolate; opacity: 0; @@ -172,17 +149,22 @@ transition: opacity 0.15s ease, transform 0.15s ease; } -.cvms__panel::after { - content: ''; - position: absolute; +.cvms__panel--align-center { + left: 50%; + right: auto; + transform: translateX(-50%) translateY(-6px); +} + +.cvms__panel--align-left { left: 0; + right: auto; + transform: translateY(-6px); +} + +.cvms__panel--align-right { + left: auto; right: 0; - bottom: 0; - height: 1.15rem; - border-radius: 0 0 0.6rem 0.6rem; - background: var(--bg-primary); - z-index: 0; - pointer-events: none; + transform: translateY(-6px); } .cvms__panel > * { @@ -193,38 +175,32 @@ .cvms--open .cvms__panel { opacity: 1; pointer-events: all; +} + +.cvms--open .cvms__panel--align-center { transform: translateX(-50%) translateY(0); } -/* Row layout */ +.cvms--open .cvms__panel--align-left, +.cvms--open .cvms__panel--align-right { + transform: translateY(0); +} + .cvms__panel--row { display: flex; flex-direction: row; gap: 0.15rem; - padding: 0.3rem 0.3rem 1.2rem; + padding: 0.3rem; white-space: nowrap; } -/* Grid layout (2 × 3) */ -.cvms__panel--grid { - display: grid; - grid-template-columns: repeat(2, 1fr); - gap: 0.2rem; - padding: 0.35rem 0.35rem 1.2rem; - min-width: 13rem; -} - -.cvms--open .cvms__badge--trigger { - opacity: 0; -} - -.cvms__panel .cvms__badge { - top: calc(100% + 0.35rem); - z-index: 12; - border-color: var(--border-accent-soft); - background: var(--bg-primary); - color: var(--text-primary); - box-shadow: var(--shadow-md); +.cvms__panel--column { + display: flex; + flex-direction: column; + align-items: stretch; + padding: 0.3rem; + white-space: normal; + width: min(13rem, calc(100vw - 1rem)); } /* ── Option buttons inside the panel ──────────────────────────────────────── */ @@ -259,63 +235,28 @@ box-shadow: var(--shadow-sm); } -/* Grid options: fill their 2-col cell */ -.cvms__panel--grid .cvms__option { +.cvms__panel--column .cvms__option { width: 100%; - justify-content: center; } -/* ═══════════════════════════════════════════════════════════════════════════ - Variant B — floating-row (chip + arrows, row panel) - ═══════════════════════════════════════════════════════════════════════════ */ -/* No extra overrides needed beyond inherited chip + panel--row styles. */ - -/* ═══════════════════════════════════════════════════════════════════════════ - Variant C — grid-popover (chip + arrows, 2×3 grid panel) - ═══════════════════════════════════════════════════════════════════════════ */ -/* No extra overrides — grid layout is handled by .cvms__panel--grid. */ - -/* ═══════════════════════════════════════════════════════════════════════════ - Variant D — fan - Minimal chip (no arrows, no chevron icon). - Panel is a floating row that fans in with a staggered entrance. - ═══════════════════════════════════════════════════════════════════════════ */ -.cvms--fan .cvms__trigger { - padding: 0.3rem 0.9rem; - letter-spacing: 0.01em; +.cvms__option-content { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.35rem; + min-width: 0; } -/* Fan panel: each option starts invisible and slightly offset */ -.cvms--fan .cvms__option { - opacity: 0; - transform: translateY(-5px) scale(0.95); - transition: - opacity 0.14s ease, - transform 0.14s ease, - background 0.12s, - color 0.12s; +.cvms__option-label { + min-width: 0; } -.cvms--fan.cvms--open .cvms__option { +.cvms__trigger:hover .cvms__indicator-text, +.cvms__trigger:focus-visible .cvms__indicator-text, +.cvms--open .cvms__trigger .cvms__indicator-text, +.cvms__option:hover .cvms__indicator-text, +.cvms__option:focus-visible .cvms__indicator-text, +.cvms__option--active .cvms__indicator-text { + max-width: 7rem; opacity: 1; - transform: translateY(0) scale(1); -} - -/* Stagger: each of the 6 options enters 20 ms after the previous */ -.cvms--fan.cvms--open .cvms__option:nth-child(1) { transition-delay: 0ms; } -.cvms--fan.cvms--open .cvms__option:nth-child(2) { transition-delay: 20ms; } -.cvms--fan.cvms--open .cvms__option:nth-child(3) { transition-delay: 40ms; } -.cvms--fan.cvms--open .cvms__option:nth-child(4) { transition-delay: 60ms; } -.cvms--fan.cvms--open .cvms__option:nth-child(5) { transition-delay: 80ms; } -.cvms--fan.cvms--open .cvms__option:nth-child(6) { transition-delay: 100ms; } - -/* ═══════════════════════════════════════════════════════════════════════════ - Disabled state - ═══════════════════════════════════════════════════════════════════════════ */ -.cvms--spinner.cvms--disabled, -.cvms--floating-row.cvms--disabled, -.cvms--grid-popover.cvms--disabled, -.cvms--fan.cvms--disabled { - opacity: 0.6; - pointer-events: none; } diff --git a/src/components/_shared/layout/CompactViewModeSelector/CompactViewModeSelector.tsx b/src/components/_shared/layout/CompactViewModeSelector/CompactViewModeSelector.tsx index c1ae64a..f1c56d6 100644 --- a/src/components/_shared/layout/CompactViewModeSelector/CompactViewModeSelector.tsx +++ b/src/components/_shared/layout/CompactViewModeSelector/CompactViewModeSelector.tsx @@ -1,12 +1,10 @@ -import { useRef, useState, useCallback, useEffect } from 'react'; +import { useRef, useState, useCallback, useEffect, useLayoutEffect } from 'react'; import { ChevronLeft, ChevronRight, ChevronDown } from 'lucide-react'; import type { SelectableViewMode, ViewMode } from '../../../../types/viewMode'; import { SELECTABLE_VIEW_MODES } from '../../../../utils/viewModePreferences'; import { getDisplayModeLabel } from '../../../../utils/payPeriod'; import './CompactViewModeSelector.css'; -export type CompactViewModeVariant = 'spinner' | 'floating-row' | 'grid-popover' | 'fan'; - export interface CompactSelectorOption { value: T; label: string; @@ -17,19 +15,11 @@ export interface CompactViewModeSelectorProps void; options?: CompactSelectorOption[]; - /** Optional highlighted option shown with a small badge in the selector/panel. */ + /** Optional highlighted option shown with a subtle indicator in the selector/panel. */ highlightedValue?: T; highlightedLabel?: string; disabled?: boolean; hidden?: boolean; - /** - * Controls the visual/interaction style: - * - `spinner` — Active label with ← → arrows, no expand. - * - `floating-row` — Compact chip; hovers to reveal a floating row of all 6 options. - * - `grid-popover` — Compact chip; hovers to reveal a floating 2×3 grid card. - * - `fan` — Minimal single label; hovers to reveal a floating strip with a staggered entrance. - */ - variant?: CompactViewModeVariant; } const defaultOptions: CompactSelectorOption[] = SELECTABLE_VIEW_MODES.map((mode) => ({ @@ -37,6 +27,9 @@ const defaultOptions: CompactSelectorOption[] = SELECTABLE_V label: getDisplayModeLabel(mode), })); +type CompactPanelLayout = 'row' | 'column'; +type CompactPanelAlignment = 'center' | 'left' | 'right'; + const CompactViewModeSelector = ({ mode, onChange, @@ -45,10 +38,12 @@ const CompactViewModeSelector = ({ highlightedLabel = 'Highlighted', disabled = false, hidden = false, - variant = 'floating-row', }: CompactViewModeSelectorProps) => { const [expanded, setExpanded] = useState(false); + const [panelLayout, setPanelLayout] = useState('row'); + const [panelAlignment, setPanelAlignment] = useState('center'); const containerRef = useRef(null); + const panelRef = useRef(null); const closeTimeoutRef = useRef(null); const clearCloseTimeout = useCallback(() => { @@ -59,10 +54,16 @@ const CompactViewModeSelector = ({ }, []); const openPanel = useCallback(() => { - if (disabled || hidden || variant === 'spinner') return; + if (disabled || hidden) return; clearCloseTimeout(); - setExpanded(true); - }, [clearCloseTimeout, disabled, hidden, variant]); + if (!expanded) { + // Measure placement from a consistent baseline only when transitioning + // from closed -> open, so hover/focus within the open panel does not snap. + setPanelLayout('row'); + setPanelAlignment('center'); + setExpanded(true); + } + }, [clearCloseTimeout, disabled, hidden, expanded]); const scheduleClosePanel = useCallback((delay = 140) => { clearCloseTimeout(); @@ -83,6 +84,10 @@ const CompactViewModeSelector = ({ const currentLabel = currentOption?.label ?? ''; const isHighlighted = activeMode === highlightedValue; + const triggerAriaLabel = isHighlighted && highlightedLabel + ? `View mode: ${currentLabel}, ${highlightedLabel}` + : `View mode: ${currentLabel}`; + const cycleMode = useCallback( (direction: 1 | -1) => { if (disabled) return; @@ -123,6 +128,68 @@ const CompactViewModeSelector = ({ }; }, [clearCloseTimeout]); + useLayoutEffect(() => { + if (!expanded) { + return; + } + + const updatePanelPlacement = () => { + const container = containerRef.current; + const panel = panelRef.current; + if (!container || !panel) { + return; + } + + const viewportPadding = 8; + const viewportWidth = window.innerWidth; + const containerRect = container.getBoundingClientRect(); + const rowWidth = Math.max(panel.scrollWidth, panel.getBoundingClientRect().width); + + const centeredLeft = containerRect.left + (containerRect.width / 2) - (rowWidth / 2); + const centeredRight = centeredLeft + rowWidth; + if (centeredLeft >= viewportPadding && centeredRight <= viewportWidth - viewportPadding) { + setPanelLayout('row'); + setPanelAlignment('center'); + return; + } + + const rightAnchoredLeft = containerRect.right - rowWidth; + if (rightAnchoredLeft >= viewportPadding) { + setPanelLayout('row'); + setPanelAlignment('right'); + return; + } + + const leftAnchoredRight = containerRect.left + rowWidth; + if (leftAnchoredRight <= viewportWidth - viewportPadding) { + setPanelLayout('row'); + setPanelAlignment('left'); + return; + } + + const columnWidth = Math.min(208, viewportWidth - (viewportPadding * 2)); + const canAlignRightColumn = containerRect.right - columnWidth >= viewportPadding; + const canAlignLeftColumn = containerRect.left + columnWidth <= viewportWidth - viewportPadding; + + let nextAlignment: CompactPanelAlignment = 'center'; + if (canAlignRightColumn && (!canAlignLeftColumn || containerRect.right > viewportWidth / 2)) { + nextAlignment = 'right'; + } else if (canAlignLeftColumn) { + nextAlignment = 'left'; + } + + setPanelLayout('column'); + setPanelAlignment(nextAlignment); + }; + + updatePanelPlacement(); + window.addEventListener('resize', updatePanelPlacement); + + return () => { + window.removeEventListener('resize', updatePanelPlacement); + }; + }, [expanded, resolvedOptions]); + const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { if (disabled) return; @@ -139,148 +206,115 @@ const CompactViewModeSelector = ({ [disabled, cycleMode], ); - // ── Variant A: Spinner ──────────────────────────────────────────────────── - if (variant === 'spinner') { - const prevIndex = (currentIndex - 1 + resolvedOptions.length) % resolvedOptions.length; - const nextIndex = (currentIndex + 1) % resolvedOptions.length; - return ( - - ); - } - - // ── Variants B, C, D: Chip + Floating Panel ─────────────────────────────── - const isFan = variant === 'fan'; - const showArrows = variant === 'floating-row' || variant === 'grid-popover'; - const panelLayout = variant === 'grid-popover' ? 'grid' : 'row'; - return ( ); diff --git a/src/components/_shared/layout/CompactViewModeSelector/index.ts b/src/components/_shared/layout/CompactViewModeSelector/index.ts index 9604847..498020c 100644 --- a/src/components/_shared/layout/CompactViewModeSelector/index.ts +++ b/src/components/_shared/layout/CompactViewModeSelector/index.ts @@ -1,2 +1,2 @@ export { default } from './CompactViewModeSelector'; -export type { CompactViewModeSelectorProps, CompactViewModeVariant, CompactSelectorOption } from './CompactViewModeSelector'; +export type { CompactViewModeSelectorProps, CompactSelectorOption } from './CompactViewModeSelector'; From 6653588b95b3c63e2ce8b8d4848fac2f4c4b52d5 Mon Sep 17 00:00:00 2001 From: Joseph Date: Wed, 25 Mar 2026 08:35:31 -0400 Subject: [PATCH 52/81] Update PaySettingsModal.tsx --- src/components/modals/PaySettingsModal/PaySettingsModal.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/components/modals/PaySettingsModal/PaySettingsModal.tsx b/src/components/modals/PaySettingsModal/PaySettingsModal.tsx index d01fe45..23589cc 100644 --- a/src/components/modals/PaySettingsModal/PaySettingsModal.tsx +++ b/src/components/modals/PaySettingsModal/PaySettingsModal.tsx @@ -11,7 +11,7 @@ import { getDisplayModeLabel, getPaychecksPerYear, getPayFrequencyViewMode } fro import { normalizeStoredAllocationAmount } from '../../../utils/allocationEditor'; import { APP_CUSTOM_EVENTS } from '../../../constants/events'; import { formatSuggestedLeftover, getSuggestedLeftoverPerPaycheck } from '../../../utils/paySuggestions'; -import { Modal, Button, ErrorDialog, Dropdown, FormGroup, InputWithPrefix, FormattedNumberInput, RadioGroup } from '../../_shared'; +import { Modal, Button, ErrorDialog, Dropdown, FormGroup, InputWithPrefix, FormattedNumberInput, RadioGroup, InfoBox } from '../../_shared'; import '../../_shared/payEditorShared.css'; import './PaySettingsModal.css'; import { Banknote } from 'lucide-react'; @@ -432,6 +432,9 @@ const PaySettingsModal: React.FC = ({ isOpen, onClose, se ]} /> + + Your chosen pay frequency will be indicated by a dot next to the corresponding pay frequency label in the view mode selector for your plan. + {/* Paycheck scheduling inputs intentionally disabled for now. From a248ba4315e92106ac0ebbb907ef3225aca4885b Mon Sep 17 00:00:00 2001 From: Joseph Date: Wed, 25 Mar 2026 08:39:01 -0400 Subject: [PATCH 53/81] Replace view mode selector and simplify logic Swap the full ViewModeSelector for a CompactViewModeSelector and remove the view-mode-favorites management and settings modal. Initialization and effects now directly use budget settings or the pay-frequency fallback (getPayFrequencyViewMode) instead of sanitizing/storing per-plan favorites. Also import APP_VERSION and use it in feedback context, add lucide Edit/Copy icons to modal headers, and clean up related state/handlers and modal wiring to open the main Settings modal instead of the removed ViewModeSettingsModal. --- .../PlanDashboard/PlanDashboard.tsx | 107 +++--------------- 1 file changed, 17 insertions(+), 90 deletions(-) diff --git a/src/components/PlanDashboard/PlanDashboard.tsx b/src/components/PlanDashboard/PlanDashboard.tsx index 742181d..3051cb2 100644 --- a/src/components/PlanDashboard/PlanDashboard.tsx +++ b/src/components/PlanDashboard/PlanDashboard.tsx @@ -6,8 +6,9 @@ interface StatusToastState { } import React, { useState, useEffect, useLayoutEffect, useRef, useCallback, useMemo, lazy, Suspense } from 'react'; import { flushSync } from 'react-dom'; -import { Sheet, Copy, Eye, Lock, LockOpen, Save, FolderOpen, MessageSquareText, Banknote, Settings } from 'lucide-react'; +import { Sheet, Copy, Eye, Lock, LockOpen, Save, FolderOpen, MessageSquareText, Banknote, Settings, Edit } from 'lucide-react'; import { APP_CUSTOM_EVENTS, MENU_EVENTS } from '../../constants/events'; +import { APP_VERSION } from '../../constants/appMeta'; import { useBudget } from '../../contexts/BudgetContext'; import { useAppDialogs, useEncryptionSetupFlow, useFileRelinkFlow } from '../../hooks'; import { FileStorageService } from '../../services/fileStorage'; @@ -21,13 +22,10 @@ import TaxBreakdown from '../tabViews/TaxBreakdown'; import SettingsModal from '../modals/SettingsModal'; import AccountsModal from '../modals/AccountsModal'; import PaySettingsModal from '../modals/PaySettingsModal'; -import ViewModeSettingsModal from '../modals/ViewModeSettingsModal'; import { PlanTabs, TabManagementModal } from './PlanTabs'; -import { Toast, Modal, Button, ConfirmDialog, ErrorDialog, FileRelinkModal, FormGroup, EncryptionConfigPanel, Dropdown, ViewModeSelector } from '../_shared'; +import { Toast, Modal, Button, ConfirmDialog, ErrorDialog, FileRelinkModal, FormGroup, EncryptionConfigPanel, Dropdown, CompactViewModeSelector } from '../_shared'; import { initializeTabConfigs, getVisibleTabs, getHiddenTabs, toggleTabVisibility, reorderTabs, normalizeLegacyTabId } from '../../utils/tabManagement'; import { getPayFrequencyViewMode } from '../../utils/payPeriod'; -import { DEFAULT_FAVORITE_VIEW_MODES, sanitizeFavoriteViewModes, syncFavoritesForCadence } from '../../utils/viewModePreferences'; -import type { SelectableViewMode } from '../../types/viewMode'; import { useGlobalKeyboardShortcuts } from '../../hooks'; import type { SearchResult } from '../../utils/planSearch'; import { getActionHandler, type SearchActionContext } from '../../utils/searchRegistry'; @@ -99,10 +97,6 @@ const isElementVisibleInContainer = (element: HTMLElement, container: HTMLElemen ); }; -const getFirstVisibleFavoriteMode = (favorites: SelectableViewMode[]): ViewMode => { - return favorites[0] as ViewMode; -}; - const isEditableTarget = (target: EventTarget | null): boolean => { if (!(target instanceof HTMLElement)) { return false; @@ -164,22 +158,11 @@ const PlanDashboard: React.FC = ({ onResetSetup, viewMode, o const [shouldScrollToRetirement, setShouldScrollToRetirement] = useState(false); const [pendingTabScroll, setPendingTabScroll] = useState<{ tab: TabId; position: TabScrollPosition } | null>(null); const [displayMode, setDisplayMode] = useState(() => { - const favorites = sanitizeFavoriteViewModes(budgetData?.settings?.viewModeFavorites); if (budgetData?.settings?.displayMode) { - return favorites.includes(budgetData.settings.displayMode as never) - ? budgetData.settings.displayMode - : getFirstVisibleFavoriteMode(favorites); + return budgetData.settings.displayMode; } - const cadenceMode = getPayFrequencyViewMode(budgetData?.paySettings?.payFrequency ?? 'bi-weekly'); - // New plan (no stored favorites): always select the pay cadence so it matches - // the frequency the user just chose in setup. - if (budgetData?.settings?.viewModeFavorites == null) { - return cadenceMode; - } - return favorites.includes(cadenceMode as never) - ? cadenceMode - : getFirstVisibleFavoriteMode(favorites); + return getPayFrequencyViewMode(budgetData?.paySettings?.payFrequency ?? 'bi-weekly'); }); const [showCopyModal, setShowCopyModal] = useState(false); const [newYear, setNewYear] = useState(''); @@ -187,7 +170,6 @@ const PlanDashboard: React.FC = ({ onResetSetup, viewMode, o const [showSettings, setShowSettings] = useState(false); const [settingsInitialSection, setSettingsInitialSection] = useState(undefined); const [showPaySettingsModal, setShowPaySettingsModal] = useState(false); - const [showViewModeSettings, setShowViewModeSettings] = useState(false); const [pendingPaySettingsFieldHighlight, setPendingPaySettingsFieldHighlight] = useState(undefined); const [pendingBillsSearchAction, setPendingBillsSearchAction] = useState< | 'add-bill' @@ -497,53 +479,18 @@ const PlanDashboard: React.FC = ({ onResetSetup, viewMode, o setTabDisplayMode(budgetData.settings.tabDisplayMode); } if (budgetData?.settings?.displayMode) { - const favorites = sanitizeFavoriteViewModes(budgetData.settings.viewModeFavorites); - setDisplayMode( - favorites.includes(budgetData.settings.displayMode as never) - ? budgetData.settings.displayMode - : favorites[0], - ); + setDisplayMode(budgetData.settings.displayMode); } else if (budgetData?.paySettings?.payFrequency) { - const cadenceMode = getPayFrequencyViewMode(budgetData.paySettings.payFrequency); - if (budgetData.settings?.viewModeFavorites == null) { - setDisplayMode(cadenceMode); - // Persist initial favorites with the cadence included so the View Mode - // Favorites modal reflects it and re-selecting the cadence tab works. - const selectableCadenceMode = cadenceMode as SelectableViewMode; - const initialFavorites = - syncFavoritesForCadence(DEFAULT_FAVORITE_VIEW_MODES, selectableCadenceMode) ?? - DEFAULT_FAVORITE_VIEW_MODES; - updateBudgetSettings({ - ...budgetData.settings, - viewModeFavorites: initialFavorites, - }); - } else { - const favorites = sanitizeFavoriteViewModes(budgetData.settings.viewModeFavorites); - setDisplayMode(favorites.includes(cadenceMode as never) ? cadenceMode : favorites[0]); - } + setDisplayMode(getPayFrequencyViewMode(budgetData.paySettings.payFrequency)); } }, [ budgetData?.settings, budgetData?.settings?.tabDisplayMode, budgetData?.settings?.tabPosition, budgetData?.settings?.displayMode, - budgetData?.settings?.viewModeFavorites, budgetData?.paySettings?.payFrequency, - updateBudgetSettings, ]); - // When plan-specific favorites change, ensure displayMode is still in the list. - // No-op when favorites haven't been stored yet — the cadence-based initializer - // already picked the right mode and there's nothing user-configured to enforce. - useEffect(() => { - if (budgetData?.settings?.viewModeFavorites == null) return; - const favorites = sanitizeFavoriteViewModes(budgetData.settings.viewModeFavorites); - if (!favorites.includes(displayMode as never)) { - setDisplayMode(favorites[0]); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [budgetData?.settings?.viewModeFavorites]); - // Handle tab position changes const handleTabPositionChange = useCallback((newPosition: TabPosition) => { if (newPosition === tabPosition || !budgetData) return; @@ -1226,26 +1173,13 @@ const PlanDashboard: React.FC = ({ onResetSetup, viewMode, o setSettingsInitialSection(action.sectionId); setShowSettings(true); } else if (action.type === 'open-view-mode-settings') { - setShowViewModeSettings(true); + setSettingsInitialSection(undefined); + setShowSettings(true); } }, [openTabFromLink, selectTab, setPendingBillsSearchAction, setPendingBillsSearchTargetId, setBillsSearchRequestKey, setPendingLoansSearchAction, setPendingLoansSearchTargetId, setLoansSearchRequestKey, setPendingSavingsSearchAction, setPendingSavingsSearchTargetId, setSavingsSearchRequestKey, setTaxSearchOpenSettingsRequestKey, setShowAccountsModal, setShowSettings, setSettingsInitialSection, setScrollToAccountId, setPendingPaySettingsFieldHighlight], ); - const handleOpenViewModeSettings = useCallback(() => { - setShowViewModeSettings(true); - }, []); - - const handleViewModeFavoritesChange = useCallback((newFavorites: SelectableViewMode[]) => { - if (!budgetData) return; - updateBudgetData({ - settings: { - ...budgetData.settings, - viewModeFavorites: newFavorites, - }, - }); - }, [budgetData, updateBudgetData]); - const handleOpenObjectHistory = useCallback((target: AuditHistoryTarget) => { setHistoryTarget(target); }, []); @@ -1724,13 +1658,12 @@ const PlanDashboard: React.FC = ({ onResetSetup, viewMode, o
- handleDisplayModeChange(m)} + highlightedValue={getPayFrequencyViewMode(budgetData.paySettings.payFrequency)} + highlightedLabel="Your pay frequency" + hidden={activeTab === 'metrics'} />