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, updateTransactionNotes,
getAllCategories, getAllCategories,
getAllImportSources, getAllImportSources,
autoCategorizeTransactions,
} from "../services/transactionService"; } from "../services/transactionService";
interface TransactionsState { interface TransactionsState {
@ -28,6 +29,7 @@ interface TransactionsState {
categories: Category[]; categories: Category[];
sources: ImportSource[]; sources: ImportSource[];
isLoading: boolean; isLoading: boolean;
isAutoCategorizing: boolean;
error: string | null; error: string | null;
} }
@ -41,7 +43,8 @@ type TransactionsAction =
| { type: "SET_CATEGORIES"; payload: Category[] } | { type: "SET_CATEGORIES"; payload: Category[] }
| { type: "SET_SOURCES"; payload: ImportSource[] } | { type: "SET_SOURCES"; payload: ImportSource[] }
| { type: "UPDATE_ROW_CATEGORY"; payload: { txId: number; categoryId: number | null; categoryName: string | null; categoryColor: string | null } } | { 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 = { const initialFilters: TransactionFilters = {
search: "", search: "",
@ -65,6 +68,7 @@ const initialState: TransactionsState = {
categories: [], categories: [],
sources: [], sources: [],
isLoading: false, isLoading: false,
isAutoCategorizing: false,
error: null, error: null,
}; };
@ -120,6 +124,8 @@ function reducer(state: TransactionsState, action: TransactionsAction): Transact
r.id === action.payload.txId ? { ...r, notes: action.payload.notes } : r r.id === action.payload.txId ? { ...r, notes: action.payload.notes } : r
), ),
}; };
case "SET_AUTO_CATEGORIZING":
return { ...state, isAutoCategorizing: action.payload };
default: default:
return state; return state;
} }
@ -268,6 +274,25 @@ export function useTransactions() {
[state.sort, state.page, state.pageSize, fetchData] [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 { return {
state, state,
setFilter, setFilter,
@ -275,5 +300,6 @@ export function useTransactions() {
setPage, setPage,
updateCategory, updateCategory,
saveNotes, saveNotes,
autoCategorize,
}; };
} }

View file

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

View file

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

View file

@ -1,4 +1,6 @@
import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Wand2 } from "lucide-react";
import { useTransactions } from "../hooks/useTransactions"; import { useTransactions } from "../hooks/useTransactions";
import TransactionFilterBar from "../components/transactions/TransactionFilterBar"; import TransactionFilterBar from "../components/transactions/TransactionFilterBar";
import TransactionSummaryBar from "../components/transactions/TransactionSummaryBar"; import TransactionSummaryBar from "../components/transactions/TransactionSummaryBar";
@ -7,12 +9,41 @@ import TransactionPagination from "../components/transactions/TransactionPaginat
export default function TransactionsPage() { export default function TransactionsPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const { state, setFilter, setSort, setPage, updateCategory, saveNotes } = const { state, setFilter, setSort, setPage, updateCategory, saveNotes, autoCategorize } =
useTransactions(); 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 ( return (
<div> <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 <TransactionFilterBar
filters={state.filters} filters={state.filters}

View file

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

View file

@ -1,4 +1,5 @@
import { getDb } from "./db"; import { getDb } from "./db";
import { categorizeBatch } from "./categorizationService";
import type { import type {
Transaction, Transaction,
TransactionRow, TransactionRow,
@ -233,3 +234,28 @@ export async function getAllImportSources(): Promise<ImportSource[]> {
`SELECT * FROM import_sources ORDER BY name` `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;
}