feat(balance): Modified Dietz returns + transfer linking (#142) #151
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