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>
This commit is contained in:
le king fu 2026-04-25 16:38:55 -04:00
parent 0e996a5aa1
commit faa09614a3

View file

@ -22,12 +22,14 @@ import {
Tooltip, Tooltip,
ResponsiveContainer, ResponsiveContainer,
Legend, Legend,
ReferenceLine,
} from "recharts"; } from "recharts";
import type { import type {
SnapshotTotalPoint, SnapshotTotalPoint,
SnapshotCategoryBreakdownPoint, SnapshotCategoryBreakdownPoint,
} from "../../services/balance.service"; } from "../../services/balance.service";
import type { BalanceChartMode } from "../../hooks/useBalanceOverview"; import type { BalanceChartMode } from "../../hooks/useBalanceOverview";
import type { BalanceAccountTransferWithTransaction } from "../../shared/types";
// Stable palette for the stacked-by-category areas. Indexed deterministically // Stable palette for the stacked-by-category areas. Indexed deterministically
// by category sort order so the colour assignment stays consistent across // by category sort order so the colour assignment stays consistent across
@ -51,6 +53,13 @@ export interface BalanceEvolutionChartProps {
byCategory: SnapshotCategoryBreakdownPoint[]; byCategory: SnapshotCategoryBreakdownPoint[];
/** Map category_key → translated label so the legend reads naturally. */ /** Map category_key → translated label so the legend reads naturally. */
categoryLabels?: Record<string, string>; 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({ export default function BalanceEvolutionChart({
@ -58,6 +67,7 @@ export default function BalanceEvolutionChart({
totals, totals,
byCategory, byCategory,
categoryLabels = {}, categoryLabels = {},
transferMarkers = [],
}: BalanceEvolutionChartProps) { }: BalanceEvolutionChartProps) {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
@ -114,6 +124,31 @@ export default function BalanceEvolutionChart({
const isEmpty = const isEmpty =
mode === "line" ? lineData.length === 0 : stackedData.length === 0; 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) { if (isEmpty) {
return ( return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6"> <div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6">
@ -168,6 +203,28 @@ export default function BalanceEvolutionChart({
dot={{ r: 3 }} dot={{ r: 3 }}
activeDot={{ r: 5 }} 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> </LineChart>
) : ( ) : (
<AreaChart <AreaChart
@ -210,6 +267,18 @@ export default function BalanceEvolutionChart({
name={key} 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> </AreaChart>
)} )}
</ResponsiveContainer> </ResponsiveContainer>