Collapse the three Compare tabs (MoM / YoY / Budget) into two modes. The
new "Actual vs actual" mode exposes an explicit reference-month dropdown
in the header (defaults to the previous month, wraps around January) and
a MoM/YoY sub-toggle. The chart is rewritten to a grouped side-by-side
BarChart with two bars per category (reference period vs comparison
period) so both values are visible at a glance instead of just the
delta. The URL PeriodSelector stays in sync with the reference month.
- useCompare: state splits into { mode: "actual"|"budget", subMode:
"mom"|"yoy", year, month }. Pure helpers previousMonth(),
defaultReferencePeriod(), comparisonMeta() extracted for tests
- CompareModeTabs: 2 modes instead of 3
- New CompareSubModeToggle and CompareReferenceMonthPicker components
- ComparePeriodChart: grouped bars via two <Bar dataKey="..."/> on a
vertical BarChart
- i18n: modeActual / subModeMoM / subModeYoY / referenceMonth (FR+EN),
retire modeMoM / modeYoY
- 9 new vitest cases covering the pure helpers (January wrap-around for
both MoM and YoY, default reference period, month/year arithmetic)
Closes #96
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
111 lines
3.2 KiB
TypeScript
111 lines
3.2 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();
|
|
|
|
if (rows.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>
|
|
);
|
|
}
|
|
|
|
// Sort by current-period amount (largest spending first) so the user's eye
|
|
// lands on the biggest categories, then reverse so the biggest appears at
|
|
// the top of the vertical bar chart.
|
|
const chartData = [...rows]
|
|
.sort((a, b) => b.currentAmount - a.currentAmount)
|
|
.map((r) => ({
|
|
name: r.categoryName,
|
|
previousAmount: r.previousAmount,
|
|
currentAmount: r.currentAmount,
|
|
color: r.categoryColor,
|
|
}));
|
|
|
|
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>
|
|
);
|
|
}
|