From 2b2e01a8e2a94987bf4fe01a10d77c9232bd3188 Mon Sep 17 00:00:00 2001 From: ZProger Date: Thu, 20 Aug 2026 23:00:44 +0300 Subject: [PATCH] feat: add search to Transactions tab, spanning all history MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lets the user find a past purchase by keyword instead of paging through months manually — search hits description/merchant/notes and ignores the month filter, with a "jump to month" action on each result to view it in its normal monthly context. Co-Authored-By: Claude Sonnet 5 --- backend/app/api/routes/transactions.py | 15 +++- backend/tests/test_transactions.py | 51 +++++++++++ frontend/package.json | 2 +- frontend/src/api/transactions.ts | 1 + .../transactions/TransactionsTable.tsx | 26 +++++- frontend/src/lib/format.ts | 10 ++- frontend/src/lib/i18n.ts | 10 +++ frontend/src/pages/TransactionsPage.tsx | 90 +++++++++++++++---- 8 files changed, 182 insertions(+), 23 deletions(-) diff --git a/backend/app/api/routes/transactions.py b/backend/app/api/routes/transactions.py index 8664d59..ce3f9e8 100644 --- a/backend/app/api/routes/transactions.py +++ b/backend/app/api/routes/transactions.py @@ -2,7 +2,7 @@ from datetime import date as date_ from typing import Literal from fastapi import APIRouter, Depends, HTTPException, Query -from sqlalchemy import func, select +from sqlalchemy import func, or_, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload @@ -51,6 +51,7 @@ async def list_transactions( account_id: int | None = None, category_id: int | None = None, type: TransactionType | None = None, + search: str | None = Query(default=None, min_length=1, max_length=255), sort: Literal["date_desc", "amount_desc", "amount_asc"] = Query(default="date_desc"), page: int = Query(default=1, ge=1), page_size: int = Query(default=20, ge=1, le=200), @@ -80,6 +81,18 @@ async def list_transactions( if type is not None: stmt = stmt.where(Transaction.type == type) count_stmt = count_stmt.where(Transaction.type == type) + if search is not None: + # Lets the user find a transaction from any period by keyword (e.g. an + # item bought months ago) without knowing which month to look in first — + # matches description, merchant, and notes so any of those fields can surface it. + pattern = f"%{search.strip()}%" + search_clause = or_( + Transaction.description.ilike(pattern), + Transaction.merchant.ilike(pattern), + Transaction.notes.ilike(pattern), + ) + stmt = stmt.where(search_clause) + count_stmt = count_stmt.where(search_clause) total = (await session.execute(count_stmt)).scalar_one() diff --git a/backend/tests/test_transactions.py b/backend/tests/test_transactions.py index adb2646..51ac40f 100644 --- a/backend/tests/test_transactions.py +++ b/backend/tests/test_transactions.py @@ -111,6 +111,57 @@ async def test_list_filters_by_explicit_date_range(client: AsyncClient, account_ assert body["items"][0]["date"] == "2024-06-15" +async def test_search_matches_description_across_any_period_ignoring_month_filter( + client: AsyncClient, account_id, categories +): + """The point of search is finding a purchase from an unknown month without + already knowing which month to filter by, so it must not be scoped by the + year/month params even when the caller happens to also pass them.""" + category_id = categories["Groceries"]["id"] + for txn_date, description in [ + ("2021-03-12", "PlayStation 5 console"), + ("2024-11-02", "grocery run"), + ("2026-08-16", "current month coffee"), + ]: + resp = await client.post( + "/transactions", json=_txn(account_id, category_id=category_id, date=txn_date, description=description) + ) + assert resp.status_code == 201 + + resp = await client.get("/transactions", params={"search": "playstation"}) + body = resp.json() + assert body["total"] == 1 + assert body["items"][0]["description"] == "PlayStation 5 console" + + +async def test_search_matches_merchant_and_notes(client: AsyncClient, account_id, categories): + category_id = categories["Groceries"]["id"] + await client.post( + "/transactions", + json=_txn(account_id, category_id=category_id, description="misc", merchant="Best Buy"), + ) + await client.post( + "/transactions", + json=_txn(account_id, category_id=category_id, description="misc", notes="bought for a birthday gift"), + ) + + merchant_resp = await client.get("/transactions", params={"search": "best buy"}) + assert merchant_resp.json()["total"] == 1 + + notes_resp = await client.get("/transactions", params={"search": "birthday"}) + assert notes_resp.json()["total"] == 1 + + +async def test_search_with_no_matches_returns_empty(client: AsyncClient, account_id, categories): + category_id = categories["Groceries"]["id"] + await client.post("/transactions", json=_txn(account_id, category_id=category_id, description="groceries")) + + resp = await client.get("/transactions", params={"search": "nonexistent item xyz"}) + body = resp.json() + assert body["total"] == 0 + assert body["items"] == [] + + async def test_pagination_covers_every_item_with_no_overlap(client: AsyncClient, account_id, categories): category_id = categories["Groceries"]["id"] for day in range(1, 26): diff --git a/frontend/package.json b/frontend/package.json index f812081..30a300c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "aurum-frontend", "private": true, - "version": "0.20.2", + "version": "0.21.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/api/transactions.ts b/frontend/src/api/transactions.ts index 542ccfe..3964649 100644 --- a/frontend/src/api/transactions.ts +++ b/frontend/src/api/transactions.ts @@ -11,6 +11,7 @@ export interface TransactionFilters { account_id?: number; category_id?: number; type?: string; + search?: string; sort?: TransactionSort; page?: number; page_size?: number; diff --git a/frontend/src/components/transactions/TransactionsTable.tsx b/frontend/src/components/transactions/TransactionsTable.tsx index 94b27b9..8933efb 100644 --- a/frontend/src/components/transactions/TransactionsTable.tsx +++ b/frontend/src/components/transactions/TransactionsTable.tsx @@ -1,5 +1,5 @@ import { useState } from "react"; -import { ArrowLeftRight, Pencil, StickyNote, Trash2 } from "lucide-react"; +import { ArrowLeftRight, CalendarSearch, Pencil, StickyNote, Trash2 } from "lucide-react"; import { Dialog } from "@/components/ui/Dialog"; import { getCategoryIcon } from "@/lib/icons"; import { formatCurrency, formatTransactionDate } from "@/lib/format"; @@ -11,14 +11,22 @@ interface TransactionsTableProps { items: Transaction[]; onEdit: (transaction: Transaction) => void; onDelete: (transaction: Transaction) => void; + /** Present only while showing search results, which span every month — + * lets a row's date carry the year and offers a way to jump back to + * browsing that transaction's month instead of just listing it flat. */ + onJumpToMonth?: (transaction: Transaction) => void; } -export function TransactionsTable({ items, onEdit, onDelete }: TransactionsTableProps) { +export function TransactionsTable({ items, onEdit, onDelete, onJumpToMonth }: TransactionsTableProps) { const { t } = useTranslation(); const [noteTransaction, setNoteTransaction] = useState(null); if (items.length === 0) { - return

{t("transactions.noneFound")}

; + return ( +

+ {onJumpToMonth ? t("transactions.searchNoneFound") : t("transactions.noneFound")} +

+ ); } return ( @@ -42,7 +50,7 @@ export function TransactionsTable({ items, onEdit, onDelete }: TransactionsTable {tx.description} - {formatTransactionDate(tx.date)} · {tx.account.name} + {formatTransactionDate(tx.date, Boolean(onJumpToMonth))} · {tx.account.name} {isTransfer && tx.transfer_account_id ? ` ${t("transactions.transferSuffix")}` : ""} {tx.category ? ` · ${translateCategoryName(tx.category.name)}` : ""} @@ -58,6 +66,16 @@ export function TransactionsTable({ items, onEdit, onDelete }: TransactionsTable + {onJumpToMonth && ( + + )} {tx.notes && ( +
+ + setSearchInput(event.target.value)} + placeholder={t("transactions.searchPlaceholder")} + className="pl-9 pr-9" + /> + {searchInput && ( + + )} +
+ {isSearching &&

{t("transactions.searchAcrossAllTime")}

} +