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:
parent
0e996a5aa1
commit
faa09614a3
1 changed files with 69 additions and 0 deletions
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue