Files
finance-duck/web/src/Classification.tsx
T
Lars Nolden 676065292e Harden quick-add against review findings
Independent review of the quick-add range surfaced real holes:

- The emptyLabel guard suppressed creation for any name that happened
  to be a substring of the label — typing "Rent" in the parent picker
  (a substring of "No parent (root)") silently offered nothing. The
  guard is gone; the exact-match rule already suppresses creates when
  the full label is typed.
- Async creates resolved against click-time snapshots, so a checkbox
  toggled or chip removed during the server round trip was silently
  reverted. Consumers now apply functional updates or a latest-value
  ref.
- Created names are capped at 200 characters, matching the registry
  forms; over-long text fails inline instead of minting a permanent
  multi-kilobyte name.
- A create failing after the user blurred mid-flight reopens the list
  so the error is never invisible, and option rows are locked while a
  create is in flight so a race cannot override an explicit pick.
2026-09-14 12:20:33 +02:00

828 lines
27 KiB
TypeScript

import { useEffect, useRef, useState } from "react";
import {
Sparkles,
ShieldCheck,
Check,
X,
ArrowRight,
RotateCcw,
} from "lucide-react";
import type {
Dataset,
Enrichment,
Preview,
PreviewProgress,
State,
} from "./api";
import { categoryPath, money, request } from "./api";
import {
CategoryCombobox,
Combobox,
createTag,
DateField,
Empty,
ErrorMessage,
Field,
Modal,
ModelOptions,
} from "./ui";
import type { Mutate } from "./ui";
export function Classification({
state,
acceptState,
mutate,
}: {
state: State;
acceptState: (state: State, message?: string) => void;
mutate: Mutate;
}) {
const dates = state.data.transactions.map((t) => t.facts.booking_date).sort();
const [from, setFrom] = useState(dates[0] || "");
const [to, setTo] = useState(dates[dates.length - 1] || "");
const [model, setModel] = useState(state.settings.model);
const [fields, setFields] = useState({
merchant: true,
category: true,
tags: true,
});
const [preview, setPreview] = useState<Preview | null>(null);
const [selected, setSelected] = useState<string[]>([]);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [confirm, setConfirm] = useState(false);
const [running, setRunning] = useState<PreviewProgress | null>(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<Record<string, CorrectionValue>>({});
const finalize = (result: Preview) => {
result.changes ??= [];
result.errors ??= [];
result.new_merchants ??= [];
for (const change of result.changes) {
change.before.tag_ids ??= [];
change.after.tag_ids ??= [];
}
const confidenceRank: Record<string, number> = {
low: 0,
medium: 1,
high: 2,
};
result.changes.sort(
(a, b) =>
(confidenceRank[a.after.classification.confidence || "low"] ?? 0) -
(confidenceRank[b.after.classification.confidence || "low"] ?? 0),
);
setPreview(result);
setEdits({});
setSelected(
result.changes
.filter((change) => change.after.classification.confidence !== "low")
.map((change) => change.id),
);
};
// A run keeps going on the server while this page is closed; re-attach to
// it on mount instead of presenting a fresh, contradictory setup form.
useEffect(() => {
let stale = false;
(async () => {
try {
const p = await request<PreviewProgress>("/api/reclassify/progress", {
id: "",
});
if (stale) return;
if (!p.done) {
runStart.current = { time: Date.now(), analysed: p.analysed };
setRunning(p);
} else if (
!p.error &&
p.preview &&
p.preview.revision === state.revision
) {
finalize(p.preview);
}
} catch {
// No run to re-attach to.
}
})();
return () => {
stale = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (!running || running.done) return;
const timer = setTimeout(async () => {
try {
const p = await request<PreviewProgress>("/api/reclassify/progress", {
id: running.id,
});
p.errors ??= [];
if (!p.done) {
setRunning(p);
return;
}
setRunning(null);
if (p.error) setError(p.error);
else if (p.preview) finalize(p.preview);
} catch (err) {
setRunning(null);
setError(err instanceof Error ? err.message : String(err));
}
}, 1200);
return () => clearTimeout(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [running]);
const cancel = async (id: string) => {
setBusy(true);
setError("");
try {
const response = await request<{ ok: boolean }>(
"/api/reclassify/cancel",
{ id },
);
if (!response.ok)
throw new Error("The server did not confirm cancellation.");
setRunning(null);
setPreview(null);
setSelected([]);
setEdits({});
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
};
const previewData = preview
? {
...state.data,
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 (
<>
<div className="section-heading">
<div>
<h2>AI classification</h2>
<p>A second look at your transactions. You stay in control.</p>
</div>
<span
className={`badge ${state.status.ai_configured ? "" : "neutral"}`}
>
<Sparkles size={14} />
{state.status.ai_configured ? "AI configured" : "AI not configured"}
</span>
</div>
<ErrorMessage error={error} />
<div className="privacy-banner">
<ShieldCheck size={24} />
<div>
<strong>Review first. Apply only what you choose.</strong>
<p>
Only identifier-shaped values are redacted before sending. Merchant
and counterparty text, amount, date and currency are sent so the
provider can classify the row. Your own account IBAN, account IDs,
transaction IDs, references and configured private names are never
sent. AI requests may incur provider charges.
</p>
</div>
</div>
{running ? (
<section className="panel">
<div className="panel-heading">
<div>
<h3>Classifying transactions</h3>
<p>
{running.analysed} of {running.total} analysed ·{" "}
{running.changes} proposed changes · {running.unchanged}{" "}
unchanged · {running.errors.length} errors
</p>
</div>
</div>
<div className="form-body">
<div
className="progress-track"
role="progressbar"
aria-valuemin={0}
aria-valuemax={running.total}
aria-valuenow={running.analysed}
>
<div
className="progress-fill"
style={{
width: running.total
? `${Math.round((running.analysed / running.total) * 100)}%`
: "100%",
}}
/>
</div>
<p role="status" className="muted">
Provider requests are spaced several seconds apart to respect rate
limits
{remainingEstimate(running, runStart.current)}. You can leave this
page; the preview keeps building and will be here when you return.
</p>
{running.errors.length > 0 && (
<div className="alert error">
<div>
<strong>
{running.errors.length} transaction
{running.errors.length === 1 ? "" : "s"} failed so far
</strong>
<p>{running.errors[running.errors.length - 1].error}</p>
</div>
</div>
)}
<div className="form-actions">
<button
className="button secondary"
disabled={busy}
onClick={() => cancel(running.id)}
>
<X size={16} />
Stop
</button>
</div>
</div>
</section>
) : !preview ? (
<section className="panel classification-setup">
<div className="panel-heading">
<div>
<h3>Prepare a preview</h3>
<p>
Nothing in your journal changes until you explicitly apply a
preview.
</p>
</div>
</div>
<form
className="form-body"
onSubmit={async (e) => {
e.preventDefault();
// The month-name picker is not a native date control, so the
// range is validated here instead of by form constraints.
if (!from || !to) {
setError("Choose a start and an end date for the range.");
return;
}
setBusy(true);
setError("");
try {
// Refresh registry labels for the review, but let the server
// take its own snapshot so another write cannot race analysis.
acceptState(await request<State>("/api/state"));
const start = await request<PreviewProgress>(
"/api/reclassify/preview",
{
from,
to,
model: model.trim(),
fields,
},
);
if (!start.id)
throw new Error(
"The server returned an incompatible preview run.",
);
start.errors ??= [];
runStart.current = { time: Date.now(), analysed: 0 };
setRunning(start);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
}}
>
<div className="two-columns">
<DateField
label="From"
value={from}
max={to || undefined}
onChange={setFrom}
/>
<DateField
label="To"
value={to}
min={from || undefined}
onChange={setTo}
/>
</div>
<Field
label="Model"
hint="An OpenAI-compatible model supported by your configured provider."
>
<input
required
value={model}
onChange={(e) => setModel(e.target.value)}
list="model-options"
/>
<ModelOptions id="model-options" />
</Field>
<fieldset className="tag-picker">
<legend>Fields to reclassify</legend>
{(["merchant", "category", "tags"] as const).map((field) => (
<label className="check-chip" key={field}>
<input
type="checkbox"
checked={fields[field]}
onChange={(e) =>
setFields({ ...fields, [field]: e.target.checked })
}
/>
{field === "tags"
? "Tags"
: field[0].toUpperCase() + field.slice(1)}
</label>
))}
</fieldset>
{!state.status.ai_configured && (
<p className="muted">
Configure your AI provider API key on the server to generate
previews. Existing manual classifications remain usable without
AI.
</p>
)}
<button
className="button primary"
disabled={
busy ||
!state.status.ai_configured ||
!Object.values(fields).some(Boolean) ||
!state.data.transactions.length
}
>
<Sparkles size={17} />
{busy ? "Starting…" : "Generate preview"}
</button>
{!state.data.transactions.length && (
<p className="muted">
Import transactions from Accounts before generating a preview.
</p>
)}
</form>
</section>
) : (
<>
<div className="preview-summary">
<span>
<strong>{preview.analysed}</strong> analysed
</span>
<span>
<strong>{preview.changes.length}</strong> proposed changes
</span>
<span>
<strong>{preview.unchanged}</strong> unchanged
</span>
<span>
<strong>{preview.errors.length}</strong> errors
</span>
</div>
{preview.revision !== state.revision && (
<div className="alert">
Your journal changed since this preview. Selected changes still
apply as long as their transactions were not edited in the
meantime.
</div>
)}
<section className="panel">
<div className="panel-heading">
<div>
<h3>Review changes</h3>
<p>
{selected.length} of {preview.changes.length} selected
correct any proposed category or tags in place; corrections
are recorded as manual classifications.
</p>
</div>
<div className="row-actions">
<button
className="button subtle"
disabled={busy}
onClick={() => setSelected(preview.changes.map((c) => c.id))}
>
Select all
</button>
<button
className="button subtle"
disabled={busy}
onClick={() => setSelected([])}
>
Clear selection
</button>
</div>
</div>
{preview.changes.length ? (
<div className="preview-list">
{preview.changes.map((change) => (
<div
className={`preview-row ${selected.includes(change.id) ? "selected" : ""}`}
key={change.id}
>
<input
type="checkbox"
aria-label={`Apply ${change.description || change.counterparty || change.id}`}
checked={selected.includes(change.id)}
disabled={busy}
onChange={(e) =>
setSelected(
e.target.checked
? [...selected, change.id]
: selected.filter((id) => id !== change.id),
)
}
/>
<div>
<strong>
{change.description || change.counterparty || change.id}
</strong>
<span className="amount">
{money(change.amount, change.currency)}
</span>
<small className="muted">{change.id}</small>
<span className="badge neutral">
Confidence:{" "}
{change.after.classification.confidence || "unknown"}
</span>
<div className="diff">
<EnrichmentView
data={state.data}
value={change.before}
label="Before"
/>
<ArrowRight size={18} />
<CorrectionEditor
data={previewData}
change={change}
value={effective(change)}
edited={change.id in edits}
disabled={busy}
mutate={mutate}
onChange={(value) => correct(change, value)}
/>
</div>
</div>
</div>
))}
</div>
) : (
<Empty title="No changes proposed">
Your classifications already match the result for this
selection.
</Empty>
)}
<div className="form-actions">
<button
className="button secondary"
disabled={busy}
onClick={() => cancel(preview.id)}
>
<X size={16} />
{busy ? "Working…" : "Cancel preview"}
</button>
<button
className="button primary"
disabled={busy || !selected.length}
onClick={() => setConfirm(true)}
>
<Check size={16} />
Apply {selected.length} selected
</button>
</div>
</section>
{preview.errors.length > 0 && (
<section className="panel">
<div className="panel-heading">
<h3>Transactions that could not be classified</h3>
</div>
<div className="form-body">
{preview.errors.map((item, i) => (
<div className="alert error" key={`${item.id}-${i}`}>
<div>
<strong>{item.id}</strong>
<p>{item.error}</p>
</div>
</div>
))}
</div>
</section>
)}
</>
)}
{confirm && preview && (
<Modal
title="Apply selected classifications?"
close={() => {
if (!busy) setConfirm(false);
}}
>
<div className="form-body">
<p>
This will replace the selected enrichment fields on{" "}
<strong>{selected.length} transactions</strong> in one journal
commit.
{selected.filter((id) => id in edits).length > 0 && (
<>
{" "}
<strong>
{selected.filter((id) => id in edits).length}
</strong>{" "}
of them carry your corrections and will be recorded as manual
classifications.
</>
)}{" "}
Unselected proposals will not be applied. Original bank facts
remain unchanged.
</p>
<ErrorMessage error={error} />
</div>
<div className="form-actions">
<button
className="button secondary"
disabled={busy}
onClick={() => setConfirm(false)}
>
Keep reviewing
</button>
<button
className="button primary"
disabled={busy}
onClick={async () => {
setBusy(true);
setError("");
try {
const result = await request<State>("/api/reclassify/apply", {
id: preview.id,
revision: preview.revision,
transaction_ids: selected,
edits: selected
.filter((id) => id in edits)
.map((id) => ({ id, ...edits[id] })),
});
acceptState(
result,
`Applied ${selected.length} classifications`,
);
const remaining = preview.changes.filter(
(c) => !selected.includes(c.id),
);
setPreview(
remaining.length
? { ...preview, changes: remaining }
: null,
);
setEdits((prev) =>
Object.fromEntries(
Object.entries(prev).filter(
([id]) => !selected.includes(id),
),
),
);
setSelected([]);
setConfirm(false);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
}}
>
{busy ? "Applying…" : "Confirm and apply"}
</button>
</div>
</Modal>
)}
</>
);
}
function EnrichmentView({
data,
value,
label,
}: {
data: Dataset;
value: Enrichment;
label: string;
}) {
return (
<div className="diff-value">
<span className="eyebrow">{label}</span>
<dl>
<div>
<dt>Merchant</dt>
<dd>
{value.merchant_id
? data.merchants.find((m) => m.id === value.merchant_id)?.name ||
`New merchant (${value.merchant_id})`
: "None"}
</dd>
</div>
<div>
<dt>Category</dt>
<dd>{categoryPath(data, value.category_id)}</dd>
</div>
<div>
<dt>Tags</dt>
<dd>
{value.tag_ids.length
? value.tag_ids
.map((id) => data.tags.find((t) => t.id === id)?.name || id)
.join(", ")
: "None"}
</dd>
</div>
</dl>
</div>
);
}
// 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 and can create a missing entry in place; the category
// list is limited to leaves of the change's kind because that is what
// validation will accept. Creating mid-review bumps the journal revision,
// which the apply path tolerates as long as the transactions themselves are
// untouched.
function CorrectionEditor({
data,
change,
value,
edited,
disabled,
mutate,
onChange,
}: {
data: Dataset;
change: Preview["changes"][number];
value: CorrectionValue;
edited: boolean;
disabled: boolean;
mutate: Mutate;
onChange: (value: CorrectionValue) => void;
}) {
// Async creates resolve against the freshest correction, not the snapshot
// captured when the create row was clicked: a chip removed during the
// server round trip must survive the create landing.
const latest = useRef(value);
latest.current = value;
const addable = data.tags
.filter((t) => !value.tag_ids.includes(t.id))
.map((t) => ({ value: t.id, label: t.name }));
return (
<div className="diff-value">
<div className="diff-edit-head">
<span className="eyebrow">Proposed{edited ? " · edited" : ""}</span>
{edited && (
<button
type="button"
className="button subtle"
disabled={disabled}
onClick={() =>
onChange({
category_id: change.after.category_id || "",
tag_ids: change.after.tag_ids,
})
}
>
<RotateCcw size={12} />
Reset
</button>
)}
</div>
<dl>
<div>
<dt>Merchant</dt>
<dd>
{change.after.merchant_id
? data.merchants.find((m) => m.id === change.after.merchant_id)
?.name || `New merchant (${change.after.merchant_id})`
: "None"}
</dd>
</div>
<div>
<dt>Category</dt>
<dd>
<CategoryCombobox
data={data}
kind={change.after.kind}
leavesOnly
mutate={mutate}
value={value.category_id}
disabled={disabled}
onChange={(category_id) =>
onChange({ ...latest.current, category_id })
}
/>
</dd>
</div>
<div>
<dt>Tags</dt>
<dd>
<div className="tag-edit">
{value.tag_ids.map((id) => (
<button
type="button"
className="tag-chip"
key={id}
disabled={disabled}
aria-label={`Remove tag ${data.tags.find((t) => t.id === id)?.name || id}`}
onClick={() =>
onChange({
...value,
tag_ids: value.tag_ids.filter((t) => t !== id),
})
}
>
{data.tags.find((t) => t.id === id)?.name || id}
<X size={12} />
</button>
))}
<Combobox
options={addable}
value=""
disabled={disabled}
onChange={(id) =>
onChange({ ...value, tag_ids: [...value.tag_ids, id] })
}
placeholder={data.tags.length ? "Add tag" : "Add or create tag"}
emptyText="No matching tag. Type a name to create it."
create={(text) =>
data.tags.some(
(t) => t.name.toLowerCase() === text.toLowerCase(),
)
? []
: [
{
key: "tag",
label: `Create tag "${text}"`,
run: async () => {
const id = await createTag(mutate, data, text);
onChange({
...latest.current,
tag_ids: [...latest.current.tag_ids, id],
});
},
},
]
}
/>
</div>
</dd>
</div>
</dl>
</div>
);
}
// 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
// done before it.
function remainingEstimate(
p: PreviewProgress,
start: { time: number; analysed: number },
): string {
const sampled = p.analysed - start.analysed;
const remaining = p.total - p.analysed;
if (remaining <= 0 || sampled < 2 || !start.time) return "";
const seconds = Math.round(
((Date.now() - start.time) / sampled / 1000) * remaining,
);
if (seconds < 90) return ` — roughly ${seconds} seconds remaining`;
return ` — roughly ${Math.round(seconds / 60)} minutes remaining`;
}