Skip to content

Commit 9342df5

Browse files
authored
Fix Split Categories (#291)
* Add tests for transaction category state and split editor functionality * Bump version to 2.6.1 in package.json * Update CHANGELOG for version 2.6.1 with fixes for split-category behavior and transaction modal
1 parent 3d3209e commit 9342df5

11 files changed

Lines changed: 540 additions & 83 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [2.6.1] - 2026-04-03
11+
12+
### Fixed
13+
14+
- Fixed ledger split-category behavior so split transactions participate correctly in ledger category filtering, uncategorized views, and invalid-category cleanup.
15+
- Fixed the split transaction modal to preserve in-progress edits instead of resetting split rows while the dialog is open.
16+
- Fixed the split transaction amount field so partial decimal values can be entered and edited naturally without forced reformatting on every keystroke.
17+
1018
## [2.6.0] - 2026-04-03
1119

1220
### Changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "luca-ledger",
3-
"version": "2.6.0",
3+
"version": "2.6.1",
44
"homepage": "https://lucaledger.app/",
55
"type": "module",
66
"license": "MIT",
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import { act } from 'react';
2+
import { createRoot } from 'react-dom/client';
3+
import { afterEach, describe, expect, it } from 'vitest';
4+
import { Provider } from 'react-redux';
5+
import { configureStore } from '@reduxjs/toolkit';
6+
import { reducer as transactionSplitsReducer } from '@/store/transactionSplits';
7+
import { useSplitEditor } from '@/components/SplitEditorModal/hooks/useSplitEditor';
8+
9+
const transaction = {
10+
id: '11111111-1111-1111-1111-111111111111',
11+
amount: -2500,
12+
categoryId: '22222222-2222-2222-2222-222222222222',
13+
};
14+
15+
describe('useSplitEditor', () => {
16+
let container = null;
17+
let root = null;
18+
19+
afterEach(async () => {
20+
if (root) {
21+
await act(async () => {
22+
root.unmount();
23+
});
24+
root = null;
25+
}
26+
27+
if (container) {
28+
container.remove();
29+
container = null;
30+
}
31+
});
32+
33+
it('preserves local split edits while the modal is open', async () => {
34+
const store = configureStore({
35+
reducer: {
36+
transactionSplits: transactionSplitsReducer,
37+
},
38+
});
39+
40+
let latestHookState = null;
41+
42+
function TestComponent() {
43+
latestHookState = useSplitEditor(true, transaction);
44+
return null;
45+
}
46+
47+
container = document.createElement('div');
48+
document.body.appendChild(container);
49+
root = createRoot(container);
50+
51+
await act(async () => {
52+
root.render(
53+
<Provider store={store}>
54+
<TestComponent />
55+
</Provider>,
56+
);
57+
});
58+
59+
expect(latestHookState.splits).toHaveLength(1);
60+
61+
await act(async () => {
62+
latestHookState.handleAddSplit();
63+
});
64+
65+
expect(latestHookState.splits).toHaveLength(2);
66+
expect(latestHookState.splits[0].amount).toBe(2500);
67+
expect(latestHookState.splits[1].amount).toBe(0);
68+
expect(latestHookState.amountInputs[latestHookState.splits[0].id]).toBe(
69+
'25.00',
70+
);
71+
expect(latestHookState.amountInputs[latestHookState.splits[1].id]).toBe('');
72+
});
73+
74+
it('preserves partial decimal input while updating numeric cents', async () => {
75+
const store = configureStore({
76+
reducer: {
77+
transactionSplits: transactionSplitsReducer,
78+
},
79+
});
80+
81+
let latestHookState = null;
82+
83+
function TestComponent() {
84+
latestHookState = useSplitEditor(true, transaction);
85+
return null;
86+
}
87+
88+
container = document.createElement('div');
89+
document.body.appendChild(container);
90+
root = createRoot(container);
91+
92+
await act(async () => {
93+
root.render(
94+
<Provider store={store}>
95+
<TestComponent />
96+
</Provider>,
97+
);
98+
});
99+
100+
const splitId = latestHookState.splits[0].id;
101+
102+
await act(async () => {
103+
latestHookState.handleAmountChange(splitId, '12.');
104+
});
105+
106+
expect(latestHookState.amountInputs[splitId]).toBe('12.');
107+
expect(latestHookState.splits[0].amount).toBe(1200);
108+
109+
await act(async () => {
110+
latestHookState.handleAmountChange(splitId, '.5');
111+
});
112+
113+
expect(latestHookState.amountInputs[splitId]).toBe('.5');
114+
expect(latestHookState.splits[0].amount).toBe(50);
115+
});
116+
});
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import { describe, expect, it } from 'vitest';
2+
import {
3+
buildCategoriesById,
4+
buildSplitsByTransactionId,
5+
hasTransactionInvalidCategories,
6+
isTransactionUncategorized,
7+
transactionMatchesCategoryFilter,
8+
} from '@/utils/transactionCategoryState';
9+
10+
const categories = [
11+
{ id: 'food', name: 'Food', parentId: null },
12+
{ id: 'groceries', name: 'Groceries', parentId: 'food' },
13+
{ id: 'utilities', name: 'Utilities', parentId: null },
14+
];
15+
16+
describe('transactionCategoryState', () => {
17+
it('treats split categories as the authoritative ledger category', () => {
18+
const transaction = {
19+
id: 'txn-1',
20+
categoryId: 'deleted-category',
21+
};
22+
const categoriesById = buildCategoriesById(categories);
23+
const splitsByTransaction = buildSplitsByTransactionId([
24+
{
25+
id: 'split-1',
26+
transactionId: 'txn-1',
27+
categoryId: 'groceries',
28+
amount: 1250,
29+
},
30+
]);
31+
32+
expect(
33+
transactionMatchesCategoryFilter(
34+
transaction,
35+
'groc',
36+
categoriesById,
37+
splitsByTransaction,
38+
),
39+
).toBe(true);
40+
expect(
41+
transactionMatchesCategoryFilter(
42+
transaction,
43+
'food',
44+
categoriesById,
45+
splitsByTransaction,
46+
),
47+
).toBe(true);
48+
expect(
49+
transactionMatchesCategoryFilter(
50+
transaction,
51+
'util',
52+
categoriesById,
53+
splitsByTransaction,
54+
),
55+
).toBe(false);
56+
expect(
57+
hasTransactionInvalidCategories(
58+
transaction,
59+
categoriesById,
60+
splitsByTransaction,
61+
),
62+
).toBe(false);
63+
});
64+
65+
it('does not count fully categorized split transactions as uncategorized', () => {
66+
const transaction = {
67+
id: 'txn-2',
68+
categoryId: null,
69+
};
70+
const splitsByTransaction = buildSplitsByTransactionId([
71+
{
72+
id: 'split-2',
73+
transactionId: 'txn-2',
74+
categoryId: 'groceries',
75+
amount: 500,
76+
},
77+
{
78+
id: 'split-3',
79+
transactionId: 'txn-2',
80+
categoryId: 'utilities',
81+
amount: 500,
82+
},
83+
]);
84+
85+
expect(isTransactionUncategorized(transaction, splitsByTransaction)).toBe(
86+
false,
87+
);
88+
});
89+
90+
it('flags partially categorized splits as uncategorized', () => {
91+
const transaction = {
92+
id: 'txn-3',
93+
categoryId: null,
94+
};
95+
const splitsByTransaction = buildSplitsByTransactionId([
96+
{
97+
id: 'split-4',
98+
transactionId: 'txn-3',
99+
categoryId: 'groceries',
100+
amount: 500,
101+
},
102+
{
103+
id: 'split-5',
104+
transactionId: 'txn-3',
105+
categoryId: null,
106+
amount: 500,
107+
},
108+
]);
109+
110+
expect(isTransactionUncategorized(transaction, splitsByTransaction)).toBe(
111+
true,
112+
);
113+
});
114+
115+
it('still falls back to the primary category when no splits exist', () => {
116+
const transaction = {
117+
id: 'txn-4',
118+
categoryId: 'utilities',
119+
};
120+
const categoriesById = buildCategoriesById(categories);
121+
const splitsByTransaction = buildSplitsByTransactionId([]);
122+
123+
expect(
124+
transactionMatchesCategoryFilter(
125+
transaction,
126+
'util',
127+
categoriesById,
128+
splitsByTransaction,
129+
),
130+
).toBe(true);
131+
expect(
132+
isTransactionUncategorized(transaction, splitsByTransaction),
133+
).toBe(false);
134+
});
135+
});

src/components/LedgerRow/CategoryCell.jsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,12 @@ export default function CategoryCell({ transaction, isSelected }) {
2323
const dispatch = useDispatch();
2424
const [modalOpen, setModalOpen] = useState(false);
2525
const categories = useSelector(categorySelectors.selectAllCategories);
26+
const selectTransactionSplits = useMemo(
27+
() => transactionSplitSelectors.selectSplitsByTransactionId(transaction.id),
28+
[transaction.id],
29+
);
2630
const transactionSplits = useSelector(
27-
transactionSplitSelectors.selectSplitsByTransactionId(transaction.id),
31+
selectTransactionSplits,
2832
);
2933

3034
const hasSplits = transactionSplits.length > 0;
@@ -114,4 +118,3 @@ export default function CategoryCell({ transaction, isSelected }) {
114118
</>
115119
);
116120
}
117-

src/components/LedgerTable/LedgerTable.jsx

Lines changed: 28 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
selectors as accountSelectors,
55
} from '@/store/accounts';
66
import { selectors as transactionSelectors } from '@/store/transactions';
7+
import { selectors as transactionSplitSelectors } from '@/store/transactionSplits';
78
import { selectors as categorySelectors } from '@/store/categories';
89
import { selectors as statementSelectors } from '@/store/statements';
910
import { selectors as recurringTransactionSelectors } from '@/store/recurringTransactions';
@@ -22,6 +23,12 @@ import {
2223
import { Fragment, useCallback, useMemo } from 'react';
2324
import { useSelector } from 'react-redux';
2425
import { useParams } from 'react-router-dom';
26+
import {
27+
buildCategoriesById,
28+
buildSplitsByTransactionId,
29+
isTransactionUncategorized,
30+
transactionMatchesCategoryFilter,
31+
} from '@/utils/transactionCategoryState';
2532
import LedgerHeader from './LedgerHeader';
2633
import SeparatorRow from './SeparatorRow';
2734
import StatementSeparatorRow from './StatementSeparatorRow';
@@ -46,6 +53,9 @@ export default function LedgerTable({
4653
const transactions = useSelector(selectAccountTransactions);
4754

4855
const categories = useSelector(categorySelectors.selectAllCategories);
56+
const transactionSplits = useSelector(
57+
transactionSplitSelectors.selectTransactionSplits,
58+
);
4959
const accountStatements = useSelector(
5060
statementSelectors.selectStatementsByAccountId(accountId),
5161
);
@@ -60,6 +70,14 @@ export default function LedgerTable({
6070
const recurringProjection = useSelector(
6171
settingsSelectors.selectRecurringProjection,
6272
);
73+
const categoriesById = useMemo(
74+
() => buildCategoriesById(categories),
75+
[categories],
76+
);
77+
const splitsByTransaction = useMemo(
78+
() => buildSplitsByTransactionId(transactionSplits),
79+
[transactionSplits],
80+
);
6381

6482
// Generate virtual transactions from recurring rules
6583
const virtualTransactions = useMemo(() => {
@@ -116,7 +134,9 @@ export default function LedgerTable({
116134

117135
// Apply uncategorized filter
118136
if (showUncategorizedOnly) {
119-
filtered = filtered.filter((transaction) => !transaction.categoryId);
137+
filtered = filtered.filter((transaction) =>
138+
isTransactionUncategorized(transaction, splitsByTransaction),
139+
);
120140
}
121141

122142
// Apply text filter
@@ -128,27 +148,17 @@ export default function LedgerTable({
128148
.toLowerCase()
129149
.includes(lowerFilter);
130150

131-
// Check category name
132-
const category = categories.find(
133-
(cat) => cat.id === transaction.categoryId,
151+
const matchesCategory = transactionMatchesCategoryFilter(
152+
transaction,
153+
filterValue,
154+
categoriesById,
155+
splitsByTransaction,
134156
);
135-
const matchesCategory = category?.name
136-
.toLowerCase()
137-
.includes(lowerFilter);
138-
139-
// Check parent category name if this is a subcategory
140-
const parentCategory = category?.parentId
141-
? categories.find((cat) => cat.id === category.parentId)
142-
: null;
143-
const matchesParentCategory = parentCategory?.name
144-
.toLowerCase()
145-
.includes(lowerFilter);
146157

147158
// Include if matches description, category, parent category, or is already selected
148159
return (
149160
matchesDescription ||
150161
matchesCategory ||
151-
matchesParentCategory ||
152162
selectedTransactions.has(transaction.id)
153163
);
154164
});
@@ -161,7 +171,8 @@ export default function LedgerTable({
161171
transactionsWithBalance,
162172
selectedTransactions,
163173
selectedYear,
164-
categories,
174+
categoriesById,
175+
splitsByTransaction,
165176
]);
166177

167178
const toggleGroupCollapse = (groupId) => {
@@ -589,4 +600,3 @@ export default function LedgerTable({
589600
</TableContainer>
590601
);
591602
}
592-

0 commit comments

Comments
 (0)