feat(balance): Modified Dietz returns + transfer linking (#142) #151

Merged
maximus merged 8 commits from issue-142-bilan-4 into main 2026-04-26 13:25:32 +00:00
Showing only changes of commit faa09614a3 - Show all commits

View file

@ -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<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({
@ -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<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">
@ -168,6 +203,28 @@ export default function BalanceEvolutionChart({
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
@ -210,6 +267,18 @@ export default function BalanceEvolutionChart({
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>