feat(web): batch categorizer with auto-suggested categories + coverage

This commit is contained in:
Munkherdene 2026-08-23 00:35:21 +08:00
parent ed4f7ca97b
commit b9e58cb319
4 changed files with 415 additions and 71 deletions

View file

@ -4,11 +4,13 @@ import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { useCategories, useTransactions, todayLocalDate } from "@/api/hooks/reads";
import { useCategorize } from "@/api/hooks/mutations";
import type { Txn } from "@/api/schemas";
import type { Category, Txn } from "@/api/schemas";
import { Card, EmptyState, IconChip, MercuryButton } from "@/ds";
import { categoryStyle } from "@/ds/categoryStyle";
import { Icon } from "@/ds/icons";
import { MASKED, tugrikRaw } from "@/ds/money";
import { CategorizeSheet } from "./CategorizeSheet";
import { suggestCategory } from "./suggestCategory";
import { accountingStrings as s } from "./strings";
import { useHiddenAmounts } from "./useHiddenAmounts";
@ -26,6 +28,17 @@ function threeMonthsAgo(base: Date = new Date()): Date {
return new Date(base.getFullYear(), base.getMonth() - 3, base.getDate());
}
/** Spending transactions worth reviewing: excludes auto-detected salary
* deposits, any income, and inter-account transfers those aren't expenses
* to categorize. Shared by the uncategorized queue and the coverage
* denominator so both count the same population. */
function isReviewableSpend(t: Txn): boolean {
if (t.salary === true) return false;
if (t.direction === "income") return false;
if (t.transfer === true) return false;
return Boolean(t.matchKey || t.title);
}
/** Groups uncategorized transactions by matchKey, biggest total spend first
* a handful of taps then covers most of the uncategorized money instead of
* burning through many trivial merchants. Ports the grouping in
@ -34,11 +47,7 @@ function buildQueue(txns: Txn[]): ReviewItem[] {
const groups = new Map<string, ReviewItem>();
for (const t of txns) {
if (t.category) continue; // defensive — the fetch already scopes to Uncategorized
// Only uncategorized SPENDING needs sorting. Salary (auto-detected income),
// any income, and transfers between accounts are not expenses to categorize.
if (t.salary === true) continue;
if (t.direction === "income") continue;
if (t.transfer === true) continue;
if (!isReviewableSpend(t)) continue;
const key = t.matchKey || t.title;
if (!key) continue;
const amount = parseFloat(t.amount) || 0;
@ -59,11 +68,165 @@ function buildQueue(txns: Txn[]): ReviewItem[] {
return Array.from(groups.values()).sort((a, b) => b.total - a.total);
}
// Masked card strings like "554835******6886:13-08-2026 09:41:22" carry a
// trailing timestamp and no real merchant name — collapse them to a short
// "card ending in ####" label instead of dumping the raw string on the row.
const CARD_LIKE = /^(\d{3,})\*{2,}(\d{2,})/;
function prettyMerchant(raw: string): string {
const head = (raw.split(":")[0] ?? raw).trim();
const m = head.match(CARD_LIKE);
if (m) return `Карт •••• ${m[2]}`;
return head || raw;
}
interface Row extends ReviewItem {
displayName: string;
suggestion: string | null;
}
function toRows(queue: ReviewItem[], categories: Category[]): Row[] {
return queue.map((item) => ({
...item,
displayName: prettyMerchant(item.merchant),
suggestion: suggestCategory(item.merchant, categories),
}));
}
const checkSvg = (
<svg viewBox="0 0 24 24" width={18} height={18} fill="none" stroke="currentColor" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round">
<path d="M5 12.5l4.5 4.5L19 7" />
</svg>
);
function ProgressBar({ percent }: { percent: number }) {
return (
<div
style={{
height: 6,
borderRadius: 999,
background: "var(--seed-color-bg-neutral-subtle, #eef0f2)",
overflow: "hidden",
}}
>
<div
style={{
height: "100%",
width: `${Math.max(0, Math.min(100, percent))}%`,
borderRadius: 999,
background: "var(--mercury-success, #1e9e6b)",
transition: "width 300ms ease",
}}
/>
</div>
);
}
function ReviewRow({
row,
hidden,
busy,
removing,
onConfirm,
onPick,
}: {
row: Row;
hidden: boolean;
busy: boolean;
removing: boolean;
onConfirm: (row: Row) => void;
onPick: (row: Row) => void;
}) {
const chip = row.suggestion ? categoryStyle(row.suggestion, false) : null;
return (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 12,
padding: "10px 0",
opacity: removing ? 0 : 1,
transform: removing ? "translateX(6px)" : "translateX(0)",
transition: "opacity 220ms ease, transform 220ms ease",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 12, minWidth: 0 }}>
<IconChip
icon={chip?.icon ?? "receipt"}
tint={chip?.tint ?? "var(--seed-color-bg-neutral-subtle, #eef0f2)"}
fg={chip?.fg ?? "var(--seed-color-fg-neutral-muted, #8b8b8b)"}
/>
<div style={{ display: "flex", flexDirection: "column", gap: 2, minWidth: 0 }}>
<span style={{ fontSize: 15, fontWeight: 700, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{row.displayName}
</span>
<span style={{ fontSize: 12, color: "var(--seed-color-fg-neutral-muted, #8b8b8b)" }}>
×{row.count} · {hidden ? MASKED : tugrikRaw(row.total)}
</span>
</div>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 8, flexShrink: 0 }}>
<button
type="button"
onClick={() => onPick(row)}
disabled={busy}
style={{
all: "unset",
cursor: busy ? "default" : "pointer",
display: "flex",
alignItems: "center",
gap: 6,
padding: "8px 12px",
borderRadius: 999,
fontSize: 13,
fontWeight: 600,
opacity: busy ? 0.6 : 1,
background: chip?.tint ?? "var(--seed-color-bg-neutral-subtle, #eef0f2)",
color: chip?.fg ?? "var(--seed-color-fg-neutral-muted, #8b8b8b)",
}}
>
<Icon name={chip?.icon ?? "list"} size={14} />
{chip ? chip.name : s.review.pickCategory}
</button>
{row.suggestion ? (
<button
type="button"
aria-label={s.review.confirm}
onClick={() => onConfirm(row)}
disabled={busy}
style={{
all: "unset",
cursor: busy ? "default" : "pointer",
display: "grid",
placeItems: "center",
width: 32,
height: 32,
borderRadius: "50%",
opacity: busy ? 0.6 : 1,
background: "var(--mercury-success, #1e9e6b)",
color: "#fff",
}}
>
{checkSvg}
</button>
) : null}
</div>
</div>
);
}
/**
* Full-screen categorize-review flow at `/accounting/review` (ports
* `CategorizeReviewView.swift`): groups the last three months' uncategorized
* transactions by merchant, biggest spend first, and asks the user to assign
* or skip a category one merchant at a time.
* Batch categorize-review screen at `/accounting/review`: every uncategorized
* merchant from the last three months, biggest spend first, in one scannable
* list with an auto-suggested category per row. Confirming a suggestion (or
* picking a different category) posts a retroactive rule via `useCategorize`
* that categorizes all matching past + future transactions server-side the
* row then animates out. "Бүгдийг санал болгосноор ангилах" applies every
* confident suggestion in one pass. Replaces the old one-merchant-at-a-time
* `CategorizeReviewView.swift` port.
*/
export function CategorizeReview() {
const router = useRouter();
@ -72,40 +235,84 @@ export function CategorizeReview() {
return { from: todayLocalDate(threeMonthsAgo(now)), to: todayLocalDate(now) };
}, []);
const { data, isLoading } = useTransactions({ from, to, category: "Uncategorized", limit: 500 });
const { data: categories = [] } = useCategories();
// Same 3-month window, unfiltered — only used as the coverage bar's fixed
// denominator (total reviewable spend transactions), not re-rendered as a list.
const { data: allData } = useTransactions({ from, to, limit: 500 });
const { data: categoriesData } = useCategories();
const categories = categoriesData ?? [];
const categorize = useCategorize();
const hidden = useHiddenAmounts();
// The queue is seeded once from the fetch, then mutated locally (skip
// removes, assign removes on success) — re-deriving it from `data` on every
// background refetch (categorize invalidates the transactions cache) would
// otherwise re-insert items the user already handled in this session.
// The queue is seeded once from the fetch, then mutated locally (assign
// removes on success) — re-deriving it from `data` on every background
// refetch (categorize invalidates the transactions cache) would otherwise
// re-insert items the user already handled in this session.
const [queue, setQueue] = useState<ReviewItem[] | null>(null);
useEffect(() => {
if (data && queue === null) setQueue(buildQueue(data));
}, [data, queue]);
const [removingKeys, setRemovingKeys] = useState<Set<string>>(new Set());
const [pickerFor, setPickerFor] = useState<ReviewItem | null>(null);
const [bulkRunning, setBulkRunning] = useState(false);
const [bulkProgress, setBulkProgress] = useState({ done: 0, total: 0 });
const rows = useMemo(() => (queue ? toRows(queue, categories) : []), [queue, categories]);
const mainCategories = useMemo(() => categories.filter((c) => c.depth === 1), [categories]);
const totalSpend = useMemo(
() => (allData ? allData.filter(isReviewableSpend).length : null),
[allData],
);
const remainingCount = useMemo(() => rows.reduce((sum, r) => sum + r.count, 0), [rows]);
const percent =
totalSpend && totalSpend > 0 ? Math.round(((totalSpend - remainingCount) / totalSpend) * 100) : null;
function close() {
router.push("/accounting");
}
function skip(item: ReviewItem) {
setQueue((q) => (q ? q.filter((i) => i.matchKey !== item.matchKey) : q));
function removeItem(matchKey: string) {
setRemovingKeys((r) => new Set(r).add(matchKey));
setTimeout(() => {
setQueue((q) => (q ? q.filter((i) => i.matchKey !== matchKey) : q));
setRemovingKeys((r) => {
const next = new Set(r);
next.delete(matchKey);
return next;
});
}, 220);
}
function assign(item: ReviewItem, category: string) {
categorize.mutate(
{ matchKey: item.matchKey, category, kind: item.direction },
{ onSuccess: () => setQueue((q) => (q ? q.filter((i) => i.matchKey !== item.matchKey) : q)) },
{ onSuccess: () => removeItem(item.matchKey) },
);
}
const current = queue?.[0];
async function runBulk() {
const targets = rows.filter((r) => r.suggestion);
if (targets.length === 0) return;
setBulkRunning(true);
setBulkProgress({ done: 0, total: targets.length });
for (const row of targets) {
try {
await categorize.mutateAsync({ matchKey: row.matchKey, category: row.suggestion!, kind: row.direction });
removeItem(row.matchKey);
} catch {
// Leave it in the queue on failure so the user can retry it manually.
}
setBulkProgress((p) => ({ ...p, done: p.done + 1 }));
}
setBulkRunning(false);
}
const confidentCount = rows.filter((r) => r.suggestion).length;
const busy = categorize.isPending || bulkRunning;
return (
<div style={{ display: "flex", flexDirection: "column", gap: 20 }}>
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<h1 style={{ fontSize: 18, fontWeight: 700, margin: 0 }}>{s.review.title}</h1>
<button
@ -118,64 +325,60 @@ export function CategorizeReview() {
</div>
{isLoading && queue === null ? (
<Card style={{ minHeight: 320 }} />
) : current ? (
<Card style={{ minHeight: 200 }} />
) : rows.length > 0 ? (
<>
<p style={{ margin: 0, fontSize: 13, color: "var(--seed-color-fg-neutral-muted, #8b8b8b)" }}>
{s.review.remaining(queue!.length)}
</p>
<Card style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 8, padding: "24px 16px" }}>
<IconChip icon="cart" tint="var(--seed-color-bg-neutral-subtle, #eef0f2)" fg="var(--seed-color-fg-neutral)" size={56} />
<span style={{ fontSize: 16, fontWeight: 700, textAlign: "center" }}>{current.merchant}</span>
<span style={{ fontSize: 12, color: "var(--seed-color-fg-neutral-muted, #8b8b8b)" }}>
{s.review.transactionCount(current.count)} · {hidden ? MASKED : tugrikRaw(current.total)}
<Card style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<div style={{ display: "flex", alignItems: "baseline", justifyContent: "space-between" }}>
<span style={{ fontSize: 14, fontWeight: 700 }}>{s.review.remaining(remainingCount)}</span>
{percent !== null ? (
<span style={{ fontSize: 13, color: "var(--seed-color-fg-neutral-muted, #8b8b8b)" }}>
{s.review.coverage(percent)}
</span>
) : null}
</div>
{percent !== null ? <ProgressBar percent={percent} /> : null}
</Card>
<p style={{ margin: 0, fontSize: 14, fontWeight: 700 }}>{s.review.question}</p>
<div style={{ display: "flex", flexWrap: "wrap", gap: 10 }}>
{mainCategories.map((cat) => {
const style = categoryStyle(cat.name, cat.kind === "income");
return (
<button
key={cat.name}
type="button"
onClick={() => assign(current, cat.name)}
disabled={categorize.isPending}
style={{
all: "unset",
cursor: categorize.isPending ? "default" : "pointer",
display: "flex",
alignItems: "center",
gap: 6,
padding: "10px 14px",
borderRadius: 999,
fontSize: 14,
opacity: categorize.isPending ? 0.6 : 1,
background: "var(--seed-color-bg-neutral-subtle, #eef0f2)",
}}
>
<Icon name={style.icon} size={16} />
{style.name}
</button>
);
})}
</div>
<MercuryButton variant="secondary" onClick={() => skip(current)}>
{s.review.skip}
<MercuryButton variant="primary" onClick={runBulk} disabled={busy || confidentCount === 0}>
{bulkRunning ? s.review.bulkProgress(bulkProgress.done, bulkProgress.total) : s.review.bulkApply}
</MercuryButton>
<Card style={{ display: "flex", flexDirection: "column" }}>
{rows.map((row) => (
<ReviewRow
key={row.matchKey}
row={row}
hidden={hidden}
busy={busy}
removing={removingKeys.has(row.matchKey)}
onConfirm={(r) => assign(r, r.suggestion!)}
onPick={(r) => setPickerFor(r)}
/>
))}
</Card>
</>
) : (
<>
<EmptyState icon="calendar-check" title={s.review.done} />
<EmptyState icon="calendar-check" title={s.review.done} hint={s.review.doneHint} />
<MercuryButton variant="primary" onClick={close}>
{s.review.close}
</MercuryButton>
</>
)}
<CategorizeSheet
open={pickerFor !== null}
onOpenChange={(open) => {
if (!open) setPickerFor(null);
}}
categories={mainCategories}
selected={pickerFor ? (suggestCategory(pickerFor.merchant, categories) ?? undefined) : undefined}
onSelect={(cat) => {
if (pickerFor) assign(pickerFor, cat.name);
setPickerFor(null);
}}
/>
</div>
);
}

