new classification ui

This commit is contained in:
Lars Nolden
2026-09-13 13:52:30 +02:00
parent 10314fb1cd
commit 62a7d6daf4
7 changed files with 494 additions and 115 deletions
+58 -7
View File
@@ -242,7 +242,7 @@ func TestCancelledPreviewRunProducesNoPreview(t *testing.T) {
} }
time.Sleep(5 * time.Millisecond) time.Sleep(5 * time.Millisecond)
} }
if _, err := a.ApplyPreview(context.Background(), start.ID, s.Revision, []string{"any"}); err == nil { if _, err := a.ApplyPreview(context.Background(), start.ID, s.Revision, []string{"any"}, nil); err == nil {
t.Fatal("cancelled run produced an applicable preview") t.Fatal("cancelled run produced an applicable preview")
} }
after, err := a.Snapshot(context.Background()) after, err := a.Snapshot(context.Background())
@@ -334,7 +334,7 @@ func TestPreviewIsReadOnlySelectedApplyPreservesFactsAndOtherFields(t *testing.T
t.Fatal("preview mutated canonical records") t.Fatal("preview mutated canonical records")
} }
id := preview.Changes[0].ID id := preview.Changes[0].ID
applied, err := a.ApplyPreview(context.Background(), preview.ID, preview.Revision, []string{id}) applied, err := a.ApplyPreview(context.Background(), preview.ID, preview.Revision, []string{id}, nil)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -356,7 +356,7 @@ func TestPreviewIsReadOnlySelectedApplyPreservesFactsAndOtherFields(t *testing.T
t.Fatal("unselected transaction changed") t.Fatal("unselected transaction changed")
} }
} }
if _, err = a.ApplyPreview(context.Background(), preview.ID, preview.Revision, []string{id}); err == nil { if _, err = a.ApplyPreview(context.Background(), preview.ID, preview.Revision, []string{id}, nil); err == nil {
t.Fatal("consumed preview applied twice") t.Fatal("consumed preview applied twice")
} }
} }
@@ -375,7 +375,7 @@ func TestStalePreviewCannotOverwriteManualCorrection(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if _, err = a.ApplyPreview(context.Background(), p.ID, p.Revision, []string{p.Changes[0].ID}); err == nil { if _, err = a.ApplyPreview(context.Background(), p.ID, p.Revision, []string{p.Changes[0].ID}, nil); err == nil {
t.Fatal("stale preview overwrote manual edit") t.Fatal("stale preview overwrote manual edit")
} }
after, err := a.Snapshot(context.Background()) after, err := a.Snapshot(context.Background())
@@ -410,13 +410,13 @@ func TestApplyPreviewSurvivesUnrelatedCommitsAndPartialApplies(t *testing.T) {
}); err != nil { }); err != nil {
t.Fatal(err) t.Fatal(err)
} }
first, err := a.ApplyPreview(ctx, p.ID, p.Revision, []string{p.Changes[0].ID}) first, err := a.ApplyPreview(ctx, p.ID, p.Revision, []string{p.Changes[0].ID}, nil)
if err != nil { if err != nil {
t.Fatalf("unrelated commit invalidated the preview: %v", err) t.Fatalf("unrelated commit invalidated the preview: %v", err)
} }
// The partial apply moved the revision again; the remaining proposal must // The partial apply moved the revision again; the remaining proposal must
// still apply without another paced provider run. // still apply without another paced provider run.
second, err := a.ApplyPreview(ctx, p.ID, p.Revision, []string{p.Changes[1].ID}) second, err := a.ApplyPreview(ctx, p.ID, p.Revision, []string{p.Changes[1].ID}, nil)
if err != nil { if err != nil {
t.Fatalf("partial apply consumed the remaining proposals: %v", err) t.Fatalf("partial apply consumed the remaining proposals: %v", err)
} }
@@ -429,11 +429,62 @@ func TestApplyPreviewSurvivesUnrelatedCommitsAndPartialApplies(t *testing.T) {
} }
} }
// Both changes are consumed now; re-applying must fail, not double-write. // Both changes are consumed now; re-applying must fail, not double-write.
if _, err = a.ApplyPreview(ctx, p.ID, p.Revision, []string{p.Changes[0].ID}); err == nil { if _, err = a.ApplyPreview(ctx, p.ID, p.Revision, []string{p.Changes[0].ID}, nil); err == nil {
t.Fatal("consumed change applied twice") t.Fatal("consumed change applied twice")
} }
} }
// A reviewer can correct a proposal before applying it: the corrected fields
// land instead of the model's, provenance becomes manual, and an invalid or
// unselected correction rejects the whole apply.
func TestApplyPreviewHonoursReviewerEdits(t *testing.T) {
ctx := context.Background()
a, s := testApp(t)
s = seed(t, a, s)
s, err := a.Mutate(ctx, s.Revision, func(d *domain.Dataset) error {
d.Categories = append(d.Categories, domain.Category{ID: "dining", Name: "Dining", ParentID: "cat_expenses", Kind: "expense"})
return nil
})
if err != nil {
t.Fatal(err)
}
mockClassifier(t, a)
p, err := runPreview(t, a, PreviewRequest{Revision: s.Revision, From: "2026-09-01", To: "2026-09-30", Model: "test/model", Fields: Fields{Category: true, Tags: true}})
if err != nil {
t.Fatal(err)
}
if len(p.Changes) != 2 {
t.Fatalf("expected two proposed changes: %+v", p)
}
edited, other := p.Changes[0], p.Changes[1]
if _, err = a.ApplyPreview(ctx, p.ID, p.Revision, []string{edited.ID}, []EnrichmentEdit{{ID: edited.ID, CategoryID: "nonexistent", TagIDs: []string{}}}); err == nil {
t.Fatal("edit naming an unknown category was applied")
}
if _, err = a.ApplyPreview(ctx, p.ID, p.Revision, []string{edited.ID}, []EnrichmentEdit{{ID: other.ID, CategoryID: "dining", TagIDs: []string{}}}); err == nil {
t.Fatal("edit for an unselected transaction was accepted")
}
applied, err := a.ApplyPreview(ctx, p.ID, p.Revision, []string{edited.ID, other.ID}, []EnrichmentEdit{{ID: edited.ID, CategoryID: "dining", TagIDs: []string{"home"}}})
if err != nil {
t.Fatal(err)
}
for _, tx := range applied.Data.Transactions {
e := tx.Enrichment
switch tx.Facts.ID {
case edited.ID:
if e.CategoryID != "dining" || !reflect.DeepEqual(e.TagIDs, []string{"home"}) {
t.Fatalf("reviewer correction lost: %+v", e)
}
if e.Classification.Source != "manual" {
t.Fatalf("corrected change kept model provenance: %+v", e.Classification)
}
case other.ID:
if e.CategoryID != "groceries" || e.Classification.Source == "manual" {
t.Fatalf("uncorrected change altered: %+v", e)
}
}
}
}
// Imports auto-apply only what the model is sure about: a low-confidence // Imports auto-apply only what the model is sure about: a low-confidence
// category lands on the editable fallback while the merchant link and the // category lands on the editable fallback while the merchant link and the
// recorded confidence survive for review in Analyse. // recorded confidence survive for review in Analyse.
+33 -3
View File
@@ -34,6 +34,16 @@ type Change struct {
Before domain.Enrichment `json:"before"` Before domain.Enrichment `json:"before"`
After domain.Enrichment `json:"after"` After domain.Enrichment `json:"after"`
} }
// EnrichmentEdit is a reviewer's correction to one proposal: it replaces the
// proposed category and tags before the change is applied. A corrected
// transaction is classified by the human, not the model, so its provenance
// becomes manual and later runs treat it accordingly.
type EnrichmentEdit struct {
ID string `json:"id"`
CategoryID string `json:"category_id"`
TagIDs []string `json:"tag_ids"`
}
type ClassificationError struct { type ClassificationError struct {
ID string `json:"id"` ID string `json:"id"`
Error string `json:"error"` Error string `json:"error"`
@@ -329,7 +339,7 @@ func enrichmentEqual(a, b domain.Enrichment) bool {
// invalidate the review; only a selected transaction whose own enrichment // invalidate the review; only a selected transaction whose own enrichment
// changed since the preview snapshot conflicts. Applied changes are pruned so // changed since the preview snapshot conflicts. Applied changes are pruned so
// the remaining proposals stay appliable without another paced provider run. // the remaining proposals stay appliable without another paced provider run.
func (a *App) ApplyPreview(ctx context.Context, id, rev string, ids []string) (State, error) { func (a *App) ApplyPreview(ctx context.Context, id, rev string, ids []string, edits []EnrichmentEdit) (State, error) {
a.mu.Lock() a.mu.Lock()
defer a.mu.Unlock() defer a.mu.Unlock()
p, ok := a.previews[id] p, ok := a.previews[id]
@@ -357,6 +367,17 @@ func (a *App) ApplyPreview(ctx context.Context, id, rev string, ids []string) (S
if len(selected) == 0 { if len(selected) == 0 {
return State{}, errors.New("select at least one change") return State{}, errors.New("select at least one change")
} }
edited := map[string]EnrichmentEdit{}
for _, e := range edits {
if !selected[e.ID] {
return State{}, errors.New("edited transaction is not selected")
}
edited[e.ID] = e
}
// Edits are validated against the dataset the change will land in, which
// includes merchants this preview mints only when the change is applied.
validation := s.Data
validation.Merchants = append(append([]domain.Merchant{}, s.Data.Merchants...), p.NewMerchants...)
applied := 0 applied := 0
needed := map[string]bool{} needed := map[string]bool{}
for i, t := range s.Data.Transactions { for i, t := range s.Data.Transactions {
@@ -367,8 +388,17 @@ func (a *App) ApplyPreview(ctx context.Context, id, rev string, ids []string) (S
if !enrichmentEqual(t.Enrichment, c.Before) { if !enrichmentEqual(t.Enrichment, c.Before) {
return State{}, errors.New("revision conflict: a selected transaction changed after the preview; analyse it again") return State{}, errors.New("revision conflict: a selected transaction changed after the preview; analyse it again")
} }
s.Data.Transactions[i].Enrichment = c.After after := c.After
needed[c.After.MerchantID] = true if e, ok := edited[t.Facts.ID]; ok {
after.CategoryID = e.CategoryID
after.TagIDs = append([]string{}, e.TagIDs...)
after.Classification = domain.Provenance{Source: "manual", Timestamp: time.Now().UTC().Format(time.RFC3339)}
if err := domain.ValidateEnrichment(validation, t.Facts, after); err != nil {
return State{}, fmt.Errorf("edited classification for %s is invalid: %w", t.Facts.ID, err)
}
}
s.Data.Transactions[i].Enrichment = after
needed[after.MerchantID] = true
applied++ applied++
} }
if applied != len(selected) { if applied != len(selected) {
+5 -4
View File
@@ -508,14 +508,15 @@ func (s *Server) previewProgress(w http.ResponseWriter, r *http.Request) {
} }
func (s *Server) apply(w http.ResponseWriter, r *http.Request) { func (s *Server) apply(w http.ResponseWriter, r *http.Request) {
var b struct { var b struct {
ID string `json:"id"` ID string `json:"id"`
Revision string `json:"revision"` Revision string `json:"revision"`
TransactionIDs []string `json:"transaction_ids"` TransactionIDs []string `json:"transaction_ids"`
Edits []app.EnrichmentEdit `json:"edits"`
} }
if !decode(w, r, &b) { if !decode(w, r, &b) {
return return
} }
v, e := s.app.ApplyPreview(r.Context(), b.ID, b.Revision, b.TransactionIDs) v, e := s.app.ApplyPreview(r.Context(), b.ID, b.Revision, b.TransactionIDs, b.Edits)
respond(w, v, e) respond(w, v, e)
} }
func (s *Server) cancel(w http.ResponseWriter, r *http.Request) { func (s *Server) cancel(w http.ResponseWriter, r *http.Request) {
+21 -81
View File
@@ -12,7 +12,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import type { Account, Institution, PreparedImport, State } from "./api"; import type { Account, Institution, PreparedImport, State } from "./api";
import { localInstant, money, request } from "./api"; import { localInstant, money, request } from "./api";
import { Empty, ErrorMessage, Field, FormActions, Modal } from "./ui"; import { Combobox, Empty, ErrorMessage, Field, FormActions, Modal } from "./ui";
import type { Mutate } from "./ui"; import type { Mutate } from "./ui";
interface Balance { interface Balance {
amount: string; amount: string;
@@ -1110,8 +1110,6 @@ function InstitutionSelect({
}) { }) {
const [institutions, setInstitutions] = useState<Institution[] | null>(null); const [institutions, setInstitutions] = useState<Institution[] | null>(null);
const [loadError, setLoadError] = useState(""); const [loadError, setLoadError] = useState("");
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
useEffect(() => { useEffect(() => {
setInstitutions(null); setInstitutions(null);
setLoadError(""); setLoadError("");
@@ -1147,90 +1145,32 @@ function InstitutionSelect({
/> />
</Field> </Field>
); );
const filter = query.trim().toLowerCase();
const matches = (institutions ?? []).filter((i) =>
i.name.toLowerCase().includes(filter),
);
const exact = filter
? matches.find((i) => i.name.toLowerCase() === filter)
: undefined;
const shown = exact
? [exact, ...matches.filter((i) => i !== exact).slice(0, 59)]
: matches.slice(0, 60);
const selected = institutions?.find((i) => i.name === value); const selected = institutions?.find((i) => i.name === value);
return ( return (
<Field <Field
label="Institution" label="Institution"
hint="Choose your bank as listed by Enable Banking." hint="Choose your bank as listed by Enable Banking."
> >
<div className="bank-select"> <Combobox
<input required
required disabled={!institutions}
role="combobox" options={(institutions ?? []).map((i) => ({
aria-expanded={open} value: i.name,
aria-autocomplete="list" label: i.name,
disabled={!institutions} icon: i.logo ? (
value={open ? query : value} <img src={i.logo} alt="" loading="lazy" />
placeholder={institutions ? "Search your bank" : "Loading banks…"} ) : (
onFocus={() => { <Landmark size={16} />
setQuery(""); ),
setOpen(true); }))}
}} value={value}
onChange={(e) => { onChange={(name) =>
setQuery(e.target.value); onChange(name, institutions?.find((i) => i.name === name)?.psu_types)
setOpen(true); }
}} placeholder={institutions ? "Search your bank" : "Loading banks…"}
onBlur={() => setOpen(false)} adornment={selected?.logo ? <img src={selected.logo} alt="" /> : null}
onKeyDown={(e) => { emptyText="No banks match your search."
if (e.key === "Escape") setOpen(false); />
if (e.key === "Enter" && open) {
e.preventDefault();
if (shown.length === 1) {
onChange(shown[0].name, shown[0].psu_types);
setOpen(false);
}
}
}}
/>
{selected?.logo && !open && (
<img className="bank-selected-logo" src={selected.logo} alt="" />
)}
{open && institutions && (
<ul className="bank-options" role="listbox">
{shown.map((i) => (
<li key={i.name}>
<button
type="button"
className="bank-option"
role="option"
aria-selected={i.name === value}
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
onChange(i.name, i.psu_types);
setOpen(false);
}}
>
{i.logo ? (
<img src={i.logo} alt="" loading="lazy" />
) : (
<Landmark size={16} />
)}
<span>{i.name}</span>
</button>
</li>
))}
{shown.length === 0 && (
<li className="bank-empty">No banks match “{query}”.</li>
)}
{matches.length > shown.length && (
<li className="bank-empty">
{matches.length - shown.length} more — keep typing to narrow
down.
</li>
)}
</ul>
)}
</div>
</Field> </Field>
); );
} }
+199 -9
View File
@@ -1,5 +1,12 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { Sparkles, ShieldCheck, Check, X, ArrowRight } from "lucide-react"; import {
Sparkles,
ShieldCheck,
Check,
X,
ArrowRight,
RotateCcw,
} from "lucide-react";
import type { import type {
Dataset, Dataset,
Enrichment, Enrichment,
@@ -9,6 +16,7 @@ import type {
} from "./api"; } from "./api";
import { categoryPath, money, request } from "./api"; import { categoryPath, money, request } from "./api";
import { import {
Combobox,
DateField, DateField,
Empty, Empty,
ErrorMessage, ErrorMessage,
@@ -39,6 +47,9 @@ export function Classification({
const [confirm, setConfirm] = useState(false); const [confirm, setConfirm] = useState(false);
const [running, setRunning] = useState<PreviewProgress | null>(null); const [running, setRunning] = useState<PreviewProgress | null>(null);
const runStart = useRef({ time: 0, analysed: 0 }); 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) => { const finalize = (result: Preview) => {
result.changes ??= []; result.changes ??= [];
result.errors ??= []; result.errors ??= [];
@@ -58,6 +69,7 @@ export function Classification({
(confidenceRank[b.after.classification.confidence || "low"] ?? 0), (confidenceRank[b.after.classification.confidence || "low"] ?? 0),
); );
setPreview(result); setPreview(result);
setEdits({});
setSelected( setSelected(
result.changes result.changes
.filter((change) => change.after.classification.confidence !== "low") .filter((change) => change.after.classification.confidence !== "low")
@@ -129,6 +141,7 @@ export function Classification({
setRunning(null); setRunning(null);
setPreview(null); setPreview(null);
setSelected([]); setSelected([]);
setEdits({});
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : String(err)); setError(err instanceof Error ? err.message : String(err));
} finally { } finally {
@@ -141,6 +154,34 @@ export function Classification({
merchants: [...state.data.merchants, ...preview.new_merchants], merchants: [...state.data.merchants, ...preview.new_merchants],
} }
: state.data; : 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 ( return (
<> <>
<div className="section-heading"> <div className="section-heading">
@@ -372,7 +413,9 @@ export function Classification({
<div> <div>
<h3>Review changes</h3> <h3>Review changes</h3>
<p> <p>
{selected.length} of {preview.changes.length} selected {selected.length} of {preview.changes.length} selected
correct any proposed category or tags in place; corrections
are recorded as manual classifications.
</p> </p>
</div> </div>
<div className="row-actions"> <div className="row-actions">
@@ -395,12 +438,13 @@ export function Classification({
{preview.changes.length ? ( {preview.changes.length ? (
<div className="preview-list"> <div className="preview-list">
{preview.changes.map((change) => ( {preview.changes.map((change) => (
<label <div
className={`preview-row ${selected.includes(change.id) ? "selected" : ""}`} className={`preview-row ${selected.includes(change.id) ? "selected" : ""}`}
key={change.id} key={change.id}
> >
<input <input
type="checkbox" type="checkbox"
aria-label={`Apply ${change.description || change.counterparty || change.id}`}
checked={selected.includes(change.id)} checked={selected.includes(change.id)}
disabled={busy} disabled={busy}
onChange={(e) => onChange={(e) =>
@@ -430,14 +474,17 @@ export function Classification({
label="Before" label="Before"
/> />
<ArrowRight size={18} /> <ArrowRight size={18} />
<EnrichmentView <CorrectionEditor
data={previewData} data={previewData}
value={change.after} change={change}
label="Proposed" value={effective(change)}
edited={change.id in edits}
disabled={busy}
onChange={(value) => correct(change, value)}
/> />
</div> </div>
</div> </div>
</label> </div>
))} ))}
</div> </div>
) : ( ) : (
@@ -495,8 +542,19 @@ export function Classification({
<p> <p>
This will replace the selected enrichment fields on{" "} This will replace the selected enrichment fields on{" "}
<strong>{selected.length} transactions</strong> in one journal <strong>{selected.length} transactions</strong> in one journal
commit. Unselected proposals will not be applied. Original bank commit.
facts remain unchanged. {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> </p>
<ErrorMessage error={error} /> <ErrorMessage error={error} />
</div> </div>
@@ -519,6 +577,9 @@ export function Classification({
id: preview.id, id: preview.id,
revision: preview.revision, revision: preview.revision,
transaction_ids: selected, transaction_ids: selected,
edits: selected
.filter((id) => id in edits)
.map((id) => ({ id, ...edits[id] })),
}); });
acceptState( acceptState(
result, result,
@@ -532,6 +593,13 @@ export function Classification({
? { ...preview, changes: remaining } ? { ...preview, changes: remaining }
: null, : null,
); );
setEdits((prev) =>
Object.fromEntries(
Object.entries(prev).filter(
([id]) => !selected.includes(id),
),
),
);
setSelected([]); setSelected([]);
setConfirm(false); setConfirm(false);
} catch (err) { } catch (err) {
@@ -589,6 +657,128 @@ function EnrichmentView({
</div> </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; the category list is limited to leaves of the change's
// kind because that is what validation will accept.
function CorrectionEditor({
data,
change,
value,
edited,
disabled,
onChange,
}: {
data: Dataset;
change: Preview["changes"][number];
value: CorrectionValue;
edited: boolean;
disabled: boolean;
onChange: (value: CorrectionValue) => void;
}) {
const categories = data.categories
.filter(
(c) =>
c.kind === change.after.kind &&
!data.categories.some((child) => child.parent_id === c.id),
)
.map((c) => ({ value: c.id, label: categoryPath(data, c.id) }));
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>
<Combobox
options={categories}
value={value.category_id}
disabled={disabled}
onChange={(category_id) => onChange({ ...value, category_id })}
placeholder="Search categories"
emptyText="No matching category. Create it in Categories first."
/>
</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 || !addable.length}
onChange={(id) =>
onChange({ ...value, tag_ids: [...value.tag_ids, id] })
}
placeholder={
data.tags.length
? "Add tag"
: "No tags yet — create them in Tags"
}
emptyText="No matching tag. Create it in Tags first."
/>
</div>
</dd>
</div>
</dl>
</div>
);
}
// remainingEstimate projects the finish time from the pace observed since // remainingEstimate projects the finish time from the pace observed since
// this page attached to the run; the server paces provider requests, so the // 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 // first sample is meaningless and re-attaching mid-run must not count work
+75 -11
View File
@@ -1997,24 +1997,38 @@ footer span:first-child {
.callback-details code { .callback-details code {
font-size: 10px; font-size: 10px;
} }
.bank-select { .combo {
position: relative; position: relative;
} }
.bank-select > input { .combo > input {
width: 100%; width: 100%;
padding-right: 40px; padding-right: 40px;
border: 1px solid #dbe2ea;
border-radius: 5px;
min-height: 39px;
padding-top: 10px;
padding-bottom: 10px;
padding-left: 11px;
min-width: 0;
color: #33445a;
background: #fff;
font-weight: 400;
} }
.bank-selected-logo { .combo-adornment {
position: absolute; position: absolute;
right: 11px; right: 11px;
top: 50%; top: 50%;
transform: translateY(-50%); transform: translateY(-50%);
pointer-events: none;
display: flex;
}
.combo-adornment img,
.combo-adornment svg {
width: 22px; width: 22px;
height: 22px; height: 22px;
object-fit: contain; object-fit: contain;
pointer-events: none;
} }
.bank-options { .combo-options {
position: absolute; position: absolute;
z-index: 30; z-index: 30;
top: calc(100% + 4px); top: calc(100% + 4px);
@@ -2030,7 +2044,7 @@ footer span:first-child {
max-height: 264px; max-height: 264px;
overflow-y: auto; overflow-y: auto;
} }
.bank-option { .combo-option {
display: flex; display: flex;
width: 100%; width: 100%;
align-items: center; align-items: center;
@@ -2044,23 +2058,73 @@ footer span:first-child {
font-size: 13px; font-size: 13px;
color: inherit; color: inherit;
} }
.bank-option:hover, .combo-option:hover,
.bank-option[aria-selected="true"] { .combo-option[aria-selected="true"] {
background: #f0f7f4; background: #f0f7f4;
} }
.bank-option img, .combo-option img,
.bank-option svg { .combo-option svg {
width: 22px; width: 22px;
height: 22px; height: 22px;
object-fit: contain; object-fit: contain;
flex: none; flex: none;
color: var(--muted); color: var(--muted);
} }
.bank-empty { .combo-empty {
padding: 8px 10px; padding: 8px 10px;
color: var(--muted); color: var(--muted);
font-size: 12px; font-size: 12px;
} }
/* The proposed side of a review row is editable in place: compact combobox
inputs so a correction fits the diff card, removable chips for tags. */
.diff-value .combo > input {
min-height: 31px;
padding: 6px 24px 6px 9px;
font-size: 12px;
}
.diff-value .combo-option {
font-size: 12px;
padding: 6px 9px;
}
.diff-edit-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-height: 22px;
}
.diff-edit-head .button {
padding: 2px 8px;
font-size: 10px;
}
.tag-edit {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
}
.tag-edit .combo {
flex: 1;
min-width: 130px;
}
.tag-chip {
display: inline-flex;
align-items: center;
gap: 5px;
border: 1px solid #cfe4da;
background: #fff;
color: #2c6d57;
border-radius: 20px;
padding: 3px 5px 3px 10px;
font-size: 11px;
font-weight: 600;
}
.tag-chip svg {
color: #7fa295;
}
.tag-chip:hover svg {
color: var(--danger);
}
.date-select { .date-select {
position: relative; position: relative;
} }
+103
View File
@@ -98,6 +98,109 @@ export function Field({
); );
} }
export interface ComboOption {
value: string;
label: string;
icon?: ReactNode;
}
// Combobox is a free-text input that autocompletes against a fixed option
// list: typing filters by label, Enter takes the exact or only match, and
// picking an option reports its value. The caller keeps working with stable
// ids while the user only ever sees names.
export function Combobox({
options,
value,
onChange,
placeholder,
disabled = false,
required = false,
adornment,
emptyText = "No matches.",
}: {
options: ComboOption[];
value: string;
onChange: (value: string) => void;
placeholder?: string;
disabled?: boolean;
required?: boolean;
adornment?: ReactNode;
emptyText?: string;
}) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const filter = query.trim().toLowerCase();
const matches = options.filter((o) => o.label.toLowerCase().includes(filter));
const exact = filter
? matches.find((o) => o.label.toLowerCase() === filter)
: undefined;
const shown = exact
? [exact, ...matches.filter((o) => o !== exact).slice(0, 59)]
: matches.slice(0, 60);
const selected = options.find((o) => o.value === value);
const pick = (v: string) => {
onChange(v);
setOpen(false);
};
return (
<div className="combo">
<input
required={required}
role="combobox"
aria-expanded={open}
aria-autocomplete="list"
disabled={disabled}
value={open ? query : (selected?.label ?? value)}
placeholder={placeholder}
onFocus={() => {
setQuery("");
setOpen(true);
}}
onChange={(e) => {
setQuery(e.target.value);
setOpen(true);
}}
onBlur={() => setOpen(false)}
onKeyDown={(e) => {
if (e.key === "Escape") setOpen(false);
if (e.key === "Enter" && open) {
e.preventDefault();
const hit = exact ?? (shown.length === 1 ? shown[0] : undefined);
if (hit) pick(hit.value);
}
}}
/>
{adornment && !open && (
<span className="combo-adornment">{adornment}</span>
)}
{open && (
<ul className="combo-options" role="listbox">
{shown.map((o) => (
<li key={o.value}>
<button
type="button"
className="combo-option"
role="option"
aria-selected={o.value === value}
onMouseDown={(e) => e.preventDefault()}
onClick={() => pick(o.value)}
>
{o.icon}
<span>{o.label}</span>
</button>
</li>
))}
{shown.length === 0 && <li className="combo-empty">{emptyText}</li>}
{matches.length > shown.length && (
<li className="combo-empty">
{matches.length - shown.length} more keep typing to narrow down.
</li>
)}
</ul>
)}
</div>
);
}
// Dates are handled as calendar days, never as instants: every helper works on // Dates are handled as calendar days, never as instants: every helper works on
// the ISO string's integer parts so a browser time zone can never shift a // the ISO string's integer parts so a browser time zone can never shift a
// booking date. "Sept" follows the four-letter form used in the journal UI. // booking date. "Sept" follows the four-letter form used in the journal UI.