Skip to content

feat(categorization): add category rules CRUD so tier 1 can be used - #120

Merged
KenTaniguchi-R merged 2 commits into
mainfrom
feat/89-category-rules-crud
Aug 30, 2026
Merged

KenTaniguchi-R merged 2 commits into
mainfrom
feat/89-category-rules-crud

Conversation

@KenTaniguchi-R

Copy link
Copy Markdown
Owner

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, or src/actions references it" — true — but it is easy to read that as "the pipeline ignores the table." It does not. categorizeSyncedTransactions loads 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.

Worth noting how nearly this went wrong: a plain grep for categoryRules returned only the schema definition, which reads as "nothing consumes this table." That was a literal NUL byte in engine.ts making the file binary to grep — fixed separately in #119.

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 *Scoped and an authorized variant, following actions/merchants.ts.
  • /rules page + 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:

  • Matching is a case-insensitive substringtarget.includes(pattern.toLowerCase()). The field is labelled "Pattern contains", not "matches", because a "matches" label invites a regex that would silently never fire.
  • Rules run at sync time, on null-category transactions only. A new rule cannot re-file the 496 already in the review queue, and never overrides a manual choice. A banner says so — a rule that appears to do nothing otherwise reads as broken. (Retroactive application was scoped out; it is a natural follow-up.)
  • Priority order is evaluation order, so the list renders in it rather than by creation date. Showing any other order would misrepresent which rule wins.

Two guards worth calling out

  • Empty patterns are rejected. 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.
  • The target category must belong to the household. Without it, a caller could aim a rule at another household's category id and read its name back off the rules list. Tested.

Route choice

/rules, not /settings/rules: the sidebar computes its active item with pathname.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, and pnpm build all clean (/rules routes).

Follow-ups this surfaced (not in scope)

  • There is no Transfer category 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 needs is_transfer, which is its own change.
  • Applying a new rule retroactively to the existing review queue.

⚠️ mutation (diff) may report red — see #103. Compare against a main baseline before treating it as a finding.

🤖 Generated with Claude Code

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.
@KenTaniguchi-R

Copy link
Copy Markdown
Owner Author

Mutation gate

file killed survived no-cov score
lib/categorization/rule-pattern.ts 8 0 0 100.00%
actions/category-rules.ts 0 0 38 0.00%
queries/category-rules.ts 0 0 0 (n/a, 5 errors) n/a
All files 8 0 38 17.39%

Matches the local run exactly.

No main baseline is possible here — every one of these files is new in this PR, so there is nothing to compare against. Stating that rather than implying a comparison I did not make. What the table shows instead is structural: the one file the mutation config can reach is at 100% with zero survivors; the DB-backed action is entirely no-coverage because tests/integration/ is excluded.

The important change is that it reports at all. The earlier run on this branch did not score low — it aborted:

ERROR Stryker  No tests were executed. Stryker will exit prematurely.
ConfigError: No tests were executed.

Zero related tests were found across the whole mutated set, so there was no report. Extracting rule-pattern.ts (worth doing on its own merits — see the commit) restored a completing run. Filed as a third failure mode on #103, since requiring every backend PR to also ship a pure module is not a workable contract.

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.

@KenTaniguchi-R
KenTaniguchi-R merged commit a697ded into main Aug 30, 2026
4 of 5 checks passed
@KenTaniguchi-R
KenTaniguchi-R deleted the feat/89-category-rules-crud branch August 30, 2026 02:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

category_rules is tier 1 of the categorization pipeline but has no UI or actions at all — 62% of spending is Uncategorized

1 participant