From 62a7d6daf447f555b82c51cdcc1493ce83e5617e Mon Sep 17 00:00:00 2001 From: Lars Nolden Date: Sun, 13 Sep 2026 13:52:30 +0200 Subject: [PATCH] new classification ui --- internal/app/app_test.go | 65 ++++++++++-- internal/app/reclassify.go | 36 ++++++- internal/server/server.go | 9 +- web/src/Accounts.tsx | 102 ++++-------------- web/src/Classification.tsx | 208 +++++++++++++++++++++++++++++++++++-- web/src/styles.css | 86 +++++++++++++-- web/src/ui.tsx | 103 ++++++++++++++++++ 7 files changed, 494 insertions(+), 115 deletions(-) diff --git a/internal/app/app_test.go b/internal/app/app_test.go index ce25e29..bb7adfc 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -242,7 +242,7 @@ func TestCancelledPreviewRunProducesNoPreview(t *testing.T) { } time.Sleep(5 * time.Millisecond) } - if _, err := a.ApplyPreview(context.Background(), start.ID, s.Revision, []string{"any"}); err == nil { + if _, err := a.ApplyPreview(context.Background(), start.ID, s.Revision, []string{"any"}, nil); err == nil { t.Fatal("cancelled run produced an applicable preview") } after, err := a.Snapshot(context.Background()) @@ -334,7 +334,7 @@ func TestPreviewIsReadOnlySelectedApplyPreservesFactsAndOtherFields(t *testing.T t.Fatal("preview mutated canonical records") } id := preview.Changes[0].ID - applied, err := a.ApplyPreview(context.Background(), preview.ID, preview.Revision, []string{id}) + applied, err := a.ApplyPreview(context.Background(), preview.ID, preview.Revision, []string{id}, nil) if err != nil { t.Fatal(err) } @@ -356,7 +356,7 @@ func TestPreviewIsReadOnlySelectedApplyPreservesFactsAndOtherFields(t *testing.T t.Fatal("unselected transaction changed") } } - if _, err = a.ApplyPreview(context.Background(), preview.ID, preview.Revision, []string{id}); err == nil { + if _, err = a.ApplyPreview(context.Background(), preview.ID, preview.Revision, []string{id}, nil); err == nil { t.Fatal("consumed preview applied twice") } } @@ -375,7 +375,7 @@ func TestStalePreviewCannotOverwriteManualCorrection(t *testing.T) { if err != nil { t.Fatal(err) } - if _, err = a.ApplyPreview(context.Background(), p.ID, p.Revision, []string{p.Changes[0].ID}); err == nil { + if _, err = a.ApplyPreview(context.Background(), p.ID, p.Revision, []string{p.Changes[0].ID}, nil); err == nil { t.Fatal("stale preview overwrote manual edit") } after, err := a.Snapshot(context.Background()) @@ -410,13 +410,13 @@ func TestApplyPreviewSurvivesUnrelatedCommitsAndPartialApplies(t *testing.T) { }); err != nil { t.Fatal(err) } - first, err := a.ApplyPreview(ctx, p.ID, p.Revision, []string{p.Changes[0].ID}) + first, err := a.ApplyPreview(ctx, p.ID, p.Revision, []string{p.Changes[0].ID}, nil) if err != nil { t.Fatalf("unrelated commit invalidated the preview: %v", err) } // The partial apply moved the revision again; the remaining proposal must // still apply without another paced provider run. - second, err := a.ApplyPreview(ctx, p.ID, p.Revision, []string{p.Changes[1].ID}) + second, err := a.ApplyPreview(ctx, p.ID, p.Revision, []string{p.Changes[1].ID}, nil) if err != nil { t.Fatalf("partial apply consumed the remaining proposals: %v", err) } @@ -429,11 +429,62 @@ func TestApplyPreviewSurvivesUnrelatedCommitsAndPartialApplies(t *testing.T) { } } // Both changes are consumed now; re-applying must fail, not double-write. - if _, err = a.ApplyPreview(ctx, p.ID, p.Revision, []string{p.Changes[0].ID}); err == nil { + if _, err = a.ApplyPreview(ctx, p.ID, p.Revision, []string{p.Changes[0].ID}, nil); err == nil { t.Fatal("consumed change applied twice") } } +// A reviewer can correct a proposal before applying it: the corrected fields +// land instead of the model's, provenance becomes manual, and an invalid or +// unselected correction rejects the whole apply. +func TestApplyPreviewHonoursReviewerEdits(t *testing.T) { + ctx := context.Background() + a, s := testApp(t) + s = seed(t, a, s) + s, err := a.Mutate(ctx, s.Revision, func(d *domain.Dataset) error { + d.Categories = append(d.Categories, domain.Category{ID: "dining", Name: "Dining", ParentID: "cat_expenses", Kind: "expense"}) + return nil + }) + if err != nil { + t.Fatal(err) + } + mockClassifier(t, a) + p, err := runPreview(t, a, PreviewRequest{Revision: s.Revision, From: "2026-09-01", To: "2026-09-30", Model: "test/model", Fields: Fields{Category: true, Tags: true}}) + if err != nil { + t.Fatal(err) + } + if len(p.Changes) != 2 { + t.Fatalf("expected two proposed changes: %+v", p) + } + edited, other := p.Changes[0], p.Changes[1] + if _, err = a.ApplyPreview(ctx, p.ID, p.Revision, []string{edited.ID}, []EnrichmentEdit{{ID: edited.ID, CategoryID: "nonexistent", TagIDs: []string{}}}); err == nil { + t.Fatal("edit naming an unknown category was applied") + } + if _, err = a.ApplyPreview(ctx, p.ID, p.Revision, []string{edited.ID}, []EnrichmentEdit{{ID: other.ID, CategoryID: "dining", TagIDs: []string{}}}); err == nil { + t.Fatal("edit for an unselected transaction was accepted") + } + applied, err := a.ApplyPreview(ctx, p.ID, p.Revision, []string{edited.ID, other.ID}, []EnrichmentEdit{{ID: edited.ID, CategoryID: "dining", TagIDs: []string{"home"}}}) + if err != nil { + t.Fatal(err) + } + for _, tx := range applied.Data.Transactions { + e := tx.Enrichment + switch tx.Facts.ID { + case edited.ID: + if e.CategoryID != "dining" || !reflect.DeepEqual(e.TagIDs, []string{"home"}) { + t.Fatalf("reviewer correction lost: %+v", e) + } + if e.Classification.Source != "manual" { + t.Fatalf("corrected change kept model provenance: %+v", e.Classification) + } + case other.ID: + if e.CategoryID != "groceries" || e.Classification.Source == "manual" { + t.Fatalf("uncorrected change altered: %+v", e) + } + } + } +} + // Imports auto-apply only what the model is sure about: a low-confidence // category lands on the editable fallback while the merchant link and the // recorded confidence survive for review in Analyse. diff --git a/internal/app/reclassify.go b/internal/app/reclassify.go index 95056a9..e865dd6 100644 --- a/internal/app/reclassify.go +++ b/internal/app/reclassify.go @@ -34,6 +34,16 @@ type Change struct { Before domain.Enrichment `json:"before"` After domain.Enrichment `json:"after"` } + +// EnrichmentEdit is a reviewer's correction to one proposal: it replaces the +// proposed category and tags before the change is applied. A corrected +// transaction is classified by the human, not the model, so its provenance +// becomes manual and later runs treat it accordingly. +type EnrichmentEdit struct { + ID string `json:"id"` + CategoryID string `json:"category_id"` + TagIDs []string `json:"tag_ids"` +} type ClassificationError struct { ID string `json:"id"` Error string `json:"error"` @@ -329,7 +339,7 @@ func enrichmentEqual(a, b domain.Enrichment) bool { // invalidate the review; only a selected transaction whose own enrichment // changed since the preview snapshot conflicts. Applied changes are pruned so // the remaining proposals stay appliable without another paced provider run. -func (a *App) ApplyPreview(ctx context.Context, id, rev string, ids []string) (State, error) { +func (a *App) ApplyPreview(ctx context.Context, id, rev string, ids []string, edits []EnrichmentEdit) (State, error) { a.mu.Lock() defer a.mu.Unlock() p, ok := a.previews[id] @@ -357,6 +367,17 @@ func (a *App) ApplyPreview(ctx context.Context, id, rev string, ids []string) (S if len(selected) == 0 { return State{}, errors.New("select at least one change") } + edited := map[string]EnrichmentEdit{} + for _, e := range edits { + if !selected[e.ID] { + return State{}, errors.New("edited transaction is not selected") + } + edited[e.ID] = e + } + // Edits are validated against the dataset the change will land in, which + // includes merchants this preview mints only when the change is applied. + validation := s.Data + validation.Merchants = append(append([]domain.Merchant{}, s.Data.Merchants...), p.NewMerchants...) applied := 0 needed := map[string]bool{} for i, t := range s.Data.Transactions { @@ -367,8 +388,17 @@ func (a *App) ApplyPreview(ctx context.Context, id, rev string, ids []string) (S if !enrichmentEqual(t.Enrichment, c.Before) { return State{}, errors.New("revision conflict: a selected transaction changed after the preview; analyse it again") } - s.Data.Transactions[i].Enrichment = c.After - needed[c.After.MerchantID] = true + after := c.After + if e, ok := edited[t.Facts.ID]; ok { + after.CategoryID = e.CategoryID + after.TagIDs = append([]string{}, e.TagIDs...) + after.Classification = domain.Provenance{Source: "manual", Timestamp: time.Now().UTC().Format(time.RFC3339)} + if err := domain.ValidateEnrichment(validation, t.Facts, after); err != nil { + return State{}, fmt.Errorf("edited classification for %s is invalid: %w", t.Facts.ID, err) + } + } + s.Data.Transactions[i].Enrichment = after + needed[after.MerchantID] = true applied++ } if applied != len(selected) { diff --git a/internal/server/server.go b/internal/server/server.go index 86606fe..f2338b7 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -508,14 +508,15 @@ func (s *Server) previewProgress(w http.ResponseWriter, r *http.Request) { } func (s *Server) apply(w http.ResponseWriter, r *http.Request) { var b struct { - ID string `json:"id"` - Revision string `json:"revision"` - TransactionIDs []string `json:"transaction_ids"` + ID string `json:"id"` + Revision string `json:"revision"` + TransactionIDs []string `json:"transaction_ids"` + Edits []app.EnrichmentEdit `json:"edits"` } if !decode(w, r, &b) { return } - v, e := s.app.ApplyPreview(r.Context(), b.ID, b.Revision, b.TransactionIDs) + v, e := s.app.ApplyPreview(r.Context(), b.ID, b.Revision, b.TransactionIDs, b.Edits) respond(w, v, e) } func (s *Server) cancel(w http.ResponseWriter, r *http.Request) { diff --git a/web/src/Accounts.tsx b/web/src/Accounts.tsx index 9991c36..4b50bec 100644 --- a/web/src/Accounts.tsx +++ b/web/src/Accounts.tsx @@ -12,7 +12,7 @@ import { } from "lucide-react"; import type { Account, Institution, PreparedImport, State } from "./api"; import { localInstant, money, request } from "./api"; -import { Empty, ErrorMessage, Field, FormActions, Modal } from "./ui"; +import { Combobox, Empty, ErrorMessage, Field, FormActions, Modal } from "./ui"; import type { Mutate } from "./ui"; interface Balance { amount: string; @@ -1110,8 +1110,6 @@ function InstitutionSelect({ }) { const [institutions, setInstitutions] = useState(null); const [loadError, setLoadError] = useState(""); - const [open, setOpen] = useState(false); - const [query, setQuery] = useState(""); useEffect(() => { setInstitutions(null); setLoadError(""); @@ -1147,90 +1145,32 @@ function InstitutionSelect({ /> ); - const filter = query.trim().toLowerCase(); - const matches = (institutions ?? []).filter((i) => - i.name.toLowerCase().includes(filter), - ); - const exact = filter - ? matches.find((i) => i.name.toLowerCase() === filter) - : undefined; - const shown = exact - ? [exact, ...matches.filter((i) => i !== exact).slice(0, 59)] - : matches.slice(0, 60); const selected = institutions?.find((i) => i.name === value); return ( -
- { - setQuery(""); - setOpen(true); - }} - onChange={(e) => { - setQuery(e.target.value); - setOpen(true); - }} - onBlur={() => setOpen(false)} - onKeyDown={(e) => { - if (e.key === "Escape") setOpen(false); - if (e.key === "Enter" && open) { - e.preventDefault(); - if (shown.length === 1) { - onChange(shown[0].name, shown[0].psu_types); - setOpen(false); - } - } - }} - /> - {selected?.logo && !open && ( - - )} - {open && institutions && ( -
    - {shown.map((i) => ( -
  • - -
  • - ))} - {shown.length === 0 && ( -
  • No banks match “{query}”.
  • - )} - {matches.length > shown.length && ( -
  • - {matches.length - shown.length} more — keep typing to narrow - down. -
  • - )} -
- )} -
+ ({ + value: i.name, + label: i.name, + icon: i.logo ? ( + + ) : ( + + ), + }))} + value={value} + onChange={(name) => + onChange(name, institutions?.find((i) => i.name === name)?.psu_types) + } + placeholder={institutions ? "Search your bank" : "Loading banks…"} + adornment={selected?.logo ? : null} + emptyText="No banks match your search." + />
); } diff --git a/web/src/Classification.tsx b/web/src/Classification.tsx index df017e6..b632502 100644 --- a/web/src/Classification.tsx +++ b/web/src/Classification.tsx @@ -1,5 +1,12 @@ import { useEffect, useRef, useState } from "react"; -import { Sparkles, ShieldCheck, Check, X, ArrowRight } from "lucide-react"; +import { + Sparkles, + ShieldCheck, + Check, + X, + ArrowRight, + RotateCcw, +} from "lucide-react"; import type { Dataset, Enrichment, @@ -9,6 +16,7 @@ import type { } from "./api"; import { categoryPath, money, request } from "./api"; import { + Combobox, DateField, Empty, ErrorMessage, @@ -39,6 +47,9 @@ export function Classification({ const [confirm, setConfirm] = useState(false); const [running, setRunning] = useState(null); const runStart = useRef({ time: 0, analysed: 0 }); + // Reviewer corrections to proposals, keyed by transaction id. A correction + // that matches the proposal again is dropped, so presence means "edited". + const [edits, setEdits] = useState>({}); const finalize = (result: Preview) => { result.changes ??= []; result.errors ??= []; @@ -58,6 +69,7 @@ export function Classification({ (confidenceRank[b.after.classification.confidence || "low"] ?? 0), ); setPreview(result); + setEdits({}); setSelected( result.changes .filter((change) => change.after.classification.confidence !== "low") @@ -129,6 +141,7 @@ export function Classification({ setRunning(null); setPreview(null); setSelected([]); + setEdits({}); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { @@ -141,6 +154,34 @@ export function Classification({ merchants: [...state.data.merchants, ...preview.new_merchants], } : state.data; + // The value a change will be applied with: the reviewer's correction when + // one exists, otherwise the model's proposal. + const effective = (change: Preview["changes"][number]): CorrectionValue => + edits[change.id] ?? { + category_id: change.after.category_id || "", + tag_ids: change.after.tag_ids, + }; + const correct = ( + change: Preview["changes"][number], + value: CorrectionValue, + ) => { + const proposal = change.after; + const same = + value.category_id === (proposal.category_id || "") && + value.tag_ids.length === proposal.tag_ids.length && + value.tag_ids.every((id) => proposal.tag_ids.includes(id)); + setEdits((prev) => { + const next = { ...prev }; + if (same) delete next[change.id]; + else next[change.id] = value; + return next; + }); + // Correcting a row is a decision to apply it. + if (!same) + setSelected((ids) => + ids.includes(change.id) ? ids : [...ids, change.id], + ); + }; return ( <>
@@ -372,7 +413,9 @@ export function Classification({

Review changes

- {selected.length} of {preview.changes.length} selected + {selected.length} of {preview.changes.length} selected — + correct any proposed category or tags in place; corrections + are recorded as manual classifications.

@@ -395,12 +438,13 @@ export function Classification({ {preview.changes.length ? (
{preview.changes.map((change) => ( -
- +
))} ) : ( @@ -495,8 +542,19 @@ export function Classification({

This will replace the selected enrichment fields on{" "} {selected.length} transactions in one journal - commit. Unselected proposals will not be applied. Original bank - facts remain unchanged. + commit. + {selected.filter((id) => id in edits).length > 0 && ( + <> + {" "} + + {selected.filter((id) => id in edits).length} + {" "} + of them carry your corrections and will be recorded as manual + classifications. + + )}{" "} + Unselected proposals will not be applied. Original bank facts + remain unchanged.

@@ -519,6 +577,9 @@ export function Classification({ id: preview.id, revision: preview.revision, transaction_ids: selected, + edits: selected + .filter((id) => id in edits) + .map((id) => ({ id, ...edits[id] })), }); acceptState( result, @@ -532,6 +593,13 @@ export function Classification({ ? { ...preview, changes: remaining } : null, ); + setEdits((prev) => + Object.fromEntries( + Object.entries(prev).filter( + ([id]) => !selected.includes(id), + ), + ), + ); setSelected([]); setConfirm(false); } catch (err) { @@ -589,6 +657,128 @@ function EnrichmentView({ ); } +// CorrectionValue is the pair of fields a reviewer may correct on a proposal +// before applying it. Merchants are minted by the model and stay read-only. +interface CorrectionValue { + category_id: string; + tag_ids: string[]; +} +// CorrectionEditor is the "Proposed" side of a review row, editable in place. +// Category and tags are free-text inputs that autocomplete against the +// existing taxonomy; the category list is limited to leaves of the change's +// kind because that is what validation will accept. +function CorrectionEditor({ + data, + change, + value, + edited, + disabled, + onChange, +}: { + data: Dataset; + change: Preview["changes"][number]; + value: CorrectionValue; + edited: boolean; + disabled: boolean; + onChange: (value: CorrectionValue) => void; +}) { + const categories = data.categories + .filter( + (c) => + c.kind === change.after.kind && + !data.categories.some((child) => child.parent_id === c.id), + ) + .map((c) => ({ value: c.id, label: categoryPath(data, c.id) })); + const addable = data.tags + .filter((t) => !value.tag_ids.includes(t.id)) + .map((t) => ({ value: t.id, label: t.name })); + return ( +
+
+ Proposed{edited ? " · edited" : ""} + {edited && ( + + )} +
+
+
+
Merchant
+
+ {change.after.merchant_id + ? data.merchants.find((m) => m.id === change.after.merchant_id) + ?.name || `New merchant (${change.after.merchant_id})` + : "None"} +
+
+
+
Category
+
+ onChange({ ...value, category_id })} + placeholder="Search categories" + emptyText="No matching category. Create it in Categories first." + /> +
+
+
+
Tags
+
+
+ {value.tag_ids.map((id) => ( + + ))} + + onChange({ ...value, tag_ids: [...value.tag_ids, id] }) + } + placeholder={ + data.tags.length + ? "Add tag" + : "No tags yet — create them in Tags" + } + emptyText="No matching tag. Create it in Tags first." + /> +
+
+
+
+
+ ); +} // remainingEstimate projects the finish time from the pace observed since // this page attached to the run; the server paces provider requests, so the // first sample is meaningless and re-attaching mid-run must not count work diff --git a/web/src/styles.css b/web/src/styles.css index b074e81..6cb9761 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -1997,24 +1997,38 @@ footer span:first-child { .callback-details code { font-size: 10px; } -.bank-select { +.combo { position: relative; } -.bank-select > input { +.combo > input { width: 100%; padding-right: 40px; + border: 1px solid #dbe2ea; + border-radius: 5px; + min-height: 39px; + padding-top: 10px; + padding-bottom: 10px; + padding-left: 11px; + min-width: 0; + color: #33445a; + background: #fff; + font-weight: 400; } -.bank-selected-logo { +.combo-adornment { position: absolute; right: 11px; top: 50%; transform: translateY(-50%); + pointer-events: none; + display: flex; +} +.combo-adornment img, +.combo-adornment svg { width: 22px; height: 22px; object-fit: contain; - pointer-events: none; } -.bank-options { +.combo-options { position: absolute; z-index: 30; top: calc(100% + 4px); @@ -2030,7 +2044,7 @@ footer span:first-child { max-height: 264px; overflow-y: auto; } -.bank-option { +.combo-option { display: flex; width: 100%; align-items: center; @@ -2044,23 +2058,73 @@ footer span:first-child { font-size: 13px; color: inherit; } -.bank-option:hover, -.bank-option[aria-selected="true"] { +.combo-option:hover, +.combo-option[aria-selected="true"] { background: #f0f7f4; } -.bank-option img, -.bank-option svg { +.combo-option img, +.combo-option svg { width: 22px; height: 22px; object-fit: contain; flex: none; color: var(--muted); } -.bank-empty { +.combo-empty { padding: 8px 10px; color: var(--muted); font-size: 12px; } +/* The proposed side of a review row is editable in place: compact combobox + inputs so a correction fits the diff card, removable chips for tags. */ +.diff-value .combo > input { + min-height: 31px; + padding: 6px 24px 6px 9px; + font-size: 12px; +} +.diff-value .combo-option { + font-size: 12px; + padding: 6px 9px; +} +.diff-edit-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + min-height: 22px; +} +.diff-edit-head .button { + padding: 2px 8px; + font-size: 10px; +} +.tag-edit { + display: flex; + flex-wrap: wrap; + gap: 6px; + align-items: center; +} +.tag-edit .combo { + flex: 1; + min-width: 130px; +} +.tag-chip { + display: inline-flex; + align-items: center; + gap: 5px; + border: 1px solid #cfe4da; + background: #fff; + color: #2c6d57; + border-radius: 20px; + padding: 3px 5px 3px 10px; + font-size: 11px; + font-weight: 600; +} +.tag-chip svg { + color: #7fa295; +} +.tag-chip:hover svg { + color: var(--danger); +} .date-select { position: relative; } diff --git a/web/src/ui.tsx b/web/src/ui.tsx index 2d4b1d6..2dd7186 100644 --- a/web/src/ui.tsx +++ b/web/src/ui.tsx @@ -98,6 +98,109 @@ export function Field({ ); } +export interface ComboOption { + value: string; + label: string; + icon?: ReactNode; +} +// Combobox is a free-text input that autocompletes against a fixed option +// list: typing filters by label, Enter takes the exact or only match, and +// picking an option reports its value. The caller keeps working with stable +// ids while the user only ever sees names. +export function Combobox({ + options, + value, + onChange, + placeholder, + disabled = false, + required = false, + adornment, + emptyText = "No matches.", +}: { + options: ComboOption[]; + value: string; + onChange: (value: string) => void; + placeholder?: string; + disabled?: boolean; + required?: boolean; + adornment?: ReactNode; + emptyText?: string; +}) { + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(""); + const filter = query.trim().toLowerCase(); + const matches = options.filter((o) => o.label.toLowerCase().includes(filter)); + const exact = filter + ? matches.find((o) => o.label.toLowerCase() === filter) + : undefined; + const shown = exact + ? [exact, ...matches.filter((o) => o !== exact).slice(0, 59)] + : matches.slice(0, 60); + const selected = options.find((o) => o.value === value); + const pick = (v: string) => { + onChange(v); + setOpen(false); + }; + return ( +
+ { + setQuery(""); + setOpen(true); + }} + onChange={(e) => { + setQuery(e.target.value); + setOpen(true); + }} + onBlur={() => setOpen(false)} + onKeyDown={(e) => { + if (e.key === "Escape") setOpen(false); + if (e.key === "Enter" && open) { + e.preventDefault(); + const hit = exact ?? (shown.length === 1 ? shown[0] : undefined); + if (hit) pick(hit.value); + } + }} + /> + {adornment && !open && ( + {adornment} + )} + {open && ( +
    + {shown.map((o) => ( +
  • + +
  • + ))} + {shown.length === 0 &&
  • {emptyText}
  • } + {matches.length > shown.length && ( +
  • + {matches.length - shown.length} more — keep typing to narrow down. +
  • + )} +
+ )} +
+ ); +} + // Dates are handled as calendar days, never as instants: every helper works on // the ISO string's integer parts so a browser time zone can never shift a // booking date. "Sept" follows the four-letter form used in the journal UI.