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'
77import { useTheme } from '../context/ThemeContext'
88import { usePortfolioAnalytics } from '../hooks/queries/useAnalyticsQuery'
99import { 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+
3286function 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 {
51105const 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