Addresses /pr-review REQUEST_CHANGES on #255. - useCompare: the "skip first sync" boolean was not StrictMode-safe — the dev double-invoke of effects flipped the flag on setup #1, so setup #2 re-synced the reference month to the civil-year December, re-introducing the very bug Changement 2 fixes (dev only; prod has no double-invoke). Replace it with a value-change guard: a ref seeded with the initial `to` plus a pure syncReferenceOnPeriodChange() that only dispatches when `to` actually changes. Idempotent across the double-invoke, and now unit-tested (5 cases) since the decision is a pure function (the project has no renderHook harness). - Remove the now-orphaned reports.compare.totalRow i18n key (both locales) — the flat grand total it labelled was replaced by the result lines. - ComparePeriodTable: gate the "before transfers" line on results.hasTransfers (previously computed/tested but unused). - ComparePeriodChart: show the no-data empty state when the expense filter leaves nothing (a pure income/transfer period) instead of bare axes. Build + 690 vitest green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
117 lines
3.7 KiB
TypeScript
117 lines
3.7 KiB
TypeScript
import { useTranslation } from "react-i18next";
|
|
import {
|
|
BarChart,
|
|
Bar,
|
|
XAxis,
|
|
YAxis,
|
|
Tooltip,
|
|
Legend,
|
|
ResponsiveContainer,
|
|
CartesianGrid,
|
|
} from "recharts";
|
|
import type { CategoryDelta } from "../../shared/types";
|
|
|
|
export interface ComparePeriodChartProps {
|
|
rows: CategoryDelta[];
|
|
previousLabel: string;
|
|
currentLabel: string;
|
|
}
|
|
|
|
function formatCurrency(amount: number, language: string): string {
|
|
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
|
|
style: "currency",
|
|
currency: "CAD",
|
|
maximumFractionDigits: 0,
|
|
}).format(amount);
|
|
}
|
|
|
|
export default function ComparePeriodChart({
|
|
rows,
|
|
previousLabel,
|
|
currentLabel,
|
|
}: ComparePeriodChartProps) {
|
|
const { t, i18n } = useTranslation();
|
|
|
|
// The chart stays a spending view: the income-statement result (revenues +
|
|
// result lines, Issue #253) lives in the table, so keep only expense leaves
|
|
// here — mixing revenue bars into the same axis would misread. Drop subtotal
|
|
// (is_parent) rows too so a group is not double-counted against its own
|
|
// leaves. Sorted by current amount (largest spending first) for a top-down
|
|
// read of the biggest movers.
|
|
const chartData = rows
|
|
.filter((r) => !r.is_parent && (r.category_type ?? "expense") === "expense")
|
|
.sort((a, b) => b.currentAmount - a.currentAmount)
|
|
.map((r) => ({
|
|
name: r.categoryName,
|
|
previousAmount: r.previousAmount,
|
|
currentAmount: r.currentAmount,
|
|
color: r.categoryColor,
|
|
}));
|
|
|
|
// Empty when there is no data at all, or when the period is pure
|
|
// income/transfers (no expense bars to draw) — avoid rendering bare axes.
|
|
if (chartData.length === 0) {
|
|
return (
|
|
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 text-center text-[var(--muted-foreground)] italic">
|
|
{t("reports.empty.noData")}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const previousFill = "var(--muted-foreground)";
|
|
const currentFill = "var(--primary)";
|
|
|
|
return (
|
|
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-4">
|
|
<ResponsiveContainer width="100%" height={Math.max(280, chartData.length * 44 + 60)}>
|
|
<BarChart
|
|
data={chartData}
|
|
layout="vertical"
|
|
margin={{ top: 10, right: 24, bottom: 10, left: 10 }}
|
|
barCategoryGap="20%"
|
|
>
|
|
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" horizontal={false} />
|
|
<XAxis
|
|
type="number"
|
|
tickFormatter={(v) => formatCurrency(v, i18n.language)}
|
|
stroke="var(--muted-foreground)"
|
|
fontSize={11}
|
|
/>
|
|
<YAxis
|
|
type="category"
|
|
dataKey="name"
|
|
width={140}
|
|
stroke="var(--muted-foreground)"
|
|
fontSize={11}
|
|
/>
|
|
<Tooltip
|
|
formatter={(value) =>
|
|
typeof value === "number" ? formatCurrency(value, i18n.language) : String(value)
|
|
}
|
|
contentStyle={{
|
|
backgroundColor: "var(--card)",
|
|
border: "1px solid var(--border)",
|
|
borderRadius: "0.5rem",
|
|
}}
|
|
cursor={{ fill: "var(--muted)", fillOpacity: 0.2 }}
|
|
/>
|
|
<Legend
|
|
wrapperStyle={{ paddingTop: 8, fontSize: 12, color: "var(--muted-foreground)" }}
|
|
/>
|
|
<Bar
|
|
dataKey="previousAmount"
|
|
name={previousLabel}
|
|
fill={previousFill}
|
|
radius={[0, 4, 4, 0]}
|
|
/>
|
|
<Bar
|
|
dataKey="currentAmount"
|
|
name={currentLabel}
|
|
fill={currentFill}
|
|
radius={[0, 4, 4, 0]}
|
|
/>
|
|
</BarChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
);
|
|
}
|