From ca531262f79ee98cfcd8414ec90375e1a98347df Mon Sep 17 00:00:00 2001 From: Le-King-Fu Date: Mon, 9 Feb 2026 00:02:51 +0000 Subject: [PATCH] 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 --- src/hooks/useTransactions.ts | 28 ++++++++++++++++++++- src/i18n/locales/en.json | 5 +++- src/i18n/locales/fr.json | 5 +++- src/pages/TransactionsPage.tsx | 35 +++++++++++++++++++++++++-- src/services/categorizationService.ts | 6 +++-- src/services/transactionService.ts | 26 ++++++++++++++++++++ 6 files changed, 98 insertions(+), 7 deletions(-) diff --git a/src/hooks/useTransactions.ts b/src/hooks/useTransactions.ts index fe7b676..a6989dd 100644 --- a/src/hooks/useTransactions.ts +++ b/src/hooks/useTransactions.ts @@ -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, }; } diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 5318edc..7ec0d4c 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -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", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 098abdd..cfdaf0a 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -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", diff --git a/src/pages/TransactionsPage.tsx b/src/pages/TransactionsPage.tsx index b63fab4..b34912a 100644 --- a/src/pages/TransactionsPage.tsx +++ b/src/pages/TransactionsPage.tsx @@ -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(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 (
-

{t("transactions.title")}

+
+

{t("transactions.title")}

+ + {resultMessage && ( + + {resultMessage} + + )} +
{ `SELECT * FROM import_sources ORDER BY name` ); } + +export async function autoCategorizeTransactions(): Promise { + const db = await getDb(); + const uncategorized = await db.select>( + `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; +}