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:
+174
-53
@@ -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`;
|
||||
}
|
||||
|
||||
@@ -200,6 +200,20 @@ export interface Preview {
|
||||
unchanged: number;
|
||||
errors: { id: string; error: string }[];
|
||||
}
|
||||
// PreviewProgress is the live state of a background classification run.
|
||||
// Errors accumulate as they happen; preview is present only when done
|
||||
// without a fatal error.
|
||||
export interface PreviewProgress {
|
||||
id: string;
|
||||
total: number;
|
||||
analysed: number;
|
||||
changes: number;
|
||||
unchanged: number;
|
||||
errors: { id: string; error: string }[];
|
||||
done: boolean;
|
||||
error?: string;
|
||||
preview?: Preview;
|
||||
}
|
||||
export interface ProposedCategory {
|
||||
name: string;
|
||||
parent?: string;
|
||||
@@ -395,6 +409,7 @@ export function normalizeState(state: State): State {
|
||||
else if (!Array.isArray(state.data[key]))
|
||||
throw new Error(`The server state has invalid ${key}.`);
|
||||
}
|
||||
state.settings.private_names ??= [];
|
||||
for (const tx of state.data.transactions) tx.enrichment.tag_ids ??= [];
|
||||
for (const merchant of state.data.merchants) {
|
||||
merchant.aliases ??= [];
|
||||
|
||||
@@ -1389,6 +1389,18 @@ summary .badge {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
.progress-track {
|
||||
height: 8px;
|
||||
border-radius: 4px;
|
||||
background: #e1e9e5;
|
||||
overflow: hidden;
|
||||
}
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
border-radius: 4px;
|
||||
background: var(--emerald);
|
||||
transition: width 0.6s ease;
|
||||
}
|
||||
footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
Reference in New Issue
Block a user