feat: add auto-categorize button and fix keyword word-boundary matching

Replace substring matching (.includes) with \b word-boundary regex so
keywords like "Pay" no longer match "Payment". Add an auto-categorize
button on the transactions page that re-runs keyword matching on
uncategorized transactions and displays the result count.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Le-King-Fu 2026-02-09 00:02:51 +00:00
parent 84eca47afd
commit ca531262f7
6 changed files with 98 additions and 7 deletions

View file

@ -13,6 +13,7 @@ import {
updateTransactionNotes,
getAllCategories,
getAllImportSources,
autoCategorizeTransactions,
} from "../services/transactionService";
interface TransactionsState {
@ -28,6 +29,7 @@ interface TransactionsState {
categories: Category[];
sources: ImportSource[];
isLoading: boolean;
isAutoCategorizing: boolean;
error: string | null;
}
@ -41,7 +43,8 @@ type TransactionsAction =
| { type: "SET_CATEGORIES"; payload: Category[] }
| { type: "SET_SOURCES"; payload: ImportSource[] }
| { type: "UPDATE_ROW_CATEGORY"; payload: { txId: number; categoryId: number | null; categoryName: string | null; categoryColor: string | null } }
| { type: "UPDATE_ROW_NOTES"; payload: { txId: number; notes: string } };
| { type: "UPDATE_ROW_NOTES"; payload: { txId: number; notes: string } }
| { type: "SET_AUTO_CATEGORIZING"; payload: boolean };
const initialFilters: TransactionFilters = {
search: "",
@ -65,6 +68,7 @@ const initialState: TransactionsState = {
categories: [],
sources: [],
isLoading: false,
isAutoCategorizing: false,
error: null,
};
@ -120,6 +124,8 @@ function reducer(state: TransactionsState, action: TransactionsAction): Transact
r.id === action.payload.txId ? { ...r, notes: action.payload.notes } : r
),
};
case "SET_AUTO_CATEGORIZING":
return { ...state, isAutoCategorizing: action.payload };
default:
return state;
}
@ -268,6 +274,25 @@ export function useTransactions() {
[state.sort, state.page, state.pageSize, fetchData]
);
const autoCategorize = useCallback(async () => {
dispatch({ type: "SET_AUTO_CATEGORIZING", payload: true });
try {
const count = await autoCategorizeTransactions();
if (count > 0) {
fetchData(debouncedFiltersRef.current, state.sort, state.page, state.pageSize);
}
return count;
} catch (e) {
dispatch({
type: "SET_ERROR",
payload: e instanceof Error ? e.message : String(e),
});
return 0;
} finally {
dispatch({ type: "SET_AUTO_CATEGORIZING", payload: false });
}
}, [state.sort, state.page, state.pageSize, fetchData]);
return {
state,
setFilter,
@ -275,5 +300,6 @@ export function useTransactions() {
setPage,
updateCategory,
saveNotes,
autoCategorize,
};
}

View file

@ -165,7 +165,10 @@
},
"notes": {
"placeholder": "Add a note..."
}
},
"autoCategorize": "Auto-categorize",
"autoCategorizeResult": "{{count}} transaction(s) categorized",
"autoCategorizeNone": "No new matches found"
},
"categories": {
"title": "Categories",

View file

@ -165,7 +165,10 @@
},
"notes": {
"placeholder": "Ajouter une note..."
}
},
"autoCategorize": "Auto-catégoriser",
"autoCategorizeResult": "{{count}} transaction(s) catégorisée(s)",
"autoCategorizeNone": "Aucune correspondance trouvée"
},
"categories": {
"title": "Catégories",

View file

@ -1,4 +1,6 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Wand2 } from "lucide-react";
import { useTransactions } from "../hooks/useTransactions";
import TransactionFilterBar from "../components/transactions/TransactionFilterBar";
import TransactionSummaryBar from "../components/transactions/TransactionSummaryBar";
@ -7,12 +9,41 @@ import TransactionPagination from "../components/transactions/TransactionPaginat
export default function TransactionsPage() {
const { t } = useTranslation();
const { state, setFilter, setSort, setPage, updateCategory, saveNotes } =
const { state, setFilter, setSort, setPage, updateCategory, saveNotes, autoCategorize } =
useTransactions();
const [resultMessage, setResultMessage] = useState<string | null>(null);
const handleAutoCategorize = async () => {
setResultMessage(null);
const count = await autoCategorize();
if (count > 0) {
setResultMessage(t("transactions.autoCategorizeResult", { count }));
} else {
setResultMessage(t("transactions.autoCategorizeNone"));
}
setTimeout(() => setResultMessage(null), 4000);
};
return (
<div>
<h1 className="text-2xl font-bold mb-6">{t("transactions.title")}</h1>
<div className="flex items-center gap-3 mb-6">
<h1 className="text-2xl font-bold">{t("transactions.title")}</h1>
<button
onClick={handleAutoCategorize}
disabled={state.isAutoCategorizing}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-lg bg-[var(--primary)] text-white hover:opacity-90 disabled:opacity-50 transition-opacity"
>
<Wand2 size={16} />
{state.isAutoCategorizing
? t("common.loading")
: t("transactions.autoCategorize")}
</button>
{resultMessage && (
<span className="text-sm text-[var(--muted-foreground)]">
{resultMessage}
</span>
)}
</div>
<TransactionFilterBar
filters={state.filters}

View file

@ -37,7 +37,8 @@ export async function categorizeDescription(
for (const kw of keywords) {
const normalizedKeyword = normalizeDescription(kw.keyword);
if (normalized.includes(normalizedKeyword)) {
const escaped = normalizedKeyword.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
if (new RegExp(`\\b${escaped}\\b`).test(normalized)) {
return {
category_id: kw.category_id,
supplier_id: kw.supplier_id ?? null,
@ -64,7 +65,8 @@ export async function categorizeBatch(
const normalized = normalizeDescription(desc);
for (const kw of keywords) {
const normalizedKeyword = normalizeDescription(kw.keyword);
if (normalized.includes(normalizedKeyword)) {
const escaped = normalizedKeyword.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
if (new RegExp(`\\b${escaped}\\b`).test(normalized)) {
return {
category_id: kw.category_id,
supplier_id: kw.supplier_id ?? null,

View file

@ -1,4 +1,5 @@
import { getDb } from "./db";
import { categorizeBatch } from "./categorizationService";
import type {
Transaction,
TransactionRow,
@ -233,3 +234,28 @@ export async function getAllImportSources(): Promise<ImportSource[]> {
`SELECT * FROM import_sources ORDER BY name`
);
}
export async function autoCategorizeTransactions(): Promise<number> {
const db = await getDb();
const uncategorized = await db.select<Array<{ id: number; description: string }>>(
`SELECT id, description FROM transactions WHERE category_id IS NULL AND is_manually_categorized = 0`
);
if (uncategorized.length === 0) return 0;
const results = await categorizeBatch(uncategorized.map((tx) => tx.description));
let count = 0;
for (let i = 0; i < uncategorized.length; i++) {
const result = results[i];
if (result.category_id !== null) {
await db.execute(
`UPDATE transactions SET category_id = $1, supplier_id = $2, updated_at = CURRENT_TIMESTAMP WHERE id = $3`,
[result.category_id, result.supplier_id, uncategorized[i].id]
);
count++;
}
}
return count;
}