mango-ui-v3/components/account_page/AccountInterest.tsx

604 lines
22 KiB
TypeScript

import { getTokenBySymbol } from '@blockworks-foundation/mango-client'
import { useEffect, useMemo, useState } from 'react'
import dayjs from 'dayjs'
import useMangoStore from '../../stores/useMangoStore'
import {
Table,
TableDateDisplay,
Td,
Th,
TrBody,
TrHead,
} from '../TableElements'
import { useTranslation } from 'next-i18next'
import isEmpty from 'lodash/isEmpty'
import usePagination from '../../hooks/usePagination'
import { numberCompactFormatter, roundToDecimal } from '../../utils/'
import Pagination from '../Pagination'
import { useViewport } from '../../hooks/useViewport'
import { breakpoints } from '../TradePageGrid'
import { ExpandableRow } from '../TableElements'
import MobileTableHeader from '../mobile/MobileTableHeader'
import Chart from '../Chart'
import Switch from '../Switch'
import useLocalStorageState from '../../hooks/useLocalStorageState'
const utc = require('dayjs/plugin/utc')
dayjs.extend(utc)
import { exportDataToCSV } from '../../utils/export'
import { SaveIcon } from '@heroicons/react/solid'
import Button from '../Button'
import TabButtons from 'components/TabButtons'
interface InterestStats {
[key: string]: {
total_borrow_interest: number
total_deposit_interest: number
}
}
export const handleDustTicks = (v) =>
Math.abs(v) < 0.0000099
? v === 0
? 0
: v.toExponential()
: numberCompactFormatter.format(v)
const handleUsdDustTicks = (v) =>
Math.abs(v) < 0.0000099
? v === 0
? '$0'
: `$${v.toExponential()}`
: `$${numberCompactFormatter.format(v)}`
const AccountInterest = () => {
const { t } = useTranslation('common')
const mangoAccount = useMangoStore((s) => s.selectedMangoAccount.current)
const groupConfig = useMangoStore((s) => s.selectedMangoGroup.config)
const mangoGroup = useMangoStore((s) => s.selectedMangoGroup.current)
const mangoCache = useMangoStore((s) => s.selectedMangoGroup.cache)
const [interestStats, setInterestStats] = useState<any>([])
const [hourlyInterestStats, setHourlyInterestStats] = useState<any>({})
const [loadHourlyStats, setLoadHourlyStats] = useState(false)
const [loadTotalStats, setLoadTotalStats] = useState(false)
const [selectedAsset, setSelectedAsset] = useState<string>('')
const [chartData, setChartData] = useState<any[]>([])
const {
paginatedData,
setData,
totalPages,
nextPage,
previousPage,
page,
firstPage,
lastPage,
} = usePagination(hourlyInterestStats[selectedAsset] || [])
const { width } = useViewport()
const isMobile = width ? width < breakpoints.md : false
const [hideInterestDust, sethideInterestDust] = useLocalStorageState(
'hideInterestDust',
false
)
const mangoAccountPk = useMemo(() => {
if (mangoAccount) {
return mangoAccount.publicKey.toString()
}
}, [mangoAccount])
const token = useMemo(() => {
if (selectedAsset) {
return getTokenBySymbol(groupConfig, selectedAsset)
}
}, [selectedAsset])
const exportInterestDataToCSV = () => {
const assets = Object.keys(hourlyInterestStats)
let dataToExport: any[] = []
for (const asset of assets) {
dataToExport = [
...dataToExport,
...hourlyInterestStats[asset].map((interest) => {
const timestamp = new Date(interest.time)
return {
timestamp: `${timestamp.toLocaleDateString()} ${timestamp.toLocaleTimeString()}`,
asset: asset,
deposit_interest: interest.deposit_interest,
borrow_interest: interest.borrow_interest,
}
}),
]
}
const title = `${
mangoAccount?.name || mangoAccount?.publicKey
}-Interest-${new Date().toLocaleDateString()}`
const headers = [
'Timestamp',
'Asset',
'Deposit Interest',
'Borrow Interest',
]
exportDataToCSV(dataToExport, title, headers, t)
}
useEffect(() => {
if (!isEmpty(hourlyInterestStats)) {
setData(hourlyInterestStats[selectedAsset] || [])
}
}, [selectedAsset, hourlyInterestStats])
useEffect(() => {
if (!selectedAsset && Object.keys(hourlyInterestStats).length > 0) {
setSelectedAsset(Object.keys(hourlyInterestStats)[0])
}
}, [hourlyInterestStats])
useEffect(() => {
const hideDust: any[] = []
const fetchInterestStats = async () => {
setLoadTotalStats(true)
const response = await fetch(
`https://mango-transaction-log.herokuapp.com/v3/stats/total-interest-earned?mango-account=${mangoAccountPk}`
)
const parsedResponse: InterestStats = await response.json()
if (hideInterestDust) {
Object.entries(parsedResponse).forEach((r) => {
const tokens = groupConfig.tokens
const token = tokens.find((t) => t.symbol === r[0])
if (!token || !mangoGroup || !mangoCache) {
return
}
const tokenIndex = mangoGroup.getTokenIndex(token.mintKey)
const price = mangoGroup.getPrice(tokenIndex, mangoCache).toNumber()
const interest =
r[1].total_deposit_interest > 0
? r[1].total_deposit_interest
: r[1].total_borrow_interest
if (price * interest > 1) {
hideDust.push(r)
}
})
setLoadTotalStats(false)
setInterestStats(hideDust)
} else {
const stats = Object.entries(parsedResponse)
const filterMicroBalances = stats.filter(([symbol, stats]) => {
const decimals = getTokenBySymbol(groupConfig, symbol).decimals
const smallestValue = Math.pow(10, (decimals + 1) * -1)
return (
stats.total_borrow_interest > smallestValue ||
stats.total_deposit_interest > smallestValue
)
})
setLoadTotalStats(false)
setInterestStats(filterMicroBalances)
}
}
const fetchHourlyInterestStats = async () => {
setLoadHourlyStats(true)
const response = await fetch(
`https://mango-transaction-log.herokuapp.com/v3/stats/hourly-interest-prices?mango-account=${mangoAccountPk}`
)
const parsedResponse = await response.json()
let assets
if (hideInterestDust) {
const assetsToShow = hideDust.map((a) => a[0])
assets = Object.keys(parsedResponse).filter((a) =>
assetsToShow.includes(a)
)
setSelectedAsset(assetsToShow[0])
} else {
assets = Object.keys(parsedResponse)
}
const stats = {}
for (const asset of assets) {
const x: any = Object.entries(parsedResponse[asset])
const decimals = getTokenBySymbol(groupConfig, asset).decimals
stats[asset] = x
.map(([key, value, price]) => {
const borrows = roundToDecimal(value.borrow_interest, decimals + 1)
const deposits = roundToDecimal(
value.deposit_interest,
decimals + 1
)
if (borrows > 0 || deposits > 0) {
return { ...value, time: key, ...price }
} else {
return null
}
})
.filter((x) => x)
.reverse()
if (stats[asset].length === 0) {
delete stats[asset]
}
}
setLoadHourlyStats(false)
setHourlyInterestStats(stats)
}
const getStats = async () => {
fetchInterestStats()
fetchHourlyInterestStats()
}
getStats()
}, [mangoAccountPk, hideInterestDust])
useEffect(() => {
if (hourlyInterestStats[selectedAsset]) {
const start = new Date(
// @ts-ignore
dayjs().utc().hour(0).minute(0).subtract(29, 'day')
).getTime()
const filtered = hourlyInterestStats[selectedAsset].filter(
(d) => new Date(d.time).getTime() > start
)
const dailyInterest: any[] = []
for (let i = 0; i < 30; i++) {
dailyInterest.push({
interest: 0,
value: 0,
time: new Date(
// @ts-ignore
dayjs().utc().hour(0).minute(0).subtract(i, 'day')
).getTime(),
})
}
filtered.forEach((d) => {
const found = dailyInterest.find(
(x) =>
dayjs(x.time).format('DD-MMM') === dayjs(d.time).format('DD-MMM')
)
if (found) {
const newInterest =
d.borrow_interest > 0 ? d.borrow_interest * -1 : d.deposit_interest
const newValue =
d.borrow_interest > 0
? d.borrow_interest * -1 * d.price
: d.deposit_interest * d.price
found.interest = found.interest + newInterest
found.value = found.value + newValue
}
})
setChartData(dailyInterest.reverse())
}
}, [hourlyInterestStats, selectedAsset])
const increaseYAxisWidth = !!chartData.find((data) => data.value < 0.001)
return (
<>
<div className="flex flex-col pb-4 sm:flex-row sm:items-center sm:space-x-3">
<div className="flex w-full items-center justify-between pb-4 sm:pb-0">
<h2>{t('interest-earned')}</h2>
<Switch
checked={hideInterestDust}
className="ml-2 text-xs"
onChange={() => sethideInterestDust(!hideInterestDust)}
>
{t('hide-dust')}
</Switch>
</div>
<Button
className={`h-8 pt-0 pb-0 pl-3 pr-3 text-xs`}
onClick={exportInterestDataToCSV}
>
<div className={`flex items-center justify-center whitespace-nowrap`}>
<SaveIcon className={`mr-1.5 h-4 w-4`} />
{t('export-data')}
</div>
</Button>
</div>
{mangoAccount ? (
<div>
{loadTotalStats ? (
<div className="space-y-2">
<div className="h-12 w-full animate-pulse rounded-md bg-th-bkg-3" />
<div className="h-12 w-full animate-pulse rounded-md bg-th-bkg-3" />
<div className="h-12 w-full animate-pulse rounded-md bg-th-bkg-3" />
</div>
) : !isMobile ? (
<Table>
<thead>
<TrHead>
<Th>{t('token')}</Th>
<Th>{t('total-deposit-interest')}</Th>
<Th>{t('total-borrow-interest')}</Th>
<Th>{t('net')}</Th>
</TrHead>
</thead>
<tbody>
{interestStats.length === 0 ? (
<TrBody>
<td colSpan={4}>
<div className="flex rounded-md bg-th-bkg-3 text-th-fgd-3">
<div className="mx-auto py-4">{t('no-interest')}</div>
</div>
</td>
</TrBody>
) : (
interestStats.map(([symbol, stats]) => {
const decimals = getTokenBySymbol(
groupConfig,
symbol
).decimals
return (
<TrBody key={symbol}>
<Td>
<div className="flex items-center">
<img
alt=""
width="20"
height="20"
src={`/assets/icons/${symbol.toLowerCase()}.svg`}
className={`mr-2.5`}
/>
{symbol}
</div>
</Td>
<Td>
{stats.total_deposit_interest.toFixed(decimals)}{' '}
{symbol}
</Td>
<Td>
{stats.total_borrow_interest.toFixed(decimals)}{' '}
{symbol}
</Td>
<Td>
{(
stats.total_deposit_interest -
stats.total_borrow_interest
).toFixed(decimals)}{' '}
{symbol}
</Td>
</TrBody>
)
})
)}
</tbody>
</Table>
) : interestStats.length === 0 ? (
<div className="flex rounded-md bg-th-bkg-3 text-th-fgd-3">
<div className="mx-auto py-4">{t('no-interest')}</div>
</div>
) : (
<>
<MobileTableHeader
colOneHeader={t('token')}
colTwoHeader={t('net')}
/>
{interestStats.map(([symbol, stats], index) => {
const decimals = getTokenBySymbol(groupConfig, symbol).decimals
return (
<ExpandableRow
buttonTemplate={
<div className="text-fgd-1 flex w-full items-center justify-between">
<div className="text-fgd-1 flex items-center">
<img
alt=""
width="20"
height="20"
src={`/assets/icons/${symbol.toLowerCase()}.svg`}
className={`mr-2.5`}
/>
{symbol}
</div>
<div className="text-fgd-1 text-right">
{(
stats.total_deposit_interest -
stats.total_borrow_interest
).toFixed(decimals)}{' '}
{symbol}
</div>
</div>
}
key={`${symbol}${index}`}
panelTemplate={
<>
<div className="grid grid-flow-row grid-cols-2 gap-4">
<div className="text-left">
<div className="pb-0.5 text-xs text-th-fgd-3">
{t('total-deposit-interest')}
</div>
{stats.total_deposit_interest.toFixed(decimals)}{' '}
{symbol}
</div>
<div className="text-left">
<div className="pb-0.5 text-xs text-th-fgd-3">
{t('total-borrow-interest')}
</div>
{stats.total_borrow_interest.toFixed(decimals)}{' '}
{symbol}
</div>
</div>
</>
}
/>
)
})}
</>
)}
<>
{!isEmpty(hourlyInterestStats) && !loadHourlyStats ? (
<>
<div className="pb-2 pt-8">
<h2 className="mb-4">{t('history')}</h2>
<TabButtons
activeTab={selectedAsset}
tabs={Object.keys(hourlyInterestStats).map(
(token: string) => ({ label: token, key: token })
)}
onClick={setSelectedAsset}
showSymbolIcon
/>
</div>
{selectedAsset && chartData.length > 0 ? (
<div className="flex w-full flex-col space-x-0 sm:flex-row sm:space-x-4">
{chartData.find((d) => d.interest !== 0) ? (
<div
className="relative mb-6 w-full rounded-md border border-th-bkg-3 p-4 sm:w-1/2"
style={{ height: '330px' }}
>
<Chart
hideRangeFilters
title={t('interest-chart-title', {
symbol: selectedAsset,
})}
xAxis="time"
yAxis="interest"
data={chartData}
labelFormat={(x) => {
return x === 0
? 0
: token
? x.toFixed(token.decimals + 1)
: null
}}
tickFormat={handleDustTicks}
titleValue={chartData.reduce(
(a, c) => a + c.interest,
0
)}
type="bar"
useMulticoloredBars
yAxisWidth={increaseYAxisWidth ? 70 : 50}
zeroLine
/>
</div>
) : null}
{chartData.find((d) => d.value !== 0) ? (
<div
className="relative mb-6 w-full rounded-md border border-th-bkg-3 p-4 sm:w-1/2"
style={{ height: '330px' }}
>
{token ? (
<Chart
hideRangeFilters
title={t('interest-chart-value-title', {
symbol: selectedAsset,
})}
xAxis="time"
yAxis="value"
data={chartData}
labelFormat={(x) =>
x === 0
? 0
: x < 0
? `-$${Math.abs(x)?.toFixed(
token.decimals + 1
)}`
: `$${x?.toFixed(token.decimals + 1)}`
}
tickFormat={handleUsdDustTicks}
titleValue={chartData.reduce(
(a, c) => a + c.value,
0
)}
type="bar"
useMulticoloredBars
yAxisWidth={increaseYAxisWidth ? 70 : 50}
zeroLine
/>
) : null}
</div>
) : null}
</div>
) : null}
<div>
<div>
{paginatedData.length ? (
<Table>
<thead>
<TrHead>
<Th>{t('time')}</Th>
<Th>{t('interest')}</Th>
<Th>{t('value')}</Th>
</TrHead>
</thead>
<tbody>
{paginatedData.map((stat) => {
// @ts-ignore
const utc = dayjs.utc(stat.time).format()
return (
<TrBody key={stat.time}>
<Td className="w-1/3">
<TableDateDisplay date={utc} />
</Td>
{token ? (
<Td className="w-1/3">
{stat.borrow_interest > 0
? `-${stat.borrow_interest.toFixed(
token.decimals + 1
)}`
: stat.deposit_interest.toFixed(
token.decimals + 1
)}{' '}
{selectedAsset}
</Td>
) : null}
{token ? (
<Td className="w-1/3">
{stat.borrow_interest > 0
? `-$${(
stat.borrow_interest * stat.price
).toFixed(token.decimals + 1)}`
: `$${(
stat.deposit_interest * stat.price
).toFixed(token.decimals + 1)}`}
</Td>
) : null}
</TrBody>
)
})}
</tbody>
</Table>
) : (
<div className="flex w-full justify-center bg-th-bkg-3 py-4 text-th-fgd-3">
{t('no-interest')}
</div>
)}
</div>
<Pagination
page={page}
totalPages={totalPages}
nextPage={nextPage}
lastPage={lastPage}
firstPage={firstPage}
previousPage={previousPage}
/>
</div>
</>
) : loadHourlyStats ? (
<div className="space-y-2 pt-8">
<div className="h-12 w-full animate-pulse rounded-md bg-th-bkg-3" />
<div className="h-12 w-full animate-pulse rounded-md bg-th-bkg-3" />
<div className="h-12 w-full animate-pulse rounded-md bg-th-bkg-3" />
</div>
) : null}
</>
</div>
) : (
<div>{t('connect-wallet')}</div>
)}
</>
)
}
export default AccountInterest