Skip to content

Commit 41c1708

Browse files
authored
Merge pull request #7 from actualbudget/matiss/category-merges
Matiss/category merges
2 parents f749e20 + 45273cd commit 41c1708

File tree

8 files changed

+320
-106
lines changed

8 files changed

+320
-106
lines changed

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# Changelog
2+
3+
All notable changes to this project will be documented in this file.
4+
5+
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.
6+
7+
## 2026-01-04
8+
9+
### Added
10+
11+
- Created CHANGELOG.md file to track project changes

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ A beautiful year-in-review application for your Actual Budget data, styled like
2020
- 💱 **Currency Override**: Change currency display without modifying your budget data
2121
- 🔄 **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
2222

23+
See [CHANGELOG.md](CHANGELOG.md) for a detailed history of changes.
24+
2325
## Prerequisites
2426

2527
- Node.js 20+ and Yarn 4.12.0+

src/components/pages/BudgetVsActualPage.test.tsx

Lines changed: 45 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -293,7 +293,7 @@ describe('BudgetVsActualPage', () => {
293293
expect(incomeIndex).toBeGreaterThan(groceriesIndex);
294294
});
295295

296-
it('sorts deleted categories last within their group', () => {
296+
it('filters out deleted categories', () => {
297297
const mockData = createMockWrappedData({
298298
budgetComparison: createMockBudgetComparison({
299299
categoryBudgets: [
@@ -338,12 +338,14 @@ describe('BudgetVsActualPage', () => {
338338
const anotherActiveIndex = options.findIndex(opt => opt.text === 'Another Active');
339339
const deletedIndex = options.findIndex(opt => opt.text === 'Deleted: Old Category');
340340

341-
// Deleted category should appear last
342-
expect(deletedIndex).toBeGreaterThan(activeIndex);
343-
expect(deletedIndex).toBeGreaterThan(anotherActiveIndex);
341+
// Active categories should be present
342+
expect(activeIndex).toBeGreaterThan(-1);
343+
expect(anotherActiveIndex).toBeGreaterThan(-1);
344+
// Deleted category should be filtered out
345+
expect(deletedIndex).toBe(-1);
344346
});
345347

346-
it('sorts deleted groups last in the group list', () => {
348+
it('filters out deleted categories even in deleted groups', () => {
347349
const mockData = createMockWrappedData({
348350
budgetComparison: createMockBudgetComparison({
349351
categoryBudgets: [
@@ -357,6 +359,16 @@ describe('BudgetVsActualPage', () => {
357359
totalVariance: -200,
358360
totalVariancePercentage: -20,
359361
},
362+
{
363+
categoryId: 'cat1b',
364+
categoryName: 'Another Category in Active Group',
365+
categoryGroup: 'Active Group',
366+
monthlyBudgets: [],
367+
totalBudgeted: 800,
368+
totalActual: 700,
369+
totalVariance: -100,
370+
totalVariancePercentage: -12.5,
371+
},
360372
{
361373
categoryId: 'cat2',
362374
categoryName: 'Deleted: Category in Deleted Group',
@@ -381,22 +393,21 @@ describe('BudgetVsActualPage', () => {
381393

382394
// Find the optgroups
383395
const activeGroupOption = options.find(opt => opt.text === 'Category in Active Group');
396+
const anotherActiveOption = options.find(
397+
opt => opt.text === 'Another Category in Active Group',
398+
);
384399
const deletedGroupOption = options.find(
385400
opt => opt.text === 'Deleted: Category in Deleted Group',
386401
);
387402

388-
// Both should exist
403+
// Active categories should exist
389404
expect(activeGroupOption).toBeDefined();
390-
expect(deletedGroupOption).toBeDefined();
391-
392-
// The deleted group category should appear after the active group category
393-
// (since groups are sorted and deleted groups go last)
394-
const activeIndex = options.findIndex(opt => opt === activeGroupOption);
395-
const deletedIndex = options.findIndex(opt => opt === deletedGroupOption);
396-
expect(deletedIndex).toBeGreaterThan(activeIndex);
405+
expect(anotherActiveOption).toBeDefined();
406+
// Deleted category should be filtered out
407+
expect(deletedGroupOption).toBeUndefined();
397408
});
398409

399-
it('handles groups with all deleted categories as deleted groups', () => {
410+
it('filters out groups with all deleted categories', () => {
400411
const mockData = createMockWrappedData({
401412
budgetComparison: createMockBudgetComparison({
402413
categoryBudgets: [
@@ -412,8 +423,8 @@ describe('BudgetVsActualPage', () => {
412423
},
413424
{
414425
categoryId: 'cat2',
415-
categoryName: 'Deleted: Category 1',
416-
categoryGroup: 'All Deleted Group',
426+
categoryName: 'Another Active',
427+
categoryGroup: 'Active Group',
417428
monthlyBudgets: [],
418429
totalBudgeted: 500,
419430
totalActual: 400,
@@ -422,14 +433,24 @@ describe('BudgetVsActualPage', () => {
422433
},
423434
{
424435
categoryId: 'cat3',
425-
categoryName: 'Deleted: Category 2',
436+
categoryName: 'Deleted: Category 1',
426437
categoryGroup: 'All Deleted Group',
427438
monthlyBudgets: [],
428439
totalBudgeted: 300,
429440
totalActual: 250,
430441
totalVariance: -50,
431442
totalVariancePercentage: -16.67,
432443
},
444+
{
445+
categoryId: 'cat4',
446+
categoryName: 'Deleted: Category 2',
447+
categoryGroup: 'All Deleted Group',
448+
monthlyBudgets: [],
449+
totalBudgeted: 200,
450+
totalActual: 150,
451+
totalVariance: -50,
452+
totalVariancePercentage: -25,
453+
},
433454
],
434455
groupSortOrder: new Map([
435456
['Active Group', 1],
@@ -443,11 +464,15 @@ describe('BudgetVsActualPage', () => {
443464
const options = Array.from(select.options);
444465

445466
const activeIndex = options.findIndex(opt => opt.text === 'Active Category');
467+
const anotherActiveIndex = options.findIndex(opt => opt.text === 'Another Active');
446468
const deleted1Index = options.findIndex(opt => opt.text === 'Deleted: Category 1');
447469
const deleted2Index = options.findIndex(opt => opt.text === 'Deleted: Category 2');
448470

449-
// All deleted group should appear after active group
450-
expect(deleted1Index).toBeGreaterThan(activeIndex);
451-
expect(deleted2Index).toBeGreaterThan(activeIndex);
471+
// Active categories should be present
472+
expect(activeIndex).toBeGreaterThan(-1);
473+
expect(anotherActiveIndex).toBeGreaterThan(-1);
474+
// Deleted categories should be filtered out
475+
expect(deleted1Index).toBe(-1);
476+
expect(deleted2Index).toBe(-1);
452477
});
453478
});

src/components/pages/BudgetVsActualPage.tsx

Lines changed: 31 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -76,28 +76,44 @@ export function BudgetVsActualPage({ data }: BudgetVsActualPageProps) {
7676
const groupedCategories = useMemo(() => {
7777
const groups = new Map<string, typeof budgetComparison.categoryBudgets>();
7878

79+
// Filter out:
80+
// 1. Deleted categories (categories with names starting with "deleted: ")
81+
// 2. Categories that cannot be matched (deleted entirely from DB - category name is just the UUID)
82+
const activeCategories = budgetComparison.categoryBudgets.filter(cat => {
83+
// Filter out deleted categories
84+
if (cat.categoryName.toLowerCase().startsWith('deleted: ')) {
85+
return false;
86+
}
87+
// Filter out categories that cannot be matched (category name is just the UUID)
88+
// Special categories (uncategorized, off-budget, transfers) are always valid
89+
if (
90+
cat.categoryId === 'uncategorized' ||
91+
cat.categoryId === 'off-budget' ||
92+
cat.categoryId.startsWith('transfer:')
93+
) {
94+
return true;
95+
}
96+
// If category name matches the category ID (UUID format), it means the category doesn't exist in DB
97+
// UUIDs are typically long strings with hyphens (e.g., "123e4567-e89b-12d3-a456-426614174000")
98+
return cat.categoryName !== cat.categoryId;
99+
});
100+
79101
// Group all categories by their group (undefined group goes to "Other")
80-
budgetComparison.categoryBudgets.forEach(cat => {
102+
activeCategories.forEach(cat => {
81103
const groupName = cat.categoryGroup || 'Other';
82104
if (!groups.has(groupName)) {
83105
groups.set(groupName, []);
84106
}
85107
groups.get(groupName)!.push(cat);
86108
});
87109

88-
// Sort categories alphabetically within each group, but put deleted and income categories last
110+
// Sort categories alphabetically within each group, but put income categories last
89111
groups.forEach(categories => {
90112
categories.sort((a, b) => {
91-
const aIsDeleted = a.categoryName.toLowerCase().startsWith('deleted: ');
92-
const bIsDeleted = b.categoryName.toLowerCase().startsWith('deleted: ');
93113
const aIsIncome = a.categoryName.toLowerCase().includes('income');
94114
const bIsIncome = b.categoryName.toLowerCase().includes('income');
95115

96-
// Deleted categories go last
97-
if (aIsDeleted && !bIsDeleted) return 1;
98-
if (!aIsDeleted && bIsDeleted) return -1;
99-
100-
// Income categories go last (but before deleted)
116+
// Income categories go last
101117
if (aIsIncome && !bIsIncome) return 1;
102118
if (!aIsIncome && bIsIncome) return -1;
103119

@@ -110,18 +126,12 @@ export function BudgetVsActualPage({ data }: BudgetVsActualPageProps) {
110126
const groupSortOrder = budgetComparison.groupSortOrder || new Map<string, number>();
111127
const groupTombstones = budgetComparison.groupTombstones || new Map<string, boolean>();
112128
return Array.from(groups.entries()).sort((a, b) => {
113-
const [groupA, categoriesA] = [a[0], a[1]];
114-
const [groupB, categoriesB] = [b[0], b[1]];
115-
116-
// Check if groups are deleted (either from tombstone map or if all categories are deleted)
117-
const aIsDeleted =
118-
groupTombstones.get(groupA) === true ||
119-
(categoriesA.length > 0 &&
120-
categoriesA.every(cat => cat.categoryName.toLowerCase().startsWith('deleted: ')));
121-
const bIsDeleted =
122-
groupTombstones.get(groupB) === true ||
123-
(categoriesB.length > 0 &&
124-
categoriesB.every(cat => cat.categoryName.toLowerCase().startsWith('deleted: ')));
129+
const [groupA] = [a[0]];
130+
const [groupB] = [b[0]];
131+
132+
// Check if groups are deleted (from tombstone map)
133+
const aIsDeleted = groupTombstones.get(groupA) === true;
134+
const bIsDeleted = groupTombstones.get(groupB) === true;
125135

126136
const aIsIncome = groupA.toLowerCase().includes('income');
127137
const bIsIncome = groupB.toLowerCase().includes('income');

src/services/fileApi.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
getPayees,
1515
getAllTransactionsForYear,
1616
getCategoryGroupTombstones,
17+
getCategoryMappings,
1718
shutdown,
1819
clearBudget,
1920
integerToAmount,
@@ -365,6 +366,50 @@ describe('fileApi', () => {
365366
});
366367
});
367368

369+
describe('getCategoryMappings', () => {
370+
it('throws DatabaseError when database is not initialized', async () => {
371+
await expect(getCategoryMappings()).rejects.toThrow(DatabaseError);
372+
});
373+
374+
it('returns map of old category IDs to merged category IDs', async () => {
375+
const file = createMockFile();
376+
Object.defineProperty(file, 'size', { value: 1000, writable: false });
377+
378+
await initialize(file);
379+
380+
mockStatement.step
381+
.mockReturnValueOnce(true)
382+
.mockReturnValueOnce(true)
383+
.mockReturnValueOnce(false);
384+
mockStatement.getAsObject
385+
.mockReturnValueOnce({ id: 'cat1', transferId: 'cat2' }) // Merged: cat1 -> cat2
386+
.mockReturnValueOnce({ id: 'cat3', transferId: 'cat3' }); // Self-reference (not merged)
387+
388+
const categoryMappings = await getCategoryMappings();
389+
390+
expect(categoryMappings.size).toBe(1);
391+
expect(categoryMappings.get('cat1')).toBe('cat2');
392+
expect(categoryMappings.has('cat3')).toBe(false); // Self-references excluded
393+
expect(mockDatabase.prepare).toHaveBeenCalledWith(
394+
'SELECT id, transferId FROM category_mapping',
395+
);
396+
});
397+
398+
it('returns empty map when category_mapping table does not exist', async () => {
399+
const file = createMockFile();
400+
Object.defineProperty(file, 'size', { value: 1000, writable: false });
401+
402+
await initialize(file);
403+
mockDatabase.prepare.mockImplementation(() => {
404+
throw new Error('no such table: category_mapping');
405+
});
406+
407+
const categoryMappings = await getCategoryMappings();
408+
409+
expect(categoryMappings.size).toBe(0);
410+
});
411+
});
412+
368413
describe('getPayees', () => {
369414
it('throws DatabaseError when database is not initialized', async () => {
370415
await expect(getPayees()).rejects.toThrow(DatabaseError);

0 commit comments

Comments
 (0)