Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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+
Expand Down
65 changes: 45 additions & 20 deletions src/components/pages/BudgetVsActualPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down Expand Up @@ -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: [
Expand All @@ -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',
Expand All @@ -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: [
Expand All @@ -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,
Expand All @@ -422,14 +433,24 @@ describe('BudgetVsActualPage', () => {
},
{
categoryId: 'cat3',
categoryName: 'Deleted: Category 2',
categoryName: 'Deleted: Category 1',
categoryGroup: 'All Deleted Group',
monthlyBudgets: [],
totalBudgeted: 300,
totalActual: 250,
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],
Expand All @@ -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);
});
});
52 changes: 31 additions & 21 deletions src/components/pages/BudgetVsActualPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,28 +76,44 @@ export function BudgetVsActualPage({ data }: BudgetVsActualPageProps) {
const groupedCategories = useMemo(() => {
const groups = new Map<string, typeof budgetComparison.categoryBudgets>();

// 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, []);
}
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;

Expand All @@ -110,18 +126,12 @@ export function BudgetVsActualPage({ data }: BudgetVsActualPageProps) {
const groupSortOrder = budgetComparison.groupSortOrder || new Map<string, number>();
const groupTombstones = budgetComparison.groupTombstones || new Map<string, boolean>();
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');
Expand Down
45 changes: 45 additions & 0 deletions src/services/fileApi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
getPayees,
getAllTransactionsForYear,
getCategoryGroupTombstones,
getCategoryMappings,
shutdown,
clearBudget,
integerToAmount,
Expand Down Expand Up @@ -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);
Expand Down
Loading