Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

CentrifugeApp: Portfolio page Overview graph #1670

Merged
merged 16 commits into from
Dec 6, 2023
27 changes: 25 additions & 2 deletions centrifuge-app/src/components/DataTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
} from '@centrifuge/fabric'
import css from '@styled-system/css'
import BN from 'bn.js'
import Decimal from 'decimal.js-light'
import * as React from 'react'
import { Link, LinkProps } from 'react-router-dom'
import styled from 'styled-components'
Expand Down Expand Up @@ -54,12 +55,34 @@ const sorter = <T extends Record<string, any>>(data: Array<T>, order: OrderBy, s
if (!sortKey) return data
if (order === 'asc') {
return data.sort((a, b) => {
if (sortKey === 'nftIdSortKey') return new BN(a[sortKey]).gt(new BN(b[sortKey])) ? 1 : -1
try {
if (
(a[sortKey] instanceof Decimal && b[sortKey] instanceof Decimal) ||
(BN.isBN(a[sortKey]) && BN.isBN(b[sortKey]))
)
return a[sortKey].gt(b[sortKey]) ? 1 : -1

if (typeof a[sortKey] === 'string' && typeof b[sortKey] === 'string') {
return new BN(a[sortKey]).gt(new BN(b[sortKey])) ? 1 : -1
}
} catch {}

return a[sortKey] > b[sortKey] ? 1 : -1
})
}
return data.sort((a, b) => {
if (sortKey === 'nftIdSortKey') return new BN(b[sortKey]).gt(new BN(a[sortKey])) ? 1 : -1
try {
if (
(a[sortKey] instanceof Decimal && b[sortKey] instanceof Decimal) ||
(BN.isBN(a[sortKey]) && BN.isBN(b[sortKey]))
)
return b[sortKey].gt(a[sortKey]) ? 1 : -1

if (typeof a[sortKey] === 'string' && typeof b[sortKey] === 'string') {
return new BN(b[sortKey]).gt(new BN(a[sortKey])) ? 1 : -1
}
} catch {}

return b[sortKey] > a[sortKey] ? 1 : -1
})
}
Expand Down
110 changes: 110 additions & 0 deletions centrifuge-app/src/components/Portfolio/CardPortfolioValue.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { useAddress } from '@centrifuge/centrifuge-react'
import { Box, Shelf, Stack, Text, TextWithPlaceholder } from '@centrifuge/fabric'
import * as React from 'react'
import styled, { useTheme } from 'styled-components'
import { config } from '../../config'
import { Dec } from '../../utils/Decimal'
import { formatBalance } from '../../utils/formatting'
import { PortfolioValue } from './PortfolioValue'
import { usePortfolioTokens } from './usePortfolio'

const RangeFilterButton = styled(Stack)`
&:hover {
cursor: pointer;
}
`

const rangeFilters = [
{ value: '30d', label: '30 days' },
{ value: '90d', label: '90 days' },
{ value: 'ytd', label: 'Year to date' },
// { value: 'all', label: 'All' },
] as const

