Skip to content

Commit 77096d6

Browse files
authored
Merge pull request #1590 from Handynfts2/feature/allocation-history-diff-view
feat: add diff view between two allocation-history points
2 parents 258c722 + 8f56a11 commit 77096d6

2 files changed

Lines changed: 316 additions & 14 deletions

File tree

frontend/src/components/AllocationHistory.test.tsx

Lines changed: 131 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import React from "react";
22
import { describe, it, expect, vi, beforeEach } from "vitest";
33
import { render, screen, cleanup, fireEvent } from "@testing-library/react";
4-
import AllocationHistory from "./AllocationHistory";
4+
import AllocationHistory, { computeAllocationDiff } from "./AllocationHistory";
55

66
const queryMocks = vi.hoisted(() => ({
77
usePortfolioAnalytics: vi.fn(),
@@ -248,4 +248,134 @@ describe("AllocationHistory", () => {
248248
render(<AllocationHistory portfolioId="pf-123" />);
249249
expect(screen.getByTestId("reference-line")).toBeDefined();
250250
});
251+
252+
describe("computeAllocationDiff", () => {
253+
it("correctly identifies allocation increases", () => {
254+
const fromSnapshot = {
255+
timestamp: "2025-01-01T00:00:00Z",
256+
allocations: { XLM: 40, USDC: 35, BTC: 25 },
257+
};
258+
const toSnapshot = {
259+
timestamp: "2025-01-02T00:00:00Z",
260+
allocations: { XLM: 45, USDC: 35, BTC: 20 },
261+
};
262+
const allAssets = ["XLM", "USDC", "BTC"];
263+
264+
const diffs = computeAllocationDiff(fromSnapshot, toSnapshot, allAssets);
265+
266+
expect(diffs).toHaveLength(3);
267+
expect(diffs.find((d) => d.asset === "XLM")?.type).toBe("increase");
268+
expect(diffs.find((d) => d.asset === "XLM")?.change).toBe(5);
269+
expect(diffs.find((d) => d.asset === "BTC")?.type).toBe("decrease");
270+
expect(diffs.find((d) => d.asset === "BTC")?.change).toBe(-5);
271+
expect(diffs.find((d) => d.asset === "USDC")?.type).toBe("unchanged");
272+
});
273+
274+
it("correctly identifies added assets", () => {
275+
const fromSnapshot = {
276+
timestamp: "2025-01-01T00:00:00Z",
277+
allocations: { XLM: 40, USDC: 35 },
278+
};
279+
const toSnapshot = {
280+
timestamp: "2025-01-02T00:00:00Z",
281+
allocations: { XLM: 40, USDC: 35, BTC: 25 },
282+
};
283+
const allAssets = ["XLM", "USDC", "BTC"];
284+
285+
const diffs = computeAllocationDiff(fromSnapshot, toSnapshot, allAssets);
286+
287+
expect(diffs).toHaveLength(3);
288+
expect(diffs.find((d) => d.asset === "BTC")?.type).toBe("added");
289+
expect(diffs.find((d) => d.asset === "BTC")?.fromValue).toBe(0);
290+
expect(diffs.find((d) => d.asset === "BTC")?.toValue).toBe(25);
291+
});
292+
293+
it("correctly identifies removed assets", () => {
294+
const fromSnapshot = {
295+
timestamp: "2025-01-01T00:00:00Z",
296+
allocations: { XLM: 40, USDC: 35, BTC: 25 },
297+
};
298+
const toSnapshot = {
299+
timestamp: "2025-01-02T00:00:00Z",
300+
allocations: { XLM: 40, USDC: 35 },
301+
};
302+
const allAssets = ["XLM", "USDC", "BTC"];
303+
304+
const diffs = computeAllocationDiff(fromSnapshot, toSnapshot, allAssets);
305+
306+
expect(diffs).toHaveLength(3);
307+
expect(diffs.find((d) => d.asset === "BTC")?.type).toBe("removed");
308+
expect(diffs.find((d) => d.asset === "BTC")?.fromValue).toBe(25);
309+
expect(diffs.find((d) => d.asset === "BTC")?.toValue).toBe(0);
310+
});
311+
312+
it("handles mixed changes correctly", () => {
313+
const fromSnapshot = {
314+
timestamp: "2025-01-01T00:00:00Z",
315+
allocations: { XLM: 40, USDC: 35, BTC: 25 },
316+
};
317+
const toSnapshot = {
318+
timestamp: "2025-01-02T00:00:00Z",
319+
allocations: { XLM: 50, USDC: 30, ETH: 20 },
320+
};
321+
const allAssets = ["XLM", "USDC", "BTC", "ETH"];
322+
323+
const diffs = computeAllocationDiff(fromSnapshot, toSnapshot, allAssets);
324+
325+
expect(diffs).toHaveLength(4);
326+
expect(diffs.find((d) => d.asset === "XLM")?.type).toBe("increase");
327+
expect(diffs.find((d) => d.asset === "USDC")?.type).toBe("decrease");
328+
expect(diffs.find((d) => d.asset === "BTC")?.type).toBe("removed");
329+
expect(diffs.find((d) => d.asset === "ETH")?.type).toBe("added");
330+
});
331+
332+
it("sorts diffs by absolute change magnitude", () => {
333+
const fromSnapshot = {
334+
timestamp: "2025-01-01T00:00:00Z",
335+
allocations: { XLM: 40, USDC: 35, BTC: 25 },
336+
};
337+
const toSnapshot = {
338+
timestamp: "2025-01-02T00:00:00Z",
339+
allocations: { XLM: 50, USDC: 30, BTC: 20 },
340+
};
341+
const allAssets = ["XLM", "USDC", "BTC"];
342+
343+
const diffs = computeAllocationDiff(fromSnapshot, toSnapshot, allAssets);
344+
345+
expect(diffs[0].asset).toBe("XLM"); // +10
346+
expect(diffs[1].asset).toBe("BTC"); // -5
347+
expect(diffs[2].asset).toBe("USDC"); // -5
348+
});
349+
350+
it("handles empty allocations", () => {
351+
const fromSnapshot = {
352+
timestamp: "2025-01-01T00:00:00Z",
353+
allocations: {},
354+
};
355+
const toSnapshot = {
356+
timestamp: "2025-01-02T00:00:00Z",
357+
allocations: { XLM: 50, USDC: 50 },
358+
};
359+
const allAssets = ["XLM", "USDC"];
360+
361+
const diffs = computeAllocationDiff(fromSnapshot, toSnapshot, allAssets);
362+
363+
expect(diffs).toHaveLength(2);
364+
expect(diffs.every((d) => d.type === "added")).toBe(true);
365+
});
366+
367+
it("handles null snapshots", () => {
368+
const fromSnapshot = null;
369+
const toSnapshot = {
370+
timestamp: "2025-01-02T00:00:00Z",
371+
allocations: { XLM: 50, USDC: 50 },
372+
};
373+
const allAssets = ["XLM", "USDC"];
374+
375+
const diffs = computeAllocationDiff(fromSnapshot, toSnapshot, allAssets);
376+
377+
expect(diffs).toHaveLength(2);
378+
expect(diffs.every((d) => d.type === "added")).toBe(true);
379+
});
380+
});
251381
});

frontend/src/components/AllocationHistory.tsx

Lines changed: 185 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import {
33
AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
44
Legend, ReferenceLine,
55
} from 'recharts'
6-
import { BarChart3, AlertCircle } from 'lucide-react'
6+
import { BarChart3, AlertCircle, TrendingUp, TrendingDown, ArrowRight } from 'lucide-react'
77
import { useTheme } from '../context/ThemeContext'
88
import { usePortfolioAnalytics } from '../hooks/queries/useAnalyticsQuery'
99
import { useRebalanceHistory } from '../hooks/queries/useHistoryQuery'
@@ -29,6 +29,60 @@ interface AllocationHistoryProps {
2929
portfolioId: string | null
3030
}
3131

32+
type DiffType = 'increase' | 'decrease' | 'unchanged' | 'added' | 'removed'
33+
34+
interface AllocationDiff {
35+
asset: string
36+
fromValue: number
37+
toValue: number
38+
change: number
39+
type: DiffType
40+
}
41+
42+
// Export for testing
43+
export function computeAllocationDiff(
44+
fromSnapshot: any,
45+
toSnapshot: any,
46+
allAssets: string[]
47+
): AllocationDiff[] {
48+
const fromAllocations = fromSnapshot?.allocations || {}
49+
const toAllocations = toSnapshot?.allocations || {}
50+
51+
const diffs: AllocationDiff[] = []
52+
53+
for (const asset of allAssets) {
54+
const fromValue = fromAllocations[asset] || 0
55+
const toValue = toAllocations[asset] || 0
56+
57+
if (fromValue === 0 && toValue === 0) continue
58+
59+
const change = toValue - fromValue
60+
let type: DiffType
61+
62+
if (fromValue === 0 && toValue > 0) {
63+
type = 'added'
64+
} else if (fromValue > 0 && toValue === 0) {
65+
type = 'removed'
66+
} else if (change > 0) {
67+
type = 'increase'
68+
} else if (change < 0) {
69+
type = 'decrease'
70+
} else {
71+
type = 'unchanged'
72+
}
73+
74+
diffs.push({
75+
asset,
76+
fromValue,
77+
toValue,
78+
change,
79+
type,
80+
})
81+
}
82+
83+
return diffs.sort((a, b) => Math.abs(b.change) - Math.abs(a.change))
84+
}
85+
3286
function formatChartDate(timestamp: string): string {
3387
const date = new Date(timestamp)
3488
if (!Number.isFinite(date.getTime())) return 'Unknown'
@@ -51,6 +105,9 @@ function formatTooltipDate(timestamp: string): string {
51105
const AllocationHistory: React.FC<AllocationHistoryProps> = ({ portfolioId }) => {
52106
const [days, setDays] = useState(30)
53107
const [hiddenAssets, setHiddenAssets] = useState<Set<string>>(new Set())
108+
const [diffMode, setDiffMode] = useState(false)
109+
const [selectedFromIndex, setSelectedFromIndex] = useState<number | null>(null)
110+
const [selectedToIndex, setSelectedToIndex] = useState<number | null>(null)
54111
const { isDark } = useTheme()
55112

56113
const { data: analyticsDataResult, isLoading: analyticsLoading, error: analyticsError } = usePortfolioAnalytics(portfolioId, days)
@@ -109,6 +166,31 @@ const AllocationHistory: React.FC<AllocationHistoryProps> = ({ portfolioId }) =>
109166
})
110167
}
111168

169+
const handlePointSelect = (index: number) => {
170+
if (!diffMode) return
171+
if (selectedFromIndex === null) {
172+
setSelectedFromIndex(index)
173+
} else if (selectedToIndex === null && index !== selectedFromIndex) {
174+
setSelectedToIndex(index)
175+
} else {
176+
setSelectedFromIndex(index)
177+
setSelectedToIndex(null)
178+
}
179+
}
180+
181+
const exitDiffMode = () => {
182+
setDiffMode(false)
183+
setSelectedFromIndex(null)
184+
setSelectedToIndex(null)
185+
}
186+
187+
const allocationDiffs = useMemo(() => {
188+
if (selectedFromIndex === null || selectedToIndex === null) return []
189+
const fromSnapshot = dailyValues[selectedFromIndex]
190+
const toSnapshot = dailyValues[selectedToIndex]
191+
return computeAllocationDiff(fromSnapshot, toSnapshot, assetNames)
192+
}, [selectedFromIndex, selectedToIndex, dailyValues, assetNames])
193+
112194
const CustomTooltip = ({ active, payload, label }: any) => {
113195
if (!active || !payload?.length) return null
114196
const data = payload[0].payload
@@ -214,22 +296,43 @@ const AllocationHistory: React.FC<AllocationHistoryProps> = ({ portfolioId }) =>
214296
Allocation History
215297
</h2>
216298
<div className="flex items-center gap-2" role="group" aria-label="Time range selector">
217-
{TIME_RANGES.map((range) => (
299+
{!diffMode ? (
300+
<>
301+
{TIME_RANGES.map((range) => (
302+
<button
303+
key={range.days}
304+
type="button"
305+
onClick={() => setDays(range.days)}
306+
aria-pressed={days === range.days}
307+
aria-label={`Show ${range.label}`}
308+
className={`px-3 py-1.5 text-sm rounded-lg font-medium transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 ${
309+
days === range.days
310+
? 'bg-blue-600 text-white'
311+
: 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600'
312+
}`}
313+
>
314+
{range.label}
315+
</button>
316+
))}
317+
<button
318+
type="button"
319+
onClick={() => setDiffMode(true)}
320+
className="px-3 py-1.5 text-sm rounded-lg font-medium transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 bg-purple-600 text-white hover:bg-purple-700"
321+
aria-label="Compare two points in history"
322+
>
323+
Compare
324+
</button>
325+
</>
326+
) : (
218327
<button
219-
key={range.days}
220328
type="button"
221-
onClick={() => setDays(range.days)}
222-
aria-pressed={days === range.days}
223-
aria-label={`Show ${range.label}`}
224-
className={`px-3 py-1.5 text-sm rounded-lg font-medium transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 ${
225-
days === range.days
226-
? 'bg-blue-600 text-white'
227-
: 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600'
228-
}`}
329+
onClick={exitDiffMode}
330+
className="px-3 py-1.5 text-sm rounded-lg font-medium transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 bg-gray-600 text-white hover:bg-gray-700"
331+
aria-label="Exit comparison mode"
229332
>
230-
{range.label}
333+
Exit Compare
231334
</button>
232-
))}
335+
)}
233336
</div>
234337
</div>
235338

@@ -299,6 +402,75 @@ const AllocationHistory: React.FC<AllocationHistoryProps> = ({ portfolioId }) =>
299402
</AreaChart>
300403
</ResponsiveContainer>
301404
</div>
405+
406+
{diffMode && (
407+
<div className="mt-6 border-t border-gray-200 dark:border-gray-700 pt-6">
408+
<h3 className="text-md font-semibold text-gray-900 dark:text-white mb-4">
409+
Compare Allocations
410+
</h3>
411+
<div className="mb-4 text-sm text-gray-600 dark:text-gray-400">
412+
{selectedFromIndex === null ? (
413+
<p>Select the first point to compare by clicking on the chart</p>
414+
) : selectedToIndex === null ? (
415+
<p>
416+
First point selected: <strong>{formatChartDate(dailyValues[selectedFromIndex]?.timestamp)}</strong>.
417+
Select the second point to compare.
418+
</p>
419+
) : (
420+
<p>
421+
Comparing <strong>{formatChartDate(dailyValues[selectedFromIndex]?.timestamp)}</strong>
422+
<ArrowRight className="inline w-4 h-4 mx-2" />
423+
<strong>{formatChartDate(dailyValues[selectedToIndex]?.timestamp)}</strong>
424+
</p>
425+
)}
426+
</div>
427+
428+
{allocationDiffs.length > 0 && (
429+
<div className="space-y-2">
430+
{allocationDiffs.map((diff) => (
431+
<div
432+
key={diff.asset}
433+
className={`flex items-center justify-between rounded-lg px-4 py-3 ${
434+
diff.type === 'increase'
435+
? 'bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800'
436+
: diff.type === 'decrease'
437+
? 'bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800'
438+
: diff.type === 'added'
439+
? 'bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800'
440+
: diff.type === 'removed'
441+
? 'bg-orange-50 dark:bg-orange-900/20 border border-orange-200 dark:border-orange-800'
442+
: 'bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700'
443+
}`}
444+
>
445+
<div className="flex items-center gap-3">
446+
{diff.type === 'increase' && <TrendingUp className="w-4 h-4 text-green-600 dark:text-green-400" />}
447+
{diff.type === 'decrease' && <TrendingDown className="w-4 h-4 text-red-600 dark:text-red-400" />}
448+
{diff.type === 'added' && <TrendingUp className="w-4 h-4 text-blue-600 dark:text-blue-400" />}
449+
{diff.type === 'removed' && <TrendingDown className="w-4 h-4 text-orange-600 dark:text-orange-400" />}
450+
<span className="font-medium text-gray-900 dark:text-white">{diff.asset}</span>
451+
</div>
452+
<div className="flex items-center gap-4 text-sm">
453+
<span className="text-gray-600 dark:text-gray-400">
454+
{diff.fromValue.toFixed(1)}% → {diff.toValue.toFixed(1)}%
455+
</span>
456+
<span
457+
className={`font-semibold ${
458+
diff.change > 0
459+
? 'text-green-600 dark:text-green-400'
460+
: diff.change < 0
461+
? 'text-red-600 dark:text-red-400'
462+
: 'text-gray-600 dark:text-gray-400'
463+
}`}
464+
>
465+
{diff.change > 0 ? '+' : ''}{diff.change.toFixed(1)}%
466+
</span>
467+
</div>
468+
</div>
469+
))}
470+
</div>
471+
)}
472+
</div>
473+
)}
302474
</div>
303475
)}
304476
</div>

0 commit comments

Comments
 (0)