Run classification previews in the background with live progress

The preview endpoint held one HTTP request open while classifying
serially at three-second pacing, so any real range meant minutes of a
grayed-out button and per-row errors were invisible until the loop
ended. Analyse now starts a single background run against its own
snapshot; a progress endpoint reports analysed counts, proposed
changes and errors as they happen, and the page polls it with a
progress bar, pace-based estimate and a Stop button. Navigating away
no longer orphans the run: the page re-attaches to it on return.

A run that has produced no successful proposal and fails three times
in a row with the identical error stops early and reports that error,
so a wrong key or unsupported model surfaces in seconds instead of
repeating across the whole paced range.

Also normalize a null settings.private_names, which crashed the whole
UI on a workspace that had never saved preferences.
This commit is contained in:
Lars Nolden
2026-09-12 12:24:50 +02:00
parent 635c11be56
commit 9092c5721d
10 changed files with 449 additions and 91 deletions
+174 -53
View File
@@ -1,6 +1,12 @@
import { useState } from "react";
import { useEffect, useRef, useState } from "react";
import { Sparkles, ShieldCheck, Check, X, ArrowRight } from "lucide-react";
import type { Dataset, Enrichment, Preview, State } from "./api";
import type {
Dataset,
Enrichment,
Preview,
PreviewProgress,
State,
} from "./api";
import { categoryPath, request } from "./api";
import { DateField, Empty, ErrorMessage, Field, Modal } from "./ui";
export function Classification({
@@ -24,17 +30,96 @@ export function Classification({
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [confirm, setConfirm] = useState(false);
const cancel = async () => {
if (!preview) return;
const [running, setRunning] = useState<PreviewProgress | null>(null);
const runStart = useRef({ time: 0, analysed: 0 });
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);
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: preview.id },
{ id },
);
if (!response.ok)
throw new Error("The server did not confirm cancellation.");
setRunning(null);
setPreview(null);
setSelected([]);
} catch (err) {
@@ -77,7 +162,65 @@ export function Classification({
</p>
</div>
</div>
{!preview ? (
{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>
@@ -101,7 +244,7 @@ export function Classification({
setBusy(true);
setError("");
try {
const result = await request<Preview>(
const start = await request<PreviewProgress>(
"/api/reclassify/preview",
{
revision: state.revision,
@@ -111,46 +254,13 @@ export function Classification({
fields,
},
);
if (
!result.id ||
!result.revision ||
!("changes" in result) ||
!("errors" in result) ||
!("new_merchants" in result)
)
if (!start.id)
throw new Error(
"The server returned an incompatible preview.",
"The server returned an incompatible preview run.",
);
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);
setSelected(
result.changes
.filter(
(change) =>
change.after.classification.confidence !== "low",
)
.map((change) => change.id),
);
start.errors ??= [];
runStart.current = { time: Date.now(), analysed: 0 };
setRunning(start);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
@@ -223,14 +333,8 @@ export function Classification({
}
>
<Sparkles size={17} />
{busy ? "Classifying transactions…" : "Generate preview"}
{busy ? "Starting…" : "Generate preview"}
</button>
{busy && (
<p role="status" className="muted">
This can take a while for a large date range. Keep this page
open.
</p>
)}
{!state.data.transactions.length && (
<p className="muted">
Import transactions from Accounts before generating a preview.
@@ -338,7 +442,7 @@ export function Classification({
<button
className="button secondary"
disabled={busy}
onClick={cancel}
onClick={() => cancel(preview.id)}
>
<X size={16} />
{busy ? "Working…" : "Cancel preview"}
@@ -474,3 +578,20 @@ function EnrichmentView({
</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`;
}