From d9ec300cc0d593d735f4d571a87a630ca136755f Mon Sep 17 00:00:00 2001 From: antoncoding Date: Mon, 2 Feb 2026 16:16:58 +0800 Subject: [PATCH 1/5] feat: postino graph --- .../components/supplied-markets-detail.tsx | 67 +-- .../supplied-morpho-blue-grouped-table.tsx | 18 +- .../components/user-positions-chart.tsx | 392 ++++++++++++++++++ src/hooks/usePositionHistoryChart.ts | 308 ++++++++++++++ src/hooks/useUserPositionsSummaryData.ts | 2 + 5 files changed, 730 insertions(+), 57 deletions(-) create mode 100644 src/features/positions/components/user-positions-chart.tsx create mode 100644 src/hooks/usePositionHistoryChart.ts diff --git a/src/features/positions/components/supplied-markets-detail.tsx b/src/features/positions/components/supplied-markets-detail.tsx index d59b35d9..1d0a1839 100644 --- a/src/features/positions/components/supplied-markets-detail.tsx +++ b/src/features/positions/components/supplied-markets-detail.tsx @@ -8,14 +8,18 @@ import { MarketRiskIndicators } from '@/features/markets/components/market-risk- import { APYCell } from '@/features/markets/components/apy-breakdown-tooltip'; import { useRateLabel } from '@/hooks/useRateLabel'; import { useModal } from '@/hooks/useModal'; -import { formatReadable, formatBalance } from '@/utils/balance'; +import { formatBalance } from '@/utils/balance'; import type { MarketPosition, GroupedPosition } from '@/utils/types'; -import { getCollateralColorFromPalette, OTHER_COLOR } from '@/features/positions/utils/colors'; -import { useChartColors } from '@/constants/chartColors'; import { AllocationCell } from './allocation-cell'; +import { UserPositionsChart } from './user-positions-chart'; +import type { UserTransaction } from '@/utils/types'; +import type { PositionSnapshot } from '@/utils/positions'; + type SuppliedMarketsDetailProps = { groupedPosition: GroupedPosition; - showCollateralExposure: boolean; + transactions: UserTransaction[]; + snapshotsByChain: Record>; + chainBlockData: Record; }; function MarketRow({ position, totalSupply, rateLabel }: { position: MarketPosition; totalSupply: number; rateLabel: string }) { @@ -106,9 +110,13 @@ function MarketRow({ position, totalSupply, rateLabel }: { position: MarketPosit } // shared similar style with @vault-allocation-detail.tsx -export function SuppliedMarketsDetail({ groupedPosition, showCollateralExposure }: SuppliedMarketsDetailProps) { +export function SuppliedMarketsDetail({ + groupedPosition, + transactions, + snapshotsByChain, + chainBlockData, +}: SuppliedMarketsDetailProps) { const { short: rateLabel } = useRateLabel(); - const { pie: pieColors } = useChartColors(); // Sort markets by size const sortedMarkets = [...groupedPosition.markets].sort( @@ -128,45 +136,14 @@ export function SuppliedMarketsDetail({ groupedPosition, showCollateralExposure className="overflow-hidden" >
- {/* Conditionally render collateral exposure section */} - {showCollateralExposure && ( -
-
-

Collateral Exposure

-
- {groupedPosition.processedCollaterals.map((collateral, colIndex) => ( -
- ))} -
-
- {groupedPosition.processedCollaterals.map((collateral, colIndex) => ( - - - ■ - {' '} - {collateral.symbol}: {formatReadable(collateral.percentage)}% - - ))} -
-
-
- )} + {/* Position History Chart with synchronized pie */} + {/* Markets Table - Always visible */} diff --git a/src/features/positions/components/supplied-morpho-blue-grouped-table.tsx b/src/features/positions/components/supplied-morpho-blue-grouped-table.tsx index 252599b0..71107ceb 100644 --- a/src/features/positions/components/supplied-morpho-blue-grouped-table.tsx +++ b/src/features/positions/components/supplied-morpho-blue-grouped-table.tsx @@ -109,10 +109,12 @@ export function SuppliedMorphoBlueGroupedTable({ account }: SuppliedMorphoBlueGr isRefetching, isEarningsLoading, actualBlockData, + transactions, + snapshotsByChain, } = useUserPositionsSummaryData(account, period); const [expandedRows, setExpandedRows] = useState>(new Set()); - const { showCollateralExposure, setShowCollateralExposure, showEarningsInUsd, setShowEarningsInUsd } = usePositionsPreferences(); + const { showEarningsInUsd, setShowEarningsInUsd } = usePositionsPreferences(); const { isOpen: isSettingsOpen, onOpen: onSettingsOpen, onOpenChange: onSettingsOpenChange } = useDisclosure(); const { address } = useConnection(); const { isAprDisplay } = useAppSettings(); @@ -389,7 +391,9 @@ export function SuppliedMorphoBlueGroupedTable({ account }: SuppliedMorphoBlueGr > @@ -451,16 +455,6 @@ export function SuppliedMorphoBlueGroupedTable({ account }: SuppliedMorphoBlueGr title="Display Options" helper="Customize what information is shown in the table" > - - - >; + chainBlockData: Record; +}; + +// Props for standalone usage (history page) +type StandaloneChartProps = BaseChartProps & { + variant: 'standalone'; + chainId: SupportedNetworks; + loanAssetAddress: Address; + loanAssetSymbol: string; + loanAssetDecimals: number; + startTimestamp: number; + endTimestamp: number; + markets: { + uniqueKey: string; + collateralSymbol: string; + collateralAddress: string; + currentSupplyAssets: string; + }[]; + transactions: UserTransaction[]; + snapshots: Map | undefined; +}; + +export type UserPositionsChartProps = GroupedPositionChartProps | StandaloneChartProps; + +// Pie chart data type +type PieDataPoint = { + name: string; + value: number; + color: string; + percentage: number; +}; + +function ChartContent({ + dataPoints, + markets, + loanAssetSymbol, + height, +}: { + dataPoints: PositionHistoryDataPoint[]; + markets: MarketInfo[]; + loanAssetSymbol: string; + height: number; +}) { + const chartColors = useChartColors(); + + // Track which data point is being hovered (for synced pie chart) + const [hoveredIndex, setHoveredIndex] = useState(null); + + // Get color for a market based on its collateral address + const getMarketColor = useCallback( + (collateralAddress: string): string => { + return getCollateralColorFromPalette(collateralAddress, chartColors.pie); + }, + [chartColors.pie], + ); + + + + // Current data point for pie chart (hovered or latest) + const currentDataPoint = useMemo(() => { + if (hoveredIndex !== null && dataPoints[hoveredIndex]) { + return dataPoints[hoveredIndex]; + } + return dataPoints[dataPoints.length - 1]; + }, [hoveredIndex, dataPoints]); + + // Pie chart data derived from current data point + const pieData = useMemo((): PieDataPoint[] => { + if (!currentDataPoint) return []; + + const total = currentDataPoint.total || 0; + return markets + .map((market) => { + const value = Number(currentDataPoint[market.uniqueKey] ?? 0); + return { + name: market.collateralSymbol, + value, + color: getMarketColor(market.collateralAddress), + percentage: total > 0 ? (value / total) * 100 : 0, + }; + }) + .filter((d) => d.value > 0) + .sort((a, b) => b.value - a.value); + }, [currentDataPoint, markets, getMarketColor]); + + // Format date for display + const formatDate = (timestamp: number) => { + return new Date(timestamp * 1000).toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + }); + }; + + // Don't render if no meaningful data + if (dataPoints.length <= 1 || markets.length === 0) { + return null; + } + + const currentTimestamp = currentDataPoint?.timestamp; + const isHistorical = hoveredIndex !== null && hoveredIndex < dataPoints.length - 1; + + return ( + + {/* Responsive: stack vertically on mobile, side-by-side on larger screens */} +
+ {/* Stacked Area Chart - Left side */} +
+
+

Position History

+
+
+ + { + if (state?.activeTooltipIndex !== undefined) { + setHoveredIndex(state.activeTooltipIndex); + } + }} + onMouseLeave={() => setHoveredIndex(null)} + > + + + new Date(time * 1000).toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + }) + } + tick={{ fontSize: 10, fill: 'var(--color-text-secondary)' }} + /> + formatReadable(v)} + tick={{ fontSize: 10, fill: 'var(--color-text-secondary)' }} + width={55} + domain={[0, 'auto']} + /> + { + if (!active || !payload || payload.length === 0) return null; + + const dataPoint = dataPoints.find((dp) => dp.timestamp === label); + const total = dataPoint?.total ?? 0; + + return ( +
+

+ {new Date((label ?? 0) * 1000).toLocaleString(undefined, { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + })} +

+
+ {payload + .filter((entry) => Number(entry.value) > 0) + .map((entry) => { + const market = markets.find((m) => m.uniqueKey === entry.dataKey); + const value = Number(entry.value) || 0; + const pct = total > 0 ? ((value / total) * 100).toFixed(1) : '0'; + return ( +
+
+ + {market?.collateralSymbol ?? 'Unknown'} +
+ + {formatReadable(value)} ({pct}%) + +
+ ); + })} +
+
+ Total + {formatReadable(total)} {loanAssetSymbol} +
+
+ ); + }} + /> + {markets.map((market) => { + const key = market.uniqueKey.toLowerCase(); + const color = getMarketColor(market.collateralAddress); + return ( + + ); + })} +
+
+
+
+ + {/* Synchronized Pie Chart - Right side on desktop, below on mobile */} +
+ {/* Mobile: horizontal layout with pie on left, legend on right */} + {/* Desktop: vertical layout */} +
+
+

