diff --git a/src/features/market-detail/components/borrower-table-column-visibility.ts b/src/features/market-detail/components/borrower-table-column-visibility.ts index 90e575bb..d6345502 100644 --- a/src/features/market-detail/components/borrower-table-column-visibility.ts +++ b/src/features/market-detail/components/borrower-table-column-visibility.ts @@ -1,19 +1,23 @@ export type BorrowerTableColumnVisibility = { + healthScore: boolean; daysToLiquidation: boolean; liquidationPrice: boolean; }; export const BORROWER_TABLE_COLUMN_LABELS: Record = { + healthScore: 'Health Score', daysToLiquidation: 'Days to Liquidation', liquidationPrice: 'Liquidation Price', }; export const BORROWER_TABLE_COLUMN_DESCRIPTIONS: Record = { + healthScore: 'Distance to liquidation threshold. 1.00 is the liquidation boundary.', daysToLiquidation: 'Estimated days until position reaches liquidation threshold.', liquidationPrice: 'Price where position becomes liquidatable, plus move from current oracle price.', }; export const DEFAULT_BORROWER_TABLE_COLUMN_VISIBILITY: BorrowerTableColumnVisibility = { + healthScore: true, daysToLiquidation: true, liquidationPrice: true, }; diff --git a/src/features/market-detail/components/borrower-table-settings-modal.tsx b/src/features/market-detail/components/borrower-table-settings-modal.tsx index d16c4778..39f3fdd1 100644 --- a/src/features/market-detail/components/borrower-table-settings-modal.tsx +++ b/src/features/market-detail/components/borrower-table-settings-modal.tsx @@ -1,4 +1,3 @@ -import type { ReactNode } from 'react'; import { FiSliders } from 'react-icons/fi'; import { IconSwitch } from '@/components/ui/icon-switch'; import { Modal, ModalBody, ModalHeader } from '@/components/common/Modal'; @@ -18,24 +17,6 @@ type BorrowerTableSettingsModalProps = { ) => void; }; -type SettingItemProps = { - title: string; - description: string; - children: ReactNode; -}; - -function SettingItem({ title, description, children }: SettingItemProps) { - return ( -
-
-

{title}

-

{description}

-
-
{children}
-
- ); -} - export function BorrowerTableSettingsModal({ isOpen, onOpenChange, @@ -65,11 +46,17 @@ export function BorrowerTableSettingsModal({

Visible Columns

{columnKeys.map((key) => ( - + - +
))} diff --git a/src/features/market-detail/components/borrowers-table.tsx b/src/features/market-detail/components/borrowers-table.tsx index ffb98e9d..c4e078b5 100644 --- a/src/features/market-detail/components/borrowers-table.tsx +++ b/src/features/market-detail/components/borrowers-table.tsx @@ -19,9 +19,11 @@ import { formatSimple } from '@/utils/balance'; import type { Market } from '@/utils/types'; import { LiquidateModal } from '@/modals/liquidate/liquidate-modal'; import { + computeHealthScoreFromLtv, computeLiquidationOraclePrice, computeLtv, computeOraclePriceChangePercent, + formatHealthScore, formatMarketOraclePriceWithSymbol, formatRelativeLiquidationPriceMove, isInfiniteLtv, @@ -39,6 +41,7 @@ type BorrowersTableProps = { type BorrowerRowMetric = { ltvPercent: number | null; + healthScore: string; daysToLiquidation: number | null; liquidationPrice: string; liquidationPriceMove: string; @@ -64,6 +67,7 @@ export function BorrowersTable({ chainId, market, minShares, oraclePrice, onOpen const hasActiveFilter = minShares !== '0'; const tableKey = `borrowers-table-${currentPage}`; + const showHealthScore = borrowerTableColumnVisibility.healthScore ?? DEFAULT_BORROWER_TABLE_COLUMN_VISIBILITY.healthScore; const showDaysToLiquidation = borrowerTableColumnVisibility.daysToLiquidation ?? DEFAULT_BORROWER_TABLE_COLUMN_VISIBILITY.daysToLiquidation; const showLiquidationPrice = borrowerTableColumnVisibility.liquidationPrice ?? DEFAULT_BORROWER_TABLE_COLUMN_VISIBILITY.liquidationPrice; @@ -79,6 +83,12 @@ export function BorrowersTable({ chainId, market, minShares, oraclePrice, onOpen const collateralAssets = BigInt(borrower.collateral); const ltvWad = computeLtv({ borrowAssets, collateralAssets, oraclePrice }); const ltvPercent = isInfiniteLtv(ltvWad) ? null : Number(ltvWad) / 1e16; + const healthScore = formatHealthScore( + computeHealthScoreFromLtv({ + ltv: ltvWad, + lltv, + }), + ); let daysToLiquidation: number | null = null; if (!isInfiniteLtv(ltvWad) && ltvWad > 0n && borrowApy > 0 && lltv > ltvWad) { @@ -117,6 +127,7 @@ export function BorrowersTable({ chainId, market, minShares, oraclePrice, onOpen const metrics: BorrowerRowMetric = { ltvPercent, + healthScore, daysToLiquidation, liquidationPrice, liquidationPriceMove, @@ -129,7 +140,8 @@ export function BorrowersTable({ chainId, market, minShares, oraclePrice, onOpen }); }, [borrowers, oraclePrice, market]); - const emptyStateColSpan = 5 + (showDaysToLiquidation ? 1 : 0) + (showLiquidationPrice ? 1 : 0) + (showDeveloperOptions ? 1 : 0); + const emptyStateColSpan = + 5 + (showHealthScore ? 1 : 0) + (showDaysToLiquidation ? 1 : 0) + (showLiquidationPrice ? 1 : 0) + (showDeveloperOptions ? 1 : 0); return (
@@ -198,6 +210,20 @@ export function BorrowersTable({ chainId, market, minShares, oraclePrice, onOpen BORROWED COLLATERAL LTV + {showHealthScore && ( + + + } + > + HEALTH SCORE + + + )} {showDaysToLiquidation && ( {ltvDisplay} + {showHealthScore && {borrower.healthScore}} {showDaysToLiquidation && {daysDisplay}} {showLiquidationPrice && ( diff --git a/src/features/positions/components/borrowed-morpho-blue-row-detail.tsx b/src/features/positions/components/borrowed-morpho-blue-row-detail.tsx index 315110ab..30840a84 100644 --- a/src/features/positions/components/borrowed-morpho-blue-row-detail.tsx +++ b/src/features/positions/components/borrowed-morpho-blue-row-detail.tsx @@ -1,6 +1,6 @@ 'use client'; -import { type ReactNode } from 'react'; +import type { ReactNode } from 'react'; import { motion } from 'framer-motion'; import { RateFormatted } from '@/components/shared/rate-formatted'; import { TooltipContent } from '@/components/shared/tooltip-content'; @@ -121,8 +121,7 @@ function MetricRow({ } export function BorrowedMorphoBlueRowDetail({ row }: BorrowedMorphoBlueRowDetailProps) { - const { currentLtvLabel, displayLtv, liquidationOraclePrice, lltv, lltvLabel, ltvWidth, oraclePrice } = - deriveBorrowPositionMetrics(row); + const { currentLtvLabel, displayLtv, liquidationOraclePrice, lltv, lltvLabel, ltvWidth, oraclePrice } = deriveBorrowPositionMetrics(row); const currentPrice = formatBorrowPositionPrice(row, oraclePrice); const liquidationPrice = liquidationOraclePrice == null ? '—' : formatBorrowPositionPrice(row, liquidationOraclePrice); const priceMove = @@ -138,26 +137,26 @@ export function BorrowedMorphoBlueRowDetail({ row }: BorrowedMorphoBlueRowDetail : `font-zen text-sm tabular-nums text-right ${getLTVColor(displayLtv, lltv)}`; const ltvBarClassName = displayLtv == null ? 'bg-gray-500/50' : getLTVProgressColor(displayLtv, lltv); const liquidationTooltip = - liquidationPrice === '—' - ? null - : ( - -
- Current Price - {currentPrice} -
-
- Liquidation Price - {liquidationPrice} -
-
- } - secondaryDetail={priceMove == null ? undefined : `Relative to current: ${formatRelativeLiquidationPriceMove({ percentChange: priceMove })}`} - /> - ); + liquidationPrice === '—' ? null : ( + +
+ Current Price + {currentPrice} +
+
+ Liquidation Price + {liquidationPrice} +
+ + } + secondaryDetail={ + priceMove == null ? undefined : `Relative to current: ${formatRelativeLiquidationPriceMove({ percentChange: priceMove })}` + } + /> + ); return ( - {currentLtvLabel} - / {lltvLabel} - - ) + currentLtvLabel == null ? ( + '—' + ) : ( + + {currentLtvLabel} + / {lltvLabel} + + ) } valueClassName={ltvValueClassName} /> diff --git a/src/features/positions/components/borrowed-morpho-blue-table.tsx b/src/features/positions/components/borrowed-morpho-blue-table.tsx index fa194ed9..568ec734 100644 --- a/src/features/positions/components/borrowed-morpho-blue-table.tsx +++ b/src/features/positions/components/borrowed-morpho-blue-table.tsx @@ -1,6 +1,7 @@ 'use client'; import { Fragment, useMemo, useState } from 'react'; +import { GearIcon } from '@radix-ui/react-icons'; import { AnimatePresence } from 'framer-motion'; import { useConnection } from 'wagmi'; import { Button } from '@/components/ui/button'; @@ -15,11 +16,15 @@ import { Tooltip } from '@/components/ui/tooltip'; import { MarketIdentity, MarketIdentityFocus, MarketIdentityMode } from '@/features/markets/components/market-identity'; import { useModal } from '@/hooks/useModal'; import { useRateLabel } from '@/hooks/useRateLabel'; +import { usePositionsPreferences } from '@/stores/usePositionsPreferences'; import { formatReadable } from '@/utils/balance'; import { buildBorrowPositionRows } from '@/utils/positions'; +import { computeHealthScoreFromLtv, formatHealthScore } from '@/modals/borrow/components/helpers'; import type { MarketPositionWithEarnings } from '@/utils/types'; import { BorrowPositionActionsDropdown } from './borrow-position-actions-dropdown'; import { BorrowedMorphoBlueRowDetail, deriveBorrowPositionMetrics } from './borrowed-morpho-blue-row-detail'; +import { BorrowedTableSettingsModal } from './borrowed-table-settings-modal'; +import { DEFAULT_BORROWED_TABLE_COLUMN_VISIBILITY } from './borrowed-table-column-visibility'; type BorrowedMorphoBlueTableProps = { account: string; @@ -33,9 +38,12 @@ export function BorrowedMorphoBlueTable({ account, positions, onRefetch, isRefet const { open } = useModal(); const { short: rateLabel } = useRateLabel(); const [expandedRows, setExpandedRows] = useState>(new Set()); + const [isSettingsModalOpen, setIsSettingsModalOpen] = useState(false); const borrowRows = useMemo(() => buildBorrowPositionRows(positions), [positions]); const isOwner = useMemo(() => !!account && !!address && account.toLowerCase() === address.toLowerCase(), [account, address]); + const { borrowedTableColumnVisibility, setBorrowedTableColumnVisibility } = usePositionsPreferences(); + const showHealthScore = borrowedTableColumnVisibility.healthScore ?? DEFAULT_BORROWED_TABLE_COLUMN_VISIBILITY.healthScore; const toggleRow = (rowKey: string) => { setExpandedRows((prev) => { @@ -50,26 +58,47 @@ export function BorrowedMorphoBlueTable({ account, positions, onRefetch, isRefet }; const headerActions = ( - - } - > - + <> + + } + > + + + + + + + } + > - - + + ); if (borrowRows.length === 0) { @@ -91,6 +120,7 @@ export function BorrowedMorphoBlueTable({ account, positions, onRefetch, isRefet {rateLabel} (now) Collateral LTV + {showHealthScore && Health Score} Actions @@ -209,6 +239,20 @@ export function BorrowedMorphoBlueTable({ account, positions, onRefetch, isRefet + {showHealthScore && ( + +
+ + {formatHealthScore( + metrics.displayLtv == null + ? null + : computeHealthScoreFromLtv({ ltv: metrics.displayLtv, lltv: metrics.lltv }), + )} + +
+
+ )} + @@ -274,6 +318,13 @@ export function BorrowedMorphoBlueTable({ account, positions, onRefetch, isRefet + + ); } diff --git a/src/features/positions/components/borrowed-table-column-visibility.ts b/src/features/positions/components/borrowed-table-column-visibility.ts new file mode 100644 index 00000000..1ba42c82 --- /dev/null +++ b/src/features/positions/components/borrowed-table-column-visibility.ts @@ -0,0 +1,15 @@ +export type BorrowedTableColumnVisibility = { + healthScore: boolean; +}; + +export const BORROWED_TABLE_COLUMN_LABELS: Record = { + healthScore: 'Health Score', +}; + +export const BORROWED_TABLE_COLUMN_DESCRIPTIONS: Record = { + healthScore: 'Distance to liquidation threshold. 1.00 is the liquidation boundary.', +}; + +export const DEFAULT_BORROWED_TABLE_COLUMN_VISIBILITY: BorrowedTableColumnVisibility = { + healthScore: true, +}; diff --git a/src/features/positions/components/borrowed-table-settings-modal.tsx b/src/features/positions/components/borrowed-table-settings-modal.tsx new file mode 100644 index 00000000..9d8fb9c3 --- /dev/null +++ b/src/features/positions/components/borrowed-table-settings-modal.tsx @@ -0,0 +1,77 @@ +import { FiSliders } from 'react-icons/fi'; +import { IconSwitch } from '@/components/ui/icon-switch'; +import { Modal, ModalBody, ModalHeader } from '@/components/common/Modal'; +import { + BORROWED_TABLE_COLUMN_DESCRIPTIONS, + BORROWED_TABLE_COLUMN_LABELS, + DEFAULT_BORROWED_TABLE_COLUMN_VISIBILITY, + type BorrowedTableColumnVisibility, +} from './borrowed-table-column-visibility'; + +type BorrowedTableSettingsModalProps = { + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; + columnVisibility: BorrowedTableColumnVisibility; + onColumnVisibilityChange: ( + visibilityOrUpdater: BorrowedTableColumnVisibility | ((prev: BorrowedTableColumnVisibility) => BorrowedTableColumnVisibility), + ) => void; +}; + +export function BorrowedTableSettingsModal({ + isOpen, + onOpenChange, + columnVisibility, + onColumnVisibilityChange, +}: BorrowedTableSettingsModalProps) { + const columnKeys = Object.keys(BORROWED_TABLE_COLUMN_LABELS) as (keyof BorrowedTableColumnVisibility)[]; + + return ( + + {(onClose) => ( + <> + } + onClose={onClose} + /> + +
+

Visible Columns

+
+ {columnKeys.map((key) => ( +
+ + onColumnVisibilityChange((prev) => ({ ...prev, [key]: value }))} + size="xs" + color="primary" + aria-label={`Toggle ${BORROWED_TABLE_COLUMN_LABELS[key]} column`} + /> +
+ ))} +
+
+
+ + )} +
+ ); +} diff --git a/src/modals/borrow/components/add-collateral-and-borrow.tsx b/src/modals/borrow/components/add-collateral-and-borrow.tsx index eb8fafdf..a7fd7fc7 100644 --- a/src/modals/borrow/components/add-collateral-and-borrow.tsx +++ b/src/modals/borrow/components/add-collateral-and-borrow.tsx @@ -15,6 +15,7 @@ import type { LiquiditySourcingResult } from '@/hooks/useMarketLiquiditySourcing import { ExecuteTransactionButton } from '@/components/ui/ExecuteTransactionButton'; import { TokenIcon } from '@/components/shared/token-icon'; import { BorrowPositionRiskCard } from './borrow-position-risk-card'; +import { PreviewSectionHeader } from './preview-section-header'; import { LTV_WAD, clampEditablePercent, @@ -202,7 +203,11 @@ export function AddCollateralAndBorrow({
{!transaction?.isModalVisible && (
-

My Position

+ diff --git a/src/modals/borrow/components/borrow-position-risk-card.tsx b/src/modals/borrow/components/borrow-position-risk-card.tsx index 50bb253f..782a8486 100644 --- a/src/modals/borrow/components/borrow-position-risk-card.tsx +++ b/src/modals/borrow/components/borrow-position-risk-card.tsx @@ -1,5 +1,4 @@ -import { type ReactNode, useMemo } from 'react'; -import { RefetchIcon } from '@/components/ui/refetch-icon'; +import { useMemo, type ReactNode } from 'react'; import { Tooltip } from '@/components/ui/tooltip'; import { TooltipContent } from '@/components/shared/tooltip-content'; import { TokenIcon } from '@/components/shared/token-icon'; @@ -7,8 +6,10 @@ import { formatBalance } from '@/utils/balance'; import { formatCompactTokenAmount, formatFullTokenAmount } from '@/utils/token-amount-format'; import type { Market } from '@/utils/types'; import { - computeOraclePriceChangePercent, + computeHealthScoreFromLtv, computeLiquidationOraclePrice, + computeOraclePriceChangePercent, + formatHealthScore, formatLtvPercent, formatMarketOraclePrice, getLTVColor, @@ -26,23 +27,55 @@ type BorrowPositionRiskCardProps = { currentLtv: bigint; projectedLtv: bigint; lltv: bigint; - onRefresh?: () => void; - isRefreshing?: boolean; hasChanges?: boolean; useCompactAmountDisplay?: boolean; }; -function renderAmountValue(value: bigint, decimals: number, useCompactAmountDisplay: boolean): ReactNode { - if (!useCompactAmountDisplay) { - return formatBalance(value, decimals); - } +type PreviewIndicatorProps = { + isPreview: boolean; + title: ReactNode; + detail: ReactNode; + secondaryDetail?: ReactNode; + children: ReactNode; +}; + +function formatSignedNumberDelta(value: number, suffix = ''): string { + if (!Number.isFinite(value)) return '0'; + if (value > 0) return `+${value.toFixed(2)}${suffix}`; + if (value < 0) return `-${Math.abs(value).toFixed(2)}${suffix}`; + return `0.00${suffix}`; +} + +function formatSignedTokenDelta(value: bigint, decimals: number): string { + if (value > 0n) return `+${formatBalance(value, decimals)}`; + if (value < 0n) return `-${formatBalance(-value, decimals)}`; + return '0'; +} - const compactValue = formatCompactTokenAmount(value, decimals); - const fullValue = formatFullTokenAmount(value, decimals); +function formatTokenAmountForDisplay(value: bigint, decimals: number, useCompactAmountDisplay: boolean): string { + return useCompactAmountDisplay ? formatCompactTokenAmount(value, decimals) : String(formatBalance(value, decimals)); +} + +function formatTokenAmountForTooltip(value: bigint, decimals: number, useCompactAmountDisplay: boolean): string { + return useCompactAmountDisplay ? formatFullTokenAmount(value, decimals) : String(formatBalance(value, decimals)); +} + +function PreviewIndicator({ isPreview, title, detail, secondaryDetail, children }: PreviewIndicatorProps): JSX.Element { + if (!isPreview) { + return <>{children}; + } return ( - {fullValue}}> - {compactValue} + + } + > + {children} ); } @@ -70,8 +103,6 @@ export function BorrowPositionRiskCard({ currentLtv, projectedLtv, lltv, - onRefresh, - isRefreshing = false, hasChanges = false, useCompactAmountDisplay = false, }: BorrowPositionRiskCardProps): JSX.Element { @@ -83,11 +114,12 @@ export function BorrowPositionRiskCard({ const projectedCollateralValue = projectedCollateral ?? currentCollateral; const projectedBorrowValue = projectedBorrow ?? currentBorrow; const metricLabelClassName = 'mb-1 font-zen text-xs opacity-50'; - const metricValueClassName = 'font-zen text-sm'; + const metricValueClassName = 'font-zen text-sm tabular-nums whitespace-nowrap'; const showProjectedCollateral = hasChanges && projectedCollateralValue !== currentCollateral; const showProjectedBorrow = hasChanges && projectedBorrowValue !== currentBorrow; const showProjectedLtv = hasChanges && currentLtv !== projectedLtv; + const currentPriceDisplay = useMemo( () => formatMarketOraclePrice({ @@ -97,6 +129,7 @@ export function BorrowPositionRiskCard({ }), [oraclePrice, market.collateralAsset.decimals, market.loanAsset.decimals], ); + const currentLiquidationOraclePrice = useMemo( () => currentLtv <= 0n || isInfiniteLtv(currentLtv) @@ -108,6 +141,7 @@ export function BorrowPositionRiskCard({ }), [currentLtv, lltv, oraclePrice], ); + const projectedLiquidationOraclePrice = useMemo( () => projectedLtv <= 0n || isInfiniteLtv(projectedLtv) @@ -146,9 +180,9 @@ export function BorrowPositionRiskCard({ const showProjectedLiquidationPrice = hasChanges && currentLiquidationPriceDisplay !== projectedLiquidationPriceDisplay; - const formatPriceLabel = (value: string): string => - value === '-' || value === '∞' ? value : `${value} ${market.loanAsset.symbol}`; + const formatPriceLabel = (value: string): string => (value === '-' || value === '∞' ? value : `${value} ${market.loanAsset.symbol}`); const formatPercentLabel = (value: number): string => `${value.toFixed(2).replace(/\.?0+$/u, '')}%`; + const currentLiquidationPriceChangePercent = useMemo( () => currentLiquidationOraclePrice == null @@ -159,6 +193,7 @@ export function BorrowPositionRiskCard({ }), [currentLiquidationOraclePrice, oraclePrice], ); + const projectedLiquidationPriceChangePercent = useMemo( () => projectedLiquidationOraclePrice == null @@ -169,12 +204,14 @@ export function BorrowPositionRiskCard({ }), [projectedLiquidationOraclePrice, oraclePrice], ); + const formatPriceGapFromCurrent = (percentChange: number | null): string | null => { if (percentChange == null || !Number.isFinite(percentChange)) return null; if (percentChange > 0) return `-${formatPercentLabel(percentChange)}`; if (percentChange < 0) return `+${formatPercentLabel(Math.abs(percentChange))}`; return '0%'; }; + const currentLiquidationPriceValue = renderLiquidationPriceValue( formatPriceLabel(currentLiquidationPriceDisplay), formatPriceGapFromCurrent(currentLiquidationPriceChangePercent), @@ -183,37 +220,36 @@ export function BorrowPositionRiskCard({ formatPriceLabel(projectedLiquidationPriceDisplay), formatPriceGapFromCurrent(projectedLiquidationPriceChangePercent), ); - const liquidationPriceTooltipContent = - projectedLiquidationPriceDisplay === '-' - ? null - : ( - -
- Current Price - {formatPriceLabel(currentPriceDisplay)} -
-
- Liquidation Price - {formatPriceLabel(projectedLiquidationPriceDisplay)} -
-
- } - secondaryDetail={ - projectedLiquidationPriceChangePercent == null - ? undefined - : `Relative to current: ${formatPriceGapFromCurrent(projectedLiquidationPriceChangePercent)}` - } - /> - ); + + const currentHealthScore = computeHealthScoreFromLtv({ ltv: currentLtv, lltv }); + const projectedHealthScore = computeHealthScoreFromLtv({ ltv: projectedLtv, lltv }); + const currentHealthScoreLabel = formatHealthScore(currentHealthScore, 2); + const projectedHealthScoreLabel = formatHealthScore(projectedHealthScore, 2); + const showProjectedHealthScore = hasChanges && currentHealthScoreLabel !== projectedHealthScoreLabel; + + const collateralDisplay = formatTokenAmountForDisplay(projectedCollateralValue, market.collateralAsset.decimals, useCompactAmountDisplay); + const collateralCurrentDetail = formatTokenAmountForTooltip(currentCollateral, market.collateralAsset.decimals, useCompactAmountDisplay); + const collateralProjectedDetail = formatTokenAmountForTooltip( + projectedCollateralValue, + market.collateralAsset.decimals, + useCompactAmountDisplay, + ); + const collateralDeltaLabel = formatSignedTokenDelta(projectedCollateralValue - currentCollateral, market.collateralAsset.decimals); + + const borrowDisplay = formatTokenAmountForDisplay(projectedBorrowValue, market.loanAsset.decimals, useCompactAmountDisplay); + const borrowCurrentDetail = formatTokenAmountForTooltip(currentBorrow, market.loanAsset.decimals, useCompactAmountDisplay); + const borrowProjectedDetail = formatTokenAmountForTooltip(projectedBorrowValue, market.loanAsset.decimals, useCompactAmountDisplay); + const borrowDeltaLabel = formatSignedTokenDelta(projectedBorrowValue - currentBorrow, market.loanAsset.decimals); + + const projectedLtvLabel = `${formatLtvPercent(projectedLtv)}%`; + const currentLtvLabel = `${formatLtvPercent(currentLtv)}%`; + const ltvDelta = (Number(projectedLtv) - Number(currentLtv)) / 1e16; return (
-
+
-

Total Collateral

+

Collateral

-

- {showProjectedCollateral ? ( - <> - - {renderAmountValue(currentCollateral, market.collateralAsset.decimals, useCompactAmountDisplay)} - - - {renderAmountValue(projectedCollateralValue, market.collateralAsset.decimals, useCompactAmountDisplay)} - - - ) : ( - renderAmountValue(projectedCollateralValue, market.collateralAsset.decimals, useCompactAmountDisplay) - )}{' '} - {market.collateralAsset.symbol} -

+
+ + {collateralDisplay} + + {market.collateralAsset.symbol} +
+
-

Debt

+

Debt (Loan)

-

- {showProjectedBorrow ? ( - <> - - {renderAmountValue(currentBorrow, market.loanAsset.decimals, useCompactAmountDisplay)} - - - {renderAmountValue(projectedBorrowValue, market.loanAsset.decimals, useCompactAmountDisplay)} - - - ) : ( - renderAmountValue(projectedBorrowValue, market.loanAsset.decimals, useCompactAmountDisplay) - )}{' '} - {market.loanAsset.symbol} -

+
+ + {borrowDisplay} + + {market.loanAsset.symbol} +
- {onRefresh && ( - - )} + {projectedHealthScoreLabel} + +

Loan to Value (LTV)

-

- {showProjectedLtv && {formatLtvPercent(currentLtv)}%} - - {formatLtvPercent(projectedLtv)}% - +

+ + {projectedLtvLabel} + / {formatLtvPercent(lltv)}% -

+

Liquidation Price

- {liquidationPriceTooltipContent ? ( - -

- {showProjectedLiquidationPrice && currentLiquidationPriceDisplay !== '-' && ( - {currentLiquidationPriceValue} - )} - - {projectedLiquidationPriceValue} - -

-
- ) : ( -

{projectedLiquidationPriceValue}

- )} + +
+ Current Price + {formatPriceLabel(currentPriceDisplay)} +
+ {showProjectedLiquidationPrice && currentLiquidationPriceDisplay !== '-' && ( +
+ Previous Liquidation Price + {currentLiquidationPriceValue} +
+ )} +
+ Preview Liquidation Price + {projectedLiquidationPriceValue} +
+
+ } + secondaryDetail={ + projectedLiquidationPriceChangePercent == null + ? undefined + : `Relative to current oracle: ${formatPriceGapFromCurrent(projectedLiquidationPriceChangePercent)}` + } + /> + } + > + + {projectedLiquidationPriceValue} + +
diff --git a/src/modals/borrow/components/helpers.ts b/src/modals/borrow/components/helpers.ts index bd7b087d..42aa5b40 100644 --- a/src/modals/borrow/components/helpers.ts +++ b/src/modals/borrow/components/helpers.ts @@ -132,6 +132,17 @@ export const isInfiniteLtv = (ltv: bigint): boolean => ltv >= INFINITE_LTV; export const formatLtvPercent = (ltv: bigint, fractionDigits = 2): string => isInfiniteLtv(ltv) ? '∞' : ltvWadToPercent(ltv).toFixed(fractionDigits); +export const computeHealthScoreFromLtv = ({ ltv, lltv }: { ltv: bigint; lltv: bigint }): number | null => { + if (ltv <= 0n || lltv <= 0n || isInfiniteLtv(ltv)) return null; + const healthScore = Number(lltv) / Number(ltv); + return Number.isFinite(healthScore) ? healthScore : null; +}; + +export const formatHealthScore = (healthScore: number | null, fractionDigits = 2): string => { + if (healthScore == null || !Number.isFinite(healthScore)) return '—'; + return healthScore.toFixed(fractionDigits); +}; + export const computeLiquidationOraclePrice = ({ oraclePrice, ltv, diff --git a/src/modals/borrow/components/preview-section-header.tsx b/src/modals/borrow/components/preview-section-header.tsx new file mode 100644 index 00000000..003b28d3 --- /dev/null +++ b/src/modals/borrow/components/preview-section-header.tsx @@ -0,0 +1,40 @@ +import { Button } from '@/components/ui/button'; +import { RefetchIcon } from '@/components/ui/refetch-icon'; +import { TooltipContent } from '@/components/shared/tooltip-content'; +import { Tooltip } from '@/components/ui/tooltip'; + +type PreviewSectionHeaderProps = { + title: string; + onRefresh?: () => void; + isRefreshing?: boolean; +}; + +export function PreviewSectionHeader({ title, onRefresh, isRefreshing = false }: PreviewSectionHeaderProps) { + return ( +
+

{title}

+ {onRefresh && ( + + } + > + + + )} +
+ ); +} diff --git a/src/modals/borrow/components/withdraw-collateral-and-repay.tsx b/src/modals/borrow/components/withdraw-collateral-and-repay.tsx index 15d96188..75cb0e9d 100644 --- a/src/modals/borrow/components/withdraw-collateral-and-repay.tsx +++ b/src/modals/borrow/components/withdraw-collateral-and-repay.tsx @@ -10,6 +10,7 @@ import { MarketDetailsBlock } from '@/features/markets/components/market-details import { ExecuteTransactionButton } from '@/components/ui/ExecuteTransactionButton'; import { TokenIcon } from '@/components/shared/token-icon'; import { BorrowPositionRiskCard } from './borrow-position-risk-card'; +import { PreviewSectionHeader } from './preview-section-header'; import { clampEditablePercent, clampTargetLtv, @@ -207,7 +208,11 @@ export function WithdrawCollateralAndRepay({ return (
-

My Position

+ diff --git a/src/modals/leverage/components/add-collateral-and-leverage.tsx b/src/modals/leverage/components/add-collateral-and-leverage.tsx index 4e80d8f7..48f764be 100644 --- a/src/modals/leverage/components/add-collateral-and-leverage.tsx +++ b/src/modals/leverage/components/add-collateral-and-leverage.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { erc20Abi, formatUnits } from 'viem'; import { useConnection, useReadContract } from 'wagmi'; import { BorrowPositionRiskCard } from '@/modals/borrow/components/borrow-position-risk-card'; +import { PreviewSectionHeader } from '@/modals/borrow/components/preview-section-header'; import { LTV_WAD, computeLtv } from '@/modals/borrow/components/helpers'; import { HelpTooltipIcon } from '@/components/shared/help-tooltip-icon'; import { RateFormatted } from '@/components/shared/rate-formatted'; @@ -595,7 +596,11 @@ export function AddCollateralAndLeverage({
{!transaction?.isModalVisible && (
-

Leverage Preview

+ diff --git a/src/modals/leverage/components/remove-collateral-and-deleverage.tsx b/src/modals/leverage/components/remove-collateral-and-deleverage.tsx index 6df74154..356be6a2 100644 --- a/src/modals/leverage/components/remove-collateral-and-deleverage.tsx +++ b/src/modals/leverage/components/remove-collateral-and-deleverage.tsx @@ -15,6 +15,7 @@ import type { Market, MarketPosition } from '@/utils/types'; import type { LeverageRoute } from '@/hooks/leverage/types'; import { computeLtv, formatLtvPercent, getLTVColor } from '@/modals/borrow/components/helpers'; import { BorrowPositionRiskCard } from '@/modals/borrow/components/borrow-position-risk-card'; +import { PreviewSectionHeader } from '@/modals/borrow/components/preview-section-header'; type RemoveCollateralAndDeleverageProps = { market: Market; @@ -239,7 +240,11 @@ export function RemoveCollateralAndDeleverage({
{!transaction?.isModalVisible && (
-

Deleverage Preview

+ diff --git a/src/stores/usePositionsPreferences.ts b/src/stores/usePositionsPreferences.ts index 834619f2..5fde7e2d 100644 --- a/src/stores/usePositionsPreferences.ts +++ b/src/stores/usePositionsPreferences.ts @@ -1,14 +1,22 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; +import { + DEFAULT_BORROWED_TABLE_COLUMN_VISIBILITY, + type BorrowedTableColumnVisibility, +} from '@/features/positions/components/borrowed-table-column-visibility'; type PositionsPreferencesState = { showCollateralExposure: boolean; showEarningsInUsd: boolean; + borrowedTableColumnVisibility: BorrowedTableColumnVisibility; }; type PositionsPreferencesActions = { setShowCollateralExposure: (show: boolean) => void; setShowEarningsInUsd: (show: boolean) => void; + setBorrowedTableColumnVisibility: ( + visibilityOrUpdater: BorrowedTableColumnVisibility | ((prev: BorrowedTableColumnVisibility) => BorrowedTableColumnVisibility), + ) => void; // Bulk update for migration setAll: (state: Partial) => void; @@ -30,14 +38,54 @@ export const usePositionsPreferences = create()( // Default state showCollateralExposure: true, showEarningsInUsd: false, + borrowedTableColumnVisibility: DEFAULT_BORROWED_TABLE_COLUMN_VISIBILITY, // Actions setShowCollateralExposure: (show) => set({ showCollateralExposure: show }), setShowEarningsInUsd: (show) => set({ showEarningsInUsd: show }), + setBorrowedTableColumnVisibility: (visibilityOrUpdater) => + set((state) => ({ + borrowedTableColumnVisibility: + typeof visibilityOrUpdater === 'function' ? visibilityOrUpdater(state.borrowedTableColumnVisibility) : visibilityOrUpdater, + })), setAll: (state) => set(state), }), { name: 'monarch_store_positionsPreferences', + version: 2, + migrate: (state, version) => { + if (!state || typeof state !== 'object') { + return { + showCollateralExposure: true, + showEarningsInUsd: false, + borrowedTableColumnVisibility: DEFAULT_BORROWED_TABLE_COLUMN_VISIBILITY, + } as PositionsPreferencesState; + } + + const persisted = state as Partial; + + if (version < 2) { + return { + ...persisted, + showCollateralExposure: persisted.showCollateralExposure ?? true, + showEarningsInUsd: persisted.showEarningsInUsd ?? false, + borrowedTableColumnVisibility: { + ...DEFAULT_BORROWED_TABLE_COLUMN_VISIBILITY, + ...(persisted.borrowedTableColumnVisibility ?? {}), + }, + } as PositionsPreferencesState; + } + + return { + ...persisted, + showCollateralExposure: persisted.showCollateralExposure ?? true, + showEarningsInUsd: persisted.showEarningsInUsd ?? false, + borrowedTableColumnVisibility: { + ...DEFAULT_BORROWED_TABLE_COLUMN_VISIBILITY, + ...(persisted.borrowedTableColumnVisibility ?? {}), + }, + } as PositionsPreferencesState; + }, }, ), );