View file

@ -20,12 +20,16 @@ export const accountingStrings = {
review: {
title: "Ангилалжуулах",
later: "Дараа нь",
remaining: (n: number) => `${n} ангилалгүй худалдагч үлдлээ`,
question: "Аль ангилалд хамаарах вэ?",
skip: "Алгасах",
done: "Бүгд ангилагдлаа",
close: "Хаах",
remaining: (n: number) => `${n} ангилаагүй үлдлээ`,
coverage: (pct: number) => `${pct}% ангилагдсан`,
transactionCount: (n: number) => `${n} гүйлгээ`,
bulkApply: "Бүгдийг санал болгосноор ангилах",
bulkProgress: (done: number, total: number) => `${done}/${total} ангилж байна…`,
confirm: "Баталгаажуулах",
pickCategory: "Ангилал сонгох",
done: "Бүгд ангилагдлаа",
doneHint: "Шинэ гүйлгээ ирвэл дүрмийн дагуу автоматаар ангилагдана.",
close: "Хаах",
},
detail: {
total: "Нийт",

View file

@ -0,0 +1,81 @@
import { describe, it, expect } from "vitest";
import { suggestCategory } from "./suggestCategory";
import type { Category } from "@/api/schemas";
function cat(name: string, kind: "income" | "expense" = "expense", depth = 1): Category {
return { name, kind, depth };
}
const CATEGORIES: Category[] = [
cat("Food & Drink"),
cat("Coffee"),
cat("Transport"),
cat("Shopping"),
cat("Bills & Services"),
cat("Entertainment"),
cat("Groceries"),
cat("Insurance"),
];
describe("suggestCategory", () => {
it("matches loan/leasing keywords (Cyrillic)", () => {
expect(suggestCategory("ЛИЗИНГ ХХК", CATEGORIES)).toBe("Bills & Services"); // no "Loan" category present
});
it("prefers Loan when it exists", () => {
const withLoan = [...CATEGORIES, cat("Loan")];
expect(suggestCategory("ХААН ЗЭЭЛ ТӨЛБӨР", withLoan)).toBe("Loan");
});
it("matches insurance keywords", () => {
expect(suggestCategory("MONGOL ДААТГАЛ LLC", CATEGORIES)).toBe("Insurance");
expect(suggestCategory("SOME INSURANCE CO", CATEGORIES)).toBe("Insurance");
});
it("matches grocery keywords", () => {
expect(suggestCategory("NOMIN SUPERMARKET", CATEGORIES)).toBe("Groceries");
expect(suggestCategory("CU-24 CONVENIENCE", CATEGORIES)).toBe("Groceries");
expect(suggestCategory("ХҮНСНИЙ ДЭЛГҮҮР", CATEGORIES)).toBe("Groceries");
});
it("matches food & drink keywords", () => {
expect(suggestCategory("KFC ULAANBAATAR", CATEGORIES)).toBe("Food & Drink");
expect(suggestCategory("КАФЕ МОДЕРН", CATEGORIES)).toBe("Food & Drink");
});
it("matches a coffee-only merchant (no food keyword present)", () => {
expect(suggestCategory("TOM N TOMS COFFEE", CATEGORIES)).toBe("Coffee");
expect(suggestCategory("STARBUCKS COFFEE", CATEGORIES)).toBe("Coffee");
expect(suggestCategory("КОФЕ ЦЭГ", CATEGORIES)).toBe("Coffee");
});
it("matches transport keywords", () => {
expect(suggestCategory("UBCAB TRIP", CATEGORIES)).toBe("Transport");
expect(suggestCategory("ШАТАХУУНЫ СТАНЦ", CATEGORIES)).toBe("Transport");
});
it("matches shopping keywords", () => {
expect(suggestCategory("CONVERSE STORE", CATEGORIES)).toBe("Shopping");
// Cyrillic "НОМИН" doesn't match the Latin "NOMIN" keyword, so this only
// hits the Shopping rule's "ДЭЛГҮҮР" ("store") keyword.
expect(suggestCategory("НОМИН ДЭЛГҮҮР ХХК", CATEGORIES)).toBe("Shopping");
});
it("returns null for a masked card number", () => {
expect(suggestCategory("554835******6886:13-08-2026 09:41:22", CATEGORIES)).toBeNull();
expect(suggestCategory("1234******5678", CATEGORIES)).toBeNull();
});
it("returns null when nothing matches", () => {
expect(suggestCategory("SOME RANDOM MERCHANT XYZ", CATEGORIES)).toBeNull();
});
it("returns null when the only matching candidate category doesn't exist", () => {
const noInsurance = CATEGORIES.filter((c) => c.name !== "Insurance");
expect(suggestCategory("ДААТГАЛ", noInsurance)).toBeNull();
});
it("returns null for empty input", () => {
expect(suggestCategory("", CATEGORIES)).toBeNull();
});
});

View file

@ -0,0 +1,56 @@
import type { Category } from "@/api/schemas";
/** A merchant/matchKey string that is mostly a masked card number (e.g.
* `"554835******6886:13-08-2026 09:41:22"`) carries no merchant-name signal
* never suggest a category for these, let the user pick. */
function looksLikeCardNumber(s: string): boolean {
const head = s.split(":")[0] ?? s;
if (/^\d{4,}\*{2,}/.test(head)) return true;
const digits = (s.match(/\d/g) ?? []).length;
const stars = (s.match(/\*/g) ?? []).length;
return stars >= 2 && digits / Math.max(s.length, 1) > 0.4;
}
/** Keyword rule: if the merchant text matches `pattern`, suggest the first
* name in `candidates` that actually exists in the user's category list
* later candidates are fallbacks for accounts that don't have the specific
* category. Case-insensitive, Cyrillic + Latin. */
interface Rule {
pattern: RegExp;
candidates: string[];
}
// Kept small and ordered: earlier rules win when a merchant string matches
// more than one (e.g. a name containing both "ХООЛ" and "CAFE"). Add new
// merchants here rather than growing suggestCategory()'s logic.
const RULES: Rule[] = [
{ pattern: /ЛИЗИНГ|ЗЭЭЛ|ББСБ|LOAN/i, candidates: ["Loan", "Bills & Services"] },
{ pattern: /ДААТГАЛ|INSURANCE/i, candidates: ["Insurance"] },
{ pattern: /MART|МАРКЕТ|CU-|NOMIN|ХҮНС|GROCER/i, candidates: ["Groceries"] },
{ pattern: /ХООЛ|CAFE|КАФЕ|RESTAURANT|KFC|PIZZA/i, candidates: ["Food & Drink"] },
{ pattern: /КОФЕ|COFFEE|TOM N TOMS/i, candidates: ["Coffee"] },
{ pattern: /TAXI|UBCAB|ТЭЭВЭР|PETROL|ШАТАХУУН/i, candidates: ["Transport"] },
{ pattern: /STORE|SHOP|ДЭЛГҮҮР|CONVERSE/i, candidates: ["Shopping"] },
];
/**
* Suggests one of the user's existing categories for an uncategorized
* merchant, from a small keyword heuristic. Returns `null` (no suggestion
* the user picks manually) when nothing matches, or when every matching
* rule's candidates are all absent from `categories`, or when `merchant`
* looks like a masked card number rather than a real merchant name.
*/
export function suggestCategory(merchant: string, categories: Category[]): string | null {
const text = merchant?.trim();
if (!text) return null;
if (looksLikeCardNumber(text)) return null;
const names = new Set(categories.map((c) => c.name));
for (const rule of RULES) {
if (!rule.pattern.test(text)) continue;
for (const candidate of rule.candidates) {
if (names.has(candidate)) return candidate;
}
}
return null;
}