feat: add search to Transactions tab, spanning all history

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 <noreply@anthropic.com>
This commit is contained in:
ZProger
2026-08-20 23:00:44 +03:00
parent b9ff3ab404
commit 2b2e01a8e2
8 changed files with 182 additions and 23 deletions

View File

@@ -2,7 +2,7 @@ from datetime import date as date_
from typing import Literal from typing import Literal
from fastapi import APIRouter, Depends, HTTPException, Query 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.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
@@ -51,6 +51,7 @@ async def list_transactions(
account_id: int | None = None, account_id: int | None = None,
category_id: int | None = None, category_id: int | None = None,
type: TransactionType | 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"), sort: Literal["date_desc", "amount_desc", "amount_asc"] = Query(default="date_desc"),
page: int = Query(default=1, ge=1), page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=200), page_size: int = Query(default=20, ge=1, le=200),
@@ -80,6 +81,18 @@ async def list_transactions(
if type is not None: if type is not None:
stmt = stmt.where(Transaction.type == type) stmt = stmt.where(Transaction.type == type)
count_stmt = count_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() total = (await session.execute(count_stmt)).scalar_one()

View File

@@ -111,6 +111,57 @@ async def test_list_filters_by_explicit_date_range(client: AsyncClient, account_
assert body["items"][0]["date"] == "2024-06-15" 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): async def test_pagination_covers_every_item_with_no_overlap(client: AsyncClient, account_id, categories):
category_id = categories["Groceries"]["id"] category_id = categories["Groceries"]["id"]
for day in range(1, 26): for day in range(1, 26):

View File

@@ -1,7 +1,7 @@
{ {
"name": "aurum-frontend", "name": "aurum-frontend",
"private": true, "private": true,
"version": "0.20.2", "version": "0.21.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

View File

@@ -11,6 +11,7 @@ export interface TransactionFilters {
account_id?: number; account_id?: number;
category_id?: number; category_id?: number;
type?: string; type?: string;
search?: string;
sort?: TransactionSort; sort?: TransactionSort;
page?: number; page?: number;
page_size?: number; page_size?: number;

View File

@@ -1,5 +1,5 @@
import { useState } from "react"; 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 { Dialog } from "@/components/ui/Dialog";
import { getCategoryIcon } from "@/lib/icons"; import { getCategoryIcon } from "@/lib/icons";
import { formatCurrency, formatTransactionDate } from "@/lib/format"; import { formatCurrency, formatTransactionDate } from "@/lib/format";
@@ -11,14 +11,22 @@ interface TransactionsTableProps {
items: Transaction[]; items: Transaction[];
onEdit: (transaction: Transaction) => void; onEdit: (transaction: Transaction) => void;
onDelete: (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 { t } = useTranslation();
const [noteTransaction, setNoteTransaction] = useState<Transaction | null>(null); const [noteTransaction, setNoteTransaction] = useState<Transaction | null>(null);
if (items.length === 0) { if (items.length === 0) {
return <p className="py-12 text-center text-sm text-text-muted">{t("transactions.noneFound")}</p>; return (
<p className="py-12 text-center text-sm text-text-muted">
{onJumpToMonth ? t("transactions.searchNoneFound") : t("transactions.noneFound")}
</p>
);
} }
return ( return (
@@ -42,7 +50,7 @@ export function TransactionsTable({ items, onEdit, onDelete }: TransactionsTable
<span className="min-w-0 flex-1"> <span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-text-primary">{tx.description}</span> <span className="block truncate text-sm font-medium text-text-primary">{tx.description}</span>
<span className="block truncate text-xs text-text-muted"> <span className="block truncate text-xs text-text-muted">
{formatTransactionDate(tx.date)} · {tx.account.name} {formatTransactionDate(tx.date, Boolean(onJumpToMonth))} · {tx.account.name}
{isTransfer && tx.transfer_account_id ? ` ${t("transactions.transferSuffix")}` : ""} {isTransfer && tx.transfer_account_id ? ` ${t("transactions.transferSuffix")}` : ""}
{tx.category ? ` · ${translateCategoryName(tx.category.name)}` : ""} {tx.category ? ` · ${translateCategoryName(tx.category.name)}` : ""}
</span> </span>
@@ -58,6 +66,16 @@ export function TransactionsTable({ items, onEdit, onDelete }: TransactionsTable
</span> </span>
<span className="flex shrink-0 gap-1"> <span className="flex shrink-0 gap-1">
{onJumpToMonth && (
<button
type="button"
aria-label={t("transactions.jumpToMonth")}
onClick={() => onJumpToMonth(tx)}
className="rounded-md p-1.5 text-text-muted hover:bg-surface-2 hover:text-text-primary"
>
<CalendarSearch size={15} />
</button>
)}
{tx.notes && ( {tx.notes && (
<button <button
type="button" type="button"

View File

@@ -30,9 +30,15 @@ export function getMonthLabels(language: Language): readonly string[] {
return language === "ru" ? MONTH_LABELS_RU : MONTH_LABELS_EN; return language === "ru" ? MONTH_LABELS_RU : MONTH_LABELS_EN;
} }
export function formatTransactionDate(isoDate: string): string { /** `includeYear` is for contexts spanning multiple years (e.g. an all-time
* search) where "Aug 16" alone wouldn't say which year. */
export function formatTransactionDate(isoDate: string, includeYear = false): string {
const date = new Date(`${isoDate}T00:00:00`); const date = new Date(`${isoDate}T00:00:00`);
return new Intl.DateTimeFormat(getIntlLocale(), { month: "short", day: "numeric" }).format(date); return new Intl.DateTimeFormat(getIntlLocale(), {
month: "short",
day: "numeric",
year: includeYear ? "numeric" : undefined,
}).format(date);
} }
/** Russian noun pluralization: pick the right form for 1/2-4/5+ (with the /** Russian noun pluralization: pick the right form for 1/2-4/5+ (with the

View File

@@ -29,6 +29,7 @@ const ru = {
"common.loading": "Загрузка…", "common.loading": "Загрузка…",
"common.back": "Назад", "common.back": "Назад",
"common.next": "Далее", "common.next": "Далее",
"common.clear": "Очистить",
"common.cancel": "Отмена", "common.cancel": "Отмена",
"common.save": "Сохранить", "common.save": "Сохранить",
"common.saving": "Сохранение…", "common.saving": "Сохранение…",
@@ -138,6 +139,10 @@ const ru = {
"transactions.allCategories": "Все категории", "transactions.allCategories": "Все категории",
"transactions.failedToLoad": "Не удалось загрузить транзакции.", "transactions.failedToLoad": "Не удалось загрузить транзакции.",
"transactions.noneFound": "Транзакций не найдено.", "transactions.noneFound": "Транзакций не найдено.",
"transactions.searchPlaceholder": "Поиск по описанию, продавцу или заметке…",
"transactions.searchAcrossAllTime": "Поиск ведётся по всем периодам, а не только за выбранный месяц.",
"transactions.searchNoneFound": "Ничего не найдено. Попробуйте другой запрос.",
"transactions.jumpToMonth": "Перейти к этому месяцу",
"transactions.confirmDelete": "Удалить транзакцию «{{description}}»?", "transactions.confirmDelete": "Удалить транзакцию «{{description}}»?",
"transactions.transferSuffix": "→ перевод", "transactions.transferSuffix": "→ перевод",
"transactions.sortDateDesc": "Сначала новые", "transactions.sortDateDesc": "Сначала новые",
@@ -358,6 +363,7 @@ const en: Record<keyof typeof ru, string> = {
"common.loading": "Loading…", "common.loading": "Loading…",
"common.back": "Back", "common.back": "Back",
"common.next": "Next", "common.next": "Next",
"common.clear": "Clear",
"common.cancel": "Cancel", "common.cancel": "Cancel",
"common.save": "Save", "common.save": "Save",
"common.saving": "Saving…", "common.saving": "Saving…",
@@ -467,6 +473,10 @@ const en: Record<keyof typeof ru, string> = {
"transactions.allCategories": "All categories", "transactions.allCategories": "All categories",
"transactions.failedToLoad": "Failed to load transactions.", "transactions.failedToLoad": "Failed to load transactions.",
"transactions.noneFound": "No transactions found.", "transactions.noneFound": "No transactions found.",
"transactions.searchPlaceholder": "Search by description, merchant, or note…",
"transactions.searchAcrossAllTime": "Searching across every period, not just the selected month.",
"transactions.searchNoneFound": "Nothing found. Try a different search.",
"transactions.jumpToMonth": "Jump to this month",
"transactions.confirmDelete": "Delete transaction “{{description}}”?", "transactions.confirmDelete": "Delete transaction “{{description}}”?",
"transactions.transferSuffix": "→ transfer", "transactions.transferSuffix": "→ transfer",
"transactions.sortDateDesc": "Newest first", "transactions.sortDateDesc": "Newest first",

View File

@@ -1,9 +1,9 @@
import { useState } from "react"; import { useEffect, useState } from "react";
import { useSearchParams } from "react-router-dom"; import { useSearchParams } from "react-router-dom";
import { Plus } from "lucide-react"; import { Plus, Search, X } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
import { Select } from "@/components/ui/Input"; import { Input, Select } from "@/components/ui/Input";
import { MonthSelector } from "@/components/layout/MonthSelector"; import { MonthSelector } from "@/components/layout/MonthSelector";
import { YearSelector } from "@/components/layout/YearSelector"; import { YearSelector } from "@/components/layout/YearSelector";
import { TransactionsTable } from "@/components/transactions/TransactionsTable"; import { TransactionsTable } from "@/components/transactions/TransactionsTable";
@@ -47,14 +47,30 @@ export function TransactionsPage() {
const [sort, setSort] = useState<TransactionSort>("date_desc"); const [sort, setSort] = useState<TransactionSort>("date_desc");
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
// Raw text follows every keystroke; the debounced value is what actually
// drives the query, so we're not refetching on every character typed.
const [searchInput, setSearchInput] = useState("");
const [search, setSearch] = useState("");
useEffect(() => {
const timeout = setTimeout(() => {
setSearch(searchInput.trim());
setPage(1);
}, 300);
return () => clearTimeout(timeout);
}, [searchInput]);
const isSearching = search.length > 0;
const [modalOpen, setModalOpen] = useState(false); const [modalOpen, setModalOpen] = useState(false);
const [editingTransaction, setEditingTransaction] = useState<Transaction | null>(null); const [editingTransaction, setEditingTransaction] = useState<Transaction | null>(null);
const { data: categories } = useCategories(); const { data: categories } = useCategories();
const { data: years } = useTransactionYears(); const { data: years } = useTransactionYears();
const { data, isLoading, isError } = useTransactions({ const { data, isLoading, isError } = useTransactions({
year, // A search looks for a purchase from an unknown month, so it must span
month, // every period instead of being boxed into the currently selected one.
year: isSearching ? undefined : year,
month: isSearching ? undefined : month,
search: isSearching ? search : undefined,
type: type || undefined, type: type || undefined,
category_id: categoryId ? Number(categoryId) : undefined, category_id: categoryId ? Number(categoryId) : undefined,
sort, sort,
@@ -81,11 +97,23 @@ export function TransactionsPage() {
} }
} }
/** Leaves search mode and switches the month/year selectors to whichever
* month the picked transaction is in, so the user lands back in the normal
* browsing view with it in context instead of a flat result list. */
function handleJumpToMonth(transaction: Transaction) {
const date = new Date(`${transaction.date}T00:00:00`);
setYear(date.getFullYear());
setMonth(date.getMonth() + 1);
setSearchInput("");
setSearch("");
setPage(1);
}
return ( return (
<div className="space-y-5"> <div className="space-y-5">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex min-w-0 flex-1 items-center gap-3"> <div className="flex min-w-0 flex-1 items-center gap-3">
<div className="min-w-0 flex-1"> <div className={`min-w-0 flex-1 ${isSearching ? "pointer-events-none opacity-50" : ""}`}>
<MonthSelector <MonthSelector
month={month} month={month}
onChange={(value) => { onChange={(value) => {
@@ -94,6 +122,7 @@ export function TransactionsPage() {
}} }}
/> />
</div> </div>
<div className={isSearching ? "pointer-events-none opacity-50" : ""}>
<YearSelector <YearSelector
years={years ?? [now.getFullYear()]} years={years ?? [now.getFullYear()]}
year={year} year={year}
@@ -103,12 +132,38 @@ export function TransactionsPage() {
}} }}
/> />
</div> </div>
</div>
<Button onClick={openCreateModal} className="w-full sm:w-auto"> <Button onClick={openCreateModal} className="w-full sm:w-auto">
<Plus size={16} /> <Plus size={16} />
{t("transactions.addButton")} {t("transactions.addButton")}
</Button> </Button>
</div> </div>
<div className="relative">
<Search size={16} className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-text-muted" />
<Input
value={searchInput}
onChange={(event) => setSearchInput(event.target.value)}
placeholder={t("transactions.searchPlaceholder")}
className="pl-9 pr-9"
/>
{searchInput && (
<button
type="button"
aria-label={t("common.clear")}
onClick={() => {
setSearchInput("");
setSearch("");
setPage(1);
}}
className="absolute right-2 top-1/2 -translate-y-1/2 rounded-md p-1 text-text-muted hover:bg-surface-2 hover:text-text-primary"
>
<X size={15} />
</button>
)}
</div>
{isSearching && <p className="text-xs text-text-muted">{t("transactions.searchAcrossAllTime")}</p>}
<div className="flex flex-col gap-3 sm:flex-row"> <div className="flex flex-col gap-3 sm:flex-row">
<Select <Select
value={type} value={type}
@@ -162,7 +217,12 @@ export function TransactionsPage() {
{isLoading ? ( {isLoading ? (
<p className="py-12 text-center text-sm text-text-muted">{t("common.loading")}</p> <p className="py-12 text-center text-sm text-text-muted">{t("common.loading")}</p>
) : ( ) : (
<TransactionsTable items={data?.items ?? []} onEdit={openEditModal} onDelete={handleDelete} /> <TransactionsTable
items={data?.items ?? []}
onEdit={openEditModal}
onDelete={handleDelete}
onJumpToMonth={isSearching ? handleJumpToMonth : undefined}
/>
)} )}
{totalPages > 1 && ( {totalPages > 1 && (