Files
aurum/frontend/src/components/reports/CategorySpendingChart.tsx
ZProger fe9b079c12 Initial public release
Aurum — a self-hosted personal finance operating system: cash flow,
net worth tracking, budgets, goals, recurring payments, reports,
proactive alerts, and a compound-interest returns calculator, all
bilingual (RU/EN) and mobile-first. Ships as a three-container Docker
Compose stack (Postgres + FastAPI + nginx-served React frontend).

See README.md for setup and CONTRIBUTING.md for development.
2026-08-16 18:39:52 +03:00

115 lines
4.8 KiB
TypeScript

import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis } from "recharts";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
import { formatCurrency, getIntlLocale, pluralizeRu } from "@/lib/format";
import { useTranslation, type Language } from "@/lib/i18n";
import { translateCategoryName } from "@/lib/categoryLabels";
import type { CategorySpendingReport } from "@/types";
interface CategorySpendingChartProps {
report: CategorySpendingReport | undefined;
isLoading: boolean;
}
function monthKey(year: number, month: number): string {
return `${year}-${String(month).padStart(2, "0")}-01`;
}
function formatMonthLabel(key: string): string {
return new Intl.DateTimeFormat(getIntlLocale(), { month: "short", year: "numeric" }).format(
new Date(`${key}T00:00:00`)
);
}
function transactionsCountLabel(count: number, language: Language): string {
if (language === "ru") return pluralizeRu(count, "транзакция", "транзакции", "транзакций");
return count === 1 ? "transaction" : "transactions";
}
/** Same landmark idea as the Net Worth chart: year ticks only when the bars
* span more than one calendar year, so a 5-year report reads at a glance. */
function computeYearTicks(keys: string[]): string[] {
if (keys.length < 2) return [];
const startYear = Number(keys[0].slice(0, 4));
const endYear = Number(keys[keys.length - 1].slice(0, 4));
if (startYear === endYear) return [];
const ticks: string[] = [];
for (let year = startYear + 1; year <= endYear; year++) {
const jan = `${year}-01-01`;
if (keys.includes(jan)) ticks.push(jan);
}
return ticks;
}
function ChartTooltip({ active, payload }: { active?: boolean; payload?: Array<{ payload: { key: string; amount: number } }> }) {
if (!active || !payload?.length) return null;
const point = payload[0].payload;
return (
<div className="rounded-lg border border-border bg-surface-1 px-3 py-2 text-sm shadow-md">
<p className="text-text-muted">{formatMonthLabel(point.key)}</p>
<p className="font-medium text-text-primary">{formatCurrency(point.amount)}</p>
</div>
);
}
export function CategorySpendingChart({ report, isLoading }: CategorySpendingChartProps) {
const { t, language } = useTranslation();
const chartData = report?.series.map((point) => ({
key: monthKey(point.year, point.month),
amount: Number(point.amount),
})) ?? [];
const yearTicks = computeYearTicks(chartData.map((point) => point.key));
return (
<Card>
<CardHeader className="items-start">
<div>
<CardTitle>{report ? translateCategoryName(report.category_name) : t("reports.defaultChartTitle")}</CardTitle>
<p className="mt-1.5 text-2xl font-semibold tabular-nums text-text-primary sm:text-[28px]">
{isLoading || !report ? "…" : formatCurrency(report.total_amount)}
</p>
{report && (
<p className="mt-1 text-sm text-text-muted">
{t("reports.averagePerMonth", { amount: formatCurrency(report.average_per_month) })} ·{" "}
{report.transaction_count} {transactionsCountLabel(report.transaction_count, language)}
</p>
)}
</div>
</CardHeader>
<CardContent>
<div className="h-56 w-full sm:h-64">
{isLoading || !report || chartData.length === 0 ? (
<div className="flex h-full items-center justify-center text-sm text-text-muted">
{isLoading ? t("common.loading") : t("reports.noDataForPeriod")}
</div>
) : (
<ResponsiveContainer width="100%" height="100%">
<BarChart data={chartData} margin={{ top: 8, right: 4, bottom: 0, left: 4 }}>
{yearTicks.length > 0 && (
<XAxis
dataKey="key"
type="category"
ticks={yearTicks}
tickFormatter={(value: string) => value.slice(0, 4)}
axisLine={false}
tickLine={false}
tick={{ fill: "var(--text-muted)", fontSize: 11 }}
interval="preserveStartEnd"
/>
)}
<Tooltip content={<ChartTooltip />} cursor={{ fill: "var(--surface-2)" }} />
<Bar dataKey="amount" fill={report.category_color} radius={[2, 2, 0, 0]} isAnimationActive={false} />
</BarChart>
</ResponsiveContainer>
)}
</div>
{chartData.length > 1 && (
<div className="mt-2 flex justify-between text-xs text-text-muted">
<span>{formatMonthLabel(chartData[0].key)}</span>
<span>{formatMonthLabel(chartData[chartData.length - 1].key)}</span>
</div>
)}
</CardContent>
</Card>
);
}