+ {isHistorical ? 'Historical' : 'Current'} +

+ {currentTimestamp && ( +

{formatDate(currentTimestamp)}

+ )} +
+ + + + {pieData.map((entry, index) => ( + + ))} + + + +
+ {/* Legend */} +
+
+ {pieData.slice(0, 5).map((entry) => ( +
+
+ + {entry.name} +
+ {entry.percentage.toFixed(1)}% +
+ ))} + {pieData.length > 5 && ( +

+{pieData.length - 5} more

+ )} +
+
+
+
+
+ ); +} + +// Extract params from props to normalize between variants +function useChartParams(props: UserPositionsChartProps) { + return useMemo(() => { + if (props.variant === 'grouped') { + const { groupedPosition, transactions, snapshotsByChain, chainBlockData } = props; + const chainId = groupedPosition.chainId; + const chainSnapshots = snapshotsByChain[chainId]; + const blockData = chainBlockData[chainId]; + + if (!blockData) { + return null; + } + + const startTimestamp = blockData.timestamp; + const endTimestamp = Math.floor(Date.now() / 1000); + + const markets = groupedPosition.markets.map((position) => ({ + uniqueKey: position.market.uniqueKey, + collateralSymbol: position.market.collateralAsset?.symbol ?? 'Unknown', + collateralAddress: position.market.collateralAsset?.address ?? '', + currentSupplyAssets: position.state.supplyAssets, + })); + + return { + markets, + loanAssetDecimals: groupedPosition.loanAssetDecimals, + loanAssetSymbol: groupedPosition.loanAssetSymbol, + chainId: chainId as SupportedNetworks, + startTimestamp, + endTimestamp, + transactions, + snapshots: chainSnapshots, + }; + } + + // Standalone variant + return { + markets: props.markets, + loanAssetDecimals: props.loanAssetDecimals, + loanAssetSymbol: props.loanAssetSymbol, + chainId: props.chainId, + startTimestamp: props.startTimestamp, + endTimestamp: props.endTimestamp, + transactions: props.transactions, + snapshots: props.snapshots, + }; + }, [props]); +} + +export function UserPositionsChart(props: UserPositionsChartProps) { + const height = props.height ?? 180; + const debug = props.debug ?? false; + + const chartParams = useChartParams(props); + + const { dataPoints, markets: marketInfoList } = usePositionHistoryChart({ + markets: chartParams?.markets ?? [], + loanAssetDecimals: chartParams?.loanAssetDecimals ?? 18, + chainId: chartParams?.chainId ?? (1 as SupportedNetworks), + startTimestamp: chartParams?.startTimestamp ?? 0, + endTimestamp: chartParams?.endTimestamp ?? 0, + transactions: chartParams?.transactions ?? [], + snapshots: chartParams?.snapshots, + debug, + }); + + if (!chartParams) { + return null; + } + + return ( + + ); +} diff --git a/src/hooks/usePositionHistoryChart.ts b/src/hooks/usePositionHistoryChart.ts new file mode 100644 index 00000000..01aa97ac --- /dev/null +++ b/src/hooks/usePositionHistoryChart.ts @@ -0,0 +1,308 @@ +import { useMemo } from 'react'; +import type { UserTransaction } from '@/utils/types'; +import type { PositionSnapshot } from '@/utils/positions'; +import { UserTxTypes } from '@/utils/types'; +import { formatBalance } from '@/utils/balance'; +import type { SupportedNetworks } from '@/utils/networks'; + +// Maximum number of data points before batching kicks in +const MAX_DATA_POINTS = 50; + +export type PositionHistoryDataPoint = { + timestamp: number; + total: number; + eventType?: 'supply' | 'withdraw' | 'batch'; + eventAmount?: number; + eventMarketKey?: string; + batchCount?: number; // Number of events in this batch + [marketKey: string]: number | string | undefined; +}; + +export type MarketInfo = { + uniqueKey: string; + collateralSymbol: string; + collateralAddress: string; +}; + +export type PositionHistoryDebugInfo = { + inputTransactionsCount: number; + relevantTransactionsCount: number; + filteredByMarket: number; + filteredByType: number; + filteredByTime: number; + dataPointsCount: number; + startTimestamp: number; + endTimestamp: number; + marketsCount: number; + snapshotFound: boolean; + batchingApplied: boolean; + batchWindowSeconds: number; +}; + +type UsePositionHistoryChartOptions = { + // Market data + markets: { + uniqueKey: string; + collateralSymbol: string; + collateralAddress: string; + currentSupplyAssets: string; + }[]; + loanAssetDecimals: number; + chainId: SupportedNetworks; + + // Time range + startTimestamp: number; + endTimestamp: number; + + // Data inputs + transactions: UserTransaction[]; + snapshots: Map | undefined; + + // Debug + debug?: boolean; +}; + +export type PositionHistoryChartData = { + dataPoints: PositionHistoryDataPoint[]; + markets: MarketInfo[]; + debugInfo: PositionHistoryDebugInfo | null; +}; + +export function usePositionHistoryChart({ + markets, + loanAssetDecimals, + chainId, + startTimestamp, + endTimestamp, + transactions, + snapshots, + debug = false, +}: UsePositionHistoryChartOptions): PositionHistoryChartData { + return useMemo(() => { + const decimals = loanAssetDecimals; + + // Build market info list - use lowercase keys consistently + const marketInfoList: MarketInfo[] = markets.map((m) => ({ + uniqueKey: m.uniqueKey.toLowerCase(), + collateralSymbol: m.collateralSymbol, + collateralAddress: m.collateralAddress, + })); + + const marketKeys = marketInfoList.map((m) => m.uniqueKey); + + // Debug counters + let filteredByMarket = 0; + let filteredByType = 0; + let filteredByTime = 0; + + // Filter transactions step by step for debugging + const txsWithMarket = transactions.filter((tx) => { + const txMarketKey = tx.data?.market?.uniqueKey?.toLowerCase(); + const matches = txMarketKey && marketKeys.includes(txMarketKey); + if (!matches) filteredByMarket++; + return matches; + }); + + const txsWithCorrectType = txsWithMarket.filter((tx) => { + const isSupplyOrWithdraw = tx.type === UserTxTypes.MarketSupply || tx.type === UserTxTypes.MarketWithdraw; + if (!isSupplyOrWithdraw) filteredByType++; + return isSupplyOrWithdraw; + }); + + const txsInTimeRange = txsWithCorrectType.filter((tx) => { + const inRange = Number(tx.timestamp) >= startTimestamp && Number(tx.timestamp) <= endTimestamp; + if (!inRange) filteredByTime++; + return inRange; + }); + + const relevantTxs = txsInTimeRange.sort((a, b) => Number(a.timestamp) - Number(b.timestamp)); + + // Initialize positions from "before" snapshot - use lowercase keys + const marketPositions: Record = {}; + let snapshotFound = false; + + for (const market of markets) { + const key = market.uniqueKey.toLowerCase(); + const snapshot = snapshots?.get(key); + if (snapshot) snapshotFound = true; + marketPositions[key] = snapshot ? BigInt(snapshot.supplyAssets) : 0n; + } + + // Helper to calculate total and create data point + const createDataPoint = ( + timestamp: number, + eventType?: 'supply' | 'withdraw' | 'batch', + eventAmount?: number, + eventMarketKey?: string, + batchCount?: number, + ): PositionHistoryDataPoint => { + const point: PositionHistoryDataPoint = { timestamp, total: 0 }; + if (eventType) point.eventType = eventType; + if (eventAmount !== undefined) point.eventAmount = eventAmount; + if (eventMarketKey) point.eventMarketKey = eventMarketKey; + if (batchCount !== undefined) point.batchCount = batchCount; + + let total = 0; + for (const market of marketInfoList) { + const positionBigInt = marketPositions[market.uniqueKey] ?? 0n; + const value = Number(formatBalance(positionBigInt, decimals)); + point[market.uniqueKey] = value; + total += value; + } + point.total = total; + return point; + }; + + // Group transactions by timestamp (same block = same timestamp) + const txsByTimestamp = new Map(); + for (const tx of relevantTxs) { + const ts = Number(tx.timestamp); + if (!txsByTimestamp.has(ts)) { + txsByTimestamp.set(ts, []); + } + txsByTimestamp.get(ts)!.push(tx); + } + + const uniqueTimestamps = Array.from(txsByTimestamp.keys()).sort((a, b) => a - b); + + // Determine if we need batching (only when unique timestamps exceed MAX_DATA_POINTS) + const needsBatching = uniqueTimestamps.length > MAX_DATA_POINTS; + const timeRange = endTimestamp - startTimestamp; + // If batching needed, calculate window size to get ~MAX_DATA_POINTS buckets + const batchWindowSeconds = needsBatching ? Math.ceil(timeRange / MAX_DATA_POINTS) : 0; + + const dataPoints: PositionHistoryDataPoint[] = []; + + // Add starting data point + dataPoints.push(createDataPoint(startTimestamp)); + + // Helper to apply transaction to positions + const applyTransaction = (tx: UserTransaction) => { + const key = tx.data.market.uniqueKey.toLowerCase(); + const assets = BigInt(tx.data?.assets ?? '0'); + + if (tx.type === UserTxTypes.MarketSupply) { + marketPositions[key] = (marketPositions[key] ?? 0n) + assets; + } else if (tx.type === UserTxTypes.MarketWithdraw) { + const newValue = (marketPositions[key] ?? 0n) - assets; + // Never allow negative positions - clamp to 0 + marketPositions[key] = newValue < 0n ? 0n : newValue; + } + }; + + if (needsBatching) { + // Batch timestamps into time windows + let currentWindowEnd = startTimestamp + batchWindowSeconds; + let batchTxs: UserTransaction[] = []; + let batchTimestamp = 0; + + const processBatch = () => { + if (batchTxs.length === 0) return; + + // Apply all transactions in the batch to update positions + let totalSupplied = 0n; + let totalWithdrawn = 0n; + + for (const tx of batchTxs) { + applyTransaction(tx); + const assets = BigInt(tx.data?.assets ?? '0'); + if (tx.type === UserTxTypes.MarketSupply) { + totalSupplied += assets; + } else { + totalWithdrawn += assets; + } + } + + // Create a single data point for the batch at the last timestamp in the batch + const netChange = totalSupplied - totalWithdrawn; + const eventAmount = Number(formatBalance(netChange >= 0n ? netChange : -netChange, decimals)); + + dataPoints.push(createDataPoint(batchTimestamp, 'batch', eventAmount, undefined, batchTxs.length)); + }; + + for (const ts of uniqueTimestamps) { + // Move to the correct window + while (ts >= currentWindowEnd) { + processBatch(); + batchTxs = []; + currentWindowEnd += batchWindowSeconds; + } + + const txsAtTimestamp = txsByTimestamp.get(ts) ?? []; + batchTxs.push(...txsAtTimestamp); + batchTimestamp = ts; // Use the last timestamp in the batch + } + + // Process remaining batch + processBatch(); + } else { + // Process each timestamp group as one data point + for (const ts of uniqueTimestamps) { + const txsAtTimestamp = txsByTimestamp.get(ts) ?? []; + + // Track event info for single-tx timestamps + let totalSupplied = 0n; + let totalWithdrawn = 0n; + let lastMarketKey: string | undefined; + + for (const tx of txsAtTimestamp) { + applyTransaction(tx); + const assets = BigInt(tx.data?.assets ?? '0'); + lastMarketKey = tx.data.market.uniqueKey.toLowerCase(); + if (tx.type === UserTxTypes.MarketSupply) { + totalSupplied += assets; + } else { + totalWithdrawn += assets; + } + } + + // Determine event type and amount + if (txsAtTimestamp.length === 1) { + const tx = txsAtTimestamp[0]; + const eventType = tx.type === UserTxTypes.MarketSupply ? 'supply' : 'withdraw'; + const eventAmount = Number(formatBalance(BigInt(tx.data?.assets ?? '0'), decimals)); + dataPoints.push(createDataPoint(ts, eventType, eventAmount, lastMarketKey)); + } else { + // Multiple txs at same timestamp - treat as batch + const netChange = totalSupplied - totalWithdrawn; + const eventAmount = Number(formatBalance(netChange >= 0n ? netChange : -netChange, decimals)); + dataPoints.push(createDataPoint(ts, 'batch', eventAmount, undefined, txsAtTimestamp.length)); + } + } + } + + // Add final data point with current positions + // Reset to current values + for (const market of markets) { + const key = market.uniqueKey.toLowerCase(); + marketPositions[key] = BigInt(market.currentSupplyAssets); + } + dataPoints.push(createDataPoint(endTimestamp)); + + const debugInfo: PositionHistoryDebugInfo | null = debug + ? { + inputTransactionsCount: transactions.length, + relevantTransactionsCount: relevantTxs.length, + filteredByMarket, + filteredByType, + filteredByTime, + dataPointsCount: dataPoints.length, + startTimestamp, + endTimestamp, + marketsCount: markets.length, + snapshotFound, + batchingApplied: needsBatching, + batchWindowSeconds, + } + : null; + + if (debug) { + console.log('[PositionHistoryChart] Debug Info:', debugInfo); + console.log('[PositionHistoryChart] Data Points:', dataPoints); + console.log('[PositionHistoryChart] Markets:', marketInfoList); + console.log('[PositionHistoryChart] Relevant Transactions:', relevantTxs.length); + } + + return { dataPoints, markets: marketInfoList, debugInfo }; + }, [markets, loanAssetDecimals, chainId, startTimestamp, endTimestamp, transactions, snapshots, debug]); +} diff --git a/src/hooks/useUserPositionsSummaryData.ts b/src/hooks/useUserPositionsSummaryData.ts index fb550767..74d31531 100644 --- a/src/hooks/useUserPositionsSummaryData.ts +++ b/src/hooks/useUserPositionsSummaryData.ts @@ -112,6 +112,8 @@ const useUserPositionsSummaryData = (user: string | undefined, period: EarningsP refetch, loadingStates, actualBlockData: actualBlockData ?? {}, + transactions: txData?.items ?? [], + snapshotsByChain: allSnapshots ?? {}, }; }; From 7a7f27c953ed0ebd15cf4a52cdbe93e4bce1a9cf Mon Sep 17 00:00:00 2001 From: antoncoding Date: Mon, 2 Feb 2026 16:20:32 +0800 Subject: [PATCH 2/5] chore: stacked chart over time --- src/features/positions/components/user-positions-chart.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/features/positions/components/user-positions-chart.tsx b/src/features/positions/components/user-positions-chart.tsx index 5266c930..60a0627d 100644 --- a/src/features/positions/components/user-positions-chart.tsx +++ b/src/features/positions/components/user-positions-chart.tsx @@ -244,7 +244,7 @@ function ChartContent({ stroke="none" strokeWidth={0} fill={color} - fillOpacity={0.45} + fillOpacity={0.6} /> ); })} @@ -279,7 +279,7 @@ function ChartContent({ strokeWidth={0} > {pieData.map((entry, index) => ( - + ))} From 96bca00a8d6900ca48387e32f79460c6666fc2fa Mon Sep 17 00:00:00 2001 From: antoncoding Date: Mon, 2 Feb 2026 16:57:54 +0800 Subject: [PATCH 3/5] chore: lint --- .../components/table/market-table-body.tsx | 8 +- .../components/supplied-markets-detail.tsx | 7 +- .../components/user-positions-chart.tsx | 120 ++++++++++++------ src/utils/blockEstimation.ts | 4 +- 4 files changed, 88 insertions(+), 51 deletions(-) diff --git a/src/features/markets/components/table/market-table-body.tsx b/src/features/markets/components/table/market-table-body.tsx index 8d79e8fa..7cd86017 100644 --- a/src/features/markets/components/table/market-table-body.tsx +++ b/src/features/markets/components/table/market-table-body.tsx @@ -241,9 +241,7 @@ export function MarketTableBody({ currentEntries, expandedRowId, setExpandedRowI className="z-50 text-center" style={{ minWidth: '85px', paddingLeft: 3, paddingRight: 3 }} > -

