From 39a4fd6769f818cb4cf20f09ea24a57c47a524e6 Mon Sep 17 00:00:00 2001 From: Matiss Janis Aboltins Date: Sun, 4 Jan 2026 20:33:15 +0000 Subject: [PATCH 1/4] Add CHANGELOG.md for project change tracking and update README.md to reference it --- CHANGELOG.md | 11 +++++++++++ README.md | 2 ++ 2 files changed, 13 insertions(+) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..1ee1362 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,11 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to date-based versioning with entries grouped by date. + +## 2026-01-04 + +### Added + +- Created CHANGELOG.md file to track project changes diff --git a/README.md b/README.md index cfa0640..370b899 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,8 @@ A beautiful year-in-review application for your Actual Budget data, styled like - 💱 **Currency Override**: Change currency display without modifying your budget data - 🔄 **Smart Transfer Labeling**: Transfers are automatically labeled with destination account names (e.g., "Transfer: Savings Account") in both categories and payees lists, instead of showing as uncategorized or unknown +See [CHANGELOG.md](CHANGELOG.md) for a detailed history of changes. + ## Prerequisites - Node.js 20+ and Yarn 4.12.0+ From 6d5bc13f160027401d55a67bab3145bbffde4977 Mon Sep 17 00:00:00 2001 From: Matiss Janis Aboltins Date: Sun, 4 Jan 2026 20:48:34 +0000 Subject: [PATCH 2/4] Add getCategoryMappings function and related tests - Implemented getCategoryMappings to retrieve mappings of old category IDs to merged category IDs, handling cases where the database is not initialized or the category_mapping table does not exist. - Updated getTransactions and getBudgetedAmounts functions to resolve category IDs through the merge chain, ensuring accurate category representation in transactions and budget calculations. - Added unit tests for getCategoryMappings to verify its behavior under various scenarios, including database initialization and table existence. - Enhanced data transformation tests to ensure transactions are correctly categorized under merged categories. --- src/services/fileApi.test.ts | 45 +++++++++++++ src/services/fileApi.ts | 116 +++++++++++++++++++++++++++++--- src/utils/dataTransform.test.ts | 37 ++++++++++ 3 files changed, 187 insertions(+), 11 deletions(-) diff --git a/src/services/fileApi.test.ts b/src/services/fileApi.test.ts index cebfc44..980b16c 100644 --- a/src/services/fileApi.test.ts +++ b/src/services/fileApi.test.ts @@ -14,6 +14,7 @@ import { getPayees, getAllTransactionsForYear, getCategoryGroupTombstones, + getCategoryMappings, shutdown, clearBudget, integerToAmount, @@ -365,6 +366,50 @@ describe('fileApi', () => { }); }); + describe('getCategoryMappings', () => { + it('throws DatabaseError when database is not initialized', async () => { + await expect(getCategoryMappings()).rejects.toThrow(DatabaseError); + }); + + it('returns map of old category IDs to merged category IDs', async () => { + const file = createMockFile(); + Object.defineProperty(file, 'size', { value: 1000, writable: false }); + + await initialize(file); + + mockStatement.step + .mockReturnValueOnce(true) + .mockReturnValueOnce(true) + .mockReturnValueOnce(false); + mockStatement.getAsObject + .mockReturnValueOnce({ id: 'cat1', transferId: 'cat2' }) // Merged: cat1 -> cat2 + .mockReturnValueOnce({ id: 'cat3', transferId: 'cat3' }); // Self-reference (not merged) + + const categoryMappings = await getCategoryMappings(); + + expect(categoryMappings.size).toBe(1); + expect(categoryMappings.get('cat1')).toBe('cat2'); + expect(categoryMappings.has('cat3')).toBe(false); // Self-references excluded + expect(mockDatabase.prepare).toHaveBeenCalledWith( + 'SELECT id, transferId FROM category_mapping', + ); + }); + + it('returns empty map when category_mapping table does not exist', async () => { + const file = createMockFile(); + Object.defineProperty(file, 'size', { value: 1000, writable: false }); + + await initialize(file); + mockDatabase.prepare.mockImplementation(() => { + throw new Error('no such table: category_mapping'); + }); + + const categoryMappings = await getCategoryMappings(); + + expect(categoryMappings.size).toBe(0); + }); + }); + describe('getPayees', () => { it('throws DatabaseError when database is not initialized', async () => { await expect(getPayees()).rejects.toThrow(DatabaseError); diff --git a/src/services/fileApi.ts b/src/services/fileApi.ts index 2f213bf..5d7e432 100644 --- a/src/services/fileApi.ts +++ b/src/services/fileApi.ts @@ -189,6 +189,32 @@ export async function getCategoryGroupTombstones(): Promise return groupTombstoneMap; } +/** + * Get category mappings (category ID -> merged category ID) + * Maps deleted/merged categories to the categories they were merged into + */ +export async function getCategoryMappings(): Promise> { + if (!db) { + throw new DatabaseError('Database not loaded. Call initialize() first.'); + } + + const categoryMapping = new Map(); + try { + const mappings = query('SELECT id, transferId FROM category_mapping'); + mappings.forEach(m => { + const oldId = String(m.id); + const newId = String(m.transferId); + // Only include actual merges (where id != transferId) + if (oldId !== newId) { + categoryMapping.set(oldId, newId); + } + }); + } catch { + // No category_mapping table, that's okay + } + return categoryMapping; +} + /** * Get categories from database */ @@ -295,6 +321,22 @@ async function getTransactions( categories.map(c => [String(c.id), { name: String(c.name), tombstone: c.tombstone === 1 }]), ); + // Get category mappings for resolving merged categories + let categoryMappings = new Map(); + try { + const mappings = query('SELECT id, transferId FROM category_mapping'); + mappings.forEach(m => { + const oldId = String(m.id); + const newId = String(m.transferId); + // Only include actual merges (where id != transferId) + if (oldId !== newId) { + categoryMappings.set(oldId, newId); + } + }); + } catch { + // No category_mapping table, that's okay + } + const result: Transaction[] = []; for (const t of transactions) { @@ -314,8 +356,20 @@ async function getTransactions( const payeeInfo = payeeId && payeeInfoMap ? payeeInfoMap.get(payeeId) : undefined; const payeeTombstone = payeeInfo ? payeeInfo.tombstone : false; - // Get category info (name and tombstone status) - const categoryInfo = t.category ? categoryMap.get(String(t.category)) : undefined; + // Resolve category ID through merge chain (handle transitive merges A -> B -> C) + let resolvedCategoryId = t.category ? String(t.category) : undefined; + + if (resolvedCategoryId && categoryMappings.has(resolvedCategoryId)) { + // Follow the merge chain until we reach the final target + const visited = new Set(); // Prevent infinite loops in case of circular references + while (categoryMappings.has(resolvedCategoryId) && !visited.has(resolvedCategoryId)) { + visited.add(resolvedCategoryId); + resolvedCategoryId = categoryMappings.get(resolvedCategoryId)!; + } + } + + // Get category info from the resolved (merged) category + const categoryInfo = resolvedCategoryId ? categoryMap.get(resolvedCategoryId) : undefined; const categoryName = categoryInfo ? categoryInfo.name : undefined; const categoryTombstone = categoryInfo ? categoryInfo.tombstone : false; @@ -328,7 +382,7 @@ async function getTransactions( payee_name: payeeName, payee_tombstone: payeeTombstone, notes: t.notes ? String(t.notes) : undefined, - category: t.category ? String(t.category) : undefined, + category: resolvedCategoryId, // Use resolved category ID category_name: categoryName, category_tombstone: categoryTombstone, cleared: t.cleared === 1 || false, @@ -465,11 +519,28 @@ export async function getBudgetedAmounts( 'December', ]; - // Convert to structured format - const result: Array<{ categoryId: string; month: string; budgetedAmount: number }> = []; + // Get category mappings for resolving merged categories + let categoryMappings = new Map(); + try { + const mappings = query('SELECT id, transferId FROM category_mapping'); + mappings.forEach(m => { + const oldId = String(m.id); + const newId = String(m.transferId); + // Only include actual merges (where id != transferId) + if (oldId !== newId) { + categoryMappings.set(oldId, newId); + } + }); + } catch { + // No category_mapping table, that's okay + } + + // Resolve category IDs through merge chain and accumulate budgets + // Use a map to combine budgets from merged categories + const budgetMap = new Map>(); // resolvedCategoryId -> month -> amount for (const row of budgetRows) { - const categoryId = String(row.category || ''); + let categoryId = String(row.category || ''); const monthInt = Number(row.month || 0); const amount = Number(row.amount || 0); @@ -478,6 +549,15 @@ export async function getBudgetedAmounts( continue; } + // Resolve category ID through merge chain (handle transitive merges A -> B -> C) + if (categoryMappings.has(categoryId)) { + const visited = new Set(); // Prevent infinite loops + while (categoryMappings.has(categoryId) && !visited.has(categoryId)) { + visited.add(categoryId); + categoryId = categoryMappings.get(categoryId)!; + } + } + // Convert month INTEGER (YYYYMM format) to month name // Extract month number from YYYYMM (e.g., 202501 -> 01 -> January) const monthNum = monthInt % 100; // Get last 2 digits @@ -488,13 +568,27 @@ export async function getBudgetedAmounts( const monthStr = monthNames[monthNum - 1]; const budgetedAmount = integerToAmount(amount); - result.push({ - categoryId, - month: monthStr, - budgetedAmount, - }); + // Accumulate budgets for the same resolved category and month + if (!budgetMap.has(categoryId)) { + budgetMap.set(categoryId, new Map()); + } + const monthMap = budgetMap.get(categoryId)!; + const currentAmount = monthMap.get(monthStr) || 0; + monthMap.set(monthStr, currentAmount + budgetedAmount); } + // Convert map back to array format + const result: Array<{ categoryId: string; month: string; budgetedAmount: number }> = []; + budgetMap.forEach((monthMap, categoryId) => { + monthMap.forEach((budgetedAmount, month) => { + result.push({ + categoryId, + month, + budgetedAmount, + }); + }); + }); + return result; } catch (error) { // If budget data fetch fails (e.g., table doesn't exist), return empty array diff --git a/src/utils/dataTransform.test.ts b/src/utils/dataTransform.test.ts index 964086d..4cc32a3 100644 --- a/src/utils/dataTransform.test.ts +++ b/src/utils/dataTransform.test.ts @@ -344,6 +344,43 @@ describe('transformToWrappedData', () => { expect(deleted?.categoryName).toBe('deleted: Old Category'); }); + it('handles merged categories - transactions appear under merged category', () => { + // Category cat1 was merged into cat2 + // Transaction originally had category 'cat1', but after resolution should use 'cat2' + const transactions: Transaction[] = [ + createMockTransaction({ id: 't1', category: 'cat2', amount: -10000 }), // Already resolved to cat2 + ]; + + const categories: Category[] = [ + createMockCategory({ id: 'cat1', name: 'Old Category', tombstone: true }), + createMockCategory({ id: 'cat2', name: 'New Category', tombstone: false }), + ]; + + const result = transformToWrappedData(transactions, categories, [], []); + + // Transaction should appear under merged category (cat2), not as "deleted: Old Category" + const merged = result.topCategories.find(c => c.categoryId === 'cat2'); + expect(merged?.categoryName).toBe('New Category'); // Should show merged category name + expect(merged?.amount).toBe(100); + expect(result.topCategories.find(c => c.categoryId === 'cat1')).toBeUndefined(); + }); + + it('handles merged categories - non-merged deleted categories still show deleted prefix', () => { + const transactions: Transaction[] = [ + createMockTransaction({ id: 't1', category: 'cat1', amount: -10000 }), + ]; + + const categories: Category[] = [ + createMockCategory({ id: 'cat1', name: 'Deleted Category', tombstone: true }), + ]; + + const result = transformToWrappedData(transactions, categories, [], []); + + // Non-merged deleted category should still show "deleted: " prefix + const deleted = result.topCategories.find(c => c.categoryId === 'cat1'); + expect(deleted?.categoryName).toBe('deleted: Deleted Category'); + }); + it('sorts categories by amount descending', () => { const transactions: Transaction[] = [ createMockTransaction({ id: 't1', category: 'cat1', amount: -10000 }), From b660deeb904252eba6fb9ffb7432a5faee2e381b Mon Sep 17 00:00:00 2001 From: Matiss Janis Aboltins Date: Sun, 4 Jan 2026 22:17:03 +0000 Subject: [PATCH 3/4] Refactor budget calculation logic in getBudgetedAmounts function - Removed category mapping resolution for budget calculations, simplifying the logic by directly using category IDs. - Updated comments for clarity on budget accumulation by category and month. - Enhanced performance by eliminating unnecessary mapping operations related to merged categories. --- src/services/fileApi.ts | 36 ++++++------------------------------ 1 file changed, 6 insertions(+), 30 deletions(-) diff --git a/src/services/fileApi.ts b/src/services/fileApi.ts index 5d7e432..8667f42 100644 --- a/src/services/fileApi.ts +++ b/src/services/fileApi.ts @@ -519,28 +519,13 @@ export async function getBudgetedAmounts( 'December', ]; - // Get category mappings for resolving merged categories - let categoryMappings = new Map(); - try { - const mappings = query('SELECT id, transferId FROM category_mapping'); - mappings.forEach(m => { - const oldId = String(m.id); - const newId = String(m.transferId); - // Only include actual merges (where id != transferId) - if (oldId !== newId) { - categoryMappings.set(oldId, newId); - } - }); - } catch { - // No category_mapping table, that's okay - } - - // Resolve category IDs through merge chain and accumulate budgets - // Use a map to combine budgets from merged categories - const budgetMap = new Map>(); // resolvedCategoryId -> month -> amount + // Accumulate budgets by category and month + // Note: Budget amounts are physically transferred in zero_budgets when categories are deleted, + // so we use category IDs directly (no category_mapping resolution needed for budgets) + const budgetMap = new Map>(); // categoryId -> month -> amount for (const row of budgetRows) { - let categoryId = String(row.category || ''); + const categoryId = String(row.category || ''); const monthInt = Number(row.month || 0); const amount = Number(row.amount || 0); @@ -549,15 +534,6 @@ export async function getBudgetedAmounts( continue; } - // Resolve category ID through merge chain (handle transitive merges A -> B -> C) - if (categoryMappings.has(categoryId)) { - const visited = new Set(); // Prevent infinite loops - while (categoryMappings.has(categoryId) && !visited.has(categoryId)) { - visited.add(categoryId); - categoryId = categoryMappings.get(categoryId)!; - } - } - // Convert month INTEGER (YYYYMM format) to month name // Extract month number from YYYYMM (e.g., 202501 -> 01 -> January) const monthNum = monthInt % 100; // Get last 2 digits @@ -568,7 +544,7 @@ export async function getBudgetedAmounts( const monthStr = monthNames[monthNum - 1]; const budgetedAmount = integerToAmount(amount); - // Accumulate budgets for the same resolved category and month + // Accumulate budgets for the same category and month if (!budgetMap.has(categoryId)) { budgetMap.set(categoryId, new Map()); } From 45273cd8c0fd811748635b96cb596dca753e84eb Mon Sep 17 00:00:00 2001 From: Matiss Janis Aboltins Date: Mon, 5 Jan 2026 21:04:42 +0000 Subject: [PATCH 4/4] Refactor BudgetVsActualPage to filter out deleted categories and improve category handling - Updated the BudgetVsActualPage component to filter out deleted categories and groups, ensuring they are not displayed in the UI. - Enhanced the logic for grouping and sorting categories, maintaining only active categories and special cases. - Modified related tests to verify the correct filtering behavior and ensure that deleted categories do not appear in the selection options. - Improved data transformation logic to prevent the inclusion of non-existent categories in budget calculations. --- .../pages/BudgetVsActualPage.test.tsx | 65 ++++++--- src/components/pages/BudgetVsActualPage.tsx | 52 +++++--- src/utils/dataTransform.ts | 124 ++++++++++-------- 3 files changed, 145 insertions(+), 96 deletions(-) diff --git a/src/components/pages/BudgetVsActualPage.test.tsx b/src/components/pages/BudgetVsActualPage.test.tsx index e3c72b9..87e54c7 100644 --- a/src/components/pages/BudgetVsActualPage.test.tsx +++ b/src/components/pages/BudgetVsActualPage.test.tsx @@ -293,7 +293,7 @@ describe('BudgetVsActualPage', () => { expect(incomeIndex).toBeGreaterThan(groceriesIndex); }); - it('sorts deleted categories last within their group', () => { + it('filters out deleted categories', () => { const mockData = createMockWrappedData({ budgetComparison: createMockBudgetComparison({ categoryBudgets: [ @@ -338,12 +338,14 @@ describe('BudgetVsActualPage', () => { const anotherActiveIndex = options.findIndex(opt => opt.text === 'Another Active'); const deletedIndex = options.findIndex(opt => opt.text === 'Deleted: Old Category'); - // Deleted category should appear last - expect(deletedIndex).toBeGreaterThan(activeIndex); - expect(deletedIndex).toBeGreaterThan(anotherActiveIndex); + // Active categories should be present + expect(activeIndex).toBeGreaterThan(-1); + expect(anotherActiveIndex).toBeGreaterThan(-1); + // Deleted category should be filtered out + expect(deletedIndex).toBe(-1); }); - it('sorts deleted groups last in the group list', () => { + it('filters out deleted categories even in deleted groups', () => { const mockData = createMockWrappedData({ budgetComparison: createMockBudgetComparison({ categoryBudgets: [ @@ -357,6 +359,16 @@ describe('BudgetVsActualPage', () => { totalVariance: -200, totalVariancePercentage: -20, }, + { + categoryId: 'cat1b', + categoryName: 'Another Category in Active Group', + categoryGroup: 'Active Group', + monthlyBudgets: [], + totalBudgeted: 800, + totalActual: 700, + totalVariance: -100, + totalVariancePercentage: -12.5, + }, { categoryId: 'cat2', categoryName: 'Deleted: Category in Deleted Group', @@ -381,22 +393,21 @@ describe('BudgetVsActualPage', () => { // Find the optgroups const activeGroupOption = options.find(opt => opt.text === 'Category in Active Group'); + const anotherActiveOption = options.find( + opt => opt.text === 'Another Category in Active Group', + ); const deletedGroupOption = options.find( opt => opt.text === 'Deleted: Category in Deleted Group', ); - // Both should exist + // Active categories should exist expect(activeGroupOption).toBeDefined(); - expect(deletedGroupOption).toBeDefined(); - - // The deleted group category should appear after the active group category - // (since groups are sorted and deleted groups go last) - const activeIndex = options.findIndex(opt => opt === activeGroupOption); - const deletedIndex = options.findIndex(opt => opt === deletedGroupOption); - expect(deletedIndex).toBeGreaterThan(activeIndex); + expect(anotherActiveOption).toBeDefined(); + // Deleted category should be filtered out + expect(deletedGroupOption).toBeUndefined(); }); - it('handles groups with all deleted categories as deleted groups', () => { + it('filters out groups with all deleted categories', () => { const mockData = createMockWrappedData({ budgetComparison: createMockBudgetComparison({ categoryBudgets: [ @@ -412,8 +423,8 @@ describe('BudgetVsActualPage', () => { }, { categoryId: 'cat2', - categoryName: 'Deleted: Category 1', - categoryGroup: 'All Deleted Group', + categoryName: 'Another Active', + categoryGroup: 'Active Group', monthlyBudgets: [], totalBudgeted: 500, totalActual: 400, @@ -422,7 +433,7 @@ describe('BudgetVsActualPage', () => { }, { categoryId: 'cat3', - categoryName: 'Deleted: Category 2', + categoryName: 'Deleted: Category 1', categoryGroup: 'All Deleted Group', monthlyBudgets: [], totalBudgeted: 300, @@ -430,6 +441,16 @@ describe('BudgetVsActualPage', () => { totalVariance: -50, totalVariancePercentage: -16.67, }, + { + categoryId: 'cat4', + categoryName: 'Deleted: Category 2', + categoryGroup: 'All Deleted Group', + monthlyBudgets: [], + totalBudgeted: 200, + totalActual: 150, + totalVariance: -50, + totalVariancePercentage: -25, + }, ], groupSortOrder: new Map([ ['Active Group', 1], @@ -443,11 +464,15 @@ describe('BudgetVsActualPage', () => { const options = Array.from(select.options); const activeIndex = options.findIndex(opt => opt.text === 'Active Category'); + const anotherActiveIndex = options.findIndex(opt => opt.text === 'Another Active'); const deleted1Index = options.findIndex(opt => opt.text === 'Deleted: Category 1'); const deleted2Index = options.findIndex(opt => opt.text === 'Deleted: Category 2'); - // All deleted group should appear after active group - expect(deleted1Index).toBeGreaterThan(activeIndex); - expect(deleted2Index).toBeGreaterThan(activeIndex); + // Active categories should be present + expect(activeIndex).toBeGreaterThan(-1); + expect(anotherActiveIndex).toBeGreaterThan(-1); + // Deleted categories should be filtered out + expect(deleted1Index).toBe(-1); + expect(deleted2Index).toBe(-1); }); }); diff --git a/src/components/pages/BudgetVsActualPage.tsx b/src/components/pages/BudgetVsActualPage.tsx index f8cd5b0..11f5755 100644 --- a/src/components/pages/BudgetVsActualPage.tsx +++ b/src/components/pages/BudgetVsActualPage.tsx @@ -76,8 +76,30 @@ export function BudgetVsActualPage({ data }: BudgetVsActualPageProps) { const groupedCategories = useMemo(() => { const groups = new Map(); + // Filter out: + // 1. Deleted categories (categories with names starting with "deleted: ") + // 2. Categories that cannot be matched (deleted entirely from DB - category name is just the UUID) + const activeCategories = budgetComparison.categoryBudgets.filter(cat => { + // Filter out deleted categories + if (cat.categoryName.toLowerCase().startsWith('deleted: ')) { + return false; + } + // Filter out categories that cannot be matched (category name is just the UUID) + // Special categories (uncategorized, off-budget, transfers) are always valid + if ( + cat.categoryId === 'uncategorized' || + cat.categoryId === 'off-budget' || + cat.categoryId.startsWith('transfer:') + ) { + return true; + } + // If category name matches the category ID (UUID format), it means the category doesn't exist in DB + // UUIDs are typically long strings with hyphens (e.g., "123e4567-e89b-12d3-a456-426614174000") + return cat.categoryName !== cat.categoryId; + }); + // Group all categories by their group (undefined group goes to "Other") - budgetComparison.categoryBudgets.forEach(cat => { + activeCategories.forEach(cat => { const groupName = cat.categoryGroup || 'Other'; if (!groups.has(groupName)) { groups.set(groupName, []); @@ -85,19 +107,13 @@ export function BudgetVsActualPage({ data }: BudgetVsActualPageProps) { groups.get(groupName)!.push(cat); }); - // Sort categories alphabetically within each group, but put deleted and income categories last + // Sort categories alphabetically within each group, but put income categories last groups.forEach(categories => { categories.sort((a, b) => { - const aIsDeleted = a.categoryName.toLowerCase().startsWith('deleted: '); - const bIsDeleted = b.categoryName.toLowerCase().startsWith('deleted: '); const aIsIncome = a.categoryName.toLowerCase().includes('income'); const bIsIncome = b.categoryName.toLowerCase().includes('income'); - // Deleted categories go last - if (aIsDeleted && !bIsDeleted) return 1; - if (!aIsDeleted && bIsDeleted) return -1; - - // Income categories go last (but before deleted) + // Income categories go last if (aIsIncome && !bIsIncome) return 1; if (!aIsIncome && bIsIncome) return -1; @@ -110,18 +126,12 @@ export function BudgetVsActualPage({ data }: BudgetVsActualPageProps) { const groupSortOrder = budgetComparison.groupSortOrder || new Map(); const groupTombstones = budgetComparison.groupTombstones || new Map(); return Array.from(groups.entries()).sort((a, b) => { - const [groupA, categoriesA] = [a[0], a[1]]; - const [groupB, categoriesB] = [b[0], b[1]]; - - // Check if groups are deleted (either from tombstone map or if all categories are deleted) - const aIsDeleted = - groupTombstones.get(groupA) === true || - (categoriesA.length > 0 && - categoriesA.every(cat => cat.categoryName.toLowerCase().startsWith('deleted: '))); - const bIsDeleted = - groupTombstones.get(groupB) === true || - (categoriesB.length > 0 && - categoriesB.every(cat => cat.categoryName.toLowerCase().startsWith('deleted: '))); + const [groupA] = [a[0]]; + const [groupB] = [b[0]]; + + // Check if groups are deleted (from tombstone map) + const aIsDeleted = groupTombstones.get(groupA) === true; + const bIsDeleted = groupTombstones.get(groupB) === true; const aIsIncome = groupA.toLowerCase().includes('income'); const bIsIncome = groupB.toLowerCase().includes('income'); diff --git a/src/utils/dataTransform.ts b/src/utils/dataTransform.ts index 87d663d..d967671 100644 --- a/src/utils/dataTransform.ts +++ b/src/utils/dataTransform.ts @@ -171,71 +171,85 @@ function calculateBudgetComparison( actualSpendingMap.forEach((_, categoryId) => allCategoryIds.add(categoryId)); // Build category budgets - const categoryBudgets: CategoryBudget[] = Array.from(allCategoryIds).map(categoryId => { - let baseCategoryName: string; - if (categoryId === 'uncategorized') { - baseCategoryName = 'Uncategorized'; - } else if (categoryId === 'off-budget') { - baseCategoryName = 'Off Budget'; - } else { - baseCategoryName = categoryIdToName.get(categoryId) || categoryId; - } + // Filter out categories that don't exist in the database (unless they're special categories) + const categoryBudgets: CategoryBudget[] = Array.from(allCategoryIds) + .filter(categoryId => { + // Keep special categories (uncategorized, off-budget, transfers) + if ( + categoryId === 'uncategorized' || + categoryId === 'off-budget' || + categoryId.startsWith('transfer:') + ) { + return true; + } + // Filter out categories that don't exist in the database + return categoryIdToName.has(categoryId); + }) + .map(categoryId => { + let baseCategoryName: string; + if (categoryId === 'uncategorized') { + baseCategoryName = 'Uncategorized'; + } else if (categoryId === 'off-budget') { + baseCategoryName = 'Off Budget'; + } else { + baseCategoryName = categoryIdToName.get(categoryId) || categoryId; + } - // Check if category is deleted and prefix with "Deleted: " - const isDeleted = categoryIdToTombstone.get(categoryId) || false; - const categoryName = isDeleted ? `Deleted: ${baseCategoryName}` : baseCategoryName; + // Check if category is deleted and prefix with "Deleted: " + const isDeleted = categoryIdToTombstone.get(categoryId) || false; + const categoryName = isDeleted ? `Deleted: ${baseCategoryName}` : baseCategoryName; - const categoryGroup = categoryIdToGroup.get(categoryId); + const categoryGroup = categoryIdToGroup.get(categoryId); - const budgetMapForCategory = budgetMap.get(categoryId) || new Map(); - const actualMapForCategory = actualSpendingMap.get(categoryId) || new Map(); + const budgetMapForCategory = budgetMap.get(categoryId) || new Map(); + const actualMapForCategory = actualSpendingMap.get(categoryId) || new Map(); - // Calculate monthly budgets with carry forward logic - let carryForwardFromPrevious = 0; // Carry forward from previous month - const monthlyBudgets = MONTHS.map(monthName => { - const budgetedAmount = budgetMapForCategory.get(monthName) || 0; - const actualAmount = actualMapForCategory.get(monthName) || 0; - const carryForward = carryForwardFromPrevious; - const effectiveBudget = budgetedAmount + carryForward; // Available to spend this month - const remaining = effectiveBudget - actualAmount; // What's left after spending - const variance = actualAmount - effectiveBudget; // Variance against effective budget - const variancePercentage = effectiveBudget !== 0 ? (variance / effectiveBudget) * 100 : 0; + // Calculate monthly budgets with carry forward logic + let carryForwardFromPrevious = 0; // Carry forward from previous month + const monthlyBudgets = MONTHS.map(monthName => { + const budgetedAmount = budgetMapForCategory.get(monthName) || 0; + const actualAmount = actualMapForCategory.get(monthName) || 0; + const carryForward = carryForwardFromPrevious; + const effectiveBudget = budgetedAmount + carryForward; // Available to spend this month + const remaining = effectiveBudget - actualAmount; // What's left after spending + const variance = actualAmount - effectiveBudget; // Variance against effective budget + const variancePercentage = effectiveBudget !== 0 ? (variance / effectiveBudget) * 100 : 0; - // Update carry forward for next month (only positive remaining amounts carry forward) - carryForwardFromPrevious = remaining > 0 ? remaining : 0; + // Update carry forward for next month (only positive remaining amounts carry forward) + carryForwardFromPrevious = remaining > 0 ? remaining : 0; + + return { + month: monthName, + budgetedAmount, + actualAmount, + carryForward, + effectiveBudget, + remaining, + variance, + variancePercentage, + }; + }); + + const totalBudgeted = monthlyBudgets.reduce((sum, m) => sum + m.budgetedAmount, 0); + const totalActual = monthlyBudgets.reduce((sum, m) => sum + m.actualAmount, 0); + // Total variance should use effective budgets (which include carry forward) + const totalEffectiveBudget = monthlyBudgets.reduce((sum, m) => sum + m.effectiveBudget, 0); + const totalVariance = totalActual - totalEffectiveBudget; + const totalVariancePercentage = + totalEffectiveBudget !== 0 ? (totalVariance / totalEffectiveBudget) * 100 : 0; return { - month: monthName, - budgetedAmount, - actualAmount, - carryForward, - effectiveBudget, - remaining, - variance, - variancePercentage, + categoryId, + categoryName, + categoryGroup, + monthlyBudgets, + totalBudgeted, + totalActual, + totalVariance, + totalVariancePercentage, }; }); - const totalBudgeted = monthlyBudgets.reduce((sum, m) => sum + m.budgetedAmount, 0); - const totalActual = monthlyBudgets.reduce((sum, m) => sum + m.actualAmount, 0); - // Total variance should use effective budgets (which include carry forward) - const totalEffectiveBudget = monthlyBudgets.reduce((sum, m) => sum + m.effectiveBudget, 0); - const totalVariance = totalActual - totalEffectiveBudget; - const totalVariancePercentage = - totalEffectiveBudget !== 0 ? (totalVariance / totalEffectiveBudget) * 100 : 0; - - return { - categoryId, - categoryName, - categoryGroup, - monthlyBudgets, - totalBudgeted, - totalActual, - totalVariance, - totalVariancePercentage, - }; - }); - // Calculate monthly totals (using effective budgets which include carry forward) const monthlyTotals = MONTHS.map(monthName => { let totalBudgeted = 0;