diff --git a/web/components/portfolio/portfolio-value-graph.tsx b/web/components/portfolio/portfolio-value-graph.tsx new file mode 100644 index 00000000..558fc5f6 --- /dev/null +++ b/web/components/portfolio/portfolio-value-graph.tsx @@ -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 ( +
= 800 ? 350 : 250) }} + > + 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} + > +
+ ) +}) diff --git a/web/components/portfolio/portfolio-value-section.tsx b/web/components/portfolio/portfolio-value-section.tsx new file mode 100644 index 00000000..a992e87e --- /dev/null +++ b/web/components/portfolio/portfolio-value-section.tsx @@ -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('allTime') + + if (portfolioHistory.length === 0 || !lastPortfolioMetrics) { + return
No portfolio history data yet
+ } + + return ( +
+ +
+ +
Portfolio value
+
+ {formatMoney( + lastPortfolioMetrics.balance + + lastPortfolioMetrics.investmentValue + )} +
+ +
+ { + //TODO: enable day/week/monthly as data becomes available + } +
+ +
+ ) + } +) diff --git a/web/components/user-page.tsx b/web/components/user-page.tsx index cd896c59..2019a9de 100644 --- a/web/components/user-page.tsx +++ b/web/components/user-page.tsx @@ -6,7 +6,12 @@ import { LinkIcon } from '@heroicons/react/solid' import { PencilIcon } from '@heroicons/react/outline' 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 { SEO } from './SEO' import { Page } from './page' @@ -30,6 +35,7 @@ import { getUserBets } from 'web/lib/firebase/bets' import { FollowersButton, FollowingButton } from './following-button' import { useFollows } from 'web/hooks/use-follows' import { FollowButton } from './follow-button' +import { PortfolioMetrics } from 'common/user' export function UserLink(props: { name: string @@ -67,6 +73,7 @@ export function UserPage(props: { 'loading' ) const [usersBets, setUsersBets] = useState('loading') + const [, setUsersPortfolioHistory] = useState([]) const [commentsByContract, setCommentsByContract] = useState< Map | 'loading' >('loading') @@ -83,6 +90,7 @@ export function UserPage(props: { getUsersComments(user.id).then(setUsersComments) listContracts(user.id).then(setUsersContracts) getUserBets(user.id, { includeRedemptions: false }).then(setUsersBets) + getPortfolioHistory(user.id).then(setUsersPortfolioHistory) }, [user]) // TODO: display comments on groups @@ -243,6 +251,7 @@ export function UserPage(props: { + {usersContracts !== 'loading' && commentsByContract != 'loading' ? ( + { + // TODO: add portfolio-value-section here + } ( + query( + collectionGroup(db, 'portfolioHistory'), + where('userId', '==', userId), + orderBy('timestamp', 'asc') + ) + ) +} + export function listenForFollows( userId: string, setFollowIds: (followIds: string[]) => void diff --git a/web/lib/util/time.ts b/web/lib/util/time.ts index a5844b34..4bd76b91 100644 --- a/web/lib/util/time.ts +++ b/web/lib/util/time.ts @@ -5,3 +5,13 @@ dayjs.extend(relativeTime) export function fromNow(time: number) { 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') +} diff --git a/web/pages/leaderboards.tsx b/web/pages/leaderboards.tsx index ed05413a..7e141e72 100644 --- a/web/pages/leaderboards.tsx +++ b/web/pages/leaderboards.tsx @@ -4,8 +4,8 @@ import { Page } from 'web/components/page' import { getTopCreators, getTopTraders, - LeaderboardPeriod, getTopFollowed, + Period, User, } from 'web/lib/firebase/users' import { formatMoney } from 'common/util/format' @@ -20,7 +20,7 @@ export const getStaticProps = fromPropz(getStaticPropz) export async function getStaticPropz() { return queryLeaderboardUsers('allTime') } -const queryLeaderboardUsers = async (period: LeaderboardPeriod) => { +const queryLeaderboardUsers = async (period: Period) => { const [topTraders, topCreators, topFollowed] = await Promise.all([ getTopTraders(period).catch(() => {}), getTopCreators(period).catch(() => {}), @@ -50,7 +50,7 @@ export default function Leaderboards(props: { const [topTradersState, setTopTraders] = useState(props.topTraders) const [topCreatorsState, setTopCreators] = useState(props.topCreators) const [isLoading, setLoading] = useState(false) - const [period, setPeriod] = useState('allTime') + const [period, setPeriod] = useState('allTime') useEffect(() => { setLoading(true) @@ -61,7 +61,7 @@ export default function Leaderboards(props: { }) }, [period]) - const LeaderboardWithPeriod = (period: LeaderboardPeriod) => { + const LeaderboardWithPeriod = (period: Period) => { return ( <> @@ -127,7 +127,7 @@ export default function Leaderboards(props: { defaultIndex={0} onClick={(title, index) => { const period = ['allTime', 'monthly', 'weekly', 'daily'][index] - setPeriod(period as LeaderboardPeriod) + setPeriod(period as Period) }} tabs={[ {