- {item.state.dailySupplyApy != null ? : '—'} -

+

{item.state.dailySupplyApy != null ? : '—'}

)} {columnVisibility.dailyBorrowAPY && ( @@ -252,9 +250,7 @@ export function MarketTableBody({ currentEntries, expandedRowId, setExpandedRowI className="z-50 text-center" style={{ minWidth: '85px', paddingLeft: 3, paddingRight: 3 }} > -

- {item.state.dailyBorrowApy != null ? : '—'} -

+

{item.state.dailyBorrowApy != null ? : '—'}

)} {columnVisibility.weeklySupplyAPY && ( diff --git a/src/features/positions/components/supplied-markets-detail.tsx b/src/features/positions/components/supplied-markets-detail.tsx index 1d0a1839..9c370d9b 100644 --- a/src/features/positions/components/supplied-markets-detail.tsx +++ b/src/features/positions/components/supplied-markets-detail.tsx @@ -110,12 +110,7 @@ function MarketRow({ position, totalSupply, rateLabel }: { position: MarketPosit } // shared similar style with @vault-allocation-detail.tsx -export function SuppliedMarketsDetail({ - groupedPosition, - transactions, - snapshotsByChain, - chainBlockData, -}: SuppliedMarketsDetailProps) { +export function SuppliedMarketsDetail({ groupedPosition, transactions, snapshotsByChain, chainBlockData }: SuppliedMarketsDetailProps) { const { short: rateLabel } = useRateLabel(); // Sort markets by size diff --git a/src/features/positions/components/user-positions-chart.tsx b/src/features/positions/components/user-positions-chart.tsx index 60a0627d..c47d3a23 100644 --- a/src/features/positions/components/user-positions-chart.tsx +++ b/src/features/positions/components/user-positions-chart.tsx @@ -7,12 +7,7 @@ import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContai import { useChartColors } from '@/constants/chartColors'; import { formatReadable } from '@/utils/balance'; import { chartTooltipCursor } from '@/features/market-detail/components/charts/chart-utils'; -import { getCollateralColorFromPalette } from '@/features/positions/utils/colors'; -import { - usePositionHistoryChart, - type PositionHistoryDataPoint, - type MarketInfo, -} from '@/hooks/usePositionHistoryChart'; +import { usePositionHistoryChart, type PositionHistoryDataPoint, type MarketInfo } from '@/hooks/usePositionHistoryChart'; import type { GroupedPosition, UserTransaction } from '@/utils/types'; import type { PositionSnapshot } from '@/utils/positions'; import type { SupportedNetworks } from '@/utils/networks'; @@ -60,6 +55,7 @@ export type UserPositionsChartProps = GroupedPositionChartProps | StandaloneChar // Pie chart data type type PieDataPoint = { + key: string; // unique market key name: string; value: number; color: string; @@ -82,22 +78,55 @@ function ChartContent({ // Track which data point is being hovered (for synced pie chart) const [hoveredIndex, setHoveredIndex] = useState(null); - // Get color for a market based on its collateral address + // Build a map of market uniqueKey -> index for consistent color assignment + // Note: markets from hook already have lowercase uniqueKeys + const marketColorMap = useMemo(() => { + const map: Record = {}; + markets.forEach((market, index) => { + map[market.uniqueKey] = index; + }); + return map; + }, [markets]); + + // Get color for a market based on its index in the markets array const getMarketColor = useCallback( - (collateralAddress: string): string => { - return getCollateralColorFromPalette(collateralAddress, chartColors.pie); + (marketKey: string): string => { + const index = marketColorMap[marketKey] ?? 0; + return chartColors.pie[index % chartColors.pie.length]; }, - [chartColors.pie], + [marketColorMap, chartColors.pie], ); + // Detect duplicate collateral symbols and create display names + const marketDisplayNames = useMemo(() => { + const symbolCounts: Record = {}; + + // First pass: count occurrences + markets.forEach((market) => { + symbolCounts[market.collateralSymbol] = (symbolCounts[market.collateralSymbol] || 0) + 1; + }); + // Second pass: create display names + const names: Record = {}; + markets.forEach((market) => { + if (symbolCounts[market.collateralSymbol] > 1) { + // Duplicate symbol - add market key prefix (first 8 chars) + const keyPrefix = market.uniqueKey.slice(0, 8); + names[market.uniqueKey] = `${market.collateralSymbol} (${keyPrefix}...)`; + } else { + names[market.uniqueKey] = market.collateralSymbol; + } + }); + + return names; + }, [markets]); // Current data point for pie chart (hovered or latest) const currentDataPoint = useMemo(() => { if (hoveredIndex !== null && dataPoints[hoveredIndex]) { return dataPoints[hoveredIndex]; } - return dataPoints[dataPoints.length - 1]; + return dataPoints.at(-1); }, [hoveredIndex, dataPoints]); // Pie chart data derived from current data point @@ -109,15 +138,16 @@ function ChartContent({ .map((market) => { const value = Number(currentDataPoint[market.uniqueKey] ?? 0); return { - name: market.collateralSymbol, + key: market.uniqueKey, + name: marketDisplayNames[market.uniqueKey] || market.collateralSymbol, value, - color: getMarketColor(market.collateralAddress), + color: getMarketColor(market.uniqueKey), percentage: total > 0 ? (value / total) * 100 : 0, }; }) .filter((d) => d.value > 0) .sort((a, b) => b.value - a.value); - }, [currentDataPoint, markets, getMarketColor]); + }, [currentDataPoint, markets, getMarketColor, marketDisplayNames]); // Format date for display const formatDate = (timestamp: number) => { @@ -145,7 +175,10 @@ function ChartContent({

Position History

- + setHoveredIndex(null)} > - + Number(entry.value) > 0) .map((entry) => { - const market = markets.find((m) => m.uniqueKey === entry.dataKey); + const marketKey = String(entry.dataKey); + const displayName = marketDisplayNames[marketKey] || 'Unknown'; const value = Number(entry.value) || 0; const pct = total > 0 ? ((value / total) * 100).toFixed(1) : '0'; return ( -
+
- {market?.collateralSymbol ?? 'Unknown'} + {displayName}
{formatReadable(value)} ({pct}%) @@ -225,21 +266,24 @@ function ChartContent({
Total - {formatReadable(total)} {loanAssetSymbol} + + {formatReadable(total)} {loanAssetSymbol} +
); }} /> {markets.map((market) => { - const key = market.uniqueKey.toLowerCase(); - const color = getMarketColor(market.collateralAddress); + const key = market.uniqueKey; + const color = getMarketColor(key); + const displayName = marketDisplayNames[key] || market.collateralSymbol; return (
-

- {isHistorical ? 'Historical' : 'Current'} -

- {currentTimestamp && ( -

{formatDate(currentTimestamp)}

- )} +

{isHistorical ? 'Historical' : 'Current'}

+ {currentTimestamp &&

{formatDate(currentTimestamp)}

}
- + - {pieData.map((entry, index) => ( - + {pieData.map((entry) => ( + ))} @@ -289,7 +336,10 @@ function ChartContent({
{pieData.slice(0, 5).map((entry) => ( -
+
{entry.percentage.toFixed(1)}%
))} - {pieData.length > 5 && ( -

+{pieData.length - 5} more

- )} + {pieData.length > 5 &&

+{pieData.length - 5} more

}
diff --git a/src/utils/blockEstimation.ts b/src/utils/blockEstimation.ts index 059eba1d..559bbd6d 100644 --- a/src/utils/blockEstimation.ts +++ b/src/utils/blockEstimation.ts @@ -60,9 +60,7 @@ export async function fetchBlocksWithTimestamps( currentTimestamp: number, ): Promise { // First, estimate all block numbers - const estimatedBlocks = targetTimestamps.map((ts) => - estimateBlockAtTimestamp(chainId, ts, currentBlock, currentTimestamp), - ); + const estimatedBlocks = targetTimestamps.map((ts) => estimateBlockAtTimestamp(chainId, ts, currentBlock, currentTimestamp)); // Fetch all block timestamps in parallel const blockPromises = estimatedBlocks.map(async (blockNum, index) => { From 66d1117c365a33f567e5fb664aacf0ca473cd169 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Mon, 2 Feb 2026 17:16:28 +0800 Subject: [PATCH 4/5] chore: fix height --- .../components/user-positions-chart.tsx | 80 +++++++++---------- 1 file changed, 39 insertions(+), 41 deletions(-) diff --git a/src/features/positions/components/user-positions-chart.tsx b/src/features/positions/components/user-positions-chart.tsx index c47d3a23..85c25ab0 100644 --- a/src/features/positions/components/user-positions-chart.tsx +++ b/src/features/positions/components/user-positions-chart.tsx @@ -130,6 +130,7 @@ function ChartContent({ }, [hoveredIndex, dataPoints]); // Pie chart data derived from current data point + // Keep all markets for consistent height, sort by value descending const pieData = useMemo((): PieDataPoint[] => { if (!currentDataPoint) return []; @@ -142,10 +143,9 @@ function ChartContent({ name: marketDisplayNames[market.uniqueKey] || market.collateralSymbol, value, color: getMarketColor(market.uniqueKey), - percentage: total > 0 ? (value / total) * 100 : 0, + percentage: total > 0 && value > 0 ? (value / total) * 100 : 0, }; }) - .filter((d) => d.value > 0) .sort((a, b) => b.value - a.value); }, [currentDataPoint, markets, getMarketColor, marketDisplayNames]); @@ -238,31 +238,26 @@ function ChartContent({ })}

- {payload - .filter((entry) => Number(entry.value) > 0) - .map((entry) => { - const marketKey = String(entry.dataKey); - const displayName = marketDisplayNames[marketKey] || 'Unknown'; - const value = Number(entry.value) || 0; - const pct = total > 0 ? ((value / total) * 100).toFixed(1) : '0'; - return ( -
-
- - {displayName} -
- - {formatReadable(value)} ({pct}%) - + {/* Sort by value descending, show only non-zero */} + {pieData + .filter((entry) => entry.value > 0) + .map((entry) => ( +
+
+ + {entry.name}
- ); - })} + + {formatReadable(entry.value)} ({entry.percentage.toFixed(1)}%) + +
+ ))}
Total @@ -332,24 +327,27 @@ function ChartContent({
- {/* Legend */} + {/* Legend - show all markets, dim zero values */}
- {pieData.slice(0, 5).map((entry) => ( -
-
- - {entry.name} + {pieData.slice(0, 5).map((entry) => { + const isZero = entry.value === 0; + return ( +
+
+ + {entry.name} +
+ {isZero ? '—' : `${entry.percentage.toFixed(1)}%`}
- {entry.percentage.toFixed(1)}% -
- ))} + ); + })} {pieData.length > 5 &&

+{pieData.length - 5} more

}
From 6beeb668e735e040ea84b2af633985d6f3d505eb Mon Sep 17 00:00:00 2001 From: antoncoding Date: Mon, 2 Feb 2026 17:19:47 +0800 Subject: [PATCH 5/5] chore: review fixes --- src/features/positions/components/user-positions-chart.tsx | 3 +-- src/hooks/usePositionHistoryChart.ts | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/features/positions/components/user-positions-chart.tsx b/src/features/positions/components/user-positions-chart.tsx index 85c25ab0..0cea9d4f 100644 --- a/src/features/positions/components/user-positions-chart.tsx +++ b/src/features/positions/components/user-positions-chart.tsx @@ -20,7 +20,6 @@ type BaseChartProps = { mode?: ChartMode; height?: number; debug?: boolean; - showHeader?: boolean; }; // Props for using with GroupedPosition (positions page) @@ -224,7 +223,7 @@ function ChartContent({ content={({ active, payload, label }) => { if (!active || !payload || payload.length === 0) return null; - const dataPoint = dataPoints.find((dp) => dp.timestamp === label); + const dataPoint = dataPoints.find((dp) => dp.timestamp === Number(label)); const total = dataPoint?.total ?? 0; return ( diff --git a/src/hooks/usePositionHistoryChart.ts b/src/hooks/usePositionHistoryChart.ts index 01aa97ac..f9d6434c 100644 --- a/src/hooks/usePositionHistoryChart.ts +++ b/src/hooks/usePositionHistoryChart.ts @@ -304,5 +304,5 @@ export function usePositionHistoryChart({ } return { dataPoints, markets: marketInfoList, debugInfo }; - }, [markets, loanAssetDecimals, chainId, startTimestamp, endTimestamp, transactions, snapshots, debug]); + }, [markets, loanAssetDecimals, startTimestamp, endTimestamp, transactions, snapshots, debug]); }