diff --git a/src/components/shared/custom-tag-icons.tsx b/src/components/shared/custom-tag-icons.tsx new file mode 100644 index 00000000..a9049820 --- /dev/null +++ b/src/components/shared/custom-tag-icons.tsx @@ -0,0 +1,79 @@ +'use client'; + +import type { IconType } from 'react-icons'; +import { AiOutlineThunderbolt, AiOutlineEye, AiOutlineRocket } from 'react-icons/ai'; +import { FaGem } from 'react-icons/fa'; +import { TbTrendingUp, TbTrendingDown } from 'react-icons/tb'; +import type { CustomTagIconId } from '@/stores/useMarketPreferences'; + +/** + * Mapping of icon IDs to react-icons components. + * Keep this in sync with CUSTOM_TAG_ICONS in useMarketPreferences. + */ +export const ICON_MAP: Record = { + 'trend-up': TbTrendingUp, + 'trend-down': TbTrendingDown, + rocket: AiOutlineRocket, + gem: FaGem, + bolt: AiOutlineThunderbolt, + eye: AiOutlineEye, +}; + +type CustomTagIconProps = { + iconId: CustomTagIconId; + size?: number; + className?: string; +}; + +/** + * Render a custom tag icon by its ID. + */ +export function CustomTagIcon({ iconId, size = 14, className = '' }: CustomTagIconProps) { + const IconComponent = ICON_MAP[iconId]; + if (!IconComponent) return null; + return ( + + ); +} + +/** + * Icon picker component for selecting custom tag icons. + */ +type IconPickerProps = { + selectedIcon: CustomTagIconId; + onSelect: (icon: CustomTagIconId) => void; + disabled?: boolean; +}; + +export function CustomTagIconPicker({ selectedIcon, onSelect, disabled = false }: IconPickerProps) { + return ( +
+ {(Object.keys(ICON_MAP) as CustomTagIconId[]).map((iconId) => { + const IconComponent = ICON_MAP[iconId]; + const isSelected = iconId === selectedIcon; + + return ( + + ); + })} +
+ ); +} diff --git a/src/features/markets/components/market-indicators.tsx b/src/features/markets/components/market-indicators.tsx index 99ccdfad..c872b24a 100644 --- a/src/features/markets/components/market-indicators.tsx +++ b/src/features/markets/components/market-indicators.tsx @@ -5,14 +5,66 @@ import { LuUser } from 'react-icons/lu'; import { IoWarningOutline } from 'react-icons/io5'; import { AiOutlineFire } from 'react-icons/ai'; import { TooltipContent } from '@/components/shared/tooltip-content'; -import { useTrendingMarketKeys, getMetricsKey, useEverLiquidated } from '@/hooks/queries/useMarketMetricsQuery'; +import { CustomTagIcon } from '@/components/shared/custom-tag-icons'; +import { + useOfficialTrendingMarketKeys, + useCustomTagMarketKeys, + getMetricsKey, + useEverLiquidated, + useMarketMetricsMap, + type FlowTimeWindow, +} from '@/hooks/queries/useMarketMetricsQuery'; import { computeMarketWarnings } from '@/hooks/useMarketWarnings'; -import { useMarketPreferences } from '@/stores/useMarketPreferences'; +import { useMarketPreferences, type CustomTagConfig } from '@/stores/useMarketPreferences'; import type { Market } from '@/utils/types'; import { RewardsIndicator } from '@/features/markets/components/rewards-indicator'; const ICON_SIZE = 14; +const WINDOW_LABELS: Record = { + '1h': '1 Hour', + '24h': '24 Hours', + '7d': '7 Days', +}; + +/** + * Build tooltip detail showing actual flow values for configured thresholds + */ +function buildCustomTagDetail( + config: CustomTagConfig, + flows: Record | undefined, + borrowUsd: number, +): string { + if (!flows) return 'Matches your custom tag criteria'; + + const parts: string[] = []; + + for (const [window, windowConfig] of Object.entries(config.windows)) { + const supplyThreshold = windowConfig?.supplyFlowPct ?? ''; + const borrowThreshold = windowConfig?.borrowFlowPct ?? ''; + + if (!supplyThreshold && !borrowThreshold) continue; + + const flow = flows[window as FlowTimeWindow]; + if (!flow) continue; + + const label = WINDOW_LABELS[window as FlowTimeWindow] ?? window; + const actualSupply = flow.supplyFlowPct ?? 0; + const actualBorrow = borrowUsd > 0 ? ((flow.borrowFlowUsd ?? 0) / borrowUsd) * 100 : 0; + + if (supplyThreshold && Number.isFinite(Number(supplyThreshold))) { + const sign = actualSupply >= 0 ? '+' : ''; + parts.push(`${label}: ${sign}${actualSupply.toFixed(1)}% supply`); + } + if (borrowThreshold && Number.isFinite(Number(borrowThreshold))) { + const sign = actualBorrow >= 0 ? '+' : ''; + parts.push(`${label}: ${sign}${actualBorrow.toFixed(1)}% borrow`); + } + } + + return parts.length > 0 ? parts.join('\n') : 'Matches your custom tag criteria'; +} + type MarketIndicatorsProps = { market: Market; showRisk?: boolean; @@ -22,9 +74,20 @@ type MarketIndicatorsProps = { export function MarketIndicators({ market, showRisk = false, isStared = false, hasUserPosition = false }: MarketIndicatorsProps) { const hasLiquidationProtection = useEverLiquidated(market.morphoBlue.chain.id, market.uniqueKey); - const { trendingConfig } = useMarketPreferences(); - const trendingKeys = useTrendingMarketKeys(); - const isTrending = trendingConfig.enabled && trendingKeys.has(getMetricsKey(market.morphoBlue.chain.id, market.uniqueKey)); + const { showOfficialTrending, customTagConfig } = useMarketPreferences(); + const { metricsMap } = useMarketMetricsMap(); + + const marketKey = getMetricsKey(market.morphoBlue.chain.id, market.uniqueKey); + + // Official trending (backend-computed) + const officialTrendingKeys = useOfficialTrendingMarketKeys(); + const isOfficialTrending = showOfficialTrending && officialTrendingKeys.has(marketKey); + const trendingReason = metricsMap.get(marketKey)?.trendingReason; + + // User's custom tag + const customTagKeys = useCustomTagMarketKeys(); + const hasCustomTag = customTagConfig.enabled && customTagKeys.has(marketKey); + const warnings = showRisk ? computeMarketWarnings(market, true) : []; const hasWarnings = warnings.length > 0; const alertWarning = warnings.find((w) => w.level === 'alert'); @@ -101,29 +164,62 @@ export function MarketIndicators({ market, showRisk = false, isStared = false, h whitelisted={market.whitelisted} /> - {isTrending && ( + {/* Official Trending (backend-computed) */} + {isOfficialTrending && ( } - detail="This market is trending based on flow metrics" + title="Trending" + detail={trendingReason ?? 'This market is trending based on flow activity'} /> } >
)} + {/* User's Custom Tag */} + {hasCustomTag && + (() => { + const metrics = metricsMap.get(marketKey); + const tooltipDetail = buildCustomTagDetail(customTagConfig, metrics?.flows, metrics?.currentState?.borrowUsd ?? 0); + return ( + + } + title="Custom Tag" + detail={tooltipDetail} + /> + } + > +
+ +
+
+ ); + })()} + {showRisk && hasWarnings && ( - {trendingConfig.enabled && ( + {/* Official Trending Filter (backend-computed) */} + {showOfficialTrending && ( + + + )} + {/* Custom Tag Filter (user-defined) */} + {customTagConfig.enabled && ( +
diff --git a/src/hooks/queries/useMarketMetricsQuery.ts b/src/hooks/queries/useMarketMetricsQuery.ts index b49b9b9f..d9aa8248 100644 --- a/src/hooks/queries/useMarketMetricsQuery.ts +++ b/src/hooks/queries/useMarketMetricsQuery.ts @@ -1,10 +1,13 @@ import { useMemo } from 'react'; import { useQuery } from '@tanstack/react-query'; -import { useMarketPreferences, type TrendingConfig, type FlowTimeWindow } from '@/stores/useMarketPreferences'; +import { useMarketPreferences, type CustomTagConfig, type FlowTimeWindow } from '@/stores/useMarketPreferences'; // Re-export types for convenience export type { FlowTimeWindow } from '@/stores/useMarketPreferences'; +// Legacy alias +export type TrendingConfig = CustomTagConfig; + // Flow data for a specific time window export type MarketFlowData = { // Native token units (BigInt as string) - use loanAsset.decimals to convert @@ -40,6 +43,9 @@ export type MarketMetrics = { // Key flags everLiquidated: boolean; marketScore: number | null; + // Backend-computed trending (official) + isTrending: boolean; + trendingReason: string | null; // State and flows currentState: MarketCurrentState; flows: Record; @@ -127,18 +133,14 @@ const fetchAllMarketMetrics = async (params: MarketMetricsParams): Promise { const { chainId, sortBy, sortOrder, enabled = true } = params; @@ -156,13 +158,6 @@ export const useMarketMetricsQuery = (params: MarketMetricsParams = {}) => { /** * Returns a Map for O(1) lookup of market metrics by key. * Key format: `${chainId}-${uniqueKey.toLowerCase()}` - * - * @example - * ```tsx - * const { metricsMap, isLoading } = useMarketMetricsMap(); - * const metrics = metricsMap.get(getMetricsKey(chainId, uniqueKey)); - * if (metrics?.everLiquidated) { ... } - * ``` */ export const useMarketMetricsMap = (params: MarketMetricsParams = {}) => { const { data, isLoading, ...rest } = useMarketMetricsQuery(params); @@ -185,90 +180,110 @@ export const useMarketMetricsMap = (params: MarketMetricsParams = {}) => { /** * Convert flow assets (BigInt string) to human-readable number. - * @param flowAssets - The flow assets as BigInt string - * @param decimals - Token decimals */ export const parseFlowAssets = (flowAssets: string, decimals: number): number => { return Number(flowAssets) / 10 ** decimals; }; /** - * Determines if a market is trending based on flow thresholds. + * Check if a market matches a custom tag config. * All non-empty thresholds must be met (AND logic). - * Only positive flows (inflows) are considered. + * Supports both positive (inflows) and negative (outflows) thresholds. + * + * Logic: + * - Positive threshold (e.g., "5"): actual >= threshold (growth of 5% or more) + * - Negative threshold (e.g., "-3"): actual <= threshold (decline of 3% or more) */ -export const isMarketTrending = (metrics: MarketMetrics, trendingConfig: TrendingConfig): boolean => { - if (!trendingConfig.enabled) return false; +export const matchesCustomTag = (metrics: MarketMetrics, config: CustomTagConfig): boolean => { + if (!config.enabled) return false; + + let hasAnyValidThreshold = false; + + for (const [window, windowConfig] of Object.entries(config.windows)) { + const supplyPct = windowConfig?.supplyFlowPct ?? ''; + const borrowPct = windowConfig?.borrowFlowPct ?? ''; - for (const [window, config] of Object.entries(trendingConfig.windows)) { - const supplyPct = config?.minSupplyFlowPct ?? ''; - const supplyUsd = config?.minSupplyFlowUsd ?? ''; - const borrowPct = config?.minBorrowFlowPct ?? ''; - const borrowUsd = config?.minBorrowFlowUsd ?? ''; + const supplyThreshold = Number(supplyPct); + const borrowThreshold = Number(borrowPct); - const hasSupplyThreshold = supplyPct || supplyUsd; - const hasBorrowThreshold = borrowPct || borrowUsd; + // Only consider thresholds that are valid numbers + const hasSupplyThreshold = supplyPct !== '' && Number.isFinite(supplyThreshold); + const hasBorrowThreshold = borrowPct !== '' && Number.isFinite(borrowThreshold); if (!hasSupplyThreshold && !hasBorrowThreshold) continue; + hasAnyValidThreshold = true; + const flow = metrics.flows[window as FlowTimeWindow]; if (!flow) return false; - if (supplyPct) { - const actualPct = flow.supplyFlowPct ?? 0; - if (actualPct < Number(supplyPct)) return false; - } - if (supplyUsd) { - const actualUsd = flow.supplyFlowUsd ?? 0; - if (actualUsd < Number(supplyUsd)) return false; + // Check supply threshold + if (hasSupplyThreshold) { + const actual = flow.supplyFlowPct ?? 0; + // Positive threshold: actual must be >= threshold + // Negative threshold: actual must be <= threshold (more negative) + if (supplyThreshold >= 0 && actual < supplyThreshold) return false; + if (supplyThreshold < 0 && actual > supplyThreshold) return false; } - if (borrowPct) { + // Check borrow threshold + if (hasBorrowThreshold) { const borrowBase = metrics.currentState.borrowUsd; - const actualPct = borrowBase > 0 ? ((flow.borrowFlowUsd ?? 0) / borrowBase) * 100 : 0; - if (actualPct < Number(borrowPct)) return false; - } - if (borrowUsd) { - const actualUsd = flow.borrowFlowUsd ?? 0; - if (actualUsd < Number(borrowUsd)) return false; + const actual = borrowBase > 0 ? ((flow.borrowFlowUsd ?? 0) / borrowBase) * 100 : 0; + if (borrowThreshold >= 0 && actual < borrowThreshold) return false; + if (borrowThreshold < 0 && actual > borrowThreshold) return false; } } - const hasAnyThreshold = Object.values(trendingConfig.windows).some((c) => { - const supplyPct = c?.minSupplyFlowPct ?? ''; - const supplyUsd = c?.minSupplyFlowUsd ?? ''; - const borrowPct = c?.minBorrowFlowPct ?? ''; - const borrowUsd = c?.minBorrowFlowUsd ?? ''; - return supplyPct || supplyUsd || borrowPct || borrowUsd; - }); + return hasAnyValidThreshold; +}; + +// Legacy alias +export const isMarketTrending = matchesCustomTag; - return hasAnyThreshold; +/** + * Returns a Set of market keys that are officially trending (backend-computed). + * Uses isTrending field from Monarch API. + */ +export const useOfficialTrendingMarketKeys = () => { + const { metricsMap } = useMarketMetricsMap(); + + return useMemo(() => { + const keys = new Set(); + for (const [key, metrics] of metricsMap) { + if (metrics.isTrending) { + keys.add(key); + } + } + return keys; + }, [metricsMap]); }; /** - * Returns a Set of market keys that are currently trending. - * Uses metricsMap for O(1) lookup and filters based on trending config from preferences. + * Returns a Set of market keys matching user's custom tag config. */ -export const useTrendingMarketKeys = () => { +export const useCustomTagMarketKeys = () => { const { metricsMap } = useMarketMetricsMap(); - const { trendingConfig } = useMarketPreferences(); + const { customTagConfig } = useMarketPreferences(); return useMemo(() => { const keys = new Set(); - if (!trendingConfig.enabled) return keys; + if (!customTagConfig.enabled) return keys; for (const [key, metrics] of metricsMap) { - if (isMarketTrending(metrics, trendingConfig)) { + if (matchesCustomTag(metrics, customTagConfig)) { keys.add(key); } } return keys; - }, [metricsMap, trendingConfig]); + }, [metricsMap, customTagConfig]); }; +// Legacy alias - now returns official trending (breaking change, but intended) +export const useTrendingMarketKeys = useOfficialTrendingMarketKeys; + /** * Returns whether a market has ever been liquidated. - * Uses everLiquidated field from Monarch API market metrics. */ export const useEverLiquidated = (chainId: number, uniqueKey: string): boolean => { const { metricsMap } = useMarketMetricsMap(); diff --git a/src/hooks/useFilteredMarkets.ts b/src/hooks/useFilteredMarkets.ts index 53ce30be..2eb8bc96 100644 --- a/src/hooks/useFilteredMarkets.ts +++ b/src/hooks/useFilteredMarkets.ts @@ -5,7 +5,7 @@ import { useMarketPreferences } from '@/stores/useMarketPreferences'; import { useAppSettings } from '@/stores/useAppSettings'; import { useTrustedVaults } from '@/stores/useTrustedVaults'; import { useTokensQuery } from '@/hooks/queries/useTokensQuery'; -import { useTrendingMarketKeys, getMetricsKey } from '@/hooks/queries/useMarketMetricsQuery'; +import { useOfficialTrendingMarketKeys, useCustomTagMarketKeys, getMetricsKey } from '@/hooks/queries/useMarketMetricsQuery'; import { filterMarkets, sortMarkets, createPropertySort, createStarredSort } from '@/utils/marketFilters'; import { SortColumn } from '@/features/markets/components/constants'; import { getVaultKey } from '@/constants/vaults/known_vaults'; @@ -18,7 +18,8 @@ export const useFilteredMarkets = (): Market[] => { const { showUnwhitelistedMarkets } = useAppSettings(); const { vaults: trustedVaults } = useTrustedVaults(); const { findToken } = useTokensQuery(); - const trendingKeys = useTrendingMarketKeys(); + const officialTrendingKeys = useOfficialTrendingMarketKeys(); + const customTagKeys = useCustomTagMarketKeys(); return useMemo(() => { let markets = showUnwhitelistedMarkets ? allMarkets : whitelistedMarkets; @@ -62,10 +63,19 @@ export const useFilteredMarkets = (): Market[] => { }); } - if (filters.trendingMode && trendingKeys.size > 0) { + // Official trending filter (backend-computed) + if (filters.trendingMode && officialTrendingKeys.size > 0) { markets = markets.filter((market) => { const key = getMetricsKey(market.morphoBlue.chain.id, market.uniqueKey); - return trendingKeys.has(key); + return officialTrendingKeys.has(key); + }); + } + + // Custom tag filter (user-defined) + if (filters.customTagMode && customTagKeys.size > 0) { + markets = markets.filter((market) => { + const key = getMetricsKey(market.morphoBlue.chain.id, market.uniqueKey); + return customTagKeys.has(key); }); } @@ -110,5 +120,15 @@ export const useFilteredMarkets = (): Market[] => { } return markets; - }, [allMarkets, whitelistedMarkets, showUnwhitelistedMarkets, filters, preferences, trustedVaults, findToken, trendingKeys]); + }, [ + allMarkets, + whitelistedMarkets, + showUnwhitelistedMarkets, + filters, + preferences, + trustedVaults, + findToken, + officialTrendingKeys, + customTagKeys, + ]); }; diff --git a/src/modals/settings/monarch-settings/SettingsContent.tsx b/src/modals/settings/monarch-settings/SettingsContent.tsx index b424c42f..66f8e7a3 100644 --- a/src/modals/settings/monarch-settings/SettingsContent.tsx +++ b/src/modals/settings/monarch-settings/SettingsContent.tsx @@ -4,7 +4,7 @@ import { motion, AnimatePresence } from 'framer-motion'; import { slideVariants, slideTransition, type SlideDirection } from '@/components/common/settings-modal'; import { SettingsHeader } from './SettingsHeader'; import { TransactionPanel, DisplayPanel, FiltersPanel, PreferencesPanel, ExperimentalPanel } from './panels'; -import { TrendingDetail, TrustedVaultsDetail, BlacklistedMarketsDetail, RpcDetail, ThresholdsDetail } from './details'; +import { CustomTagDetail, TrustedVaultsDetail, BlacklistedMarketsDetail, RpcDetail, ThresholdsDetail } from './details'; import type { SettingsCategory, DetailView } from './constants'; type PanelProps = { @@ -20,7 +20,7 @@ const PANEL_COMPONENTS: Record }; const DETAIL_COMPONENTS: Record, React.ComponentType> = { - 'trending-config': TrendingDetail, + 'custom-tag-config': CustomTagDetail, 'trusted-vaults': TrustedVaultsDetail, 'blacklisted-markets': BlacklistedMarketsDetail, 'rpc-config': RpcDetail, diff --git a/src/modals/settings/monarch-settings/constants.ts b/src/modals/settings/monarch-settings/constants.ts index 8645cb36..21fb1c0b 100644 --- a/src/modals/settings/monarch-settings/constants.ts +++ b/src/modals/settings/monarch-settings/constants.ts @@ -5,7 +5,7 @@ import { RiFlaskLine } from 'react-icons/ri'; export type SettingsCategory = 'transaction' | 'display' | 'filters' | 'preferences' | 'experimental'; -export type DetailView = 'trending-config' | 'trusted-vaults' | 'blacklisted-markets' | 'rpc-config' | 'filter-thresholds' | null; +export type DetailView = 'custom-tag-config' | 'trusted-vaults' | 'blacklisted-markets' | 'rpc-config' | 'filter-thresholds' | null; export type CategoryConfig = { id: SettingsCategory; @@ -23,7 +23,7 @@ export const SETTINGS_CATEGORIES: CategoryConfig[] = [ ]; export const DETAIL_TITLES: Record, string> = { - 'trending-config': 'Configure Trending', + 'custom-tag-config': 'Configure Custom Tag', 'trusted-vaults': 'Trusted Vaults', 'blacklisted-markets': 'Blacklisted Markets', 'rpc-config': 'Custom RPC', diff --git a/src/modals/settings/monarch-settings/details/CustomTagDetail.tsx b/src/modals/settings/monarch-settings/details/CustomTagDetail.tsx new file mode 100644 index 00000000..c004814a --- /dev/null +++ b/src/modals/settings/monarch-settings/details/CustomTagDetail.tsx @@ -0,0 +1,242 @@ +'use client'; + +import { useMemo } from 'react'; +import { Input } from '@/components/ui/input'; +import { IconSwitch } from '@/components/ui/icon-switch'; +import { MarketIdentity, MarketIdentityMode } from '@/features/markets/components/market-identity'; +import { useMarketPreferences, type FlowTimeWindow, type CustomTagWindowConfig } from '@/stores/useMarketPreferences'; +import { useMarketMetricsMap, matchesCustomTag, getMetricsKey } from '@/hooks/queries/useMarketMetricsQuery'; +import { useProcessedMarkets } from '@/hooks/useProcessedMarkets'; +import { CustomTagIconPicker, CustomTagIcon } from '@/components/shared/custom-tag-icons'; +import { logEvent } from '@/utils/gtag'; +import type { Market } from '@/utils/types'; + +const TIME_WINDOWS: { value: FlowTimeWindow; label: string }[] = [ + { value: '1h', label: '1 Hour' }, + { value: '24h', label: '24 Hours' }, + { value: '7d', label: '7 Days' }, +]; + +function formatThreshold(value: string): string { + if (!value || value === '') return '-'; + const num = Number(value); + return num >= 0 ? `≥${value}%` : `≤${value}%`; +} + +function generateFilterSummary(config: { enabled: boolean; windows: Record }): string { + if (!config.enabled) return 'Disabled'; + + const parts: string[] = []; + + for (const { value: window, label } of TIME_WINDOWS) { + const windowConfig = config.windows?.[window]; + if (!windowConfig) continue; + + const supply = windowConfig.supplyFlowPct ?? ''; + const borrow = windowConfig.borrowFlowPct ?? ''; + + if (!supply && !borrow) continue; + + const conditions: string[] = []; + if (supply) conditions.push(`Supply ${formatThreshold(supply)}`); + if (borrow) conditions.push(`Borrow ${formatThreshold(borrow)}`); + + parts.push(`${label}: ${conditions.join(', ')}`); + } + + if (parts.length === 0) return 'No thresholds set'; + return parts.join(' | '); +} + +function PercentInput({ value, onChange, placeholder = '0' }: { value: string; onChange: (v: string) => void; placeholder?: string }) { + return ( +
+ { + // Allow negative numbers and decimals + const v = e.target.value; + if (v === '' || v === '-' || /^-?\d*\.?\d*$/.test(v)) { + onChange(v); + } + }} + placeholder={placeholder} + className="font-inter h-8 w-16 px-2 text-center text-sm" + /> + % +
+ ); +} + +export function CustomTagDetail() { + const { customTagConfig, setCustomTagEnabled, setCustomTagIcon, setCustomTagWindowConfig } = useMarketPreferences(); + const { metricsMap } = useMarketMetricsMap(); + const { allMarkets } = useProcessedMarkets(); + const isEnabled = customTagConfig.enabled; + + const matchingMarkets = useMemo(() => { + if (!isEnabled || metricsMap.size === 0) return []; + + const matches: Array<{ market: Market; supplyFlowPct: number; window: string }> = []; + + for (const [key, metrics] of metricsMap) { + if (matchesCustomTag(metrics, customTagConfig)) { + const market = allMarkets.find((m) => getMetricsKey(m.morphoBlue.chain.id, m.uniqueKey) === key); + if (market) { + // Find which window matched for display + const matchedWindow = TIME_WINDOWS.find(({ value }) => { + const cfg = customTagConfig.windows[value]; + return cfg.supplyFlowPct || cfg.borrowFlowPct; + }); + const flowKey = matchedWindow?.value ?? '24h'; + matches.push({ + market, + supplyFlowPct: metrics.flows[flowKey]?.supplyFlowPct ?? 0, + window: matchedWindow?.label ?? '24 Hours', + }); + } + } + } + + return matches.sort((a, b) => (b.market.state?.supplyAssetsUsd ?? 0) - (a.market.state?.supplyAssetsUsd ?? 0)); + }, [isEnabled, metricsMap, customTagConfig, allMarkets]); + + const totalMatches = matchingMarkets.length; + + const handleChange = (window: FlowTimeWindow, field: keyof CustomTagWindowConfig, value: string) => { + setCustomTagWindowConfig(window, { [field]: value }); + }; + + const handleToggleEnabled = (enabled: boolean) => { + setCustomTagEnabled(enabled); + logEvent({ + action: enabled ? 'custom_tag_enabled' : 'custom_tag_disabled', + category: 'settings', + label: 'custom_tag', + value: enabled ? 1 : 0, + }); + }; + + const filterSummary = generateFilterSummary(customTagConfig); + + return ( +
+ {/* Enable Toggle - Primary action at top */} +
+
+
+ +
+
+

Custom Tag

+

{filterSummary}

+
+
+ +
+ + {/* Configuration - Only show when enabled */} + {isEnabled && ( + <> + {/* Icon Picker */} +
+ Icon + +
+ + {/* Threshold table - simplified */} +
+
+ Flow Thresholds +

Use positive values for growth (≥5%), negative for decline (≤-3%)

+
+ + {/* Header */} +
+
Period
+
Supply
+
Borrow
+
+ + {/* Rows */} + {TIME_WINDOWS.map(({ value: window, label }) => { + const config = customTagConfig.windows[window]; + + return ( +
+
{label}
+
+ handleChange(window, 'supplyFlowPct', v)} + /> +
+
+ handleChange(window, 'borrowFlowPct', v)} + /> +
+
+ ); + })} +
+ + {/* Preview */} +
+
+ Preview + + {totalMatches > 0 ? `${totalMatches} match${totalMatches !== 1 ? 'es' : ''}` : 'No matches'} + +
+
+ {matchingMarkets.length > 0 ? ( +
+ {matchingMarkets.slice(0, 3).map((m) => ( +
+ + = 0 ? 'text-green-500' : 'text-red-500'}`}> + {m.supplyFlowPct >= 0 ? '+' : ''} + {m.supplyFlowPct.toFixed(1)}% + +
+ ))} + {totalMatches > 3 && +{totalMatches - 3} more} +
+ ) : ( + No markets match current criteria + )} +
+
+ + )} +
+ ); +} diff --git a/src/modals/settings/monarch-settings/details/TrendingDetail.tsx b/src/modals/settings/monarch-settings/details/TrendingDetail.tsx deleted file mode 100644 index ce8b1019..00000000 --- a/src/modals/settings/monarch-settings/details/TrendingDetail.tsx +++ /dev/null @@ -1,236 +0,0 @@ -'use client'; - -import { useMemo } from 'react'; -import { Input } from '@/components/ui/input'; -import { IconSwitch } from '@/components/ui/icon-switch'; -import { MarketIdentity, MarketIdentityMode } from '@/features/markets/components/market-identity'; -import { useMarketPreferences, type FlowTimeWindow, type TrendingWindowConfig } from '@/stores/useMarketPreferences'; -import { useMarketMetricsMap, isMarketTrending, getMetricsKey } from '@/hooks/queries/useMarketMetricsQuery'; -import { useProcessedMarkets } from '@/hooks/useProcessedMarkets'; -import { formatReadable } from '@/utils/balance'; -import type { Market } from '@/utils/types'; - -const TIME_WINDOWS: { value: FlowTimeWindow; label: string }[] = [ - { value: '1h', label: '1h' }, - { value: '24h', label: '24h' }, - { value: '7d', label: '7d' }, - { value: '30d', label: '30d' }, -]; - -function generateFilterSummary(config: { enabled: boolean; windows: Record }): string { - if (!config.enabled) return 'Trending detection is disabled'; - - const parts: string[] = []; - - for (const { value: window, label } of TIME_WINDOWS) { - const windowConfig = config.windows?.[window]; - if (!windowConfig) continue; - - const windowParts: string[] = []; - - const supplyPct = windowConfig.minSupplyFlowPct ?? ''; - const supplyUsd = windowConfig.minSupplyFlowUsd ?? ''; - const supplyParts: string[] = []; - if (supplyPct) supplyParts.push(`+${supplyPct}%`); - if (supplyUsd) supplyParts.push(`+$${formatReadable(Number(supplyUsd))}`); - if (supplyParts.length > 0) { - windowParts.push(`supply grew ${supplyParts.join(' and ')}`); - } - - const borrowPct = windowConfig.minBorrowFlowPct ?? ''; - const borrowUsd = windowConfig.minBorrowFlowUsd ?? ''; - const borrowParts: string[] = []; - if (borrowPct) borrowParts.push(`+${borrowPct}%`); - if (borrowUsd) borrowParts.push(`+$${formatReadable(Number(borrowUsd))}`); - if (borrowParts.length > 0) { - windowParts.push(`borrow grew ${borrowParts.join(' and ')}`); - } - - if (windowParts.length > 0) { - parts.push(`${windowParts.join(', ')} in ${label}`); - } - } - - if (parts.length === 0) return 'No thresholds configured'; - return `Markets where ${parts.join('; ')}`; -} - -function CompactInput({ - value, - onChange, - disabled, - prefix, - suffix, -}: { - value: string; - onChange: (v: string) => void; - disabled: boolean; - prefix?: string; - suffix?: string; -}) { - return ( -
- {prefix && {prefix}} - { - const stripped = e.target.value.replace(/[^0-9.]/g, ''); - const parts = stripped.split('.'); - const result = parts.length <= 1 ? stripped : `${parts[0]}.${parts.slice(1).join('')}`; - onChange(result); - }} - placeholder="-" - disabled={disabled} - className="font-inter h-6 w-12 px-1 text-center text-xs" - /> - {suffix && {suffix}} -
- ); -} - -export function TrendingDetail() { - const { trendingConfig, setTrendingEnabled, setTrendingWindowConfig } = useMarketPreferences(); - const { metricsMap } = useMarketMetricsMap(); - const { allMarkets } = useProcessedMarkets(); - const isEnabled = trendingConfig.enabled; - - const matchingMarkets = useMemo(() => { - if (!isEnabled || metricsMap.size === 0) return []; - - const matches: Array<{ market: Market; supplyFlowPct1h: number }> = []; - - for (const [key, metrics] of metricsMap) { - if (isMarketTrending(metrics, trendingConfig)) { - const market = allMarkets.find((m) => getMetricsKey(m.morphoBlue.chain.id, m.uniqueKey) === key); - if (market) { - matches.push({ - market, - supplyFlowPct1h: metrics.flows['1h']?.supplyFlowPct ?? 0, - }); - } - } - } - - return matches.sort((a, b) => (b.market.state?.supplyAssetsUsd ?? 0) - (a.market.state?.supplyAssetsUsd ?? 0)); - }, [isEnabled, metricsMap, trendingConfig, allMarkets]); - - const totalMatches = matchingMarkets.length; - - const handleChange = (window: FlowTimeWindow, field: keyof TrendingWindowConfig, value: string) => { - setTrendingWindowConfig(window, { [field]: value }); - }; - - const filterSummary = generateFilterSummary(trendingConfig); - - return ( -
- {/* Toggle + Summary */} -
-
-

{filterSummary}

-
- -
- - {/* Compact threshold table */} -
- {/* Header */} -
-
-
Supply Flow
-
Borrow Flow
-
- - {/* Rows */} - {TIME_WINDOWS.map(({ value: window, label }) => { - const config = trendingConfig.windows[window]; - - return ( -
-
{label}
- - {/* Supply inputs */} -
- handleChange(window, 'minSupplyFlowPct', v)} - disabled={!isEnabled} - suffix="%" - /> - handleChange(window, 'minSupplyFlowUsd', v.replace(/[^0-9]/g, ''))} - disabled={!isEnabled} - prefix="$" - /> -
- - {/* Borrow inputs */} -
- handleChange(window, 'minBorrowFlowPct', v)} - disabled={!isEnabled} - suffix="%" - /> - handleChange(window, 'minBorrowFlowUsd', v.replace(/[^0-9]/g, ''))} - disabled={!isEnabled} - prefix="$" - /> -
-
- ); - })} -
- - {/* Preview */} - {isEnabled && ( -
-
- Preview - - {totalMatches > 0 ? `${totalMatches} market${totalMatches !== 1 ? 's' : ''} match` : 'No matches'} - -
-
- {matchingMarkets.length > 0 ? ( -
- {matchingMarkets.slice(0, 2).map((m) => ( -
- - +{m.supplyFlowPct1h.toFixed(1)}% -
- ))} - {totalMatches > 2 && +{totalMatches - 2} more} -
- ) : ( - No markets match current criteria - )} -
-
- )} -
- ); -} diff --git a/src/modals/settings/monarch-settings/details/index.ts b/src/modals/settings/monarch-settings/details/index.ts index 71d1dcc8..9a8d31f3 100644 --- a/src/modals/settings/monarch-settings/details/index.ts +++ b/src/modals/settings/monarch-settings/details/index.ts @@ -1,4 +1,4 @@ -export { TrendingDetail } from './TrendingDetail'; +export { CustomTagDetail } from './CustomTagDetail'; export { TrustedVaultsDetail } from './TrustedVaultsDetail'; export { BlacklistedMarketsDetail } from './BlacklistedMarketsDetail'; export { RpcDetail } from './RpcDetail'; diff --git a/src/modals/settings/monarch-settings/panels/ExperimentalPanel.tsx b/src/modals/settings/monarch-settings/panels/ExperimentalPanel.tsx index e01cfffc..0990d176 100644 --- a/src/modals/settings/monarch-settings/panels/ExperimentalPanel.tsx +++ b/src/modals/settings/monarch-settings/panels/ExperimentalPanel.tsx @@ -11,29 +11,47 @@ type ExperimentalPanelProps = { }; export function ExperimentalPanel({ onNavigateToDetail }: ExperimentalPanelProps) { - const { trendingConfig, setTrendingEnabled } = useMarketPreferences(); + const { showOfficialTrending, setShowOfficialTrending, customTagConfig, setCustomTagEnabled } = useMarketPreferences(); const { showDeveloperOptions, setShowDeveloperOptions, usePublicAllocator, setUsePublicAllocator } = useAppSettings(); return (
+ {/* Official Trending */}
-

Trending

+

Official Trending

- - onNavigateToDetail?.('trending-config')} +
+ + {/* Custom Tags */} +
+

Custom Tags

+ + {customTagConfig.enabled && ( + <> + + onNavigateToDetail?.('custom-tag-config')} + /> + + )}
+ {/* Liquidity Sourcing */}

Liquidity Sourcing

+ {/* Developer */}

Developer

; + icon: CustomTagIconId; // User-selected icon for their custom tag + windows: Record; }; -const DEFAULT_TRENDING_CONFIG: TrendingConfig = { +const DEFAULT_CUSTOM_TAG_CONFIG: CustomTagConfig = { enabled: false, + icon: 'trend-up', windows: { - '1h': { minSupplyFlowPct: '6', minSupplyFlowUsd: '', minBorrowFlowPct: '', minBorrowFlowUsd: '' }, - '24h': { minSupplyFlowPct: '', minSupplyFlowUsd: '', minBorrowFlowPct: '', minBorrowFlowUsd: '' }, - '7d': { minSupplyFlowPct: '', minSupplyFlowUsd: '', minBorrowFlowPct: '', minBorrowFlowUsd: '' }, - '30d': { minSupplyFlowPct: '', minSupplyFlowUsd: '', minBorrowFlowPct: '', minBorrowFlowUsd: '' }, + '1h': { supplyFlowPct: '', borrowFlowPct: '' }, + '24h': { supplyFlowPct: '3', borrowFlowPct: '2' }, + '7d': { supplyFlowPct: '', borrowFlowPct: '' }, }, }; +// Legacy alias for backwards compatibility +export type TrendingWindowConfig = CustomTagWindowConfig; +export type TrendingConfig = CustomTagConfig; + type MarketPreferencesState = { // Sorting sortColumn: SortColumn; @@ -60,8 +67,11 @@ type MarketPreferencesState = { minBorrowEnabled: boolean; minLiquidityEnabled: boolean; - // Trending Config (Beta) - trendingConfig: TrendingConfig; + // Official Trending (backend-computed) + showOfficialTrending: boolean; // Default ON - show 🔥 for API's isTrending + + // Custom Tags (user-defined) + customTagConfig: CustomTagConfig; }; type MarketPreferencesActions = { @@ -95,9 +105,17 @@ type MarketPreferencesActions = { setMinBorrowEnabled: (enabled: boolean) => void; setMinLiquidityEnabled: (enabled: boolean) => void; - // Trending Config (Beta) + // Official Trending + setShowOfficialTrending: (show: boolean) => void; + + // Custom Tags + setCustomTagEnabled: (enabled: boolean) => void; + setCustomTagIcon: (icon: CustomTagIconId) => void; + setCustomTagWindowConfig: (window: FlowTimeWindow, config: Partial) => void; + + // Legacy aliases setTrendingEnabled: (enabled: boolean) => void; - setTrendingWindowConfig: (window: FlowTimeWindow, config: Partial) => void; + setTrendingWindowConfig: (window: FlowTimeWindow, config: Partial) => void; // Bulk update for migration setAll: (state: Partial) => void; @@ -134,7 +152,8 @@ export const useMarketPreferences = create()( minSupplyEnabled: false, minBorrowEnabled: false, minLiquidityEnabled: false, - trendingConfig: DEFAULT_TRENDING_CONFIG, + showOfficialTrending: true, // Default ON + customTagConfig: DEFAULT_CUSTOM_TAG_CONFIG, setSortColumn: (column) => set({ sortColumn: column }), setSortDirection: (direction) => set({ sortDirection: direction }), @@ -166,24 +185,85 @@ export const useMarketPreferences = create()( setMinSupplyEnabled: (enabled) => set({ minSupplyEnabled: enabled }), setMinBorrowEnabled: (enabled) => set({ minBorrowEnabled: enabled }), setMinLiquidityEnabled: (enabled) => set({ minLiquidityEnabled: enabled }), + + // Official Trending + setShowOfficialTrending: (show) => set({ showOfficialTrending: show }), + + // Custom Tags + setCustomTagEnabled: (enabled) => + set((state) => ({ + customTagConfig: { ...state.customTagConfig, enabled }, + })), + setCustomTagIcon: (icon) => + set((state) => ({ + customTagConfig: { ...state.customTagConfig, icon }, + })), + setCustomTagWindowConfig: (window, config) => + set((state) => ({ + customTagConfig: { + ...state.customTagConfig, + windows: { + ...state.customTagConfig.windows, + [window]: { ...state.customTagConfig.windows[window], ...config }, + }, + }, + })), + + // Legacy aliases (point to custom tag methods) setTrendingEnabled: (enabled) => set((state) => ({ - trendingConfig: { ...state.trendingConfig, enabled }, + customTagConfig: { ...state.customTagConfig, enabled }, })), setTrendingWindowConfig: (window, config) => set((state) => ({ - trendingConfig: { - ...state.trendingConfig, + customTagConfig: { + ...state.customTagConfig, windows: { - ...state.trendingConfig.windows, - [window]: { ...state.trendingConfig.windows[window], ...config }, + ...state.customTagConfig.windows, + [window]: { ...state.customTagConfig.windows[window], ...config }, }, }, })), + setAll: (state) => set(state), }), { name: 'monarch_store_marketPreferences', + version: 2, + migrate: (persistedState: unknown, version: number) => { + try { + const state = persistedState as Record; + + if (version < 2) { + // Migration: ensure customTagConfig is valid, remove 30d window + const existingConfig = state.customTagConfig as Partial | undefined; + + // Validate icon - fall back to default if invalid + const icon = existingConfig?.icon; + const validIcon = + icon && CUSTOM_TAG_ICONS.includes(icon as CustomTagIconId) ? (icon as CustomTagIconId) : DEFAULT_CUSTOM_TAG_CONFIG.icon; + + // Migrate windows - only keep valid ones (1h, 24h, 7d), drop 30d + const oldWindows = existingConfig?.windows as Record | undefined; + const newWindows: Record = { + '1h': oldWindows?.['1h'] ?? DEFAULT_CUSTOM_TAG_CONFIG.windows['1h'], + '24h': oldWindows?.['24h'] ?? DEFAULT_CUSTOM_TAG_CONFIG.windows['24h'], + '7d': oldWindows?.['7d'] ?? DEFAULT_CUSTOM_TAG_CONFIG.windows['7d'], + }; + + state.customTagConfig = { + enabled: Boolean(existingConfig?.enabled), + icon: validIcon, + windows: newWindows, + }; + } + + return state; + } catch { + // If anything goes wrong, return state as-is (zustand will use defaults) + return persistedState as Record; + } + }, }, ), ); diff --git a/src/stores/useMarketsFilters.ts b/src/stores/useMarketsFilters.ts index e0797469..44520c5a 100644 --- a/src/stores/useMarketsFilters.ts +++ b/src/stores/useMarketsFilters.ts @@ -15,7 +15,8 @@ type MarketsFiltersState = { selectedNetwork: SupportedNetworks | null; selectedOracles: PriceFeedVendors[]; searchQuery: string; - trendingMode: boolean; // Filter toggle - thresholds are in useMarketPreferences + trendingMode: boolean; // Official trending filter (backend-computed) + customTagMode: boolean; // User's custom tag filter }; type MarketsFiltersActions = { @@ -25,6 +26,7 @@ type MarketsFiltersActions = { setSelectedOracles: (oracles: PriceFeedVendors[]) => void; setSearchQuery: (query: string) => void; toggleTrendingMode: () => void; + toggleCustomTagMode: () => void; resetFilters: () => void; }; @@ -37,6 +39,7 @@ const DEFAULT_STATE: MarketsFiltersState = { selectedOracles: [], searchQuery: '', trendingMode: false, + customTagMode: false, }; /** @@ -57,6 +60,7 @@ export const useMarketsFilters = create()((set) => ({ setSelectedOracles: (oracles) => set({ selectedOracles: oracles }), setSearchQuery: (query) => set({ searchQuery: query }), toggleTrendingMode: () => set((state) => ({ trendingMode: !state.trendingMode })), + toggleCustomTagMode: () => set((state) => ({ customTagMode: !state.customTagMode })), resetFilters: () => set(DEFAULT_STATE), })); diff --git a/src/stores/useModalStore.ts b/src/stores/useModalStore.ts index 55ae3d27..da3e1e98 100644 --- a/src/stores/useModalStore.ts +++ b/src/stores/useModalStore.ts @@ -53,7 +53,7 @@ export type ModalProps = { monarchSettings: { initialCategory?: 'transaction' | 'display' | 'filters' | 'preferences' | 'experimental'; - initialDetailView?: 'trending-config' | 'trusted-vaults' | 'blacklisted-markets' | 'rpc-config' | 'filter-thresholds'; + initialDetailView?: 'custom-tag-config' | 'trusted-vaults' | 'blacklisted-markets' | 'rpc-config' | 'filter-thresholds'; onCloseCallback?: () => void; };