feat(categorization): add category rules CRUD so tier 1 can be used - #120
Conversation
category_rules is tier 1 of the categorization pipeline. The engine
already loads the rules and applies them on every sync -- it sorts by
priority descending, matches, and stops at the first hit. What was
missing was any way to create a row, so the table stayed empty and the
highest-priority tier ran over nothing.
Adds the missing half:
- getCategoryRules() lists rules in the order the engine evaluates them,
with the target category name joined so the list needs no second query
- src/actions/category-rules.ts: create / update / delete, each in a
scoped and an authorized variant, matching actions/merchants.ts
- /rules page and manager component
The engine is untouched; it needed no changes.
Three details the UI has to be honest about, all taken from what the
engine actually does rather than from the issue text:
- Matching is a case-insensitive substring (`target.includes(pattern)`),
so the field is labelled "Pattern contains", not "matches" -- a
"matches" label invites a regex that would silently never fire.
- Rules run at sync time and only on transactions with a null category,
so a new rule cannot re-file the existing review queue and never
overrides a manual choice. A banner says so, because otherwise a rule
that appears to do nothing reads as broken.
- Priority ordering is the evaluation order, so the list is rendered in
it rather than by creation date.
An empty pattern is rejected: `includes("")` is true for every
transaction, so one blank rule would swallow the whole feed.
Rules are reachable at /rules rather than /settings/rules because the
sidebar computes its active item with pathname.startsWith(href), which
would light up both Settings and Rules for a nested route.
Closes #89.
…it-testable
The empty-pattern guard is the most consequential rule in this feature --
`"".includes()` is true for every string, so one blank pattern matches the
whole feed, and being tier 1 it outranks every other categorization step.
It was reachable only through a database round trip.
Moves it to a pure module with no DB imports and covers it with unit and
property tests: trimming, empty and whitespace-only rejection, the length
boundary on both sides, and that the limit is measured after trimming.
This also unblocks the mutation gate, which was not merely scoring low on
this branch but erroring out:
WARN Vitest failed to find test files related to mutated files
INFO No tests were found
ERROR No tests were executed. Stryker will exit prematurely.
Both new files were covered only by tests/integration/, which
vitest.stryker.config.ts excludes, so Stryker found zero related tests
and aborted before producing a report. With a unit-tested pure module in
the changed set it completes normally:
rule-pattern.ts 100.00% 8 killed, 0 survived, 0 no-coverage
category-rules.ts 0.00% 38 no-coverage (DB-backed, see #103)
That hard-failure mode is a new manifestation of #103 and is reported there.
Mutation gate
Matches the local run exactly. No The important change is that it reports at all. The earlier run on this branch did not score low — it aborted: Zero related tests were found across the whole mutated set, so there was no report. Extracting Merging on the same basis as #102, #104, #105 and #119: the threshold failure is the known structural one, not a property of this diff. |
Closes #89.
Mock reviewed before implementation: https://claude.ai/code/artifact/edd2a64c-54bc-4348-b6c5-066c0e9d0593 (the pattern tester there mirrors the engine's real matching)
Correcting the premise first
The issue says "Nothing in
src/app,src/components, orsrc/actionsreferences it" — true — but it is easy to read that as "the pipeline ignores the table." It does not.categorizeSyncedTransactionsloads the rules (engine.ts:106-113) and runs them as tier 1 on every sync, sorted by priority, first match winning.So the engine needed no changes. The only gap is that nothing can create a row, so it faithfully runs an empty rule set. That makes this a smaller and better-defined change than the issue implies.
What this adds
getCategoryRules()— lists rules in the order the engine evaluates them (priority descending), with the target category name joined so the list needs no second query.src/actions/category-rules.ts— create / update / delete, each in a*Scopedand an authorized variant, followingactions/merchants.ts./rulespage + manager component.Behaviour the UI is deliberately honest about
Each of these is taken from what the engine actually does, not from the issue text:
target.includes(pattern.toLowerCase()). The field is labelled "Pattern contains", not "matches", because a "matches" label invites a regex that would silently never fire.Two guards worth calling out
includes("")is true for every transaction, so a single blank rule would swallow the entire feed into one category. The pattern is trimmed, then required non-empty.Route choice
/rules, not/settings/rules: the sidebar computes its active item withpathname.startsWith(item.href), so a nested route would light up both Settings and Rules. Top-level avoids introducing that bug in shared nav.Tests
11 integration tests, written red first — create, trim, empty-pattern rejection, cross-household category rejection, update, cross-household update rejection, delete, cross-household delete rejection, priority ordering, category-name join, and household isolation on the list.
Full suite: 763 passed / 114 files. Typecheck,
eslint src tests, andpnpm buildall clean (/rulesroutes).Follow-ups this surfaced (not in scope)
Transfercategory in the seed, yet the largest uncategorized outflows are transfers (Apple GS Savings Transfer,Alpaca Securitie Othr). Rules can file them somewhere, but making them stop counting as spending needsis_transfer, which is its own change.mutation (diff)may report red — see #103. Compare against amainbaseline before treating it as a finding.🤖 Generated with Claude Code