feat: add free-form tags on transactions

Third of the four ideas picked from the Maybe/Sure feature audit. Tags are
orthogonal to categories (category = what kind of spend, tag = which
event/project) — many-to-many, created inline while typing in the
transaction form, filterable from the Transactions page.

Also adds tests/test_backup.py, the first test coverage the backup
export/import path has ever had, and fixes a MissingGreenlet bug it
surfaced in restore_backup's tag-wiring.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
ZProger
2026-08-25 02:38:04 +03:00
parent 09a0e383e0
commit 94782ee073
24 changed files with 585 additions and 10 deletions

14
frontend/src/api/tags.ts Normal file
View File

@@ -0,0 +1,14 @@
import { api } from "@/api/client";
import type { Tag } from "@/types";
export function fetchTags() {
return api.get<Tag[]>("/tags");
}
export function createTag(name: string) {
return api.post<Tag>("/tags", { name });
}
export function deleteTag(id: number) {
return api.delete<void>(`/tags/${id}`);
}

View File

@@ -10,6 +10,7 @@ export interface TransactionFilters {
end_date?: string;
account_id?: number;
category_id?: number;
tag_id?: number;
type?: string;
search?: string;
sort?: TransactionSort;

View File

@@ -0,0 +1,132 @@
import { useRef, useState } from "react";
import { X } from "lucide-react";
import { useCreateTag, useTags } from "@/hooks/useTags";
import { useTranslation } from "@/lib/i18n";
import { cn } from "@/lib/utils";
import type { Tag } from "@/types";
interface TagInputProps {
value: Tag[];
onChange: (tags: Tag[]) => void;
}
// Free-text, multi-select tag picker with inline create — types like an
// autocomplete, Enter either picks the top matching existing tag or creates
// a new one (the backend dedupes case-insensitively either way, see
// routes/tags.py). No dedicated tag-management page yet: renaming/deleting a
// tag globally isn't exposed here, only removing it from this transaction.
export function TagInput({ value, onChange }: TagInputProps) {
const { t } = useTranslation();
const { data: allTags } = useTags();
const createTag = useCreateTag();
const [query, setQuery] = useState("");
const [open, setOpen] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const selectedIds = new Set(value.map((tag) => tag.id));
const trimmedQuery = query.trim();
const suggestions = (allTags ?? [])
.filter((tag) => !selectedIds.has(tag.id))
.filter((tag) => (trimmedQuery ? tag.name.toLowerCase().includes(trimmedQuery.toLowerCase()) : true))
.slice(0, 8);
const exactMatch = (allTags ?? []).find((tag) => tag.name.toLowerCase() === trimmedQuery.toLowerCase());
function addTag(tag: Tag) {
onChange([...value, tag]);
setQuery("");
setOpen(false);
inputRef.current?.focus();
}
function removeTag(tagId: number) {
onChange(value.filter((tag) => tag.id !== tagId));
}
async function commitQuery() {
if (!trimmedQuery) return;
if (exactMatch) {
addTag(exactMatch);
return;
}
const created = await createTag.mutateAsync(trimmedQuery);
addTag(created);
}
function handleKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {
if (event.key === "Enter") {
event.preventDefault();
if (suggestions[0]) addTag(suggestions[0]);
else void commitQuery();
} else if (event.key === "Escape") {
setOpen(false);
} else if (event.key === "Backspace" && !query && value.length > 0) {
removeTag(value[value.length - 1].id);
}
}
return (
<div className="relative">
<div className="flex flex-wrap items-center gap-1.5 rounded-lg border border-border bg-surface-1 px-2 py-1.5 focus-within:border-series-1">
{value.map((tag) => (
<span
key={tag.id}
className="flex items-center gap-1 rounded-full bg-surface-2 py-0.5 pl-2 pr-1 text-xs text-text-secondary"
>
{tag.name}
<button
type="button"
aria-label={t("transactions.form.removeTag", { name: tag.name })}
onClick={() => removeTag(tag.id)}
className="rounded-full p-0.5 text-text-muted hover:bg-surface-1 hover:text-text-primary"
>
<X size={11} />
</button>
</span>
))}
<input
ref={inputRef}
className="h-6 min-w-[8ch] flex-1 border-none bg-transparent text-sm text-text-primary outline-none placeholder:text-text-muted"
value={query}
placeholder={value.length === 0 ? t("transactions.form.tagsPlaceholder") : ""}
onChange={(event) => {
setQuery(event.target.value);
setOpen(true);
}}
onFocus={() => setOpen(true)}
onBlur={() => setTimeout(() => setOpen(false), 100)}
onKeyDown={handleKeyDown}
/>
</div>
{open && (suggestions.length > 0 || (trimmedQuery && !exactMatch)) && (
<ul className="absolute z-10 mt-1 w-full overflow-hidden rounded-lg border border-border bg-surface-1 shadow-lg">
{suggestions.map((tag) => (
<li key={tag.id}>
<button
type="button"
onMouseDown={(event) => event.preventDefault()}
onClick={() => addTag(tag)}
className={cn("block w-full px-3 py-1.5 text-left text-sm text-text-primary hover:bg-surface-2")}
>
{tag.name}
</button>
</li>
))}
{trimmedQuery && !exactMatch && (
<li>
<button
type="button"
onMouseDown={(event) => event.preventDefault()}
onClick={() => void commitQuery()}
className="block w-full px-3 py-1.5 text-left text-sm text-series-1 hover:bg-surface-2"
>
{t("transactions.form.createTag", { name: trimmedQuery })}
</button>
</li>
)}
</ul>
)}
</div>
);
}

View File

@@ -2,12 +2,13 @@ import { useEffect, useState } from "react";
import { Dialog } from "@/components/ui/Dialog";
import { Button } from "@/components/ui/Button";
import { Input, Label, Select } from "@/components/ui/Input";
import { TagInput } from "@/components/transactions/TagInput";
import { useAccounts } from "@/hooks/useAccounts";
import { useCategories } from "@/hooks/useCategories";
import { useCreateTransaction, useUpdateTransaction } from "@/hooks/useTransactions";
import { useTranslation } from "@/lib/i18n";
import { translateCategoryName } from "@/lib/categoryLabels";
import type { Category, Transaction, TransactionInput, TransactionType } from "@/types";
import type { Category, Tag, Transaction, TransactionInput, TransactionType } from "@/types";
interface TransactionFormModalProps {
open: boolean;
@@ -39,6 +40,7 @@ export function TransactionFormModal({ open, onClose, transaction }: Transaction
const updateTransaction = useUpdateTransaction();
const [form, setForm] = useState(EMPTY_FORM);
const [tags, setTags] = useState<Tag[]>([]);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
@@ -55,8 +57,10 @@ export function TransactionFormModal({ open, onClose, transaction }: Transaction
notes: transaction.notes ?? "",
date: transaction.date,
});
setTags(transaction.tags);
} else {
setForm({ ...EMPTY_FORM, account_id: accounts?.[0] ? String(accounts[0].id) : "" });
setTags([]);
}
setError(null);
}, [open, transaction, accounts]);
@@ -104,6 +108,7 @@ export function TransactionFormModal({ open, onClose, transaction }: Transaction
merchant: form.merchant || null,
notes: form.notes || null,
date: form.date,
tag_ids: tags.map((tag) => tag.id),
};
try {
@@ -253,6 +258,11 @@ export function TransactionFormModal({ open, onClose, transaction }: Transaction
/>
</div>
<div>
<Label htmlFor="transaction-tags">{t("transactions.form.tagsLabel")}</Label>
<TagInput value={tags} onChange={setTags} />
</div>
{error && <p className="text-sm text-danger">{error}</p>}
<div className="flex justify-end gap-2 pt-2">

View File

@@ -53,6 +53,7 @@ export function TransactionsTable({ items, onEdit, onDelete, onJumpToMonth }: Tr
{formatTransactionDate(tx.date, Boolean(onJumpToMonth))} · {tx.account.name}
{isTransfer && tx.transfer_account_id ? ` ${t("transactions.transferSuffix")}` : ""}
{tx.category ? ` · ${translateCategoryName(tx.category.name)}` : ""}
{tx.tags.length > 0 ? ` · ${tx.tags.map((tag) => tag.name).join(", ")}` : ""}
</span>
</span>

View File

@@ -0,0 +1,25 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { createTag, deleteTag, fetchTags } from "@/api/tags";
export function useTags() {
return useQuery({ queryKey: ["tags"], queryFn: fetchTags });
}
export function useCreateTag() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (name: string) => createTag(name),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["tags"] }),
});
}
export function useDeleteTag() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) => deleteTag(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tags"] });
queryClient.invalidateQueries({ queryKey: ["transactions"] });
},
});
}

