From f3fbe4d546d33be2211055d7beaf786a299503c3 Mon Sep 17 00:00:00 2001 From: ZProger Date: Sun, 16 Aug 2026 22:10:49 +0300 Subject: [PATCH] feat: add year switcher to Dashboard, Transactions and Budget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Year was previously hardcoded to the current year on all three pages (useState with no setter) — only the month could be changed via MonthSelector, making it impossible to look at or compare older years even though transaction history goes back further than the current year. Adds GET /transactions/years (contiguous range from the earliest transaction through the current year) and a YearSelector component matching MonthSelector's style, wired into all three pages alongside a real year useState. --- backend/app/api/routes/transactions.py | 13 ++++++++ frontend/package.json | 2 +- frontend/src/api/transactions.ts | 7 ++++ .../src/components/layout/YearSelector.tsx | 32 +++++++++++++++++++ frontend/src/hooks/useTransactions.ts | 9 ++++++ frontend/src/pages/BudgetPage.tsx | 10 ++++-- frontend/src/pages/DashboardPage.tsx | 10 ++++-- frontend/src/pages/TransactionsPage.tsx | 30 +++++++++++------ 8 files changed, 99 insertions(+), 14 deletions(-) create mode 100644 frontend/src/components/layout/YearSelector.tsx diff --git a/backend/app/api/routes/transactions.py b/backend/app/api/routes/transactions.py index 7a5ae0f..8664d59 100644 --- a/backend/app/api/routes/transactions.py +++ b/backend/app/api/routes/transactions.py @@ -98,6 +98,19 @@ async def list_transactions( return TransactionPage(items=items, total=total, page=page, page_size=page_size) +@router.get("/years", response_model=list[int]) +async def list_transaction_years(session: AsyncSession = Depends(get_session)) -> list[int]: + """Full range of years to offer in the year picker — from the earliest + transaction through the current year, so a gap year with no activity + still shows up (as zero) instead of silently disappearing from the UI.""" + bounds = await session.execute(select(func.min(Transaction.date), func.max(Transaction.date))) + min_date, max_date = bounds.one() + current_year = date_.today().year + if min_date is None: + return [current_year] + return list(range(min_date.year, max(max_date.year, current_year) + 1)) + + @router.post("", response_model=TransactionRead, status_code=201) async def create_transaction(payload: TransactionCreate, session: AsyncSession = Depends(get_session)) -> Transaction: await _ensure_category_matches_type(session, payload.category_id, payload.type) diff --git a/frontend/package.json b/frontend/package.json index f048f38..1e302af 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "aurum-frontend", "private": true, - "version": "0.15.4", + "version": "0.16.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/api/transactions.ts b/frontend/src/api/transactions.ts index d81bdc3..542ccfe 100644 --- a/frontend/src/api/transactions.ts +++ b/frontend/src/api/transactions.ts @@ -24,6 +24,13 @@ export function fetchTransactions(filters: TransactionFilters = {}) { return api.get(`/transactions?${params.toString()}`); } +/** Full range of years to offer in the year picker, from the earliest + * transaction through the current year (see backend for the "gap year" + * rationale). */ +export function fetchTransactionYears() { + return api.get("/transactions/years"); +} + export function createTransaction(input: TransactionInput) { return api.post("/transactions", input); } diff --git a/frontend/src/components/layout/YearSelector.tsx b/frontend/src/components/layout/YearSelector.tsx new file mode 100644 index 0000000..3d8858f --- /dev/null +++ b/frontend/src/components/layout/YearSelector.tsx @@ -0,0 +1,32 @@ +import { cn } from "@/lib/utils"; + +interface YearSelectorProps { + years: number[]; + year: number; + onChange: (year: number) => void; +} + +export function YearSelector({ years, year, onChange }: YearSelectorProps) { + return ( +
+ {years.map((value) => { + const active = value === year; + return ( + + ); + })} +
+ ); +} diff --git a/frontend/src/hooks/useTransactions.ts b/frontend/src/hooks/useTransactions.ts index d606551..1600084 100644 --- a/frontend/src/hooks/useTransactions.ts +++ b/frontend/src/hooks/useTransactions.ts @@ -3,6 +3,7 @@ import { createTransaction, deleteTransaction, fetchTransactions, + fetchTransactionYears, updateTransaction, type TransactionFilters, } from "@/api/transactions"; @@ -12,6 +13,7 @@ function useInvalidateAfterTransactionChange() { const queryClient = useQueryClient(); return () => { queryClient.invalidateQueries({ queryKey: ["transactions"] }); + queryClient.invalidateQueries({ queryKey: ["transaction-years"] }); queryClient.invalidateQueries({ queryKey: ["dashboard-summary"] }); queryClient.invalidateQueries({ queryKey: ["net-worth-summary"] }); queryClient.invalidateQueries({ queryKey: ["category-spending-report"] }); @@ -30,6 +32,13 @@ export function useTransactions(filters: TransactionFilters) { }); } +export function useTransactionYears() { + return useQuery({ + queryKey: ["transaction-years"], + queryFn: fetchTransactionYears, + }); +} + export function useCreateTransaction() { const invalidate = useInvalidateAfterTransactionChange(); return useMutation({ diff --git a/frontend/src/pages/BudgetPage.tsx b/frontend/src/pages/BudgetPage.tsx index 2be16d7..77754d0 100644 --- a/frontend/src/pages/BudgetPage.tsx +++ b/frontend/src/pages/BudgetPage.tsx @@ -3,10 +3,12 @@ import { Plus } from "lucide-react"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card"; import { Button } from "@/components/ui/Button"; import { MonthSelector } from "@/components/layout/MonthSelector"; +import { YearSelector } from "@/components/layout/YearSelector"; import { AlertBanner } from "@/components/insights/AlertBanner"; import { BudgetList } from "@/components/budget/BudgetList"; import { BudgetFormModal } from "@/components/budget/BudgetFormModal"; import { useBudgets, useBudgetStatus, useDeleteBudget } from "@/hooks/useBudgets"; +import { useTransactionYears } from "@/hooks/useTransactions"; import { useTranslation } from "@/lib/i18n"; import { translateCategoryName } from "@/lib/categoryLabels"; import type { Budget, BudgetStatus } from "@/types"; @@ -14,8 +16,9 @@ import type { Budget, BudgetStatus } from "@/types"; export function BudgetPage() { const { t } = useTranslation(); const now = new Date(); - const [year] = useState(now.getFullYear()); + const [year, setYear] = useState(now.getFullYear()); const [month, setMonth] = useState(now.getMonth() + 1); + const { data: years } = useTransactionYears(); const { data: budgets } = useBudgets(); const { data: status, isLoading } = useBudgetStatus(year, month); @@ -51,7 +54,10 @@ export function BudgetPage() {
- +
+ + +
diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index 643d1ce..a0c41a8 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -1,10 +1,12 @@ import { useState } from "react"; import { MonthSelector } from "@/components/layout/MonthSelector"; +import { YearSelector } from "@/components/layout/YearSelector"; import { StatCard } from "@/components/dashboard/StatCard"; import { SpendingByCategoryCard } from "@/components/dashboard/SpendingByCategoryCard"; import { RecentTransactionsCard } from "@/components/dashboard/RecentTransactionsCard"; import { AlertBanner } from "@/components/insights/AlertBanner"; import { useDashboardSummary } from "@/hooks/useDashboard"; +import { useTransactionYears } from "@/hooks/useTransactions"; import { formatCurrency, formatSignedCurrency } from "@/lib/format"; import { useTranslation } from "@/lib/i18n"; @@ -22,8 +24,9 @@ function formatPercent(value: number): string { export function DashboardPage() { const { t } = useTranslation(); const now = new Date(); - const [year] = useState(now.getFullYear()); + const [year, setYear] = useState(now.getFullYear()); const [month, setMonth] = useState(now.getMonth() + 1); + const { data: years } = useTransactionYears(); const { data, isLoading, isError } = useDashboardSummary(year, month); const rate = data ? savingsRate(Number(data.real_income), Number(data.net)) : null; @@ -32,7 +35,10 @@ export function DashboardPage() {
- +
+ + +
{isError && (

diff --git a/frontend/src/pages/TransactionsPage.tsx b/frontend/src/pages/TransactionsPage.tsx index ad406b5..c2dd370 100644 --- a/frontend/src/pages/TransactionsPage.tsx +++ b/frontend/src/pages/TransactionsPage.tsx @@ -4,9 +4,10 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card"; import { Button } from "@/components/ui/Button"; import { Select } from "@/components/ui/Input"; import { MonthSelector } from "@/components/layout/MonthSelector"; +import { YearSelector } from "@/components/layout/YearSelector"; import { TransactionsTable } from "@/components/transactions/TransactionsTable"; import { TransactionFormModal } from "@/components/transactions/TransactionFormModal"; -import { useTransactions, useDeleteTransaction } from "@/hooks/useTransactions"; +import { useTransactions, useDeleteTransaction, useTransactionYears } from "@/hooks/useTransactions"; import { useCategories } from "@/hooks/useCategories"; import type { TransactionSort } from "@/api/transactions"; import { useTranslation } from "@/lib/i18n"; @@ -18,7 +19,7 @@ const PAGE_SIZE = 20; export function TransactionsPage() { const { t } = useTranslation(); const now = new Date(); - const [year] = useState(now.getFullYear()); + const [year, setYear] = useState(now.getFullYear()); const [month, setMonth] = useState(now.getMonth() + 1); const [type, setType] = useState(""); const [categoryId, setCategoryId] = useState(""); @@ -29,6 +30,7 @@ export function TransactionsPage() { const [editingTransaction, setEditingTransaction] = useState(null); const { data: categories } = useCategories(); + const { data: years } = useTransactionYears(); const { data, isLoading, isError } = useTransactions({ year, month, @@ -61,13 +63,23 @@ export function TransactionsPage() { return (

- { - setMonth(value); - setPage(1); - }} - /> +
+ { + setYear(value); + setPage(1); + }} + /> + { + setMonth(value); + setPage(1); + }} + /> +