Simpl-Resultat/src/components/balance/BalanceEvolutionChart.tsx
le king fu faa09614a3 feat(balance): add transfer markers on evolution chart
Issue #142 / Bilan #4 — vertical reference lines for tagged transfers.

`BalanceEvolutionChart.tsx` accepts a new optional prop
`transferMarkers?: BalanceAccountTransferWithTransaction[]`. For every
marker whose `transaction_date` matches a date already on the X axis,
the chart renders a `<ReferenceLine>` (Recharts) — green for `in`
(capital added), red for `out` (capital removed). The marker is drawn
in both `line` and `stacked` modes; in line mode an inline label
("In" / "Out") sits at the top-right of the marker so the user can
identify the direction without hovering.

Markers whose date is between two snapshot ticks are filtered out
(Recharts categorical axis silently drops unknown ticks; preferred
over an off-axis bug). A future improvement is to switch the X axis
to a numeric/time scale so markers can land anywhere — out of scope
here per the autopilot prompt's "least invasive" guideline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 16:38:55 -04:00

287 lines
9.3 KiB
TypeScript

// BalanceEvolutionChart — line / stacked-area chart of net worth over time.
//
// Issue #141 (Bilan #3). Reuses the established Recharts patterns from the
// reports/* charts (see decisions-log #141 — native SVG was reconsidered;
// Recharts is the single chart pattern in this codebase). Two modes:
// - 'line' : a single LineChart of `SUM(value)` per snapshot date.
// - 'stacked' : an AreaChart with one Area per category (stackId='all').
//
// Tooltip shows per-category breakdown in stacked mode and just the total in
// line mode.
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import {
LineChart,
Line,
AreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Legend,
ReferenceLine,
} from "recharts";
import type {
SnapshotTotalPoint,
SnapshotCategoryBreakdownPoint,
} from "../../services/balance.service";
import type { BalanceChartMode } from "../../hooks/useBalanceOverview";
import type { BalanceAccountTransferWithTransaction } from "../../shared/types";
// Stable palette for the stacked-by-category areas. Indexed deterministically
// by category sort order so the colour assignment stays consistent across
// renders and period changes. Reused from the reports CategoryBarChart palette.
const CATEGORY_PALETTE = [
"#3b82f6", // blue
"#10b981", // emerald
"#f59e0b", // amber
"#8b5cf6", // violet
"#ef4444", // red
"#06b6d4", // cyan
"#ec4899", // pink
"#84cc16", // lime
"#f97316", // orange
"#6366f1", // indigo
];
export interface BalanceEvolutionChartProps {
mode: BalanceChartMode;
totals: SnapshotTotalPoint[];
byCategory: SnapshotCategoryBreakdownPoint[];
/** Map category_key → translated label so the legend reads naturally. */
categoryLabels?: Record<string, string>;
/**
* Issue #142 — every linked transfer in the visible range. Rendered as
* vertical `<ReferenceLine>` markers on the X axis: green for `in`
* (capital added), red for `out` (capital removed). The label tooltip
* shows the underlying transaction date + description.
*/
transferMarkers?: BalanceAccountTransferWithTransaction[];
}
export default function BalanceEvolutionChart({
mode,
totals,
byCategory,
categoryLabels = {},
transferMarkers = [],
}: BalanceEvolutionChartProps) {
const { t, i18n } = useTranslation();
const cadFormatter = useMemo(
() =>
new Intl.NumberFormat(i18n.language === "fr" ? "fr-CA" : "en-CA", {
style: "currency",
currency: "CAD",
maximumFractionDigits: 0,
}),
[i18n.language]
);
const dateLocale = i18n.language === "fr" ? "fr-CA" : "en-CA";
const formatDate = (iso: string) =>
new Date(iso).toLocaleDateString(dateLocale, {
year: "numeric",
month: "short",
day: "numeric",
});
// --- Line-mode dataset ---
const lineData = useMemo(
() =>
totals.map((p) => ({
snapshot_date: p.snapshot_date,
total: p.total,
})),
[totals]
);
// --- Stacked-area dataset ---
// We transpose the per-snapshot bucket into one row per snapshot_date with
// one column per category_key. Categories absent at a snapshot date are
// emitted as 0 so Recharts renders a continuous stack.
const { stackedData, categoryKeys } = useMemo(() => {
const keys = new Set<string>();
for (const point of byCategory) {
for (const k of Object.keys(point.byCategory)) keys.add(k);
}
const orderedKeys = Array.from(keys).sort();
const data = byCategory.map((point) => {
const row: Record<string, string | number> = {
snapshot_date: point.snapshot_date,
};
for (const k of orderedKeys) {
row[k] = point.byCategory[k] ?? 0;
}
return row;
});
return { stackedData: data, categoryKeys: orderedKeys };
}, [byCategory]);
const isEmpty =
mode === "line" ? lineData.length === 0 : stackedData.length === 0;
// Filter transfer markers to dates that are actually rendered on the X
// axis (categorical scale ignores unknown ticks). We don't aggregate or
// dedupe — the user can have several transfers on the same day across
// accounts; ReferenceLine tolerates duplicates fine.
const xAxisDates = useMemo(() => {
const dates = new Set<string>();
if (mode === "line") {
for (const p of lineData) dates.add(p.snapshot_date);
} else {
for (const p of stackedData) dates.add(p.snapshot_date as string);
}
return dates;
}, [mode, lineData, stackedData]);
const renderableMarkers = useMemo(
() =>
transferMarkers
.filter((m) => xAxisDates.has(m.transaction_date))
// Sort so 'in' (green) draws before 'out' (red) for stable z-order.
.sort((a, b) =>
a.direction === b.direction ? 0 : a.direction === "in" ? -1 : 1
),
[transferMarkers, xAxisDates]
);
if (isEmpty) {
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6">
<p className="text-center text-[var(--muted-foreground)] italic py-12">
{t("balance.chart.empty")}
</p>
</div>
);
}
const tooltipContentStyle = {
backgroundColor: "var(--card)",
border: "1px solid var(--border)",
borderRadius: "0.5rem",
color: "var(--foreground)",
};
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-4">
<ResponsiveContainer width="100%" height={360}>
{mode === "line" ? (
<LineChart
data={lineData}
margin={{ top: 10, right: 16, bottom: 10, left: 10 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
<XAxis
dataKey="snapshot_date"
stroke="var(--muted-foreground)"
fontSize={11}
tickFormatter={(s: string) => formatDate(s)}
/>
<YAxis
stroke="var(--muted-foreground)"
fontSize={11}
tickFormatter={(v: number) => cadFormatter.format(v)}
width={88}
/>
<Tooltip
formatter={(value: number | undefined) =>
cadFormatter.format(value ?? 0)
}
labelFormatter={(label) => formatDate(String(label))}
contentStyle={tooltipContentStyle}
/>
<Line
type="monotone"
dataKey="total"
name={t("balance.chart.totalSeriesLabel")}
stroke="var(--primary)"
strokeWidth={2}
dot={{ r: 3 }}
activeDot={{ r: 5 }}
/>
{renderableMarkers.map((m) => (
<ReferenceLine
key={`tm-${m.id}`}
x={m.transaction_date}
stroke={
m.direction === "in" ? "var(--positive)" : "var(--negative)"
}
strokeDasharray="3 3"
strokeWidth={1}
ifOverflow="extendDomain"
label={{
value: t(
m.direction === "in"
? "balance.evolution.transferIn"
: "balance.evolution.transferOut"
),
position: "insideTopRight",
fontSize: 9,
fill: m.direction === "in" ? "var(--positive)" : "var(--negative)",
}}
/>
))}
</LineChart>
) : (
<AreaChart
data={stackedData}
margin={{ top: 10, right: 16, bottom: 10, left: 10 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
<XAxis
dataKey="snapshot_date"
stroke="var(--muted-foreground)"
fontSize={11}
tickFormatter={(s: string) => formatDate(s)}
/>
<YAxis
stroke="var(--muted-foreground)"
fontSize={11}
tickFormatter={(v: number) => cadFormatter.format(v)}
width={88}
/>
<Tooltip
formatter={(value: number | undefined, name) => [
cadFormatter.format(value ?? 0),
categoryLabels[String(name)] ?? String(name),
]}
labelFormatter={(label) => formatDate(String(label))}
contentStyle={tooltipContentStyle}
/>
<Legend
formatter={(value) => categoryLabels[String(value)] ?? String(value)}
/>
{categoryKeys.map((key, idx) => (
<Area
key={key}
type="monotone"
dataKey={key}
stackId="all"
stroke={CATEGORY_PALETTE[idx % CATEGORY_PALETTE.length]}
fill={CATEGORY_PALETTE[idx % CATEGORY_PALETTE.length]}
fillOpacity={0.5}
name={key}
/>
))}
{renderableMarkers.map((m) => (
<ReferenceLine
key={`tm-${m.id}`}
x={m.transaction_date}
stroke={
m.direction === "in" ? "var(--positive)" : "var(--negative)"
}
strokeDasharray="3 3"
strokeWidth={1}
ifOverflow="extendDomain"
/>
))}
</AreaChart>
)}
</ResponsiveContainer>
</div>
);
}