View File

@@ -138,6 +138,7 @@ const ru = {
"transactions.income": "Доходы",
"transactions.transfer": "Переводы",
"transactions.allCategories": "Все категории",
"transactions.allTags": "Все теги",
"transactions.failedToLoad": "Не удалось загрузить транзакции.",
"transactions.noneFound": "Транзакций не найдено.",
"transactions.searchPlaceholder": "Поиск по описанию, продавцу или заметке…",
@@ -167,6 +168,10 @@ const ru = {
"transactions.form.noCategory": "Без категории",
"transactions.form.merchantLabel": "Продавец (необязательно)",
"transactions.form.notesLabel": "Заметка (необязательно)",
"transactions.form.tagsLabel": "Теги (необязательно)",
"transactions.form.tagsPlaceholder": "Начните вводить, чтобы найти или создать тег…",
"transactions.form.removeTag": "Убрать тег «{{name}}»",
"transactions.form.createTag": "Создать тег «{{name}}»",
"transactions.viewNote": "Посмотреть заметку",
"transactions.noteDialogTitle": "Заметка",
"transactions.form.errorSelectAccount": "Выберите счёт",
@@ -501,6 +506,7 @@ const en: Record<keyof typeof ru, string> = {
"transactions.income": "Income",
"transactions.transfer": "Transfers",
"transactions.allCategories": "All categories",
"transactions.allTags": "All tags",
"transactions.failedToLoad": "Failed to load transactions.",
"transactions.noneFound": "No transactions found.",
"transactions.searchPlaceholder": "Search by description, merchant, or note…",
@@ -530,6 +536,10 @@ const en: Record<keyof typeof ru, string> = {
"transactions.form.noCategory": "No category",
"transactions.form.merchantLabel": "Merchant (optional)",
"transactions.form.notesLabel": "Note (optional)",
"transactions.form.tagsLabel": "Tags (optional)",
"transactions.form.tagsPlaceholder": "Type to find or create a tag…",
"transactions.form.removeTag": "Remove tag \"{{name}}\"",
"transactions.form.createTag": "Create tag \"{{name}}\"",
"transactions.viewNote": "View note",
"transactions.noteDialogTitle": "Note",
"transactions.form.errorSelectAccount": "Select an account",

View File

@@ -10,6 +10,7 @@ import { TransactionsTable } from "@/components/transactions/TransactionsTable";
import { TransactionFormModal } from "@/components/transactions/TransactionFormModal";
import { useTransactions, useDeleteTransaction, useTransactionYears } from "@/hooks/useTransactions";
import { useCategories } from "@/hooks/useCategories";
import { useTags } from "@/hooks/useTags";
import type { TransactionSort } from "@/api/transactions";
import { useTranslation } from "@/lib/i18n";
import { translateCategoryName } from "@/lib/categoryLabels";
@@ -44,6 +45,7 @@ export function TransactionsPage() {
const [month, setMonth] = useState(() => parseMonthParam(searchParams.get("month"), now.getMonth() + 1));
const [type, setType] = useState<TransactionType | "">("");
const [categoryId, setCategoryId] = useState<string>("");
const [tagId, setTagId] = useState<string>("");
const [sort, setSort] = useState<TransactionSort>("date_desc");
const [page, setPage] = useState(1);
@@ -64,6 +66,7 @@ export function TransactionsPage() {
const [editingTransaction, setEditingTransaction] = useState<Transaction | null>(null);
const { data: categories } = useCategories();
const { data: tags } = useTags();
const { data: years } = useTransactionYears();
const { data, isLoading, isError } = useTransactions({
// A search looks for a purchase from an unknown month, so it must span
@@ -73,6 +76,7 @@ export function TransactionsPage() {
search: isSearching ? search : undefined,
type: type || undefined,
category_id: categoryId ? Number(categoryId) : undefined,
tag_id: tagId ? Number(tagId) : undefined,
sort,
page,
page_size: PAGE_SIZE,
@@ -193,6 +197,23 @@ export function TransactionsPage() {
</option>
))}
</Select>
{tags && tags.length > 0 && (
<Select
value={tagId}
onChange={(event) => {
setTagId(event.target.value);
setPage(1);
}}
className="sm:w-48"
>
<option value="">{t("transactions.allTags")}</option>
{tags.map((tag) => (
<option key={tag.id} value={tag.id}>
{tag.name}
</option>
))}
</Select>
)}
<Select
value={sort}
onChange={(event) => {

View File

@@ -57,6 +57,11 @@ export interface CategoryUpdateInput {
parent_id?: number | null;
}
export interface Tag {
id: number;
name: string;
}
export interface Transaction {
id: number;
account_id: number;
@@ -70,6 +75,7 @@ export interface Transaction {
date: string;
account: Account;
category: Category | null;
tags: Tag[];
}
export interface TransactionPage {
@@ -89,6 +95,9 @@ export interface TransactionInput {
merchant?: string | null;
notes?: string | null;
date: string;
// Omitted -> tags untouched on update; sent (even as []) -> replaces the
// full tag set. Always sent on create (defaults to []).
tag_ids?: number[];
}
export interface RecurringTransaction {