[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:
Pico2x 2022-06-24 18:14:20 +01:00 committed by GitHub
parent b4e09e37b1
commit ebc4bd6bcf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 172 additions and 10 deletions

View 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>
)
})

View 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>
)
}
)

View File

@ -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}

View File

@ -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

View File

@ -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')
}

View File

@ -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={[
{ {