From ebc4bd6bcf1125e00fec285e469ac2acc068b016 Mon Sep 17 00:00:00 2001 From: Pico2x Date: Fri, 24 Jun 2022 18:14:20 +0100 Subject: [PATCH] [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 --- .../portfolio/portfolio-value-graph.tsx | 83 +++++++++++++++++++ .../portfolio/portfolio-value-section.tsx | 47 +++++++++++ web/components/user-page.tsx | 14 +++- web/lib/firebase/users.ts | 18 +++- web/lib/util/time.ts | 10 +++ web/pages/leaderboards.tsx | 10 +-- 6 files changed, 172 insertions(+), 10 deletions(-) create mode 100644 web/components/portfolio/portfolio-value-graph.tsx create mode 100644 web/components/portfolio/portfolio-value-section.tsx 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={[ {