From 0fd3c5c0dc8ac198caec9efbb3afc818805fc489 Mon Sep 17 00:00:00 2001 From: Lars Nolden Date: Mon, 14 Sep 2026 12:39:11 +0200 Subject: [PATCH] Filter transactions by classification status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Transactions toolbar gains a status filter over classification provenance — manual, AI, merchant rule, transfer match, unclassified — alongside the existing Needs review toggle. The Source column now renders the same human labels the filter options use instead of raw provenance keys, so "openrouter" reads as AI and both fallback shapes read as Unclassified. --- OPERATIONS.txt | 4 +++- README.md | 2 +- web/src/Transactions.tsx | 48 ++++++++++++++++++++++++++++++++++++++-- web/src/styles.css | 10 +++++++++ 4 files changed, 60 insertions(+), 4 deletions(-) diff --git a/OPERATIONS.txt b/OPERATIONS.txt index 09240a7..5ffe40d 100644 --- a/OPERATIONS.txt +++ b/OPERATIONS.txt @@ -172,7 +172,9 @@ response records high, medium or low confidence. Imports never auto-apply a low-confidence category: the row keeps the kind-specific unclassified category with merchant and confidence recorded. Analyse previews show the low-confidence suggestion unselected for review. Transactions exposes a -Needs review filter for low-confidence or fallback rows. A model-proposed +Needs review filter for low-confidence or fallback rows, and a +classification filter over how each row was classified: manually, by AI, +by a merchant rule, by transfer matching, or not at all. A model-proposed merchant name is dropped (the row keeps its validated category and tags) when it is identifier-shaped, longer than 100 characters, or contains control or format code points such as bidirectional overrides and diff --git a/README.md b/README.md index 2377b85..1f9ea86 100644 --- a/README.md +++ b/README.md @@ -423,7 +423,7 @@ Bank synchronization and recognized N26, ING, and Kontist CSV imports do **not** **Classify newly imported transactions with AI** under **Classification preferences** controls whether importing contacts the provider at all. It covers CSV imports and bank synchronization, is on by default, and is stored as `classify_on_import` in `config.toml`. With it off, no import makes a provider request: enabled merchant rules still classify, and everything else arrives unclassified and editable without a failure that would suggest the provider was unreachable. **AI classification → Analyse** still works on demand, so you can review a batch deliberately instead of on every import. -AI classification sends only identifier-redacted text: the transaction's own IDs, account identifiers and labels, payment references, labeled or IBAN-attached BICs, and configured private names are removed, while merchant and counterparty text remains available for recognition. Classification responses carry `high`, `medium`, or `low` confidence. Imports never auto-apply a low-confidence category — the row stays on the kind-appropriate unclassified category with the merchant link and confidence recorded — while **Analyse** previews show the low-confidence suggestion unselected for review, and **Transactions → Needs review** lists both. +AI classification sends only identifier-redacted text: the transaction's own IDs, account identifiers and labels, payment references, labeled or IBAN-attached BICs, and configured private names are removed, while merchant and counterparty text remains available for recognition. Classification responses carry `high`, `medium`, or `low` confidence. Imports never auto-apply a low-confidence category — the row stays on the kind-appropriate unclassified category with the merchant link and confidence recorded — while **Analyse** previews show the low-confidence suggestion unselected for review, and **Transactions → Needs review** lists both. Transactions also filters by classification status — manual, AI, merchant rule, transfer match, or unclassified — matching the labels its Source column shows. Classification choices retain their names, paths, hints, and aliases, but use short request-local references such as `c1`, `m1`, and `t1` instead of long database IDs. Merchant defaults and applicable classification history use the same references. Every eligible category, merchant, and tag remains available; responses are mapped back to canonical IDs and validated locally. diff --git a/web/src/Transactions.tsx b/web/src/Transactions.tsx index 765108d..612ce9b 100644 --- a/web/src/Transactions.tsx +++ b/web/src/Transactions.tsx @@ -41,6 +41,26 @@ const EVENTS: Record = { corporate_action: "Corporate action", position_transfer: "Position transfer", }; +// Human labels for classification provenance sources; the filter options and +// the Source column speak the same language. Both fallback shapes — the +// import-time "unclassified" error record and the plain sign-based +// "fallback" — read as Unclassified. +const CLASSIFICATIONS: Record = { + manual: "Manual", + openrouter: "AI", + rule: "Merchant rule", + transfer_match: "Transfer match", + fallback: "Unclassified", + unclassified: "Unclassified", +}; +const CLASSIFICATION_FILTERS: [string, string][] = [ + ["manual", "Manual"], + ["openrouter", "AI"], + ["rule", "Merchant rule"], + ["transfer_match", "Transfer match"], + ["unclassified", "Unclassified"], +]; + // A corporate action or a position transfer moves shares between holdings and // settles no money at all, so its zero amount is a fact and not a gap. function positionOnly(investment?: Investment): boolean { @@ -92,6 +112,7 @@ export function Transactions({ }) { const [query, setQuery] = useState(""); const [needsReview, setNeedsReview] = useState(false); + const [status, setStatus] = useState(""); const [editing, setEditing] = useState(null); const [page, setPage] = useState(0); const filtered = useMemo(() => { @@ -122,6 +143,12 @@ export function Transactions({ (e.kind === "income" ? "cat_income_unclassified" : "cat_expenses_unclassified")) && + (!status || + (status === "unclassified" + ? ["fallback", "unclassified", ""].includes( + e.classification.source || "", + ) + : e.classification.source === status)) && (!filter.tag_id || e.tag_ids.includes(filter.tag_id)) && (!filter.merchant_id || e.merchant_id === filter.merchant_id) && (!query || @@ -134,7 +161,7 @@ export function Transactions({ b.facts.booking_date.localeCompare(a.facts.booking_date) || a.facts.id.localeCompare(b.facts.id), ); - }, [data, filter, query, needsReview]); + }, [data, filter, query, needsReview, status]); const currentPage = Math.min( page, Math.max(0, Math.ceil(filtered.length / 40) - 1), @@ -181,6 +208,22 @@ export function Transactions({ /> Needs review + Click a transaction to edit @@ -284,7 +327,8 @@ export function Transactions({ - {e.classification.source} + {CLASSIFICATIONS[e.classification.source] ?? + e.classification.source} {e.classification.error && ( diff --git a/web/src/styles.css b/web/src/styles.css index 69f092d..35f827c 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -744,6 +744,16 @@ main { .search input::placeholder { color: #9aa6b3; } +.toolbar-select { + height: 35px; + font-size: 11px; + padding: 0 9px; + border: 1px solid #dbe2ea; + border-radius: 5px; + background: #fff; + color: #46596a; + flex-shrink: 0; +} .table-scroll { overflow-x: auto; }