From 2d31c940b0e559446aed9d95ac408ea5041afd91 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Tue, 19 May 2026 17:35:26 +0800 Subject: [PATCH 1/5] feat: vault graph --- docs/VALIDATIONS.md | 1 + .../morpho-api/vault-share-price-history.ts | 85 ++++++ .../components/vault-detail/vault-header.tsx | 27 +- src/features/autovault/vault-view.tsx | 42 +-- .../transaction-history-preview.tsx | 21 +- .../components/charts/chart-utils.tsx | 59 ++-- .../components/user-positions-chart.tsx | 6 + .../vault-adapter-position-overview.tsx | 21 +- .../components/vault-share-price-chart.tsx | 275 ++++++++++++++++++ src/features/vault/vault-view.tsx | 112 ++++--- src/graphql/vault-queries.ts | 13 + src/hooks/useMorphoMarketAdapters.ts | 108 +++++-- src/hooks/useVaultSharePriceHistory.ts | 255 ++++++++++++++++ src/hooks/useVaultV2Data.ts | 13 +- src/utils/vaultAllocation.ts | 8 + 15 files changed, 917 insertions(+), 129 deletions(-) create mode 100644 src/data-sources/morpho-api/vault-share-price-history.ts create mode 100644 src/features/vault/components/vault-share-price-chart.tsx create mode 100644 src/hooks/useVaultSharePriceHistory.ts diff --git a/docs/VALIDATIONS.md b/docs/VALIDATIONS.md index cd084511..b2061b14 100644 --- a/docs/VALIDATIONS.md +++ b/docs/VALIDATIONS.md @@ -74,6 +74,7 @@ Use this file at the end of non-trivial work. Do not front-load it at task start - Market-table data enrichments that affect visible columns or sorting must report degraded readiness to the shared market-data notice surface instead of silently replacing values with empty placeholders. - Large optional metadata or enrichment queries used only for secondary badges, warnings, filters, or tooltips must be gated or deferred so core table rendering does not wait on them during cold start. - Vault-scoped pages with configured cap or market IDs must use targeted market reads for first render; do not wait on the global market registry when the vault metadata already identifies the relevant markets. +- Vault adapter selection must be cap-aware when a vault has multiple active adapters; do not let list order alone choose the adapter used for positions, activity, withdrawals, or settings. - Expensive queries must not start with placeholder dependency data that immediately invalidates the same query. Gate on prerequisite readiness, or use a stable query key that does not refetch equivalent work. - Expensive enrichment queries derived from filtered, sorted, or paginated rows must wait for the inputs that can change those rows, such as USD price enrichment, before they start. - Periodic refreshes for RPC or API data must use React Query polling with background refetch disabled, or explicitly pause when `document.visibilityState` is hidden. Do not use raw `setInterval` for mounted data refresh unless hidden-tab behavior is handled. diff --git a/src/data-sources/morpho-api/vault-share-price-history.ts b/src/data-sources/morpho-api/vault-share-price-history.ts new file mode 100644 index 00000000..3a1ea4c7 --- /dev/null +++ b/src/data-sources/morpho-api/vault-share-price-history.ts @@ -0,0 +1,85 @@ +import { vaultV2SharePriceHistoryQuery } from '@/graphql/vault-queries'; +import type { SupportedNetworks } from '@/utils/networks'; +import type { TimeseriesOptions } from '@/utils/types'; +import { morphoGraphqlFetcher } from './fetchers'; + +export type MorphoVaultSharePricePoint = { + timestamp: number; + sharePrice: number; +}; + +type RawSharePricePoint = { + x: number; + y: unknown; +}; + +type VaultV2SharePriceHistoryResponse = { + data?: { + vaultV2ByAddress?: { + historicalState?: { + sharePrice?: RawSharePricePoint[] | null; + } | null; + } | null; + }; + errors?: { message: string }[]; +}; + +const sortByTimestamp = (left: MorphoVaultSharePricePoint, right: MorphoVaultSharePricePoint): number => + left.timestamp - right.timestamp; + +function normalizeSharePrice(value: unknown): number | null { + const numericValue = typeof value === 'string' ? Number(value) : value; + + if (typeof numericValue !== 'number' || !Number.isFinite(numericValue)) { + return null; + } + + return numericValue; +} + +export async function fetchMorphoVaultV2SharePriceHistory({ + vaultAddress, + chainId, + options, +}: { + vaultAddress: string; + chainId: SupportedNetworks; + options: TimeseriesOptions; +}): Promise { + try { + const response = await morphoGraphqlFetcher( + vaultV2SharePriceHistoryQuery, + { + address: vaultAddress, + chainId, + options, + }, + { timeoutMs: 8000 }, + ); + + const points = response?.data?.vaultV2ByAddress?.historicalState?.sharePrice; + if (!points || points.length === 0) { + return null; + } + + const normalizedPoints = points + .map((point) => { + const sharePrice = normalizeSharePrice(point.y); + if (sharePrice === null) { + return null; + } + + return { + timestamp: point.x, + sharePrice, + }; + }) + .filter((point): point is MorphoVaultSharePricePoint => point !== null) + .sort(sortByTimestamp); + + return normalizedPoints.length > 0 ? normalizedPoints : null; + } catch (error) { + console.warn('[vaultSharePriceHistory] Morpho API share price history unavailable:', error); + return null; + } +} diff --git a/src/features/autovault/components/vault-detail/vault-header.tsx b/src/features/autovault/components/vault-detail/vault-header.tsx index b03eea15..b6882f5d 100644 --- a/src/features/autovault/components/vault-detail/vault-header.tsx +++ b/src/features/autovault/components/vault-detail/vault-header.tsx @@ -23,6 +23,11 @@ import { CollateralIconsDisplay } from '@/features/positions/components/collater import { getSlicedAddress } from '@/utils/address'; import { formatVaultAdapterType } from '@/utils/vaults'; +type VaultHeaderAdapterRow = { + adapter: Address; + adapterType?: string; +}; + type VaultHeaderProps = { vaultAddress: Address; chainId: SupportedNetworks; @@ -39,7 +44,8 @@ type VaultHeaderProps = { collaterals?: { address: string; symbol: string; amount: number }[]; curator?: string; adapter?: string; - adapters?: { adapter: Address; adapterType?: string }[]; + adapters?: VaultHeaderAdapterRow[]; + capsAdapters?: VaultHeaderAdapterRow[]; onDeposit: () => void; onWithdraw: () => void; onRefresh: () => void; @@ -67,6 +73,7 @@ export function VaultHeader({ curator, adapter, adapters = [], + capsAdapters = [], onDeposit, onWithdraw, onRefresh, @@ -92,6 +99,13 @@ export function VaultHeader({ // Filter for known agents const knownAllocators = allocators.filter((addr) => findAgent(addr) !== undefined); const adapterRows = adapters.length > 0 ? adapters : adapter ? [{ adapter: adapter as Address }] : []; + const capsAdapterRows = capsAdapters.length > 0 ? capsAdapters : adapter ? [{ adapter: adapter as Address }] : []; + const capsAdapterKeys = new Set(capsAdapterRows.map((row) => row.adapter.toLowerCase())); + const showCapsAdapter = adapterRows.length > 1 && capsAdapterRows.length > 0; + const capsAdapterLabel = + capsAdapterRows.length === 1 + ? `${formatVaultAdapterType(capsAdapterRows[0].adapterType)} ${getSlicedAddress(capsAdapterRows[0].adapter)}` + : `${capsAdapterRows.length} configured adapters`; return (
@@ -147,6 +161,12 @@ export function VaultHeader({ Curator: {getSlicedAddress(curator as Address)} )} + {showCapsAdapter && ( + <> + · + Caps source: {capsAdapterLabel} + + )} {knownAllocators.length > 0 && ( <> · @@ -351,6 +371,11 @@ export function VaultHeader({ address={row.adapter} chainId={chainId} /> + {capsAdapterKeys.has(row.adapter.toLowerCase()) && ( + + Caps source + + )}
))} diff --git a/src/features/autovault/vault-view.tsx b/src/features/autovault/vault-view.tsx index 3b3daa18..0e530c16 100644 --- a/src/features/autovault/vault-view.tsx +++ b/src/features/autovault/vault-view.tsx @@ -12,14 +12,13 @@ import { useVaultQueryRefresh } from '@/hooks/useVaultQueryRefresh'; import { useVaultV2Data } from '@/hooks/useVaultV2Data'; import { useVaultV2 } from '@/hooks/useVaultV2'; import { useMorphoMarketAdapters } from '@/hooks/useMorphoMarketAdapters'; -import { useVaultAllocations } from '@/hooks/useVaultAllocations'; import { getSlicedAddress } from '@/utils/address'; import { ALL_SUPPORTED_NETWORKS, SupportedNetworks, getNetworkConfig } from '@/utils/networks'; import { parseCapIdParams } from '@/utils/morpho'; import { VaultInitializationModal } from '@/features/autovault/components/vault-detail/modals/vault-initialization-modal'; import { VaultMarketAllocations } from '@/features/autovault/components/vault-detail/vault-market-allocations'; import { VaultSettingsModal } from '@/features/autovault/components/vault-detail/modals/vault-settings'; -import { TransactionHistoryPreview } from '@/features/history/components/transaction-history-preview'; +import { VaultSharePriceChart } from '@/features/vault/components/vault-share-price-chart'; import { useVaultSettingsModalStore } from '@/stores/vault-settings-modal-store'; import { useVaultInitializationModalStore } from '@/stores/vault-initialization-modal-store'; import { VaultHeader } from '@/features/autovault/components/vault-detail/vault-header'; @@ -98,12 +97,8 @@ export default function VaultContent() { const tokenDecimals = vaultData?.tokenDecimals; const tokenSymbol = vaultData?.tokenSymbol; const assetAddress = vaultData?.assetAddress as Address | undefined; - const adapterAddress = adapterQuery.primaryAdapter; - - const adapterPortfolioHref = useMemo(() => { - if (!adapterAddress || !assetAddress) return undefined; - return `/position/${chainId}/${assetAddress}/${adapterAddress}`; - }, [adapterAddress, assetAddress, chainId]); + const positionAdapters = adapterQuery.configuredAdapters.length > 0 ? adapterQuery.configuredAdapters : adapterQuery.adapters; + const adapterAddress = positionAdapters[0]?.adapter ?? adapterQuery.primaryAdapter; // UI state from Zustand stores (for vault-view banners only) const { open: openSettings } = useVaultSettingsModalStore(); @@ -112,16 +107,6 @@ export default function VaultContent() { // Computed state flags for vault-view banners const hasNoAllocators = Boolean(vaultData?.capsData) && (vaultData?.allocators ?? []).length === 0; const capsUninitialized = vaultData?.capsData?.needSetupCaps === true; - const capsInitialized = vaultData?.capsData?.needSetupCaps === false; - const { marketAllocations: activityMarketAllocations, loading: activityMarketsLoading } = useVaultAllocations({ - vaultAddress: vaultAddressValue, - chainId, - enabled: Boolean(adapterAddress && isVaultInitialized && capsInitialized), - }); - const activityMarkets = useMemo( - () => activityMarketAllocations.map((allocation) => allocation.market), - [activityMarketAllocations], - ); // Format APY for APY card in vault-view const apyLabel = useMemo(() => { @@ -239,6 +224,8 @@ export default function VaultContent() { collaterals={collateralAddresses} curator={vaultData?.curator} adapter={adapterAddress} + adapters={adapterQuery.adapters} + capsAdapters={positionAdapters} onDeposit={handleDeposit} onWithdraw={handleWithdraw} onRefresh={handleRefreshVault} @@ -304,6 +291,13 @@ export default function VaultContent() { )} + + {/* Market Allocations */} - {/* Transaction History Preview - only show when vault is fully set up */} - {adapterAddress && isVaultInitialized && capsInitialized && ( - - )} - {/* Settings Modal - Pulls own data */} { + const sourceAccounts = accounts ?? (account ? [account] : []); + const seen = new Set(); + + return sourceAccounts.flatMap((sourceAccount) => { + const normalizedAccount = sourceAccount.toLowerCase(); + if (!normalizedAccount || seen.has(normalizedAccount)) { + return []; + } + + seen.add(normalizedAccount); + return [normalizedAccount]; + }); + }, [account, accounts]); const scopedMarketUniqueKeys = useMemo( () => (providedMarkets ? providedMarkets.map((market) => market.uniqueKey) : undefined), [providedMarkets], ); - const canLoadTransactions = Boolean(account) && (scopedMarketUniqueKeys ? scopedMarketUniqueKeys.length > 0 : allMarkets.length > 0); + const canLoadTransactions = + transactionAccounts.length > 0 && (scopedMarketUniqueKeys ? scopedMarketUniqueKeys.length > 0 : allMarkets.length > 0); const { data, isLoading: loading } = useUserTransactionsQuery({ filters: { - userAddress: account ? [account] : [], + userAddress: transactionAccounts, marketUniqueKeys: scopedMarketUniqueKeys, skip: 0, chainId, diff --git a/src/features/market-detail/components/charts/chart-utils.tsx b/src/features/market-detail/components/charts/chart-utils.tsx index f53e95a1..3e8057b1 100644 --- a/src/features/market-detail/components/charts/chart-utils.tsx +++ b/src/features/market-detail/components/charts/chart-utils.tsx @@ -79,16 +79,29 @@ export function createConcentrationGradient(color: string): GradientConfig[] { return [{ id: 'concentrationGradient', color }]; } +export function createSharePriceChartGradients(colors: ReturnType): GradientConfig[] { + return [{ id: 'sharePriceGradient', color: colors.supply.stroke }]; +} + type ChartTooltipContentProps = { active?: boolean; - payload?: any[]; + payload?: Array<{ + color?: string; + dataKey?: unknown; + name?: unknown; + payload?: { + blockNumber?: number; + isStateRead?: boolean; + }; + value?: unknown; + }>; label?: number; formatValue: (value: number) => string; }; export function ChartTooltipContent({ active, payload, label, formatValue }: ChartTooltipContentProps) { if (!active || !payload) return null; - const pointMeta = payload[0]?.payload as { isStateRead?: boolean } | undefined; + const pointMeta = payload[0]?.payload; return (
@@ -107,24 +120,30 @@ export function ChartTooltipContent({ active, payload, label, formatValue }: Cha State Read ) : null} + {pointMeta?.blockNumber ? Block {pointMeta.blockNumber.toLocaleString()} : null}
- {payload.map((entry: any) => ( -
-
- - {entry.name} + {payload.map((entry) => { + if (typeof entry.value !== 'number') return null; + const key = String(entry.dataKey ?? entry.name ?? entry.value); + + return ( +
+
+ + {String(entry.name ?? entry.dataKey ?? '')} +
+ {formatValue(entry.value)}
- {formatValue(entry.value)} -
- ))} + ); + })}
); @@ -137,18 +156,18 @@ type ChartLegendProps> = { export function createLegendClickHandler>({ visibleLines, setVisibleLines }: ChartLegendProps) { return { - onClick: (e: any) => { - const dataKey = e.dataKey as keyof T; + onClick: (entry: { dataKey?: unknown }) => { + const dataKey = String(entry.dataKey) as keyof T; setVisibleLines((prev) => ({ ...prev, [dataKey]: !prev[dataKey], })); }, - formatter: (value: string, entry: any) => ( + formatter: (value: string, entry: { dataKey?: unknown }) => ( {value} diff --git a/src/features/positions/components/user-positions-chart.tsx b/src/features/positions/components/user-positions-chart.tsx index 269a30d2..be11feea 100644 --- a/src/features/positions/components/user-positions-chart.tsx +++ b/src/features/positions/components/user-positions-chart.tsx @@ -1,6 +1,7 @@ 'use client'; import { useState, useMemo } from 'react'; +import type { ReactNode } from 'react'; import type { Address } from 'viem'; import { TableContainerWithDescription } from '@/components/common/table-container-with-header'; import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts'; @@ -20,6 +21,7 @@ type BaseChartProps = { mode?: ChartMode; height?: number; debug?: boolean; + actions?: ReactNode; }; // Props for using with GroupedPosition (positions page) @@ -66,11 +68,13 @@ function ChartContent({ markets, loanAssetSymbol, height, + actions, }: { dataPoints: PositionHistoryDataPoint[]; markets: MarketInfo[]; loanAssetSymbol: string; height: number; + actions?: ReactNode; }) { const chartColors = useChartColors(); @@ -158,6 +162,7 @@ function ChartContent({ {/* Responsive: stack vertically on mobile, side-by-side on larger screens */}
@@ -425,6 +430,7 @@ export function UserPositionsChart(props: UserPositionsChartProps) { markets={marketInfoList} loanAssetSymbol={chartParams.loanAssetSymbol} height={height} + actions={props.actions} /> ); } diff --git a/src/features/vault/components/vault-adapter-position-overview.tsx b/src/features/vault/components/vault-adapter-position-overview.tsx index 31db50e7..f28ca8b4 100644 --- a/src/features/vault/components/vault-adapter-position-overview.tsx +++ b/src/features/vault/components/vault-adapter-position-overview.tsx @@ -102,18 +102,20 @@ export function VaultAdapterPositionOverview({ }: VaultAdapterPositionOverviewProps) { const periodLabel = PERIOD_LABELS[period]; const detailHref = `/position/${chainId}/${groupedPosition.loanAssetAddress}/${adapterAddress}`; + const periodSelector = ( +
+ Period + +
+ ); return (
-
- Period - -
= { + '1d': 0.00035, + '7d': 0.0025, + '30d': 0.01, + '3m': 0.03, + '6m': 0.06, +}; +const LOWER_DOMAIN_PADDING_RATIO = 0.12; + +function getSharePriceDomain(chartData: VaultSharePriceChartPoint[], timeframe: ChartTimeframe): [number, number] { + if (chartData.length === 0) { + return [0, 1]; + } + + const values = chartData.map((point) => point.sharePrice); + const baseline = chartData[0].sharePrice; + const dataMin = Math.min(...values); + const dataMax = Math.max(...values); + const minimumGrowth = Math.max(Math.abs(baseline) * SHARE_PRICE_DOMAIN_GROWTH_BY_TIMEFRAME[timeframe], Number.EPSILON); + const lower = Math.min(dataMin, baseline - minimumGrowth * LOWER_DOMAIN_PADDING_RATIO); + const upper = Math.max(dataMax, baseline + minimumGrowth); + + return [Math.max(0, lower), upper]; +} + +function formatSharePrice(value: number, assetSymbol?: string): string { + if (!Number.isFinite(value)) { + return assetSymbol ? `-- ${assetSymbol}` : '--'; + } + + const maximumFractionDigits = Math.abs(value) >= 1 ? 6 : 8; + const formatted = value.toLocaleString('en-US', { + maximumFractionDigits, + minimumFractionDigits: Math.abs(value) >= 1 ? 4 : 0, + }); + + return assetSymbol ? `${formatted} ${assetSymbol}` : formatted; +} + +function formatChangePercent(value: number | null): string { + if (value === null || !Number.isFinite(value)) { + return '--'; + } + + return `${value >= 0 ? '+' : ''}${value.toFixed(4)}%`; +} + +function changeTextColor(value: number | null): string { + if (value === null) { + return 'text-secondary'; + } + + return value >= 0 ? 'text-emerald-500' : 'text-rose-500'; +} + +export function VaultSharePriceChart({ vaultAddress, chainId, assetDecimals, assetSymbol }: VaultSharePriceChartProps) { + const selectedTimeframe = useMarketDetailChartState((state) => state.selectedTimeframe); + const selectedTimeRange = useMarketDetailChartState((state) => state.selectedTimeRange); + const setTimeframe = useMarketDetailChartState((state) => state.setTimeframe); + const chartColors = useChartColors(); + + const { data, isError, isFetching, isLoading } = useVaultSharePriceHistory({ + assetDecimals, + vaultAddress, + chainId, + timeframe: selectedTimeframe, + timeRange: selectedTimeRange, + }); + + const chartData = useMemo(() => { + return (data?.points ?? []) + .map((point) => { + if (!Number.isFinite(point.sharePrice)) { + return null; + } + + return { + x: point.targetTimestamp, + sharePrice: point.sharePrice, + }; + }) + .filter((point): point is VaultSharePriceChartPoint => point !== null); + }, [data?.points]); + + const chartTimeRange = useMemo(() => { + const firstPoint = chartData[0]; + const lastPoint = chartData.at(-1); + + return { + ...selectedTimeRange, + startTimestamp: firstPoint?.x ?? selectedTimeRange.startTimestamp, + endTimestamp: lastPoint?.x ?? selectedTimeRange.endTimestamp, + }; + }, [chartData, selectedTimeRange]); + + const firstPoint = chartData[0]; + const lastPoint = chartData.at(-1); + const changePercent = + firstPoint && lastPoint && firstPoint.sharePrice > 0 + ? ((lastPoint.sharePrice - firstPoint.sharePrice) / firstPoint.sharePrice) * 100 + : null; + const yAxisDomain = useMemo(() => getSharePriceDomain(chartData, selectedTimeframe), [chartData, selectedTimeframe]); + const isInitialLoading = isLoading; + const isUnavailable = data?.isUnsupportedNetwork || isError || (!isInitialLoading && chartData.length < 2); + const chartActions = ( +
+ {isFetching && !isInitialLoading ? ( +
+ + Updating +
+ ) : null} + +
+ ); + + return ( + +
+
+
+

Current

+

{lastPoint ? formatSharePrice(lastPoint.sharePrice, assetSymbol) : '--'}

+
+
+

{TIMEFRAME_LABELS[selectedTimeframe]} Change

+

{formatChangePercent(changePercent)}

+
+
+
+ +
+ {isInitialLoading ? ( +
+ +
+ ) : isUnavailable ? ( +
+ Historical share price is unavailable for this vault. +
+ ) : ( + + + + + formatChartTime(time, chartTimeRange.endTimestamp - chartTimeRange.startTimestamp)} + tick={{ fontSize: 11, fill: 'var(--color-text-secondary)' }} + /> + formatSharePrice(Number(value))} + tick={{ fontSize: 11, fill: 'var(--color-text-secondary)' }} + width={74} + domain={yAxisDomain} + /> + ( + formatSharePrice(value, assetSymbol)} + /> + )} + /> + + + + )} +
+ +
+
+
+ Start + {firstPoint ? formatSharePrice(firstPoint.sharePrice, assetSymbol) : '--'} +
+
+ End + {lastPoint ? formatSharePrice(lastPoint.sharePrice, assetSymbol) : '--'} +
+
+ Change + {formatChangePercent(changePercent)} +
+
+
+
+ ); +} diff --git a/src/features/vault/vault-view.tsx b/src/features/vault/vault-view.tsx index 4d2f96a6..a6f46fa3 100644 --- a/src/features/vault/vault-view.tsx +++ b/src/features/vault/vault-view.tsx @@ -8,13 +8,13 @@ import { useConnection } from 'wagmi'; import { Breadcrumbs } from '@/components/shared/breadcrumbs'; import { Button } from '@/components/ui/button'; import Header from '@/components/layout/header/Header'; -import { TransactionHistoryPreview } from '@/features/history/components/transaction-history-preview'; import { VaultInitializationModal } from '@/features/autovault/components/vault-detail/modals/vault-initialization-modal'; import { VaultSettingsModal } from '@/features/autovault/components/vault-detail/modals/vault-settings'; import { VaultHeader } from '@/features/autovault/components/vault-detail/vault-header'; import { VaultAdapterPositionOverview } from '@/features/vault/components/vault-adapter-position-overview'; +import { VaultSharePriceChart } from '@/features/vault/components/vault-share-price-chart'; import { useModal } from '@/hooks/useModal'; -import { useMorphoMarketAdapters } from '@/hooks/useMorphoMarketAdapters'; +import { type VaultMarketAdapter, useMorphoMarketAdapters } from '@/hooks/useMorphoMarketAdapters'; import { useTokensQuery } from '@/hooks/queries/useTokensQuery'; import useUserPositionsSummaryData from '@/hooks/useUserPositionsSummaryData'; import { useVaultAllocations } from '@/hooks/useVaultAllocations'; @@ -32,18 +32,25 @@ import { getVaultURL, supportsMorphoAppLinks } from '@/utils/external'; import { parseCapIdParams } from '@/utils/morpho'; import { groupPositionsByLoanAsset, processCollaterals } from '@/utils/positions'; import { ALL_SUPPORTED_NETWORKS, getNetworkConfig, SupportedNetworks } from '@/utils/networks'; +import { formatVaultAdapterType } from '@/utils/vaults'; type VaultAdapterPositionDetailProps = { adapterAddress?: Address; + adapterType?: string; assetAddress?: Address; chainId: SupportedNetworks; isResolvingAdapter: boolean; period: EarningsPeriod; setPeriod: (period: EarningsPeriod) => void; + showAdapterLabel?: boolean; totalAssets?: bigint; vaultAddress: Address; }; +type VaultAdaptersPositionDetailProps = Omit & { + adapters: VaultMarketAdapter[]; +}; + function VaultStatusPanel({ message }: { message: string }) { return
{message}
; } @@ -84,11 +91,13 @@ function VaultPositionLoadingState() { function VaultAdapterPositionDetail({ adapterAddress, + adapterType, assetAddress, chainId, isResolvingAdapter, period, setPeriod, + showAdapterLabel = false, totalAssets, vaultAddress, }: VaultAdapterPositionDetailProps) { @@ -154,24 +163,57 @@ function VaultAdapterPositionDetail({ {isLoading && !currentPosition && } {currentPosition && adapterAddress && ( - +
+ {showAdapterLabel && ( +
+ {formatVaultAdapterType(adapterType)} + {getSlicedAddress(adapterAddress)} +
+ )} + +
)} ); } +function VaultAdaptersPositionDetail({ adapters, ...props }: VaultAdaptersPositionDetailProps) { + if (adapters.length === 0) { + return ( + + ); + } + + return ( +
+ {adapters.map((adapter) => ( + 1} + /> + ))} +
+ ); +} + export default function VaultContent() { const { chainId: chainIdParam, vaultAddress } = useParams<{ chainId: string; @@ -238,11 +280,8 @@ export default function VaultContent() { const tokenDecimals = vaultData?.tokenDecimals; const tokenSymbol = vaultData?.tokenSymbol; const assetAddress = vaultData?.assetAddress as Address | undefined; - const adapterAddress = adapterQuery.primaryAdapter; - const adapterPortfolioHref = useMemo(() => { - if (!adapterAddress || !assetAddress) return undefined; - return `/position/${chainId}/${assetAddress}/${adapterAddress}`; - }, [adapterAddress, assetAddress, chainId]); + const positionAdapters = adapterQuery.configuredAdapters.length > 0 ? adapterQuery.configuredAdapters : adapterQuery.adapters; + const adapterAddress = positionAdapters[0]?.adapter ?? adapterQuery.primaryAdapter; const morphoVaultHref = useMemo(() => { if (!supportsMorphoAppLinks(chainId)) return undefined; return getVaultURL(vaultAddressValue, chainId); @@ -253,16 +292,6 @@ export default function VaultContent() { const hasNoAllocators = Boolean(vaultData?.capsData) && (vaultData?.allocators ?? []).length === 0; const capsUninitialized = vaultData?.capsData?.needSetupCaps === true; - const capsInitialized = vaultData?.capsData?.needSetupCaps === false; - const { marketAllocations: activityMarketAllocations, loading: activityMarketsLoading } = useVaultAllocations({ - vaultAddress: vaultAddressValue, - chainId, - enabled: Boolean(adapterAddress && isVaultInitialized && capsInitialized), - }); - const activityMarkets = useMemo( - () => activityMarketAllocations.map((allocation) => allocation.market), - [activityMarketAllocations], - ); const isRefetching = vaultDataQuery.isRefetching || vaultContract.isRefetching || adapterQuery.isRefetching || isRefetchingVaultQueries; @@ -398,6 +427,7 @@ export default function VaultContent() { curator={vaultData?.curator} adapter={adapterAddress} adapters={adapterQuery.adapters} + capsAdapters={positionAdapters} onDeposit={handleDeposit} onWithdraw={handleWithdraw} onRefresh={handleRefreshVault} @@ -463,8 +493,15 @@ export default function VaultContent() {
)} - + + - {adapterAddress && isVaultInitialized && capsInitialized && ( - - )} - address?.toLowerCase() ?? ''; + export function useMorphoMarketAdapters({ vaultAddress, chainId }: { vaultAddress?: Address; chainId: SupportedNetworks }) { const query = useVaultV2Data({ vaultAddress, chainId }); + const capSummaryByAdapter = useMemo(() => { + const summaries = new Map(); + const ensureSummary = (adapterAddress: string): AdapterCapSummary => { + const key = getAddressKey(adapterAddress); + const summary = summaries.get(key) ?? { hasPositiveAdapterCap: false, marketCapCount: 0 }; + summaries.set(key, summary); + return summary; + }; + + for (const cap of query.data?.capsData?.adapterCaps ?? []) { + const parsed = parseCapIdParams(cap.idParams); + if (parsed.type !== 'adapter' || !parsed.adapterAddress) { + continue; + } + + const summary = ensureSummary(parsed.adapterAddress); + summary.hasPositiveAdapterCap ||= hasPositiveVaultCap(cap); + } + + for (const cap of query.data?.capsData?.marketCaps ?? []) { + const parsed = parseCapIdParams(cap.idParams); + if (parsed.type !== 'market' || !parsed.adapterAddress || !hasPositiveVaultCap(cap)) { + continue; + } + + const summary = ensureSummary(parsed.adapterAddress); + summary.marketCapCount += 1; + } + + return summaries; + }, [query.data?.capsData?.adapterCaps, query.data?.capsData?.marketCaps]); + const adapters = useMemo(() => { const adapterDetails = query.data?.adapterDetails ?? []; - const adapterDetailsByAddress = new Map(adapterDetails.map((adapterDetail) => [adapterDetail.address, adapterDetail])); + const adapterDetailsByAddress = new Map(adapterDetails.map((adapterDetail) => [getAddressKey(adapterDetail.address), adapterDetail])); const adapterAddresses = [...(query.data?.adapters ?? []), ...adapterDetails.map((adapterDetail) => adapterDetail.address)]; const seenAddresses = new Set(); - return adapterAddresses.flatMap((adapterAddress) => { - if (seenAddresses.has(adapterAddress)) { - return []; - } + return adapterAddresses + .flatMap((adapterAddress) => { + const adapterKey = getAddressKey(adapterAddress); + if (seenAddresses.has(adapterKey)) { + return []; + } + + seenAddresses.add(adapterKey); + const adapterDetail = adapterDetailsByAddress.get(adapterKey); + const capSummary = capSummaryByAdapter.get(adapterKey); + + return [ + { + adapter: adapterKey as Address, + adapterType: adapterDetail?.adapterType, + factoryAddress: adapterDetail?.factoryAddress as Address | undefined, + hasPositiveAdapterCap: capSummary?.hasPositiveAdapterCap ?? false, + id: `${chainId}-${vaultAddress ?? 'unknown'}-${adapterKey}`, + marketCapCount: capSummary?.marketCapCount ?? 0, + parentVault: (vaultAddress ?? zeroAddress) as Address, + }, + ]; + }) + .sort((left, right) => { + if (left.marketCapCount !== right.marketCapCount) { + return right.marketCapCount - left.marketCapCount; + } + + if (left.hasPositiveAdapterCap !== right.hasPositiveAdapterCap) { + return left.hasPositiveAdapterCap ? -1 : 1; + } + + return 0; + }); + }, [capSummaryByAdapter, chainId, query.data?.adapterDetails, query.data?.adapters, vaultAddress]); - seenAddresses.add(adapterAddress); - const adapterDetail = adapterDetailsByAddress.get(adapterAddress); - - return [ - { - adapter: adapterAddress as Address, - adapterType: adapterDetail?.adapterType, - factoryAddress: adapterDetail?.factoryAddress as Address | undefined, - id: `${chainId}-${vaultAddress ?? 'unknown'}-${adapterAddress}`, - parentVault: (vaultAddress ?? zeroAddress) as Address, - }, - ]; - }); - }, [chainId, query.data?.adapterDetails, query.data?.adapters, vaultAddress]); + const configuredAdapters = useMemo( + () => adapters.filter((adapter) => adapter.marketCapCount > 0 || adapter.hasPositiveAdapterCap), + [adapters], + ); const { primaryAdapter, primaryAdapterType, primaryFactoryAddress } = useMemo(() => { - const primary = adapters[0]; + const primary = configuredAdapters[0] ?? adapters[0]; return { primaryAdapter: primary?.adapter, primaryAdapterType: primary?.adapterType, primaryFactoryAddress: primary?.factoryAddress, }; - }, [adapters]); + }, [adapters, configuredAdapters]); return { primaryAdapter, primaryAdapterType, primaryFactoryAddress, adapters, + configuredAdapters, isFetching: query.isFetching, isLoading: query.isLoading, error: query.error, refetch: query.refetch, isRefetching: query.isRefetching, hasAdapters: adapters.length > 0, + hasMultipleAdapters: adapters.length > 1, }; } diff --git a/src/hooks/useVaultSharePriceHistory.ts b/src/hooks/useVaultSharePriceHistory.ts new file mode 100644 index 00000000..beadc8db --- /dev/null +++ b/src/hooks/useVaultSharePriceHistory.ts @@ -0,0 +1,255 @@ +import { useQuery } from '@tanstack/react-query'; +import { formatUnits, type Address, type PublicClient } from 'viem'; +import { erc4626Abi } from '@/abis/erc4626'; +import { useCustomRpcContext } from '@/components/providers/CustomRpcProvider'; +import { + fetchMorphoVaultV2SharePriceHistory, + type MorphoVaultSharePricePoint, +} from '@/data-sources/morpho-api/vault-share-price-history'; +import { TIMEFRAME_CONFIG, type ChartTimeframe } from '@/stores/useMarketDetailChartState'; +import { fetchBlocksWithTimestamps, type BlockWithTimestamp } from '@/utils/blockEstimation'; +import { supportsHistoricalStateRead, type SupportedNetworks } from '@/utils/networks'; +import { getClient } from '@/utils/rpc'; +import type { TimeseriesOptions } from '@/utils/types'; + +const PARALLEL_BATCH_SIZE = 6; +const HOUR_IN_SECONDS = 60 * 60; +const DAY_IN_SECONDS = 24 * HOUR_IN_SECONDS; + +const SHARE_PRICE_API_INTERVAL_BY_TIMEFRAME: Record = { + '1d': 'HOUR', + '7d': 'DAY', + '30d': 'DAY', + '3m': 'DAY', + '6m': 'DAY', +}; + +const SHARE_PRICE_POINT_INTERVAL_SECONDS: Record = { + '1d': 2 * HOUR_IN_SECONDS, + '7d': DAY_IN_SECONDS, + '30d': DAY_IN_SECONDS, + '3m': 3 * DAY_IN_SECONDS, + '6m': 6 * DAY_IN_SECONDS, +}; + +export type VaultSharePricePoint = { + blockNumber?: number; + sharePrice: number; + source: 'morpho-api' | 'rpc'; + timestamp: number; + targetTimestamp: number; +}; + +type VaultSharePriceHistory = { + points: VaultSharePricePoint[]; + isUnsupportedNetwork: boolean; + source: 'morpho-api' | 'none' | 'rpc'; +}; + +function getMorphoSharePriceOptions(timeframe: ChartTimeframe, timeRange: TimeseriesOptions): TimeseriesOptions { + return { + ...timeRange, + interval: SHARE_PRICE_API_INTERVAL_BY_TIMEFRAME[timeframe], + }; +} + +function calculateSharePriceTimePoints(timeframe: ChartTimeframe, endTimestamp: number): number[] { + const config = TIMEFRAME_CONFIG[timeframe]; + const startTimestamp = endTimestamp - config.durationSeconds; + const intervalSeconds = SHARE_PRICE_POINT_INTERVAL_SECONDS[timeframe]; + const points: number[] = []; + + for (let timestamp = startTimestamp; timestamp < endTimestamp; timestamp += intervalSeconds) { + points.push(timestamp); + } + + points.push(endTimestamp); + return points; +} + +function selectNearestMorphoPoints( + points: MorphoVaultSharePricePoint[], + targetTimestamps: number[], +): VaultSharePricePoint[] { + const usedIndexes = new Set(); + const selected: VaultSharePricePoint[] = []; + + for (const targetTimestamp of targetTimestamps) { + let nearestIndex = -1; + let nearestDistance = Number.POSITIVE_INFINITY; + + points.forEach((point, index) => { + if (usedIndexes.has(index)) return; + + const distance = Math.abs(point.timestamp - targetTimestamp); + if (distance < nearestDistance) { + nearestDistance = distance; + nearestIndex = index; + } + }); + + if (nearestIndex === -1) continue; + + usedIndexes.add(nearestIndex); + const point = points[nearestIndex]; + if (!point) continue; + + selected.push({ + sharePrice: point.sharePrice, + source: 'morpho-api', + timestamp: point.timestamp, + targetTimestamp, + }); + } + + return selected.sort((left, right) => left.targetTimestamp - right.targetTimestamp); +} + +async function fetchSharePricePoint( + client: PublicClient, + vaultAddress: Address, + oneShareUnit: bigint, + assetDecimals: number, + block: BlockWithTimestamp, +): Promise { + try { + const rawSharePrice = await client.readContract({ + address: vaultAddress, + abi: erc4626Abi, + functionName: 'previewRedeem', + args: [oneShareUnit], + blockNumber: BigInt(block.blockNumber), + }); + const sharePrice = Number(formatUnits(rawSharePrice, assetDecimals)); + + if (!Number.isFinite(sharePrice)) { + return null; + } + + return { + blockNumber: block.blockNumber, + sharePrice, + source: 'rpc', + timestamp: block.timestamp, + targetTimestamp: block.targetTimestamp, + }; + } catch { + return null; + } +} + +export function useVaultSharePriceHistory({ + assetDecimals, + vaultAddress, + chainId, + timeframe, + timeRange, +}: { + assetDecimals?: number; + vaultAddress?: Address; + chainId?: SupportedNetworks; + timeframe: ChartTimeframe; + timeRange: TimeseriesOptions; +}) { + const { customRpcUrls } = useCustomRpcContext(); + const customRpcUrl = chainId ? customRpcUrls[chainId] : undefined; + + return useQuery({ + queryKey: [ + 'vault-share-price-history', + vaultAddress?.toLowerCase() ?? null, + chainId ?? null, + timeframe, + timeRange.startTimestamp, + timeRange.endTimestamp, + timeRange.interval, + assetDecimals ?? null, + customRpcUrl ?? null, + ], + queryFn: async () => { + if (!vaultAddress || !chainId) { + return null; + } + + const morphoTimeRange = getMorphoSharePriceOptions(timeframe, timeRange); + const targetTimestamps = calculateSharePriceTimePoints(timeframe, timeRange.endTimestamp); + const morphoPoints = await fetchMorphoVaultV2SharePriceHistory({ + vaultAddress, + chainId, + options: morphoTimeRange, + }); + + if (morphoPoints && morphoPoints.length >= 2) { + const selectedMorphoPoints = selectNearestMorphoPoints(morphoPoints, targetTimestamps); + const fallbackMorphoPoints = morphoPoints.map((point) => ({ + sharePrice: point.sharePrice, + source: 'morpho-api' as const, + timestamp: point.timestamp, + targetTimestamp: point.timestamp, + })); + + return { + points: selectedMorphoPoints.length >= 2 ? selectedMorphoPoints : fallbackMorphoPoints, + isUnsupportedNetwork: false, + source: 'morpho-api', + }; + } + + if (!supportsHistoricalStateRead(chainId)) { + return { + points: [], + isUnsupportedNetwork: true, + source: 'none', + }; + } + + if (assetDecimals === undefined) { + return null; + } + + const client = getClient(chainId, customRpcUrl); + const [currentBlock, shareDecimals] = await Promise.all([ + client.getBlockNumber(), + client.readContract({ + address: vaultAddress, + abi: erc4626Abi, + functionName: 'decimals', + args: [], + }), + ]); + const currentBlockData = await client.getBlock({ blockNumber: currentBlock }); + const currentTimestamp = Number(currentBlockData.timestamp); + const fallbackTargetTimestamps = calculateSharePriceTimePoints(timeframe, Math.min(timeRange.endTimestamp, currentTimestamp)); + const blocksWithTimestamps = await fetchBlocksWithTimestamps( + client, + chainId, + fallbackTargetTimestamps, + Number(currentBlock), + currentTimestamp, + ); + const oneShareUnit = 10n ** BigInt(shareDecimals); + const points: VaultSharePricePoint[] = []; + + for (let i = 0; i < blocksWithTimestamps.length; i += PARALLEL_BATCH_SIZE) { + const batch = blocksWithTimestamps.slice(i, i + PARALLEL_BATCH_SIZE); + const batchResults = await Promise.all( + batch.map((block) => fetchSharePricePoint(client, vaultAddress, oneShareUnit, assetDecimals, block)), + ); + points.push(...batchResults.filter((point): point is VaultSharePricePoint => point !== null)); + } + + points.sort((a, b) => a.targetTimestamp - b.targetTimestamp); + + return { + points, + isUnsupportedNetwork: false, + source: 'rpc', + }; + }, + enabled: Boolean(vaultAddress && chainId && timeframe && timeRange), + placeholderData: (previousData) => previousData ?? null, + staleTime: 5 * 60 * 1000, + gcTime: 30 * 60 * 1000, + refetchOnWindowFocus: false, + }); +} diff --git a/src/hooks/useVaultV2Data.ts b/src/hooks/useVaultV2Data.ts index c6dabd1e..955cd059 100644 --- a/src/hooks/useVaultV2Data.ts +++ b/src/hooks/useVaultV2Data.ts @@ -8,6 +8,7 @@ import { getSlicedAddress } from '@/utils/address'; import { parseCapIdParams } from '@/utils/morpho'; import type { SupportedNetworks } from '@/utils/networks'; import { getClient } from '@/utils/rpc'; +import { hasPositiveVaultCap } from '@/utils/vaultAllocation'; type UseVaultV2DataArgs = { vaultAddress?: Address; @@ -16,6 +17,7 @@ type UseVaultV2DataArgs = { export type CapData = { adapterCap: VaultV2Cap | null; + adapterCaps: VaultV2Cap[]; collateralCaps: VaultV2Cap[]; marketCaps: VaultV2Cap[]; needSetupCaps: boolean; @@ -128,14 +130,14 @@ export function useVaultV2Data({ vaultAddress, chainId }: UseVaultV2DataArgs) { } const caps = monarchVault?.caps ?? []; - let adapterCap: VaultV2Cap | null = null; + const adapterCaps: VaultV2Cap[] = []; const collateralCaps: VaultV2Cap[] = []; const marketCaps: VaultV2Cap[] = []; for (const cap of caps) { const parsed = parseCapIdParams(cap.idParams); if (parsed.type === 'adapter') { - adapterCap = cap; + adapterCaps.push(cap); continue; } if (parsed.type === 'collateral') { @@ -146,6 +148,10 @@ export function useVaultV2Data({ vaultAddress, chainId }: UseVaultV2DataArgs) { marketCaps.push(cap); } } + const adapterCap = adapterCaps.find(hasPositiveVaultCap) ?? adapterCaps[0] ?? null; + const hasPositiveAdapterCap = adapterCaps.some(hasPositiveVaultCap); + const hasPositiveCollateralCap = collateralCaps.some(hasPositiveVaultCap); + const hasPositiveMarketCap = marketCaps.some(hasPositiveVaultCap); const assetAddress = monarchVault?.asset || rpcFallback?.assetAddress || ''; const token = assetAddress ? findToken(assetAddress, chainId) : undefined; @@ -155,9 +161,10 @@ export function useVaultV2Data({ vaultAddress, chainId }: UseVaultV2DataArgs) { const capsData = monarchVault ? { adapterCap, + adapterCaps, collateralCaps, marketCaps, - needSetupCaps: !adapterCap || collateralCaps.length === 0 || marketCaps.length === 0, + needSetupCaps: !hasPositiveAdapterCap || !hasPositiveCollateralCap || !hasPositiveMarketCap, } : undefined; diff --git a/src/utils/vaultAllocation.ts b/src/utils/vaultAllocation.ts index a2d9148d..2e1a6f44 100644 --- a/src/utils/vaultAllocation.ts +++ b/src/utils/vaultAllocation.ts @@ -39,6 +39,14 @@ export function formatVaultAbsoluteCap(cap: string, tokenDecimals: number, token } } +export function hasPositiveVaultCap(cap: { relativeCap: string; absoluteCap: string }): boolean { + try { + return BigInt(cap.relativeCap) > 0n || BigInt(cap.absoluteCap) > 0n; + } catch (_error) { + return false; + } +} + const groupVaultsByNetwork = (vaults: { address: Address; networkId: SupportedNetworks }[]): Record => { return vaults.reduce( (acc, vault) => { From 8410f3023294b8a0c463ae5a0751ffcdeb8237a9 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Tue, 19 May 2026 21:43:31 +0800 Subject: [PATCH 2/5] feat: improve vault adapter analytics display --- docs/VALIDATIONS.md | 3 + src/contexts/VaultRegistryContext.tsx | 2 +- src/data-sources/monarch-api/vaults.ts | 92 +++++++++++++++++-- .../components/vault-detail/vault-header.tsx | 6 +- .../components/overview-tab.tsx | 1 + .../components/position-period-selector.tsx | 1 + .../position-detail/position-view.tsx | 1 + .../supplied-morpho-blue-grouped-table.tsx | 1 + .../components/user-vaults-table.tsx | 1 + .../vault-adapter-position-overview.tsx | 16 +--- .../vault-analytics-period-control.tsx | 44 +++++++++ .../vault-market-allocations-table.tsx | 29 ++---- .../components/vault-share-price-chart.tsx | 52 ++++++----- src/features/vault/vault-view.tsx | 62 ++++++++----- src/hooks/useAddressLabel.ts | 2 +- src/hooks/usePositionsWithEarnings.ts | 2 + src/hooks/useVaultSharePriceHistory.ts | 6 +- src/stores/usePositionsFilters.ts | 2 +- src/utils/vaults.ts | 7 +- 19 files changed, 228 insertions(+), 102 deletions(-) create mode 100644 src/features/vault/components/vault-analytics-period-control.tsx diff --git a/docs/VALIDATIONS.md b/docs/VALIDATIONS.md index b2061b14..ec7f30da 100644 --- a/docs/VALIDATIONS.md +++ b/docs/VALIDATIONS.md @@ -97,6 +97,8 @@ Use this file at the end of non-trivial work. Do not front-load it at task start - Avoid repeated large UI blocks; extract or reuse only when it reduces real duplication. - Validate loading, empty, disabled, error, and success states for changed flows; period-derived metrics must not show stale values while recalculating. +- Related analytics sections that share period-dependent data should expose one period control at the shared container level, not competing child-level dropdowns. +- Yield, rate, and share-price charts must use period-aware minimum y-axis bands when comparing growth; do not let data-only auto-fit scaling make materially different APRs look visually identical. - Cold-start loading for optional metadata or enrichment must not trigger warning/error banners; warn only after a source is partial, stale, or unavailable. - Product routes must not add render-blocking external font CSS or multi-megabyte custom font assets to the app shell; prefer system font stacks or prove a small subset budget. - Font performance changes must preserve the intended design-token font families. Optimize with self-hosted subsets or scoped loading, not by silently mapping custom font utilities to system stacks. @@ -109,6 +111,7 @@ Use this file at the end of non-trivial work. Do not front-load it at task start - Relationship metadata must not link to the current page's own account again; only link to counterpart accounts, external protocol pages, or expandable details. - V2 vault position pages must not render native vault-address market or vault tables when the meaningful market exposure is held by a linked adapter. - Vault and adapter relationship UI should prefer short chips, address badges, and structural grouping over explanatory paragraphs. +- Vault adapter labels must come from confirmed adapter type metadata or fall back to a generic "vault adapter" label; do not infer protocol-specific adapter types from a missing `adapterType`. - Vault identity and vault action links should resolve to Monarch's canonical `/vault/:chainId/:address` route by default; external Morpho vault links belong only in explicit "View on Morpho" actions. - Use available entity icons in compact metadata chips before adding extra explanatory text. - Dense product headers should use compact chips, short address links, icon buttons, and tooltips for secondary navigation; avoid long text buttons unless they are the primary action. diff --git a/src/contexts/VaultRegistryContext.tsx b/src/contexts/VaultRegistryContext.tsx index 626c4b72..94cc9891 100644 --- a/src/contexts/VaultRegistryContext.tsx +++ b/src/contexts/VaultRegistryContext.tsx @@ -35,7 +35,7 @@ type AddressLabel = { type AdapterAddressAlias = { adapterAddress: string; - adapterType: string; + adapterType?: string; chainId: number; vaultAddress: string; vaultName: string; diff --git a/src/data-sources/monarch-api/vaults.ts b/src/data-sources/monarch-api/vaults.ts index 64cdb0cd..c6d39de0 100644 --- a/src/data-sources/monarch-api/vaults.ts +++ b/src/data-sources/monarch-api/vaults.ts @@ -18,7 +18,7 @@ export type VaultAdapterDetails = { }; export type VaultAdapterAlias = { - adapterType: string; + adapterType?: string; address: string; chainId: SupportedNetworks; vaultAddress: string; @@ -135,6 +135,20 @@ type MonarchAdapterAliasesResponse = { }; }; +type MonarchVaultAdapterAliasRecord = { + vaultAddress: string; + chainId: number; + name: string | null; + symbol: string | null; + adapters: MonarchVaultAdapter[]; +}; + +type MonarchVaultAdapterAliasesResponse = { + data?: { + Vault?: MonarchVaultAdapterAliasRecord[]; + }; +}; + const MONARCH_ADAPTER_ALIAS_PAGE_SIZE = 1000; const MONARCH_ADAPTER_ALIAS_MAX_PAGES = 20; @@ -278,6 +292,26 @@ const adapterAliasesQuery = ` } `; +const activeVaultAdapterAliasesQuery = ` + query MonarchActiveVaultAdapterAliases($limit: Int!, $offset: Int!) { + Vault( + where: { adapters: { isActive: { _eq: true } } } + order_by: [{ chainId: asc }, { vaultAddress: asc }] + limit: $limit + offset: $offset + ) { + vaultAddress + chainId + name + symbol + adapters { + adapterAddress + isActive + } + } + } +`; + export const fetchUserVaultV2DetailsAllNetworks = async (owner: string): Promise => { const response = await monarchGraphqlFetcher(userVaultsQuery, { owner: owner.toLowerCase(), @@ -359,10 +393,36 @@ const transformAdapterAliasRecord = (adapter: MonarchAdapterAliasRecord): VaultA }; }; +const transformVaultAdapterAliasRecord = (vault: MonarchVaultAdapterAliasRecord): VaultAdapterAlias[] => { + const chainId = toSupportedNetwork(vault.chainId); + const vaultAddress = normalizeAddress(vault.vaultAddress); + const vaultName = vault.name?.trim() || vault.symbol?.trim() || ''; + if (!chainId || !vaultAddress || !vaultName) { + return []; + } + + return vault.adapters + .filter((adapter) => adapter.isActive) + .map((adapter) => normalizeAddress(adapter.adapterAddress)) + .filter(Boolean) + .map((address) => ({ + address, + chainId, + vaultAddress, + vaultName, + })); +}; + export const fetchMonarchVaultAdapterAliases = async (): Promise => { try { - const aliases: VaultAdapterAlias[] = []; - const seenKeys = new Set(); + const aliasesByKey = new Map(); + const addAlias = (alias: VaultAdapterAlias) => { + const key = `${alias.chainId}:${alias.address}`; + const existing = aliasesByKey.get(key); + if (!existing || (!existing.adapterType && alias.adapterType)) { + aliasesByKey.set(key, alias); + } + }; for (let page = 0; page < MONARCH_ADAPTER_ALIAS_MAX_PAGES; page++) { const response = await monarchGraphqlFetcher(adapterAliasesQuery, { @@ -378,13 +438,25 @@ export const fetchMonarchVaultAdapterAliases = async (): Promise(activeVaultAdapterAliasesQuery, { + limit: MONARCH_ADAPTER_ALIAS_PAGE_SIZE, + offset: page * MONARCH_ADAPTER_ALIAS_PAGE_SIZE, + }); + + const records = response.data?.Vault ?? []; + for (const record of records) { + for (const alias of transformVaultAdapterAliasRecord(record)) { + addAlias(alias); + } } if (records.length < MONARCH_ADAPTER_ALIAS_PAGE_SIZE) { @@ -392,7 +464,7 @@ export const fetchMonarchVaultAdapterAliases = async (): Promise · - Caps source: {capsAdapterLabel} + Caps: {capsAdapterLabel} )} {knownAllocators.length > 0 && ( @@ -372,8 +372,8 @@ export function VaultHeader({ chainId={chainId} /> {capsAdapterKeys.has(row.adapter.toLowerCase()) && ( - - Caps source + + Active )}
diff --git a/src/features/position-detail/components/overview-tab.tsx b/src/features/position-detail/components/overview-tab.tsx index c18d51e4..5cd41ccc 100644 --- a/src/features/position-detail/components/overview-tab.tsx +++ b/src/features/position-detail/components/overview-tab.tsx @@ -22,6 +22,7 @@ const PERIOD_LABELS: Record = { day: '24h', week: '7d', month: '30d', + threemonth: '3mo', sixmonth: '6mo', all: 'All time', }; diff --git a/src/features/position-detail/components/position-period-selector.tsx b/src/features/position-detail/components/position-period-selector.tsx index 5da5e74a..9c0f7c0e 100644 --- a/src/features/position-detail/components/position-period-selector.tsx +++ b/src/features/position-detail/components/position-period-selector.tsx @@ -14,6 +14,7 @@ const PERIOD_OPTIONS: { value: EarningsPeriod; label: string }[] = [ { value: 'day', label: '24h' }, { value: 'week', label: '7 days' }, { value: 'month', label: '30 days' }, + { value: 'threemonth', label: '3 months' }, { value: 'sixmonth', label: '6 months' }, { value: 'all', label: 'All time' }, ]; diff --git a/src/features/position-detail/position-view.tsx b/src/features/position-detail/position-view.tsx index 4b3afdc9..a1169c2b 100644 --- a/src/features/position-detail/position-view.tsx +++ b/src/features/position-detail/position-view.tsx @@ -29,6 +29,7 @@ const PERIOD_LABELS: Record = { day: '24h', week: '7d', month: '30d', + threemonth: '3mo', sixmonth: '6mo', all: 'All time', }; 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 9e0e887e..43388ae0 100644 --- a/src/features/positions/components/supplied-morpho-blue-grouped-table.tsx +++ b/src/features/positions/components/supplied-morpho-blue-grouped-table.tsx @@ -385,6 +385,7 @@ export function SuppliedMorphoBlueGroupedTable({ day: '1D', week: '7D', month: '30D', + threemonth: '3M', sixmonth: '6M', all: 'All', }; diff --git a/src/features/positions/components/user-vaults-table.tsx b/src/features/positions/components/user-vaults-table.tsx index 7268efa2..183f5eab 100644 --- a/src/features/positions/components/user-vaults-table.tsx +++ b/src/features/positions/components/user-vaults-table.tsx @@ -27,6 +27,7 @@ const periodLabels: Record = { day: '1D', week: '7D', month: '30D', + threemonth: '3M', sixmonth: '6M', all: 'All', }; diff --git a/src/features/vault/components/vault-adapter-position-overview.tsx b/src/features/vault/components/vault-adapter-position-overview.tsx index f28ca8b4..d667016d 100644 --- a/src/features/vault/components/vault-adapter-position-overview.tsx +++ b/src/features/vault/components/vault-adapter-position-overview.tsx @@ -6,7 +6,6 @@ import type { Address } from 'viem'; import { Button } from '@/components/ui/button'; import { TableContainerWithHeader } from '@/components/common/table-container-with-header'; import { UserPositionsChart } from '@/features/positions/components/user-positions-chart'; -import { PositionPeriodSelector } from '@/features/position-detail/components/position-period-selector'; import { VaultMarketAllocationsTable } from '@/features/vault/components/vault-market-allocations-table'; import type { EarningsPeriod } from '@/stores/usePositionsFilters'; import type { MarketAllocation } from '@/types/vaultAllocations'; @@ -18,6 +17,7 @@ const PERIOD_LABELS: Record = { day: '24h', week: '7d', month: '30d', + threemonth: '3mo', sixmonth: '6mo', all: 'All time', }; @@ -29,7 +29,6 @@ type VaultAdapterPositionOverviewProps = { isEarningsLoading: boolean; actualBlockData: Record; period: EarningsPeriod; - setPeriod: (period: EarningsPeriod) => void; transactions: UserTransaction[]; snapshotsByChain: Record>; marketAllocations: MarketAllocation[]; @@ -94,7 +93,6 @@ export function VaultAdapterPositionOverview({ isEarningsLoading, actualBlockData, period, - setPeriod, transactions, snapshotsByChain, marketAllocations, @@ -102,17 +100,6 @@ export function VaultAdapterPositionOverview({ }: VaultAdapterPositionOverviewProps) { const periodLabel = PERIOD_LABELS[period]; const detailHref = `/position/${chainId}/${groupedPosition.loanAssetAddress}/${adapterAddress}`; - const periodSelector = ( -
- Period - -
- ); return (
@@ -123,7 +110,6 @@ export function VaultAdapterPositionOverview({ snapshotsByChain={snapshotsByChain} chainBlockData={actualBlockData} height={220} - actions={periodSelector} /> void; +}; + +const PERIOD_OPTIONS: ButtonOption[] = [ + { key: '1d', value: '1d', label: '24H' }, + { key: '7d', value: '7d', label: '7D' }, + { key: '30d', value: '30d', label: '30D' }, + { key: '3m', value: '3m', label: '3M' }, + { key: '6m', value: '6m', label: '6M' }, +]; + +export const vaultAnalyticsTimeframeToEarningsPeriod: Record = { + '1d': 'day', + '7d': 'week', + '30d': 'month', + '3m': 'threemonth', + '6m': 'sixmonth', +}; + +export function VaultAnalyticsPeriodControl({ value, onChange }: VaultAnalyticsPeriodControlProps) { + return ( +
+
+ Period + onChange(nextValue as ChartTimeframe)} + size="sm" + variant="compact" + equalWidth + /> +
+
+ ); +} diff --git a/src/features/vault/components/vault-market-allocations-table.tsx b/src/features/vault/components/vault-market-allocations-table.tsx index 81077ba4..0ae108fd 100644 --- a/src/features/vault/components/vault-market-allocations-table.tsx +++ b/src/features/vault/components/vault-market-allocations-table.tsx @@ -22,7 +22,6 @@ export type VaultMarketAllocationsTableMode = 'summary' | 'position'; type VaultMarketAllocationsTableRow = { market: Market; allocation: MarketAllocation; - currentSupplyAssets?: bigint; earnedAssets?: bigint; realizedApy?: number | null; }; @@ -40,10 +39,10 @@ type VaultMarketAllocationsTableProps = { showExplorerLink?: boolean; }; -const sortRows = (rows: VaultMarketAllocationsTableRow[], mode: VaultMarketAllocationsTableMode) => { +const sortRows = (rows: VaultMarketAllocationsTableRow[]) => { return [...rows].sort((a, b) => { - const aValue = mode === 'position' ? (a.currentSupplyAssets ?? a.allocation.allocation) : a.allocation.allocation; - const bValue = mode === 'position' ? (b.currentSupplyAssets ?? b.allocation.allocation) : b.allocation.allocation; + const aValue = a.allocation.allocation; + const bValue = b.allocation.allocation; return bValue > aValue ? 1 : bValue < aValue ? -1 : 0; }); }; @@ -81,14 +80,13 @@ export function VaultMarketAllocationsTable({ return { market, allocation, - currentSupplyAssets: position ? BigInt(position.state.supplyAssets) : allocation.allocation, earnedAssets: position?.earned ? BigInt(position.earned) : 0n, realizedApy: position?.actualApy, }; }); - }, [chainId, marketAllocations, positionByMarket]); + }, [marketAllocations, positionByMarket]); - const sortedRows = useMemo(() => sortRows(rows, mode), [rows, mode]); + const sortedRows = useMemo(() => sortRows(rows), [rows]); const totalAllocation = useMemo(() => { if (totalAssets !== undefined) return totalAssets; return marketAllocations.reduce((sum, allocation) => sum + allocation.allocation, 0n); @@ -103,7 +101,7 @@ export function VaultMarketAllocationsTable({ Allocation {isPositionMode ? ( <> - Current Supply + Liquidity -
- {formatReadable(currentSupply)} - -
+ {liquidity}
= { '3m': 0.03, '6m': 0.06, }; -const LOWER_DOMAIN_PADDING_RATIO = 0.12; function getSharePriceDomain(chartData: VaultSharePriceChartPoint[], timeframe: ChartTimeframe): [number, number] { if (chartData.length === 0) { @@ -51,10 +51,12 @@ function getSharePriceDomain(chartData: VaultSharePriceChartPoint[], timeframe: const dataMin = Math.min(...values); const dataMax = Math.max(...values); const minimumGrowth = Math.max(Math.abs(baseline) * SHARE_PRICE_DOMAIN_GROWTH_BY_TIMEFRAME[timeframe], Number.EPSILON); - const lower = Math.min(dataMin, baseline - minimumGrowth * LOWER_DOMAIN_PADDING_RATIO); + // Center normal period moves around the starting share price so low-growth vaults do not sit on the chart floor. + const lower = Math.min(dataMin, baseline - minimumGrowth); const upper = Math.max(dataMax, baseline + minimumGrowth); - return [Math.max(0, lower), upper]; + const safeLower = Math.max(0, lower); + return upper > safeLower ? [safeLower, upper] : [safeLower, safeLower + Number.EPSILON]; } function formatSharePrice(value: number, assetSymbol?: string): string { @@ -87,7 +89,13 @@ function changeTextColor(value: number | null): string { return value >= 0 ? 'text-emerald-500' : 'text-rose-500'; } -export function VaultSharePriceChart({ vaultAddress, chainId, assetDecimals, assetSymbol }: VaultSharePriceChartProps) { +export function VaultSharePriceChart({ + vaultAddress, + chainId, + assetDecimals, + assetSymbol, + showPeriodControl = true, +}: VaultSharePriceChartProps) { const selectedTimeframe = useMarketDetailChartState((state) => state.selectedTimeframe); const selectedTimeRange = useMarketDetailChartState((state) => state.selectedTimeRange); const setTimeframe = useMarketDetailChartState((state) => state.setTimeframe); @@ -136,7 +144,7 @@ export function VaultSharePriceChart({ vaultAddress, chainId, assetDecimals, ass const yAxisDomain = useMemo(() => getSharePriceDomain(chartData, selectedTimeframe), [chartData, selectedTimeframe]); const isInitialLoading = isLoading; const isUnavailable = data?.isUnsupportedNetwork || isError || (!isInitialLoading && chartData.length < 2); - const chartActions = ( + const chartActions = showPeriodControl || (isFetching && !isInitialLoading) ? (
{isFetching && !isInitialLoading ? (
@@ -144,23 +152,25 @@ export function VaultSharePriceChart({ vaultAddress, chainId, assetDecimals, ass Updating
) : null} - + {showPeriodControl && ( + + )}
- ); + ) : undefined; return ( void; showAdapterLabel?: boolean; totalAssets?: bigint; vaultAddress: Address; @@ -96,7 +99,6 @@ function VaultAdapterPositionDetail({ chainId, isResolvingAdapter, period, - setPeriod, showAdapterLabel = false, totalAssets, vaultAddress, @@ -149,7 +151,7 @@ function VaultAdapterPositionDetail({ }, [currentPosition, transactions]); if (!adapterAddress && !isResolvingAdapter) { - return ; + return ; } const isLoading = isResolvingAdapter || marketHintsLoading || isPositionsLoading; @@ -177,7 +179,6 @@ function VaultAdapterPositionDetail({ isEarningsLoading={isEarningsLoading} actualBlockData={actualBlockData} period={period} - setPeriod={setPeriod} transactions={relevantTransactions} snapshotsByChain={snapshotsByChain} marketAllocations={marketAllocations} @@ -224,13 +225,21 @@ export default function VaultContent() { const [hasMounted, setHasMounted] = useState(false); const { open: openModal } = useModal(); const { findToken } = useTokensQuery(); - const period = usePositionDetailPreferences((state) => state.period); - const setPeriod = usePositionDetailPreferences((state) => state.setPeriod); + const selectedAnalyticsTimeframe = useMarketDetailChartState((state) => state.selectedTimeframe); + const setAnalyticsTimeframe = useMarketDetailChartState((state) => state.setTimeframe); + const analyticsPeriod = vaultAnalyticsTimeframeToEarningsPeriod[selectedAnalyticsTimeframe]; useEffect(() => { setHasMounted(true); }, []); + const handleAnalyticsPeriodChange = useCallback( + (timeframe: ChartTimeframe) => { + setAnalyticsTimeframe(timeframe); + }, + [setAnalyticsTimeframe], + ); + const connectedAddress = hasMounted ? address : undefined; const chainId = useMemo(() => { @@ -493,23 +502,30 @@ export default function VaultContent() {
)} - +
+ - + + + +
{ return now - 7 * 86_400; case 'month': return now - 30 * 86_400; + case 'threemonth': + return now - 90 * 86_400; case 'sixmonth': return now - 180 * 86_400; case 'all': diff --git a/src/hooks/useVaultSharePriceHistory.ts b/src/hooks/useVaultSharePriceHistory.ts index beadc8db..f7834685 100644 --- a/src/hooks/useVaultSharePriceHistory.ts +++ b/src/hooks/useVaultSharePriceHistory.ts @@ -78,15 +78,15 @@ function selectNearestMorphoPoints( let nearestIndex = -1; let nearestDistance = Number.POSITIVE_INFINITY; - points.forEach((point, index) => { - if (usedIndexes.has(index)) return; + for (const [index, point] of points.entries()) { + if (usedIndexes.has(index)) continue; const distance = Math.abs(point.timestamp - targetTimestamp); if (distance < nearestDistance) { nearestDistance = distance; nearestIndex = index; } - }); + } if (nearestIndex === -1) continue; diff --git a/src/stores/usePositionsFilters.ts b/src/stores/usePositionsFilters.ts index 8a566283..7adc9969 100644 --- a/src/stores/usePositionsFilters.ts +++ b/src/stores/usePositionsFilters.ts @@ -4,7 +4,7 @@ import { persist } from 'zustand/middleware'; /** * Earnings calculation periods for the positions summary page. */ -export type EarningsPeriod = 'day' | 'week' | 'month' | 'sixmonth' | 'all'; +export type EarningsPeriod = 'day' | 'week' | 'month' | 'threemonth' | 'sixmonth' | 'all'; type PositionsFiltersState = { /** Currently selected earnings period */ diff --git a/src/utils/vaults.ts b/src/utils/vaults.ts index 7688c298..653191fb 100644 --- a/src/utils/vaults.ts +++ b/src/utils/vaults.ts @@ -115,9 +115,10 @@ export function isMarketTrustedByVault(market: Market, trustedVaultMap: Map Date: Tue, 19 May 2026 22:32:50 +0800 Subject: [PATCH 3/5] fix: address vault review comments --- .../morpho-api/vault-share-price-history.ts | 7 +++-- .../components/vault-detail/vault-header.tsx | 2 +- .../transaction-history-preview.tsx | 2 +- .../components/charts/chart-utils.tsx | 28 ++++++++++++------- .../components/overview-tab.tsx | 2 +- .../position-detail/position-view.tsx | 2 +- .../vault-adapter-position-overview.tsx | 2 +- src/hooks/useVaultSharePriceHistory.ts | 4 --- 8 files changed, 27 insertions(+), 22 deletions(-) diff --git a/src/data-sources/morpho-api/vault-share-price-history.ts b/src/data-sources/morpho-api/vault-share-price-history.ts index 3a1ea4c7..a0e76515 100644 --- a/src/data-sources/morpho-api/vault-share-price-history.ts +++ b/src/data-sources/morpho-api/vault-share-price-history.ts @@ -9,7 +9,7 @@ export type MorphoVaultSharePricePoint = { }; type RawSharePricePoint = { - x: number; + x: unknown; y: unknown; }; @@ -64,13 +64,14 @@ export async function fetchMorphoVaultV2SharePriceHistory({ const normalizedPoints = points .map((point) => { + const timestamp = Number(point.x); const sharePrice = normalizeSharePrice(point.y); - if (sharePrice === null) { + if (!Number.isFinite(timestamp) || sharePrice === null) { return null; } return { - timestamp: point.x, + timestamp, sharePrice, }; }) diff --git a/src/features/autovault/components/vault-detail/vault-header.tsx b/src/features/autovault/components/vault-detail/vault-header.tsx index 68353d2c..69fe85ee 100644 --- a/src/features/autovault/components/vault-detail/vault-header.tsx +++ b/src/features/autovault/components/vault-detail/vault-header.tsx @@ -99,7 +99,7 @@ export function VaultHeader({ // Filter for known agents const knownAllocators = allocators.filter((addr) => findAgent(addr) !== undefined); const adapterRows = adapters.length > 0 ? adapters : adapter ? [{ adapter: adapter as Address }] : []; - const capsAdapterRows = capsAdapters.length > 0 ? capsAdapters : adapter ? [{ adapter: adapter as Address }] : []; + const capsAdapterRows = capsAdapters; const capsAdapterKeys = new Set(capsAdapterRows.map((row) => row.adapter.toLowerCase())); const showCapsAdapter = adapterRows.length > 1 && capsAdapterRows.length > 0; const capsAdapterLabel = diff --git a/src/features/history/components/transaction-history-preview.tsx b/src/features/history/components/transaction-history-preview.tsx index 6497afcd..ccddc030 100644 --- a/src/features/history/components/transaction-history-preview.tsx +++ b/src/features/history/components/transaction-history-preview.tsx @@ -48,7 +48,7 @@ export function TransactionHistoryPreview({ const seen = new Set(); return sourceAccounts.flatMap((sourceAccount) => { - const normalizedAccount = sourceAccount.toLowerCase(); + const normalizedAccount = sourceAccount.trim().toLowerCase(); if (!normalizedAccount || seen.has(normalizedAccount)) { return []; } diff --git a/src/features/market-detail/components/charts/chart-utils.tsx b/src/features/market-detail/components/charts/chart-utils.tsx index 3e8057b1..94f09443 100644 --- a/src/features/market-detail/components/charts/chart-utils.tsx +++ b/src/features/market-detail/components/charts/chart-utils.tsx @@ -157,22 +157,30 @@ type ChartLegendProps> = { export function createLegendClickHandler>({ visibleLines, setVisibleLines }: ChartLegendProps) { return { onClick: (entry: { dataKey?: unknown }) => { + if (entry.dataKey == null) { + return; + } + const dataKey = String(entry.dataKey) as keyof T; setVisibleLines((prev) => ({ ...prev, [dataKey]: !prev[dataKey], })); }, - formatter: (value: string, entry: { dataKey?: unknown }) => ( - - {value} - - ), + formatter: (value: string, entry: { dataKey?: unknown }) => { + const dataKey = entry.dataKey == null ? null : (String(entry.dataKey) as keyof T); + + return ( + + {value} + + ); + }, }; } diff --git a/src/features/position-detail/components/overview-tab.tsx b/src/features/position-detail/components/overview-tab.tsx index 5cd41ccc..2bfd5cee 100644 --- a/src/features/position-detail/components/overview-tab.tsx +++ b/src/features/position-detail/components/overview-tab.tsx @@ -22,7 +22,7 @@ const PERIOD_LABELS: Record = { day: '24h', week: '7d', month: '30d', - threemonth: '3mo', + threemonth: '3M', sixmonth: '6mo', all: 'All time', }; diff --git a/src/features/position-detail/position-view.tsx b/src/features/position-detail/position-view.tsx index a1169c2b..76928057 100644 --- a/src/features/position-detail/position-view.tsx +++ b/src/features/position-detail/position-view.tsx @@ -29,7 +29,7 @@ const PERIOD_LABELS: Record = { day: '24h', week: '7d', month: '30d', - threemonth: '3mo', + threemonth: '3M', sixmonth: '6mo', all: 'All time', }; diff --git a/src/features/vault/components/vault-adapter-position-overview.tsx b/src/features/vault/components/vault-adapter-position-overview.tsx index d667016d..f1760b28 100644 --- a/src/features/vault/components/vault-adapter-position-overview.tsx +++ b/src/features/vault/components/vault-adapter-position-overview.tsx @@ -17,7 +17,7 @@ const PERIOD_LABELS: Record = { day: '24h', week: '7d', month: '30d', - threemonth: '3mo', + threemonth: '3M', sixmonth: '6mo', all: 'All time', }; diff --git a/src/hooks/useVaultSharePriceHistory.ts b/src/hooks/useVaultSharePriceHistory.ts index f7834685..5608d2ad 100644 --- a/src/hooks/useVaultSharePriceHistory.ts +++ b/src/hooks/useVaultSharePriceHistory.ts @@ -71,7 +71,6 @@ function selectNearestMorphoPoints( points: MorphoVaultSharePricePoint[], targetTimestamps: number[], ): VaultSharePricePoint[] { - const usedIndexes = new Set(); const selected: VaultSharePricePoint[] = []; for (const targetTimestamp of targetTimestamps) { @@ -79,8 +78,6 @@ function selectNearestMorphoPoints( let nearestDistance = Number.POSITIVE_INFINITY; for (const [index, point] of points.entries()) { - if (usedIndexes.has(index)) continue; - const distance = Math.abs(point.timestamp - targetTimestamp); if (distance < nearestDistance) { nearestDistance = distance; @@ -90,7 +87,6 @@ function selectNearestMorphoPoints( if (nearestIndex === -1) continue; - usedIndexes.add(nearestIndex); const point = points[nearestIndex]; if (!point) continue; From f007c6479a3dea9bff6d496c6dd25c471031d3f9 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Wed, 20 May 2026 00:05:27 +0800 Subject: [PATCH 4/5] fix: use shared period selector for vault analytics --- .../common/period-selector.tsx} | 30 +++++++++---- .../position-detail/position-view.tsx | 4 +- .../vault-analytics-period-control.tsx | 45 ++++++++++++------- src/features/vault/vault-view.tsx | 9 ++-- 4 files changed, 57 insertions(+), 31 deletions(-) rename src/{features/position-detail/components/position-period-selector.tsx => components/common/period-selector.tsx} (66%) diff --git a/src/features/position-detail/components/position-period-selector.tsx b/src/components/common/period-selector.tsx similarity index 66% rename from src/features/position-detail/components/position-period-selector.tsx rename to src/components/common/period-selector.tsx index 9c0f7c0e..39a352fd 100644 --- a/src/features/position-detail/components/position-period-selector.tsx +++ b/src/components/common/period-selector.tsx @@ -1,16 +1,14 @@ 'use client'; -import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from '@/components/ui/select'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import type { EarningsPeriod } from '@/stores/usePositionsFilters'; -type PositionPeriodSelectorProps = { - period: EarningsPeriod; - onPeriodChange: (period: EarningsPeriod) => void; - className?: string; - contentClassName?: string; +export type PeriodSelectorOption = { + value: EarningsPeriod; + label: string; }; -const PERIOD_OPTIONS: { value: EarningsPeriod; label: string }[] = [ +export const EARNINGS_PERIOD_OPTIONS: readonly PeriodSelectorOption[] = [ { value: 'day', label: '24h' }, { value: 'week', label: '7 days' }, { value: 'month', label: '30 days' }, @@ -19,7 +17,21 @@ const PERIOD_OPTIONS: { value: EarningsPeriod; label: string }[] = [ { value: 'all', label: 'All time' }, ]; -export function PositionPeriodSelector({ period, onPeriodChange, className, contentClassName }: PositionPeriodSelectorProps) { +type PeriodSelectorProps = { + period: EarningsPeriod; + onPeriodChange: (period: EarningsPeriod) => void; + options?: readonly PeriodSelectorOption[]; + className?: string; + contentClassName?: string; +}; + +export function PeriodSelector({ + period, + onPeriodChange, + options = EARNINGS_PERIOD_OPTIONS, + className, + contentClassName, +}: PeriodSelectorProps) { return (