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

@@ -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