export function CardPortfolioValue() {
const address = useAddress()
const portfolioTokens = usePortfolioTokens(address)

const { colors } = useTheme()

const [range, setRange] = React.useState<(typeof rangeFilters)[number]>({ value: 'ytd', label: 'Year to date' })

const currentPortfolioValue = portfolioTokens.reduce(
(sum, token) => sum.add(token.position.mul(token.tokenPrice.toDecimal())),
Dec(0)
)

const balanceProps = {
as: 'strong',
fontSize: [16, 18],
}
const headingProps = {
as: 'p',
variant: 'body3',
}

return (
<Box position="relative">
<Box
role="article"
borderRadius="card"
borderStyle="solid"
borderWidth={1}
borderColor="borderSecondary"
p={2}
style={{
boxShadow: `0px 3px 2px -2px ${colors.borderPrimary}`,
}}
background={colors.backgroundPage}
>
<Stack gap={2}>
<Text variant="heading2">Overview</Text>

<Shelf gap={1} alignContent="center" height="48px">
<Box width="3px" backgroundColor="#1253FF" height="48px" />
<Shelf gap={4}>
<Stack gap="4px">
<Text {...headingProps}>Current portfolio value</Text>
<TextWithPlaceholder {...balanceProps} isLoading={!currentPortfolioValue}>
{formatBalance(currentPortfolioValue || 0, config.baseCurrency)}
</TextWithPlaceholder>
</Stack>
{/* <Stack gap="4px">
<Text {...headingProps}>Profit</Text>
<TextWithPlaceholder {...balanceProps} isLoading={!portfolioValue} color="#519B10">
+ {formatBalance(Dec(portfolioValue || 0), config.baseCurrency)}
</TextWithPlaceholder>
</Stack> */}
</Shelf>
</Shelf>
</Stack>

<Stack gap={1}>
<Shelf justifyContent="flex-end" pr="20px">
{rangeFilters.map((rangeFilter, index) => (
<>
<RangeFilterButton gap={1} onClick={() => setRange(rangeFilter)}>
<Text variant="body3">
<Text variant={rangeFilter.value === range.value && 'emphasized'}>{rangeFilter.label}</Text>
</Text>
<Box
width="100%"
backgroundColor={rangeFilter.value === range.value ? '#000000' : '#E0E0E0'}
height="3px"
/>
</RangeFilterButton>
{index !== rangeFilters.length - 1 && (
<Box width="24px" backgroundColor="#E0E0E0" height="3px" alignSelf="flex-end" />
)}
</>
))}
</Shelf>
</Stack>

<Box width="100%" height="300px">
<PortfolioValue rangeValue={range.value} />
</Box>
</Box>
</Box>
)
}
51 changes: 25 additions & 26 deletions centrifuge-app/src/components/Portfolio/InvestedTokens.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Token, TokenBalance } from '@centrifuge/centrifuge-js'
import { formatBalance, useBalances, useCentrifuge } from '@centrifuge/centrifuge-react'
import { Price, Token, TokenBalance } from '@centrifuge/centrifuge-js'
import { formatBalance, useCentrifuge } from '@centrifuge/centrifuge-react'
import {
AnchorButton,
Box,
Expand All @@ -13,13 +13,13 @@ import {
Text,
Thumbnail,
} from '@centrifuge/fabric'
import { useMemo } from 'react'
import { useTheme } from 'styled-components'
import { Dec } from '../../utils/Decimal'
import { useTinlakeBalances } from '../../utils/tinlake/useTinlakeBalances'
import { usePool, usePoolMetadata, usePools } from '../../utils/usePools'
import { Column, DataTable, SortableTableHeader } from '../DataTable'
import { Eththumbnail } from '../EthThumbnail'
import { usePortfolioTokens } from './usePortfolio'

type Row = {
currency: Token['currency']
Expand Down Expand Up @@ -112,37 +112,36 @@ const columns: Column[] = [

// TODO: change canInvestRedeem to default to true once the drawer is implemented
export function InvestedTokens({ canInvestRedeem = false, address }: { canInvestRedeem?: boolean; address: string }) {
const centBalances = useBalances(address)
const { data: tinlakeBalances } = useTinlakeBalances()
const pools = usePools()
const portfolioTokens = usePortfolioTokens(address)

const balances = useMemo(() => {
return [
...(centBalances?.tranches || []),
...(tinlakeBalances?.tranches.filter((tranche) => !tranche.balance.isZero) || []),
]
}, [centBalances, tinlakeBalances])

const tableData = balances.map((balance) => {
const pool = pools?.find((pool) => pool.id === balance.poolId)
const tranche = pool?.tranches.find((tranche) => tranche.id === balance.trancheId)
return {
currency: balance.currency,
poolId: balance.poolId,
trancheId: balance.trancheId,
position: balance.balance,
tokenPrice: tranche?.tokenPrice || Dec(1),
marketValue: tranche?.tokenPrice ? balance.balance.toDecimal().mul(tranche?.tokenPrice.toDecimal()) : Dec(0),
const tokens = [
...portfolioTokens.map((token) => ({
...token,
canInvestRedeem,
}
})
})),
...(tinlakeBalances?.tranches.filter((tranche) => !tranche.balance.isZero) || []).map((balance) => {
const pool = pools?.find((pool) => pool.id === balance.poolId)
const tranche = pool?.tranches.find((tranche) => tranche.id === balance.trancheId)
return {
position: balance.balance,
marketValue: tranche?.tokenPrice ? balance.balance.toDecimal().mul(tranche?.tokenPrice.toDecimal()) : Dec(0),
tokenPrice: tranche?.tokenPrice || new Price(0),
trancheId: balance.trancheId,
poolId: balance.poolId,
currency: tranche?.currency,
canInvestRedeem,
}
}),
]

return tableData.length ? (
return tokens.length ? (
<Stack as="article" gap={2}>
<Text as="h2" variant="heading2">
Portfolio
Holdings
</Text>
<DataTable columns={columns} data={tableData} />
<DataTable columns={columns} data={tokens} defaultSortKey="position" />
</Stack>
) : null
}
Expand Down
124 changes: 124 additions & 0 deletions centrifuge-app/src/components/Portfolio/PortfolioValue.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { useAddress, useCentrifugeUtils } from '@centrifuge/centrifuge-react'
import { Card, Stack, Text } from '@centrifuge/fabric'
import { Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'
import { formatDate } from '../../utils/date'
import { formatBalance } from '../../utils/formatting'
import { useDailyPortfolioValue } from './usePortfolio'

const chartColor = '#006ef5'

const TooltipInfo = ({ payload }: any) => {
if (payload) {
const portfolioValue = payload[0]?.payload.portfolioValue
const date = payload[0]?.payload.dateInMilliseconds

return (
<Card p={1} minHeight="53px" minWidth="163px" style={{ borderRadius: '4px' }}>
<Stack gap={1}>
<Text variant="body3">
<Text variant="emphasized">{formatDate(date)}</Text>
</Text>
<Text variant="body3">{portfolioValue && formatBalance(portfolioValue, 'USD', 2, 2)}</Text>
</Stack>
</Card>
)
}

return null
}

export function PortfolioValue({ rangeValue }: { rangeValue: string }) {
const address = useAddress()
const { formatAddress } = useCentrifugeUtils()
const rangeNumber = getRangeNumber(rangeValue)
const dailyPortfolioValue = useDailyPortfolioValue(formatAddress(address || ''), rangeNumber)

const getXAxisInterval = () => {
if (rangeNumber <= 30) return 5
if (rangeNumber > 30 && rangeNumber <= 90) {
return 14
}
if (rangeNumber > 90 && rangeNumber <= 180) {
return 30
}
return 45
}

return (
<ResponsiveContainer>
<AreaChart
margin={{
top: 35,
right: 20,
bottom: 0,
}}
data={dailyPortfolioValue?.reverse()}
>
<defs>
<linearGradient id="colorPoolValue" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor={chartColor} stopOpacity={0.3} />
<stop offset="90%" stopColor="#F2F2F2" stopOpacity={0.8} />
</linearGradient>
</defs>
<CartesianGrid vertical={false} />

<XAxis
dataKey={({ dateInMilliseconds }) =>
`${dateInMilliseconds?.toLocaleString('default', { month: 'short' })} ${dateInMilliseconds?.getDate()}`
}
tickLine={false}
axisLine={false}
style={{
fontSize: '10px',
}}
dy={4}
interval={getXAxisInterval()}
/>
<YAxis
dataKey={({ portfolioValue }) => portfolioValue}
tickCount={10}
tickLine={false}
axisLine={false}
tickFormatter={(value) => value.toLocaleString()}
style={{
fontSize: '10px',
}}
label={{
value: 'USD',
position: 'top',
offset: 15,
fontSize: '12px',
}}
/>

<Tooltip content={<TooltipInfo />} />
<Area
type="monotone"
dataKey="portfolioValue"
strokeWidth={1}
fillOpacity={1}
fill="url(#colorPoolValue)"
name="Value"
stroke={`${chartColor}30`}
activeDot={{ fill: chartColor }}
/>
</AreaChart>
</ResponsiveContainer>
)
}

const getRangeNumber = (rangeValue: string) => {
if (rangeValue === '30d') {
return 30
}
if (rangeValue === '90d') {
return 90
}

const today = new Date()
const januaryFirst = new Date(today.getFullYear(), 0, 1)
const timeDifference = new Date(today).getTime() - new Date(januaryFirst).getTime()
const daysSinceJanuary1 = Math.floor(timeDifference / (1000 * 60 * 60 * 24))

return daysSinceJanuary1
}
Loading
Loading