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
+8 -1
View File
@@ -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
+2
View File
@@ -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.
+1
View File
@@ -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
+51 -10
View File
@@ -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)
}
+1 -1
View File
@@ -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)
}
+173 -25
View File
@@ -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)
}
+12 -1
View File
@@ -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) {
+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`;
}
+15
View File
@@ -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 ??= [];
+12
View File
@@ -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;