feat: custom year range on Cash Flow and Reports, drop redundant 12m preset

"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.
This commit is contained in:
ZProger
2026-08-16 22:23:25 +03:00
parent acff447673
commit 5b42e3d972
6 changed files with 124 additions and 76 deletions

View File

@@ -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<HTMLDivElement>(null);
const sortedYears = [...years].sort((a, b) => b - a);
@@ -78,3 +78,29 @@ export function YearSelector({ years, year, onChange }: YearSelectorProps) {
</div>
);
}
/** Sits to the right of MonthSelector instead of competing with it as a
* second row of pills. */
export function YearSelector(props: YearDropdownProps) {
return <YearDropdown {...props} />;
}
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 (
<div className="flex shrink-0 items-center gap-1.5">
<YearDropdown years={years} year={fromYear} onChange={(value) => onChange({ fromYear: value, toYear: Math.max(toYear, value) })} />
<span className="text-sm text-text-muted">–</span>
<YearDropdown years={years} year={toYear} onChange={(value) => onChange({ fromYear: Math.min(fromYear, value), toYear: value })} />
</div>
);
}

View File

@@ -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` };
}
}
}

View File

@@ -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<keyof typeof ru, string> = {
"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",

View File

@@ -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<RangePreset>("12m");
const now = new Date();
const { data: years } = useTransactionYears();
const [range, setRange] = useState<RangePreset>("this_year");
const [customRange, setCustomRange] = useState<CustomYearRange>({
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 (
<div className="space-y-5">
<div className="flex justify-end">
<div className="flex flex-wrap items-center justify-end gap-2">
<PillSelector options={RANGE_OPTIONS} value={range} onChange={setRange} />
{range === "custom" && (
<YearRangeSelector
years={years ?? [now.getFullYear()]}
fromYear={customRange.fromYear}
toYear={customRange.toYear}
onChange={setCustomRange}
/>
)}
</div>
<CashFlowChart cashFlow={cashFlow} isLoading={isLoading} />

View File

@@ -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<number | null>(null);
const [range, setRange] = useState<RangePreset>("all");
const [customRange, setCustomRange] = useState<CustomYearRange>({
fromYear: now.getFullYear(),
toYear: now.getFullYear(),
});
const [sort, setSort] = useState<TransactionSort>("date_desc");
const [page, setPage] = useState(1);
const [editingTransaction, setEditingTransaction] = useState<Transaction | null>(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() {
)}
</Select>
</div>
<PillSelector
options={RANGE_OPTIONS}
value={range}
onChange={(value) => {
setRange(value);
setPage(1);
}}
/>
<div className="flex flex-wrap items-center justify-end gap-2">
<PillSelector
options={RANGE_OPTIONS}
value={range}
onChange={(value) => {
setRange(value);
setPage(1);
}}
/>
{range === "custom" && (
<YearRangeSelector
years={years ?? [now.getFullYear()]}
fromYear={customRange.fromYear}
toYear={customRange.toYear}
onChange={(value) => {
setCustomRange(value);
setPage(1);
}}
/>
)}
</div>
</div>
<CategoryRankingCard