[PortfolioGraph] Shows a graph of the portfolio value over time (#570)
* [Portfolio Graph] Shows a graph of the portfolio value over time * [PortfolioGraph] Fix some nits. * [PortfolioGraph] Comment out portfolio-value-section Hides the component completely for now, so we can land today. My plan would be to land today, wait for the history to build up, and then revert this commit. As opposed to leaving the PR idle for a while, and then have to deal with conflicts. * [PortfolioGraph] Rm duplicate firestore rule
This commit is contained in:
parent
b4e09e37b1
commit
ebc4bd6bcf
83
web/components/portfolio/portfolio-value-graph.tsx
Normal file
83
web/components/portfolio/portfolio-value-graph.tsx
Normal file
|
@ -0,0 +1,83 @@
|
||||||
|
import { ResponsiveLine } from '@nivo/line'
|
||||||
|
import { PortfolioMetrics } from 'common/user'
|
||||||
|
import { formatMoney } from 'common/util/format'
|
||||||
|
import { DAY_MS } from 'common/util/time'
|
||||||
|
import { last } from 'lodash'
|
||||||
|
import { memo } from 'react'
|
||||||
|
import { useWindowSize } from 'web/hooks/use-window-size'
|
||||||
|
import { formatTime } from 'web/lib/util/time'
|
||||||
|
|
||||||
|
export const PortfolioValueGraph = memo(function PortfolioValueGraph(props: {
|
||||||
|
portfolioHistory: PortfolioMetrics[]
|
||||||
|
height?: number
|
||||||
|
period?: string
|
||||||
|
}) {
|
||||||
|
const { portfolioHistory, height, period } = props
|
||||||
|
|
||||||
|
const { width } = useWindowSize()
|
||||||
|
|
||||||
|
const portfolioHistoryFiltered = portfolioHistory.filter((p) => {
|
||||||
|
switch (period) {
|
||||||
|
case 'daily':
|
||||||
|
return p.timestamp > Date.now() - 1 * DAY_MS
|
||||||
|
case 'weekly':
|
||||||
|
return p.timestamp > Date.now() - 7 * DAY_MS
|
||||||
|
case 'monthly':
|
||||||
|
return p.timestamp > Date.now() - 30 * DAY_MS
|
||||||
|
case 'allTime':
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const points = portfolioHistoryFiltered.map((p) => {
|
||||||
|
return {
|
||||||
|
x: new Date(p.timestamp),
|
||||||
|
y: p.balance + p.investmentValue,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const data = [{ id: 'Value', data: points, color: '#11b981' }]
|
||||||
|
const numXTickValues = !width || width < 800 ? 2 : 5
|
||||||
|
const numYTickValues = 4
|
||||||
|
const endDate = last(points)?.x
|
||||||
|
const includeTime = period === 'daily'
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="w-full overflow-hidden"
|
||||||
|
style={{ height: height ?? (!width || width >= 800 ? 350 : 250) }}
|
||||||
|
>
|
||||||
|
<ResponsiveLine
|
||||||
|
data={data}
|
||||||
|
margin={{ top: 20, right: 28, bottom: 22, left: 60 }}
|
||||||
|
xScale={{
|
||||||
|
type: 'time',
|
||||||
|
min: points[0].x,
|
||||||
|
max: endDate,
|
||||||
|
}}
|
||||||
|
yScale={{
|
||||||
|
type: 'linear',
|
||||||
|
stacked: false,
|
||||||
|
min: Math.min(...points.map((p) => p.y)),
|
||||||
|
}}
|
||||||
|
gridYValues={numYTickValues}
|
||||||
|
curve="monotoneX"
|
||||||
|
colors={{ datum: 'color' }}
|
||||||
|
axisBottom={{
|
||||||
|
tickValues: numXTickValues,
|
||||||
|
format: (time) => formatTime(+time, includeTime),
|
||||||
|
}}
|
||||||
|
pointBorderColor="#fff"
|
||||||
|
pointSize={points.length > 100 ? 0 : 6}
|
||||||
|
axisLeft={{
|
||||||
|
tickValues: numYTickValues,
|
||||||
|
format: (value) => formatMoney(value),
|
||||||
|
}}
|
||||||
|
enableGridX={!!width && width >= 800}
|
||||||
|
enableGridY={true}
|
||||||
|
enableSlices="x"
|
||||||
|
animate={false}
|
||||||
|
></ResponsiveLine>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
47
web/components/portfolio/portfolio-value-section.tsx
Normal file
47
web/components/portfolio/portfolio-value-section.tsx
Normal file
|
@ -0,0 +1,47 @@
|
||||||
|
import { PortfolioMetrics } from 'common/user'
|
||||||
|
import { formatMoney } from 'common/util/format'
|
||||||
|
import { last } from 'lodash'
|
||||||
|
import { memo, useState } from 'react'
|
||||||
|
import { Period } from 'web/lib/firebase/users'
|
||||||
|
import { Col } from '../layout/col'
|
||||||
|
import { Row } from '../layout/row'
|
||||||
|
import { PortfolioValueGraph } from './portfolio-value-graph'
|
||||||
|
|
||||||
|
export const PortfolioValueSection = memo(
|
||||||
|
function PortfolioValueSection(props: {
|
||||||
|
portfolioHistory: PortfolioMetrics[]
|
||||||
|
}) {
|
||||||
|
const { portfolioHistory } = props
|
||||||
|
const lastPortfolioMetrics = last(portfolioHistory)
|
||||||
|
const [portfolioPeriod] = useState<Period>('allTime')
|
||||||
|
|
||||||
|
if (portfolioHistory.length === 0 || !lastPortfolioMetrics) {
|
||||||
|
return <div> No portfolio history data yet </div>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Row className="gap-8">
|
||||||
|
<div className="mb-4 w-full">
|
||||||
|
<Col>
|
||||||
|
<div className="text-sm text-gray-500">Portfolio value</div>
|
||||||
|
<div className="text-lg">
|
||||||
|
{formatMoney(
|
||||||
|
lastPortfolioMetrics.balance +
|
||||||
|
lastPortfolioMetrics.investmentValue
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
</div>
|
||||||
|
{
|
||||||
|
//TODO: enable day/week/monthly as data becomes available
|
||||||
|
}
|
||||||
|
</Row>
|
||||||
|
<PortfolioValueGraph
|
||||||
|
portfolioHistory={portfolioHistory}
|
||||||
|
period={portfolioPeriod}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
|
@ -6,7 +6,12 @@ import { LinkIcon } from '@heroicons/react/solid'
|
||||||
import { PencilIcon } from '@heroicons/react/outline'
|
import { PencilIcon } from '@heroicons/react/outline'
|
||||||
import Confetti from 'react-confetti'
|
import Confetti from 'react-confetti'
|
||||||
|
|
||||||
import { follow, unfollow, User } from 'web/lib/firebase/users'
|
import {
|
||||||
|
follow,
|
||||||
|
unfollow,
|
||||||
|
User,
|
||||||
|
getPortfolioHistory,
|
||||||
|
} from 'web/lib/firebase/users'
|
||||||
import { CreatorContractsList } from './contract/contracts-list'
|
import { CreatorContractsList } from './contract/contracts-list'
|
||||||
import { SEO } from './SEO'
|
import { SEO } from './SEO'
|
||||||
import { Page } from './page'
|
import { Page } from './page'
|
||||||
|
@ -30,6 +35,7 @@ import { getUserBets } from 'web/lib/firebase/bets'
|
||||||
import { FollowersButton, FollowingButton } from './following-button'
|
import { FollowersButton, FollowingButton } from './following-button'
|
||||||
import { useFollows } from 'web/hooks/use-follows'
|
import { useFollows } from 'web/hooks/use-follows'
|
||||||
import { FollowButton } from './follow-button'
|
import { FollowButton } from './follow-button'
|
||||||
|
import { PortfolioMetrics } from 'common/user'
|
||||||
|
|
||||||
export function UserLink(props: {
|
export function UserLink(props: {
|
||||||
name: string
|
name: string
|
||||||
|
@ -67,6 +73,7 @@ export function UserPage(props: {
|
||||||
'loading'
|
'loading'
|
||||||
)
|
)
|
||||||
const [usersBets, setUsersBets] = useState<Bet[] | 'loading'>('loading')
|
const [usersBets, setUsersBets] = useState<Bet[] | 'loading'>('loading')
|
||||||
|
const [, setUsersPortfolioHistory] = useState<PortfolioMetrics[]>([])
|
||||||
const [commentsByContract, setCommentsByContract] = useState<
|
const [commentsByContract, setCommentsByContract] = useState<
|
||||||
Map<Contract, Comment[]> | 'loading'
|
Map<Contract, Comment[]> | 'loading'
|
||||||
>('loading')
|
>('loading')
|
||||||
|
@ -83,6 +90,7 @@ export function UserPage(props: {
|
||||||
getUsersComments(user.id).then(setUsersComments)
|
getUsersComments(user.id).then(setUsersComments)
|
||||||
listContracts(user.id).then(setUsersContracts)
|
listContracts(user.id).then(setUsersContracts)
|
||||||
getUserBets(user.id, { includeRedemptions: false }).then(setUsersBets)
|
getUserBets(user.id, { includeRedemptions: false }).then(setUsersBets)
|
||||||
|
getPortfolioHistory(user.id).then(setUsersPortfolioHistory)
|
||||||
}, [user])
|
}, [user])
|
||||||
|
|
||||||
// TODO: display comments on groups
|
// TODO: display comments on groups
|
||||||
|
@ -243,6 +251,7 @@ export function UserPage(props: {
|
||||||
</Col>
|
</Col>
|
||||||
|
|
||||||
<Spacer h={10} />
|
<Spacer h={10} />
|
||||||
|
|
||||||
{usersContracts !== 'loading' && commentsByContract != 'loading' ? (
|
{usersContracts !== 'loading' && commentsByContract != 'loading' ? (
|
||||||
<Tabs
|
<Tabs
|
||||||
className={'pb-2 pt-1 '}
|
className={'pb-2 pt-1 '}
|
||||||
|
@ -284,6 +293,9 @@ export function UserPage(props: {
|
||||||
title: 'Bets',
|
title: 'Bets',
|
||||||
content: (
|
content: (
|
||||||
<div>
|
<div>
|
||||||
|
{
|
||||||
|
// TODO: add portfolio-value-section here
|
||||||
|
}
|
||||||
<BetsList
|
<BetsList
|
||||||
user={user}
|
user={user}
|
||||||
hideBetsBefore={isCurrentUser ? 0 : JUNE_1_2022}
|
hideBetsBefore={isCurrentUser ? 0 : JUNE_1_2022}
|
||||||
|
|
|
@ -24,7 +24,7 @@ import {
|
||||||
import { range, throttle, zip } from 'lodash'
|
import { range, throttle, zip } from 'lodash'
|
||||||
|
|
||||||
import { app } from './init'
|
import { app } from './init'
|
||||||
import { PrivateUser, User } from 'common/user'
|
import { PortfolioMetrics, PrivateUser, User } from 'common/user'
|
||||||
import { createUser } from './fn-call'
|
import { createUser } from './fn-call'
|
||||||
import { getValue, getValues, listenForValue, listenForValues } from './utils'
|
import { getValue, getValues, listenForValue, listenForValues } from './utils'
|
||||||
import { DAY_MS } from 'common/util/time'
|
import { DAY_MS } from 'common/util/time'
|
||||||
|
@ -35,7 +35,7 @@ import { filterDefined } from 'common/util/array'
|
||||||
|
|
||||||
export type { User }
|
export type { User }
|
||||||
|
|
||||||
export type LeaderboardPeriod = 'daily' | 'weekly' | 'monthly' | 'allTime'
|
export type Period = 'daily' | 'weekly' | 'monthly' | 'allTime'
|
||||||
|
|
||||||
const db = getFirestore(app)
|
const db = getFirestore(app)
|
||||||
export const auth = getAuth(app)
|
export const auth = getAuth(app)
|
||||||
|
@ -180,7 +180,7 @@ export function listenForPrivateUsers(
|
||||||
listenForValues(q, setUsers)
|
listenForValues(q, setUsers)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getTopTraders(period: LeaderboardPeriod) {
|
export function getTopTraders(period: Period) {
|
||||||
const topTraders = query(
|
const topTraders = query(
|
||||||
collection(db, 'users'),
|
collection(db, 'users'),
|
||||||
orderBy('profitCached.' + period, 'desc'),
|
orderBy('profitCached.' + period, 'desc'),
|
||||||
|
@ -190,7 +190,7 @@ export function getTopTraders(period: LeaderboardPeriod) {
|
||||||
return getValues(topTraders)
|
return getValues(topTraders)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getTopCreators(period: LeaderboardPeriod) {
|
export function getTopCreators(period: Period) {
|
||||||
const topCreators = query(
|
const topCreators = query(
|
||||||
collection(db, 'users'),
|
collection(db, 'users'),
|
||||||
orderBy('creatorVolumeCached.' + period, 'desc'),
|
orderBy('creatorVolumeCached.' + period, 'desc'),
|
||||||
|
@ -270,6 +270,16 @@ export async function unfollow(userId: string, unfollowedUserId: string) {
|
||||||
await deleteDoc(followDoc)
|
await deleteDoc(followDoc)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getPortfolioHistory(userId: string) {
|
||||||
|
return getValues<PortfolioMetrics>(
|
||||||
|
query(
|
||||||
|
collectionGroup(db, 'portfolioHistory'),
|
||||||
|
where('userId', '==', userId),
|
||||||
|
orderBy('timestamp', 'asc')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function listenForFollows(
|
export function listenForFollows(
|
||||||
userId: string,
|
userId: string,
|
||||||
setFollowIds: (followIds: string[]) => void
|
setFollowIds: (followIds: string[]) => void
|
||||||
|
|
|
@ -5,3 +5,13 @@ dayjs.extend(relativeTime)
|
||||||
export function fromNow(time: number) {
|
export function fromNow(time: number) {
|
||||||
return dayjs(time).fromNow()
|
return dayjs(time).fromNow()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function formatTime(time: number, includeTime: boolean) {
|
||||||
|
const d = dayjs(time)
|
||||||
|
|
||||||
|
if (d.isSame(Date.now(), 'day')) return d.format('ha')
|
||||||
|
|
||||||
|
if (includeTime) return dayjs(time).format('MMM D, ha')
|
||||||
|
|
||||||
|
return dayjs(time).format('MMM D')
|
||||||
|
}
|
||||||
|
|
|
@ -4,8 +4,8 @@ import { Page } from 'web/components/page'
|
||||||
import {
|
import {
|
||||||
getTopCreators,
|
getTopCreators,
|
||||||
getTopTraders,
|
getTopTraders,
|
||||||
LeaderboardPeriod,
|
|
||||||
getTopFollowed,
|
getTopFollowed,
|
||||||
|
Period,
|
||||||
User,
|
User,
|
||||||
} from 'web/lib/firebase/users'
|
} from 'web/lib/firebase/users'
|
||||||
import { formatMoney } from 'common/util/format'
|
import { formatMoney } from 'common/util/format'
|
||||||
|
@ -20,7 +20,7 @@ export const getStaticProps = fromPropz(getStaticPropz)
|
||||||
export async function getStaticPropz() {
|
export async function getStaticPropz() {
|
||||||
return queryLeaderboardUsers('allTime')
|
return queryLeaderboardUsers('allTime')
|
||||||
}
|
}
|
||||||
const queryLeaderboardUsers = async (period: LeaderboardPeriod) => {
|
const queryLeaderboardUsers = async (period: Period) => {
|
||||||
const [topTraders, topCreators, topFollowed] = await Promise.all([
|
const [topTraders, topCreators, topFollowed] = await Promise.all([
|
||||||
getTopTraders(period).catch(() => {}),
|
getTopTraders(period).catch(() => {}),
|
||||||
getTopCreators(period).catch(() => {}),
|
getTopCreators(period).catch(() => {}),
|
||||||
|
@ -50,7 +50,7 @@ export default function Leaderboards(props: {
|
||||||
const [topTradersState, setTopTraders] = useState(props.topTraders)
|
const [topTradersState, setTopTraders] = useState(props.topTraders)
|
||||||
const [topCreatorsState, setTopCreators] = useState(props.topCreators)
|
const [topCreatorsState, setTopCreators] = useState(props.topCreators)
|
||||||
const [isLoading, setLoading] = useState(false)
|
const [isLoading, setLoading] = useState(false)
|
||||||
const [period, setPeriod] = useState<LeaderboardPeriod>('allTime')
|
const [period, setPeriod] = useState<Period>('allTime')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
|
@ -61,7 +61,7 @@ export default function Leaderboards(props: {
|
||||||
})
|
})
|
||||||
}, [period])
|
}, [period])
|
||||||
|
|
||||||
const LeaderboardWithPeriod = (period: LeaderboardPeriod) => {
|
const LeaderboardWithPeriod = (period: Period) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Col className="mx-4 items-center gap-10 lg:flex-row">
|
<Col className="mx-4 items-center gap-10 lg:flex-row">
|
||||||
|
@ -127,7 +127,7 @@ export default function Leaderboards(props: {
|
||||||
defaultIndex={0}
|
defaultIndex={0}
|
||||||
onClick={(title, index) => {
|
onClick={(title, index) => {
|
||||||
const period = ['allTime', 'monthly', 'weekly', 'daily'][index]
|
const period = ['allTime', 'monthly', 'weekly', 'daily'][index]
|
||||||
setPeriod(period as LeaderboardPeriod)
|
setPeriod(period as Period)
|
||||||
}}
|
}}
|
||||||
tabs={[
|
tabs={[
|
||||||
{
|
{
|
||||||
|
|
Loading…
Reference in New Issue
Block a user