From 5b42e3d972a50fbd8eae84f5f5d09366e277a57f Mon Sep 17 00:00:00 2001 From: ZProger Date: Sun, 16 Aug 2026 22:23:25 +0300 Subject: [PATCH] feat: custom year range on Cash Flow and Reports, drop redundant 12m preset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "This year" and "12 months" were two different ways of showing roughly the same recent window, which read as duplicated. Replaced the "12m" preset with "custom": pick an arbitrary fromYear-toYear span via a new YearRangeSelector (two dropdowns built on the same internal component YearSelector already uses). Also de-duplicated the RangePreset/computeRange logic that was copied verbatim between CashFlowPage and ReportsPage into shared lib/dateRange.ts — both pages had the identical bug/duplication. --- frontend/package.json | 2 +- .../src/components/layout/YearSelector.tsx | 34 +++++++-- frontend/src/lib/dateRange.ts | 33 +++++++++ frontend/src/lib/i18n.ts | 8 +-- frontend/src/pages/CashFlowPage.tsx | 53 ++++++-------- frontend/src/pages/ReportsPage.tsx | 70 +++++++++---------- 6 files changed, 124 insertions(+), 76 deletions(-) create mode 100644 frontend/src/lib/dateRange.ts diff --git a/frontend/package.json b/frontend/package.json index 01d13d0..b05dbeb 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "aurum-frontend", "private": true, - "version": "0.16.1", + "version": "0.17.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/components/layout/YearSelector.tsx b/frontend/src/components/layout/YearSelector.tsx index 48cacb2..a9d8565 100644 --- a/frontend/src/components/layout/YearSelector.tsx +++ b/frontend/src/components/layout/YearSelector.tsx @@ -2,15 +2,15 @@ import { useEffect, useRef, useState } from "react"; import { ChevronDown } from "lucide-react"; import { cn } from "@/lib/utils"; -interface YearSelectorProps { +interface YearDropdownProps { years: number[]; year: number; onChange: (year: number) => void; } -/** Compact dropdown, most recent year first — sits to the right of - * MonthSelector instead of competing with it as a second row of pills. */ -export function YearSelector({ years, year, onChange }: YearSelectorProps) { +/** Compact single-year dropdown, most recent year first. Shared building + * block behind both YearSelector and YearRangeSelector below. */ +function YearDropdown({ years, year, onChange }: YearDropdownProps) { const [open, setOpen] = useState(false); const rootRef = useRef(null); const sortedYears = [...years].sort((a, b) => b - a); @@ -78,3 +78,29 @@ export function YearSelector({ years, year, onChange }: YearSelectorProps) { ); } + +/** Sits to the right of MonthSelector instead of competing with it as a + * second row of pills. */ +export function YearSelector(props: YearDropdownProps) { + return ; +} + +interface YearRangeSelectorProps { + years: number[]; + fromYear: number; + toYear: number; + onChange: (range: { fromYear: number; toYear: number }) => void; +} + +/** Two year dropdowns for picking an arbitrary "from year – to year" span + * (CashFlow/Reports "custom" range preset). Keeps fromYear <= toYear by + * dragging the other bound along instead of allowing an inverted range. */ +export function YearRangeSelector({ years, fromYear, toYear, onChange }: YearRangeSelectorProps) { + return ( +
+ onChange({ fromYear: value, toYear: Math.max(toYear, value) })} /> + – + onChange({ fromYear: Math.min(fromYear, value), toYear: value })} /> +
+ ); +} diff --git a/frontend/src/lib/dateRange.ts b/frontend/src/lib/dateRange.ts new file mode 100644 index 0000000..6bf2d1b --- /dev/null +++ b/frontend/src/lib/dateRange.ts @@ -0,0 +1,33 @@ +/** Shared by CashFlowPage and ReportsPage — both offer the same four + * period presets over their respective date-ranged endpoints. */ +export type RangePreset = "all" | "this_year" | "5y" | "custom"; + +export interface CustomYearRange { + fromYear: number; + toYear: number; +} + +function isoDate(date: Date): string { + return date.toISOString().slice(0, 10); +} + +export function computeRange( + preset: RangePreset, + custom?: CustomYearRange +): { startDate?: string; endDate?: string } { + const today = new Date(); + switch (preset) { + case "all": + return {}; + case "this_year": + return { startDate: `${today.getFullYear()}-01-01`, endDate: isoDate(today) }; + case "5y": { + const start = new Date(today.getFullYear() - 5, today.getMonth(), 1); + return { startDate: isoDate(start), endDate: isoDate(today) }; + } + case "custom": { + if (!custom) return {}; + return { startDate: `${custom.fromYear}-01-01`, endDate: `${custom.toYear}-12-31` }; + } + } +} diff --git a/frontend/src/lib/i18n.ts b/frontend/src/lib/i18n.ts index 4b9ad0b..97b17e3 100644 --- a/frontend/src/lib/i18n.ts +++ b/frontend/src/lib/i18n.ts @@ -169,9 +169,9 @@ const ru = { "reports.expenseGroup": "Расходы", "reports.incomeGroup": "Доходы", "reports.rangeAll": "Всё время", - "reports.range5y": "5 лет", - "reports.range12m": "12 мес", "reports.rangeThisYear": "Этот год", + "reports.range5y": "5 лет", + "reports.rangeCustom": "Диапазон лет", "reports.transactionsTitle": "Транзакции", "reports.defaultChartTitle": "Расходы по категории", "reports.averagePerMonth": "{{amount}}/мес в среднем", @@ -491,9 +491,9 @@ const en: Record = { "reports.expenseGroup": "Expenses", "reports.incomeGroup": "Income", "reports.rangeAll": "All time", - "reports.range5y": "5 years", - "reports.range12m": "12 mo", "reports.rangeThisYear": "This year", + "reports.range5y": "5 years", + "reports.rangeCustom": "Year range", "reports.transactionsTitle": "Transactions", "reports.defaultChartTitle": "Spending by category", "reports.averagePerMonth": "{{amount}}/mo average", diff --git a/frontend/src/pages/CashFlowPage.tsx b/frontend/src/pages/CashFlowPage.tsx index 82b91ea..b586256 100644 --- a/frontend/src/pages/CashFlowPage.tsx +++ b/frontend/src/pages/CashFlowPage.tsx @@ -1,51 +1,44 @@ import { useState } from "react"; import { PillSelector } from "@/components/layout/PillSelector"; +import { YearRangeSelector } from "@/components/layout/YearSelector"; import { CashFlowChart } from "@/components/cashflow/CashFlowChart"; import { useCashFlow } from "@/hooks/useCashFlow"; +import { useTransactionYears } from "@/hooks/useTransactions"; +import { computeRange, type CustomYearRange, type RangePreset } from "@/lib/dateRange"; import { useTranslation } from "@/lib/i18n"; -type RangePreset = "all" | "5y" | "12m" | "this_year"; - -function isoDate(date: Date): string { - return date.toISOString().slice(0, 10); -} - -function computeRange(preset: RangePreset): { startDate?: string; endDate?: string } { - const today = new Date(); - const end = isoDate(today); - switch (preset) { - case "all": - return {}; - case "this_year": - return { startDate: `${today.getFullYear()}-01-01`, endDate: end }; - case "12m": { - const start = new Date(today.getFullYear(), today.getMonth() - 11, 1); - return { startDate: isoDate(start), endDate: end }; - } - case "5y": { - const start = new Date(today.getFullYear() - 5, today.getMonth(), 1); - return { startDate: isoDate(start), endDate: end }; - } - } -} - export function CashFlowPage() { const { t } = useTranslation(); - const [range, setRange] = useState("12m"); + const now = new Date(); + const { data: years } = useTransactionYears(); + const [range, setRange] = useState("this_year"); + const [customRange, setCustomRange] = useState({ + fromYear: now.getFullYear(), + toYear: now.getFullYear(), + }); + const RANGE_OPTIONS: Array<{ value: RangePreset; label: string }> = [ { value: "all", label: t("reports.rangeAll") }, - { value: "5y", label: t("reports.range5y") }, - { value: "12m", label: t("reports.range12m") }, { value: "this_year", label: t("reports.rangeThisYear") }, + { value: "5y", label: t("reports.range5y") }, + { value: "custom", label: t("reports.rangeCustom") }, ]; - const { startDate, endDate } = computeRange(range); + const { startDate, endDate } = computeRange(range, customRange); const { data: cashFlow, isLoading } = useCashFlow(startDate, endDate); return (
-
+
+ {range === "custom" && ( + + )}
diff --git a/frontend/src/pages/ReportsPage.tsx b/frontend/src/pages/ReportsPage.tsx index 2cdec4a..06f3b21 100644 --- a/frontend/src/pages/ReportsPage.tsx +++ b/frontend/src/pages/ReportsPage.tsx @@ -3,56 +3,39 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card"; import { Button } from "@/components/ui/Button"; import { Label, Select } from "@/components/ui/Input"; import { PillSelector } from "@/components/layout/PillSelector"; +import { YearRangeSelector } from "@/components/layout/YearSelector"; import { CategoryRankingCard } from "@/components/reports/CategoryRankingCard"; import { CategorySpendingChart } from "@/components/reports/CategorySpendingChart"; import { TransactionsTable } from "@/components/transactions/TransactionsTable"; import { TransactionFormModal } from "@/components/transactions/TransactionFormModal"; import { useCategories } from "@/hooks/useCategories"; import { useCategoryRanking, useCategorySpendingReport } from "@/hooks/useReports"; -import { useDeleteTransaction, useTransactions } from "@/hooks/useTransactions"; +import { useDeleteTransaction, useTransactions, useTransactionYears } from "@/hooks/useTransactions"; import type { TransactionSort } from "@/api/transactions"; +import { computeRange, type CustomYearRange, type RangePreset } from "@/lib/dateRange"; import { useTranslation } from "@/lib/i18n"; import { translateCategoryName } from "@/lib/categoryLabels"; import type { Transaction } from "@/types"; -type RangePreset = "all" | "5y" | "12m" | "this_year"; - -function isoDate(date: Date): string { - return date.toISOString().slice(0, 10); -} - -function computeRange(preset: RangePreset): { startDate?: string; endDate?: string } { - const today = new Date(); - const end = isoDate(today); - switch (preset) { - case "all": - return {}; - case "this_year": - return { startDate: `${today.getFullYear()}-01-01`, endDate: end }; - case "12m": { - const start = new Date(today.getFullYear(), today.getMonth() - 11, 1); - return { startDate: isoDate(start), endDate: end }; - } - case "5y": { - const start = new Date(today.getFullYear() - 5, today.getMonth(), 1); - return { startDate: isoDate(start), endDate: end }; - } - } -} - const PAGE_SIZE = 20; export function ReportsPage() { const { t } = useTranslation(); + const now = new Date(); const RANGE_OPTIONS: Array<{ value: RangePreset; label: string }> = [ { value: "all", label: t("reports.rangeAll") }, - { value: "5y", label: t("reports.range5y") }, - { value: "12m", label: t("reports.range12m") }, { value: "this_year", label: t("reports.rangeThisYear") }, + { value: "5y", label: t("reports.range5y") }, + { value: "custom", label: t("reports.rangeCustom") }, ]; const { data: categories } = useCategories(); + const { data: years } = useTransactionYears(); const [categoryId, setCategoryId] = useState(null); const [range, setRange] = useState("all"); + const [customRange, setCustomRange] = useState({ + fromYear: now.getFullYear(), + toYear: now.getFullYear(), + }); const [sort, setSort] = useState("date_desc"); const [page, setPage] = useState(1); const [editingTransaction, setEditingTransaction] = useState(null); @@ -65,7 +48,7 @@ export function ReportsPage() { } }, [categories, categoryId]); - const { startDate, endDate } = computeRange(range); + const { startDate, endDate } = computeRange(range, customRange); const { data: ranking, isLoading: isRankingLoading } = useCategoryRanking("expense", startDate, endDate); const { data: report, isLoading: isReportLoading } = useCategorySpendingReport(categoryId, startDate, endDate); const { data: transactions, isLoading: isTransactionsLoading } = useTransactions({ @@ -126,14 +109,27 @@ export function ReportsPage() { )}
- { - setRange(value); - setPage(1); - }} - /> +
+ { + setRange(value); + setPage(1); + }} + /> + {range === "custom" && ( + { + setCustomRange(value); + setPage(1); + }} + /> + )} +