From 9092c5721dc246ea90f692c24433a2b3a358fefc Mon Sep 17 00:00:00 2001
From: Lars Nolden
Date: Sat, 12 Sep 2026 12:24:50 +0200
Subject: [PATCH] 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.
---
OPERATIONS.txt | 9 +-
README.md | 2 +
internal/app/app.go | 1 +
internal/app/app_test.go | 61 +++++++--
internal/app/openrouter_test.go | 2 +-
internal/app/reclassify.go | 198 ++++++++++++++++++++++++----
internal/server/server.go | 13 +-
web/src/Classification.tsx | 227 ++++++++++++++++++++++++--------
web/src/api.ts | 15 +++
web/src/styles.css | 12 ++
10 files changed, 449 insertions(+), 91 deletions(-)
diff --git a/OPERATIONS.txt b/OPERATIONS.txt
index 0f58144..8f32f35 100644
--- a/OPERATIONS.txt
+++ b/OPERATIONS.txt
@@ -673,7 +673,14 @@ canonical data and the index error is surfaced rather than serving stale totals.
Reclassification
----------------
AI / Classification: choose dates, model and independent Merchant/Category/Tags
-fields. Analyse produces a read-only preview. Apply all/selected writes all
+fields. Analyse starts a background run and reports live progress: analysed
+count, proposed changes, and per-transaction errors as they happen. Requests
+stay paced seconds apart, so a large range takes minutes; the page may be left
+and revisited, and Stop abandons the run without writing anything. A run that
+has produced no successful proposal and fails three times in a row with the
+same error stops early and reports that error instead of repeating it across
+the whole range. Only one run exists at a time.
+The finished run is a read-only preview. Apply all/selected writes all
selected changes in one canonical commit; financial facts never change. A
manual edit, external journal change or taxonomy change invalidates old previews.
Previews are kept in memory for up to one hour and disappear on restart. Cancel
diff --git a/README.md b/README.md
index ea71278..ead775c 100644
--- a/README.md
+++ b/README.md
@@ -417,6 +417,8 @@ Every AI classification requests `provider.data_collection = "deny"`, `provider.
Classification spaces request starts by at least **three seconds**, including successful requests, rather than sending a burst between 429s. This is a conservative application policy, not a published quota for every model. On HTTP 429, backoff starts at **15 seconds** and increases across consecutive failures; `Retry-After` seconds or HTTP dates can extend the wait. Successful retries retain the learned spacing (up to **30 seconds**) instead of immediately bursting again. Each operation makes at most **four attempts**, with at most **two minutes of automatic retry waiting**, preserving the same model, sanitized prompt, and privacy controls. Imports and previews share this pacing and cooldown. Long or exhausted limits leave records unclassified with a retry-time error; local merchant rules still work. After the cooldown, run **AI classification → Analyse** again for previously failed records—repeating a bank import does not reclassify existing transactions.
+**AI classification → Analyse** runs in the background: the page shows how many transactions have been analysed, proposed changes, and every per-transaction failure as it happens, with a **Stop** button that abandons the run without writing anything. You can navigate away and return; the run keeps building and the page re-attaches to it. A run that has produced no successful result and fails **three times in a row with the same error** stops early and reports that error — a wrong key or an unsupported model surfaces within seconds instead of repeating across the whole range.
+
## Data, backups, and recovery
Back up the **entire canonical finance directory**, including registry files, journals, `config.toml` when present, and operational/recovery state, plus any separately stored environment-managed secrets. `state/openrouter.json` and `state/enablebanking.json` contain UI-managed credentials: protect backups accordingly, including the matching banking session state. Stop the service for a consistent filesystem backup. DuckDB under `cache/` can be excluded and rebuilt.
diff --git a/internal/app/app.go b/internal/app/app.go
index 7646a68..621795d 100644
--- a/internal/app/app.go
+++ b/internal/app/app.go
@@ -71,6 +71,7 @@ type App struct {
bank banking.Provider
classifier classification.Client
previews map[string]Preview
+ previewRun *previewJob
taxonomies map[string]TaxonomyPreview
csvImports map[string]CSVImport
authStates map[string]authorization
diff --git a/internal/app/app_test.go b/internal/app/app_test.go
index 56ab438..7281bcc 100644
--- a/internal/app/app_test.go
+++ b/internal/app/app_test.go
@@ -129,6 +129,32 @@ func TestFailedClassificationStillImportsAndRetryIsIdempotent(t *testing.T) {
}
}
+// runPreview drives the background preview job to completion the way the UI
+// does: start the run, then poll progress until it reports done.
+func runPreview(t *testing.T, a *App, r PreviewRequest) (Preview, error) {
+ t.Helper()
+ start, err := a.StartPreview(context.Background(), r)
+ if err != nil {
+ return Preview{}, err
+ }
+ deadline := time.Now().Add(15 * time.Second)
+ for {
+ p, err := a.PreviewProgress(start.ID)
+ if err != nil {
+ return Preview{}, err
+ }
+ if p.Done {
+ if p.Error != "" {
+ return Preview{}, errors.New(p.Error)
+ }
+ return *p.Preview, nil
+ }
+ if time.Now().After(deadline) {
+ t.Fatal("preview run did not finish")
+ }
+ time.Sleep(5 * time.Millisecond)
+ }
+}
func TestPreviewCooldownProtectsLaterPreviewsAndImports(t *testing.T) {
a, s := testApp(t)
s = seed(t, a, s)
@@ -145,7 +171,7 @@ func TestPreviewCooldownProtectsLaterPreviewsAndImports(t *testing.T) {
defer cancel()
for _, model := range []string{"test/model", "test/another-model"} {
- preview, err := a.Preview(ctx, PreviewRequest{
+ preview, err := runPreview(t, a, PreviewRequest{
Revision: s.Revision, From: "2026-09-01", To: "2026-09-30",
Model: model, Fields: Fields{Category: true},
})
@@ -185,24 +211,39 @@ func TestPreviewCooldownProtectsLaterPreviewsAndImports(t *testing.T) {
}
}
-func TestCancelledLastClassificationDoesNotProducePreview(t *testing.T) {
+func TestCancelledPreviewRunProducesNoPreview(t *testing.T) {
a, s := testApp(t)
s = seed(t, a, s)
- ctx, cancel := context.WithCancel(context.Background())
- defer cancel()
+ ids := make(chan string, 1)
provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ // Stop the run from within its first provider call, as the UI's Stop
+ // button would mid-request.
+ a.CancelPreview(<-ids)
w.Header().Set("Retry-After", "60")
w.WriteHeader(http.StatusTooManyRequests)
- cancel()
}))
defer provider.Close()
a.classifier = classification.Client{APIKey: "test", Model: "test/model", BaseURL: provider.URL}
- p, err := a.Preview(ctx, PreviewRequest{
+ start, err := a.StartPreview(context.Background(), PreviewRequest{
Revision: s.Revision, From: "2026-09-09", To: "2026-09-09",
Model: "test/model", Fields: Fields{Category: true},
})
- if !errors.Is(err, context.Canceled) || p.ID != "" {
- t.Fatalf("cancelled final record produced a preview: id=%q, error=%v", p.ID, err)
+ if err != nil {
+ t.Fatal(err)
+ }
+ ids <- start.ID
+ deadline := time.Now().Add(10 * time.Second)
+ for {
+ if _, err := a.PreviewProgress(start.ID); err != nil {
+ break // the cancelled run is gone, never a finished preview
+ }
+ if time.Now().After(deadline) {
+ t.Fatal("cancelled preview run still reports progress")
+ }
+ time.Sleep(5 * time.Millisecond)
+ }
+ if _, err := a.ApplyPreview(context.Background(), start.ID, s.Revision, []string{"any"}); err == nil {
+ t.Fatal("cancelled run produced an applicable preview")
}
after, err := a.Snapshot(context.Background())
if err != nil {
@@ -261,7 +302,7 @@ func TestPreviewIsReadOnlySelectedApplyPreservesFactsAndOtherFields(t *testing.T
}
mockClassifier(t, a)
before := domain.Clone(s.Data)
- preview, err := a.Preview(context.Background(), PreviewRequest{Revision: s.Revision, From: "2026-09-01", To: "2026-09-30", Model: "improved/model", Fields: Fields{Category: true}})
+ preview, err := runPreview(t, a, PreviewRequest{Revision: s.Revision, From: "2026-09-01", To: "2026-09-30", Model: "improved/model", Fields: Fields{Category: true}})
if err != nil {
t.Fatal(err)
}
@@ -306,7 +347,7 @@ func TestStalePreviewCannotOverwriteManualCorrection(t *testing.T) {
a, s := testApp(t)
s = seed(t, a, s)
mockClassifier(t, a)
- p, err := a.Preview(context.Background(), PreviewRequest{Revision: s.Revision, From: "2026-09-01", To: "2026-09-30", Model: "test/model", Fields: Fields{Category: true}})
+ p, err := runPreview(t, a, PreviewRequest{Revision: s.Revision, From: "2026-09-01", To: "2026-09-30", Model: "test/model", Fields: Fields{Category: true}})
if err != nil {
t.Fatal(err)
}
diff --git a/internal/app/openrouter_test.go b/internal/app/openrouter_test.go
index 43362b0..3e7441c 100644
--- a/internal/app/openrouter_test.go
+++ b/internal/app/openrouter_test.go
@@ -12,7 +12,7 @@ import (
func checkOpenRouterPreview(t *testing.T, a *App, s State, auth <-chan string, key string) {
t.Helper()
- p, err := a.Preview(context.Background(), PreviewRequest{Revision: s.Revision, From: "2026-09-01", To: "2026-09-30", Model: "test/model", Fields: Fields{Category: true}})
+ p, err := runPreview(t, a, PreviewRequest{Revision: s.Revision, From: "2026-09-01", To: "2026-09-30", Model: "test/model", Fields: Fields{Category: true}})
if err != nil {
t.Fatal(err)
}
diff --git a/internal/app/reclassify.go b/internal/app/reclassify.go
index bf89edc..b28f2c3 100644
--- a/internal/app/reclassify.go
+++ b/internal/app/reclassify.go
@@ -3,11 +3,13 @@ package app
import (
"context"
"errors"
+ "fmt"
"reflect"
"slices"
"strings"
"time"
+ "finance-duck/internal/classification"
"finance-duck/internal/domain"
)
@@ -44,6 +46,33 @@ type Preview struct {
created time.Time
}
+// PreviewProgress is the live state of one preview run. Errors accumulate as
+// they happen so a failing provider is visible after seconds, not after the
+// whole paced range. Preview is set only when Done with an empty Error.
+type PreviewProgress struct {
+ ID string `json:"id"`
+ Total int `json:"total"`
+ Analysed int `json:"analysed"`
+ Changes int `json:"changes"`
+ Unchanged int `json:"unchanged"`
+ Errors []ClassificationError `json:"errors"`
+ Done bool `json:"done"`
+ Error string `json:"error,omitempty"`
+ Preview *Preview `json:"preview,omitempty"`
+}
+
+func (p PreviewProgress) clone() PreviewProgress {
+ p.Errors = append([]ClassificationError{}, p.Errors...)
+ return p
+}
+
+// previewJob is the single in-flight (or most recently finished) preview run.
+// status is guarded by App.mu; cancel stops the goroutine cooperatively.
+type previewJob struct {
+ cancel context.CancelFunc
+ status PreviewProgress
+}
+
func validRange(from, to string) error {
f, e := time.Parse("2006-01-02", from)
if e != nil {
@@ -58,49 +87,162 @@ func validRange(from, to string) error {
}
return nil
}
-func (a *App) Preview(ctx context.Context, r PreviewRequest) (Preview, error) {
+func validatePreviewRequest(r PreviewRequest) error {
if err := validRange(r.From, r.To); err != nil {
- return Preview{}, err
+ return err
}
if !r.Fields.Merchant && !r.Fields.Category && !r.Fields.Tags {
- return Preview{}, errors.New("select at least one enrichment field")
+ return errors.New("select at least one enrichment field")
}
if strings.TrimSpace(r.Model) == "" {
- return Preview{}, errors.New("model is required")
+ return errors.New("model is required")
+ }
+ return nil
+}
+func previewEligible(t domain.Transaction, r PreviewRequest) bool {
+ return t.Facts.BookingDate >= r.From && t.Facts.BookingDate <= r.To &&
+ t.Enrichment.Kind != "transfer" && t.Enrichment.Kind != domain.KindInvestment
+}
+
+// StartPreview validates the request against the current journal and starts a
+// background classification run. The provider is paced to one request every
+// few seconds, so any real range takes minutes: the caller polls
+// PreviewProgress instead of holding an HTTP request open for the duration.
+// Only one run exists at a time; the run owns its own snapshot and never
+// touches canonical data.
+func (a *App) StartPreview(ctx context.Context, r PreviewRequest) (PreviewProgress, error) {
+ if err := validatePreviewRequest(r); err != nil {
+ return PreviewProgress{}, err
}
a.mu.Lock()
+ defer a.mu.Unlock()
+ if a.previewRun != nil && !a.previewRun.status.Done {
+ return PreviewProgress{}, errors.New("a preview is already being generated; stop it first")
+ }
s, err := a.snapshot(ctx)
- client := a.classifier.WithModel(r.Model)
- a.mu.Unlock()
if err != nil {
- return Preview{}, err
+ return PreviewProgress{}, err
}
if r.Revision != s.Revision {
- return Preview{}, errors.New("revision conflict: reload before analysing")
+ return PreviewProgress{}, errors.New("revision conflict: reload before analysing")
}
- p := Preview{ID: domain.NewID("preview"), Revision: s.Revision, Changes: []Change{}, Errors: []ClassificationError{}, created: time.Now()}
- baseMerchants := len(s.Data.Merchants)
+ client := a.classifier.WithModel(r.Model)
+ total := 0
for _, t := range s.Data.Transactions {
- if t.Facts.BookingDate < r.From || t.Facts.BookingDate > r.To || t.Enrichment.Kind == "transfer" || t.Enrichment.Kind == domain.KindInvestment {
+ if previewEligible(t, r) {
+ total++
+ }
+ }
+ runCtx, cancel := context.WithCancel(context.Background())
+ job := &previewJob{cancel: cancel, status: PreviewProgress{ID: domain.NewID("preview"), Total: total, Errors: []ClassificationError{}}}
+ a.previewRun = job
+ go a.runPreview(runCtx, cancel, client, s, r, job)
+ return job.status.clone(), nil
+}
+
+func (a *App) runPreview(ctx context.Context, cancel context.CancelFunc, client *classification.Client, s State, r PreviewRequest, job *previewJob) {
+ defer cancel()
+ p, err := classifyRange(ctx, client, s, r, job.status.ID, func(u PreviewProgress) {
+ a.mu.Lock()
+ if a.previewRun == job {
+ job.status = u
+ }
+ a.mu.Unlock()
+ })
+ a.mu.Lock()
+ defer a.mu.Unlock()
+ if a.previewRun != job {
+ return // stopped by CancelPreview; discard the result
+ }
+ job.status.Done = true
+ if err != nil {
+ job.status.Error = err.Error()
+ return
+ }
+ for id, old := range a.previews {
+ if time.Since(old.created) > time.Hour {
+ delete(a.previews, id)
+ }
+ }
+ if len(a.previews) >= 20 {
+ job.status.Error = "too many active previews; cancel one first"
+ return
+ }
+ a.previews[p.ID] = p
+ job.status.Analysed = p.Analysed
+ job.status.Changes = len(p.Changes)
+ job.status.Unchanged = p.Unchanged
+ job.status.Errors = append([]ClassificationError{}, p.Errors...)
+ job.status.Preview = &p
+}
+
+// PreviewProgress reports the current (or most recently finished) preview run.
+// An empty id re-attaches to whatever run exists, so navigating away from the
+// page does not orphan a run that is still spending provider requests.
+func (a *App) PreviewProgress(id string) (PreviewProgress, error) {
+ a.mu.Lock()
+ defer a.mu.Unlock()
+ job := a.previewRun
+ if job == nil || (id != "" && job.status.ID != id) {
+ return PreviewProgress{}, errors.New("no matching preview run; analyse again")
+ }
+ return job.status.clone(), nil
+}
+
+// classifyRange proposes enrichment for every eligible transaction in the
+// snapshot, reporting progress after each one. It stops early when the run has
+// produced no successful proposal yet and the same error message repeats three
+// times in a row: an identical repeated failure is a configuration or provider
+// problem, and grinding through the rest of the paced range would only repeat
+// it a few seconds apart.
+func classifyRange(ctx context.Context, client *classification.Client, s State, r PreviewRequest, id string, report func(PreviewProgress)) (Preview, error) {
+ p := Preview{ID: id, Revision: s.Revision, Changes: []Change{}, Errors: []ClassificationError{}, created: time.Now()}
+ baseMerchants := len(s.Data.Merchants)
+ total := 0
+ for _, t := range s.Data.Transactions {
+ if previewEligible(t, r) {
+ total++
+ }
+ }
+ progress := func() {
+ if report != nil {
+ report(PreviewProgress{ID: id, Total: total, Analysed: p.Analysed, Changes: len(p.Changes), Unchanged: p.Unchanged, Errors: append([]ClassificationError{}, p.Errors...)})
+ }
+ }
+ succeeded := false
+ repeated := 0
+ for _, t := range s.Data.Transactions {
+ if !previewEligible(t, r) {
continue
}
- if err = ctx.Err(); err != nil {
+ if err := ctx.Err(); err != nil {
return Preview{}, err
}
p.Analysed++
proposal, e := client.Classify(ctx, t.Facts, s.Data, true)
- if err = ctx.Err(); err != nil {
+ if err := ctx.Err(); err != nil {
return Preview{}, err
}
if e != nil {
+ if n := len(p.Errors); n > 0 && p.Errors[n-1].Error == e.Error() {
+ repeated++
+ } else {
+ repeated = 1
+ }
p.Errors = append(p.Errors, ClassificationError{t.Facts.ID, e.Error()})
+ if !succeeded && repeated >= 3 {
+ return Preview{}, fmt.Errorf("stopped after %d identical failures — %s — with %d of %d transactions not analysed", repeated, e.Error(), total-p.Analysed, total)
+ }
+ progress()
continue
}
+ succeeded = true
after := t.Enrichment
if r.Fields.Merchant {
after.MerchantID = proposal.Enrichment.MerchantID
if e = addProposal(&s.Data, proposal, t.Facts); e != nil {
p.Errors = append(p.Errors, ClassificationError{t.Facts.ID, e.Error()})
+ progress()
continue
}
}
@@ -112,6 +254,7 @@ func (a *App) Preview(ctx context.Context, r PreviewRequest) (Preview, error) {
}
if e = domain.ValidateEnrichment(s.Data, t.Facts, after); e != nil {
p.Errors = append(p.Errors, ClassificationError{t.Facts.ID, e.Error()})
+ progress()
continue
}
beforeComparable, afterComparable := t.Enrichment, after
@@ -123,23 +266,14 @@ func (a *App) Preview(ctx context.Context, r PreviewRequest) (Preview, error) {
slices.Sort(afterComparable.TagIDs)
if reflect.DeepEqual(beforeComparable, afterComparable) {
p.Unchanged++
+ progress()
continue
}
after.Classification = proposal.Enrichment.Classification
p.Changes = append(p.Changes, Change{t.Facts.ID, t.Facts.RawDescription, t.Enrichment, after})
+ progress()
}
p.NewMerchants = append([]domain.Merchant{}, s.Data.Merchants[baseMerchants:]...)
- a.mu.Lock()
- defer a.mu.Unlock()
- for id, old := range a.previews {
- if time.Since(old.created) > time.Hour {
- delete(a.previews, id)
- }
- }
- if len(a.previews) >= 20 {
- return Preview{}, errors.New("too many active previews; cancel one first")
- }
- a.previews[p.ID] = p
return p, nil
}
func (a *App) ApplyPreview(ctx context.Context, id, rev string, ids []string) (State, error) {
@@ -198,6 +332,20 @@ func (a *App) ApplyPreview(ctx context.Context, id, rev string, ids []string) (S
return State{}, err
}
delete(a.previews, id)
+ if job := a.previewRun; job != nil && job.status.ID == id {
+ a.previewRun = nil
+ }
return state, nil
}
-func (a *App) CancelPreview(id string) { a.mu.Lock(); defer a.mu.Unlock(); delete(a.previews, id) }
+
+// CancelPreview stops a running preview job and discards a finished preview.
+// A run and its stored preview share one id, so a single cancel covers both.
+func (a *App) CancelPreview(id string) {
+ a.mu.Lock()
+ defer a.mu.Unlock()
+ if job := a.previewRun; job != nil && job.status.ID == id {
+ job.cancel()
+ a.previewRun = nil
+ }
+ delete(a.previews, id)
+}
diff --git a/internal/server/server.go b/internal/server/server.go
index 942f7da..33f8f5a 100644
--- a/internal/server/server.go
+++ b/internal/server/server.go
@@ -68,6 +68,7 @@ func New(a *app.App, assets fs.FS, publicURL string) (http.Handler, error) {
respond(w, v, e)
})
s.mux.HandleFunc("POST /api/reclassify/preview", s.preview)
+ s.mux.HandleFunc("POST /api/reclassify/progress", s.previewProgress)
s.mux.HandleFunc("POST /api/reclassify/apply", s.apply)
s.mux.HandleFunc("POST /api/reclassify/cancel", s.cancel)
s.mux.HandleFunc("POST /api/taxonomy/propose", s.taxonomyPropose)
@@ -490,7 +491,17 @@ func (s *Server) preview(w http.ResponseWriter, r *http.Request) {
if !decode(w, r, &b) {
return
}
- v, e := s.app.Preview(r.Context(), b)
+ v, e := s.app.StartPreview(r.Context(), b)
+ respond(w, v, e)
+}
+func (s *Server) previewProgress(w http.ResponseWriter, r *http.Request) {
+ var b struct {
+ ID string `json:"id"`
+ }
+ if !decode(w, r, &b) {
+ return
+ }
+ v, e := s.app.PreviewProgress(b.ID)
respond(w, v, e)
}
func (s *Server) apply(w http.ResponseWriter, r *http.Request) {
diff --git a/web/src/Classification.tsx b/web/src/Classification.tsx
index 8a9d822..aad48e9 100644
--- a/web/src/Classification.tsx
+++ b/web/src/Classification.tsx
@@ -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(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 = {
+ 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("/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("/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({
+ 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.
+
+ {running.errors.length > 0 && (
+
+
+
+ {running.errors.length} transaction
+ {running.errors.length === 1 ? "" : "s"} failed so far
+
+