From faa09614a3ac9e339dcf5bc144fa591c378f6299 Mon Sep 17 00:00:00 2001 From: le king fu Date: Sat, 25 Apr 2026 16:38:55 -0400 Subject: [PATCH] feat(balance): add transfer markers on evolution chart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 `` (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) --- .../balance/BalanceEvolutionChart.tsx | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/src/components/balance/BalanceEvolutionChart.tsx b/src/components/balance/BalanceEvolutionChart.tsx index f9908eb..0be8774 100644 --- a/src/components/balance/BalanceEvolutionChart.tsx +++ b/src/components/balance/BalanceEvolutionChart.tsx @@ -22,12 +22,14 @@ import { 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 @@ -51,6 +53,13 @@ export interface BalanceEvolutionChartProps { byCategory: SnapshotCategoryBreakdownPoint[]; /** Map category_key → translated label so the legend reads naturally. */ categoryLabels?: Record; + /** + * Issue #142 — every linked transfer in the visible range. Rendered as + * vertical `` 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({ @@ -58,6 +67,7 @@ export default function BalanceEvolutionChart({ totals, byCategory, categoryLabels = {}, + transferMarkers = [], }: BalanceEvolutionChartProps) { const { t, i18n } = useTranslation(); @@ -114,6 +124,31 @@ export default function BalanceEvolutionChart({ 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(); + 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 (
@@ -168,6 +203,28 @@ export default function BalanceEvolutionChart({ dot={{ r: 3 }} activeDot={{ r: 5 }} /> + {renderableMarkers.map((m) => ( + + ))} ) : ( ))} + {renderableMarkers.map((m) => ( + + ))} )}