diff --git a/common/calculate-metrics.ts b/common/calculate-metrics.ts index 47fccd86..2c544217 100644 --- a/common/calculate-metrics.ts +++ b/common/calculate-metrics.ts @@ -266,6 +266,8 @@ export const calculateMetricsByContract = ( }) } +export type ContractMetrics = ReturnType[number] + const calculatePeriodProfit = ( contract: CPMMBinaryContract, bets: Bet[], diff --git a/common/calculate.ts b/common/calculate.ts index 47fee8c6..c3461cb6 100644 --- a/common/calculate.ts +++ b/common/calculate.ts @@ -178,6 +178,8 @@ function getDpmInvested(yourBets: Bet[]) { }) } +export type ContractBetMetrics = ReturnType + export function getContractBetMetrics(contract: Contract, yourBets: Bet[]) { const { resolution } = contract const isCpmm = contract.mechanism === 'cpmm-1' diff --git a/firestore.rules b/firestore.rules index 9ab575cd..993791b2 100644 --- a/firestore.rules +++ b/firestore.rules @@ -44,6 +44,10 @@ service cloud.firestore { allow read; } + match /{somePath=**}/contract-metrics/{contractId} { + allow read; + } + match /{somePath=**}/challenges/{challengeId}{ allow read; } diff --git a/web/components/contract/contract-card.tsx b/web/components/contract/contract-card.tsx index a9ee6318..d0672f27 100644 --- a/web/components/contract/contract-card.tsx +++ b/web/components/contract/contract-card.tsx @@ -1,7 +1,11 @@ import clsx from 'clsx' import Link from 'next/link' import { Row } from '../layout/row' -import { formatLargeNumber, formatPercent } from 'common/util/format' +import { + formatLargeNumber, + formatMoney, + formatPercent, +} from 'common/util/format' import { contractPath, getBinaryProbPercent } from 'web/lib/firebase/contracts' import { Col } from '../layout/col' import { @@ -17,6 +21,7 @@ import { import { AnswerLabel, BinaryContractOutcomeLabel, + BinaryOutcomeLabel, CancelLabel, FreeResponseOutcomeLabel, } from '../outcome-label' @@ -29,7 +34,7 @@ import { AvatarDetails, MiscDetails, ShowTime } from './contract-details' import { getExpectedValue, getValueFromBucket } from 'common/calculate-dpm' import { getColor, ProbBar, QuickBet } from './quick-bet' import { useContractWithPreload } from 'web/hooks/use-contract' -import { useUser } from 'web/hooks/use-user' +import { useUser, useUserContractMetrics } from 'web/hooks/use-user' import { track } from '@amplitude/analytics-browser' import { trackCallback } from 'web/lib/service/analytics' import { getMappedValue } from 'common/pseudo-numeric' @@ -37,6 +42,7 @@ import { Tooltip } from '../tooltip' import { SiteLink } from '../site-link' import { ProbChange } from './prob-change-table' import { Card } from '../card' +import { ProfitBadgeMana } from '../profit-badge' export function ContractCard(props: { contract: Contract @@ -390,11 +396,18 @@ export function PseudoNumericResolutionOrExpectation(props: { export function ContractCardProbChange(props: { contract: CPMMContract noLinkAvatar?: boolean + showPosition?: boolean className?: string }) { - const { noLinkAvatar, className } = props + const { noLinkAvatar, showPosition, className } = props const contract = useContractWithPreload(props.contract) as CPMMBinaryContract + const user = useUser() + const metrics = useUserContractMetrics(user?.id, contract.id) + const dayMetrics = metrics && metrics.from && metrics.from.day + const outcome = + metrics && metrics.hasShares && metrics.totalShares.YES ? 'YES' : 'NO' + return ( + {showPosition && metrics && ( + + +
Position
+ {formatMoney(metrics.payout)} + +
+ + {dayMetrics && ( + <> + +
Daily profit
+ +
+ + )} +
+ )}
) } diff --git a/web/components/contract/contracts-grid.tsx b/web/components/contract/contracts-grid.tsx index 7aef2282..7f8a0a27 100644 --- a/web/components/contract/contracts-grid.tsx +++ b/web/components/contract/contracts-grid.tsx @@ -81,6 +81,7 @@ export function ContractsGrid(props: { ) : ( [m.contractId, m.from?.day.profit ?? 0] as const + ) + + const positiveProfit = sortBy( + contractProfit.filter(([, profit]) => profit > 0), + ([, profit]) => profit + ).reverse() + const positive = filterDefined( + positiveProfit.map(([contractId]) => + contracts.find((c) => c.id === contractId) + ) + ) + + const negativeProfit = sortBy( + contractProfit.filter(([, profit]) => profit < 0), + ([, profit]) => profit + ) + const negative = filterDefined( + negativeProfit.map(([contractId]) => + contracts.find((c) => c.id === contractId) + ) + ) + + if (positive.length === 0 && negative.length === 0) + return
None
+ + return ( + + + {positive.map((contract) => ( + + ))} + + + {negative.map((contract) => ( + + ))} + + + ) +} + export function ProbChangeTable(props: { changes: CPMMContract[] | undefined full?: boolean @@ -39,12 +98,20 @@ export function ProbChangeTable(props: { {filteredPositiveChanges.map((contract) => ( - + ))} {filteredNegativeChanges.map((contract) => ( - + ))} diff --git a/web/components/profit-badge.tsx b/web/components/profit-badge.tsx index f82159e6..ff7d4dc0 100644 --- a/web/components/profit-badge.tsx +++ b/web/components/profit-badge.tsx @@ -1,4 +1,5 @@ import clsx from 'clsx' +import { ENV_CONFIG } from 'common/envs/constants' export function ProfitBadge(props: { profitPercent: number @@ -26,3 +27,24 @@ export function ProfitBadge(props: { ) } + +export function ProfitBadgeMana(props: { amount: number; className?: string }) { + const { amount, className } = props + const colors = + amount > 0 ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800' + + const formatted = + ENV_CONFIG.moneyMoniker + (amount > 0 ? '+' : '') + amount.toFixed(0) + + return ( + + {formatted} + + ) +} diff --git a/web/hooks/use-user.ts b/web/hooks/use-user.ts index b355d87d..793c11ff 100644 --- a/web/hooks/use-user.ts +++ b/web/hooks/use-user.ts @@ -1,10 +1,18 @@ import { useContext } from 'react' -import { useFirestoreDocumentData } from '@react-query-firebase/firestore' -import { useQueryClient } from 'react-query' +import { + useFirestoreDocumentData, + useFirestoreQueryData, +} from '@react-query-firebase/firestore' +import { useQuery, useQueryClient } from 'react-query' import { doc, DocumentData } from 'firebase/firestore' import { getUser, User, users } from 'web/lib/firebase/users' import { AuthContext } from 'web/components/auth-context' +import { ContractMetrics } from 'common/calculate-metrics' +import { getUserContractMetricsQuery } from 'web/lib/firebase/contract-metrics' +import { getContractFromId } from 'web/lib/firebase/contracts' +import { buildArray, filterDefined } from 'common/util/array' +import { CPMMBinaryContract } from 'common/contract' export const useUser = () => { const authUser = useContext(AuthContext) @@ -38,3 +46,45 @@ export const usePrefetchUsers = (userIds: string[]) => { queryClient.prefetchQuery(['users', userId], () => getUser(userId)) ) } + +export const useUserContractMetricsByProfit = ( + userId: string, + count: number +) => { + const positiveResult = useFirestoreQueryData( + ['contract-metrics-descending', userId, count], + getUserContractMetricsQuery(userId, count, 'desc') + ) + const negativeResult = useFirestoreQueryData( + ['contract-metrics-ascending', userId, count], + getUserContractMetricsQuery(userId, count, 'asc') + ) + + const metrics = buildArray(positiveResult.data, negativeResult.data) + const contractIds = metrics.map((m) => m.contractId) + + const contractResult = useQuery(['contracts', contractIds], () => + Promise.all(contractIds.map(getContractFromId)) + ) + const contracts = contractResult.data + + if (!positiveResult.data || !negativeResult.data || !contracts) + return undefined + + const filteredContracts = filterDefined(contracts) as CPMMBinaryContract[] + const filteredMetrics = metrics.filter( + (m) => m.from && Math.abs(m.from.day.profit) >= 0.5 + ) + return { contracts: filteredContracts, metrics: filteredMetrics } +} + +export const useUserContractMetrics = (userId = '_', contractId: string) => { + const result = useFirestoreDocumentData( + ['user-contract-metrics', userId, contractId], + doc(users, userId, 'contract-metrics', contractId) + ) + + if (userId === '_') return undefined + + return result.data +} diff --git a/web/lib/firebase/contract-metrics.ts b/web/lib/firebase/contract-metrics.ts new file mode 100644 index 00000000..522b3d3f --- /dev/null +++ b/web/lib/firebase/contract-metrics.ts @@ -0,0 +1,23 @@ +import { ContractMetrics } from 'common/calculate-metrics' +import { + query, + limit, + Query, + collection, + orderBy, + where, +} from 'firebase/firestore' +import { db } from './init' + +export function getUserContractMetricsQuery( + userId: string, + count: number, + sort: 'asc' | 'desc' +) { + return query( + collection(db, 'users', userId, 'contract-metrics'), + where('from.day.profit', sort === 'desc' ? '>' : '<', 0), + orderBy('from.day.profit', sort), + limit(count) + ) as Query +} diff --git a/web/pages/daily-movers.tsx b/web/pages/daily-movers.tsx index 53e37420..f8426d7f 100644 --- a/web/pages/daily-movers.tsx +++ b/web/pages/daily-movers.tsx @@ -1,10 +1,12 @@ -import { ProbChangeTable } from 'web/components/contract/prob-change-table' +import { ProfitChangeTable } from 'web/components/contract/prob-change-table' import { Col } from 'web/components/layout/col' +import { Row } from 'web/components/layout/row' +import { LoadingIndicator } from 'web/components/loading-indicator' import { Page } from 'web/components/page' import { Title } from 'web/components/title' -import { useProbChanges } from 'web/hooks/use-prob-changes' import { useTracking } from 'web/hooks/use-tracking' -import { useUser } from 'web/hooks/use-user' +import { useUser, useUserContractMetricsByProfit } from 'web/hooks/use-user' +import { DailyProfit } from './home' export default function DailyMovers() { const user = useUser() @@ -13,7 +15,10 @@ export default function DailyMovers() { return ( - + <Row className="mt-4 items-start justify-between sm:mt-0"> + <Title className="mx-4 !mb-0 !mt-0 sm:mx-0" text="Daily movers" /> + <DailyProfit user={user} /> + </Row> {user && <ProbChangesWrapper userId={user.id} />} </Col> </Page> @@ -23,9 +28,9 @@ export default function DailyMovers() { function ProbChangesWrapper(props: { userId: string }) { const { userId } = props - const changes = useProbChanges({ bettorId: userId })?.filter( - (c) => Math.abs(c.probChanges.day) >= 0.01 - ) + const data = useUserContractMetricsByProfit(userId, 50) - return <ProbChangeTable changes={changes} full /> + if (!data) return <LoadingIndicator /> + + return <ProfitChangeTable contracts={data.contracts} metrics={data.metrics} /> } diff --git a/web/pages/home/index.tsx b/web/pages/home/index.tsx index 0b0a47c1..7844b1d5 100644 --- a/web/pages/home/index.tsx +++ b/web/pages/home/index.tsx @@ -19,19 +19,22 @@ import { useSaveReferral } from 'web/hooks/use-save-referral' import { Sort } from 'web/components/contract-search' import { Group } from 'common/group' import { SiteLink } from 'web/components/site-link' -import { usePrivateUser, useUser } from 'web/hooks/use-user' +import { + usePrivateUser, + useUser, + useUserContractMetricsByProfit, +} from 'web/hooks/use-user' import { useMemberGroupsSubscription, useTrendingGroups, } from 'web/hooks/use-group' import { Button } from 'web/components/button' import { Row } from 'web/components/layout/row' -import { ProbChangeTable } from 'web/components/contract/prob-change-table' +import { ProfitChangeTable } from 'web/components/contract/prob-change-table' import { groupPath, joinGroup, leaveGroup } from 'web/lib/firebase/groups' import { usePortfolioHistory } from 'web/hooks/use-portfolio-history' import { formatMoney } from 'common/util/format' -import { useProbChanges } from 'web/hooks/use-prob-changes' -import { calculatePortfolioProfit } from 'common/calculate-metrics' +import { ContractMetrics } from 'common/calculate-metrics' import { hasCompletedStreakToday } from 'web/components/profile/betting-streak-modal' import { ContractsGrid } from 'web/components/contract/contracts-grid' import { PillButton } from 'web/components/buttons/pill-button' @@ -74,7 +77,11 @@ export default function Home() { } }, [user, sections]) - const dailyMovers = useProbChanges({ bettorId: user?.id }) + const contractMetricsByProfit = useUserContractMetricsByProfit( + user?.id ?? '_', + 3 + ) + const trendingContracts = useTrendingContracts(6) const newContracts = useNewContracts(6) const dailyTrendingContracts = useContractsByDailyScoreNotBetOn(user?.id, 6) @@ -87,7 +94,7 @@ export default function Home() { const isLoading = !user || - !dailyMovers || + !contractMetricsByProfit || !trendingContracts || !newContracts || !dailyTrendingContracts @@ -118,7 +125,7 @@ export default function Home() { score: trendingContracts, newest: newContracts, 'daily-trending': dailyTrendingContracts, - 'daily-movers': dailyMovers, + 'daily-movers': contractMetricsByProfit, })} {groups && groupContracts && trendingGroups.length > 0 ? ( @@ -184,7 +191,10 @@ export const getHomeItems = (sections: string[]) => { function renderSections( sections: { id: string; label: string }[], sectionContracts: { - 'daily-movers': CPMMBinaryContract[] + 'daily-movers': { + contracts: CPMMBinaryContract[] + metrics: ContractMetrics[] + } 'daily-trending': CPMMBinaryContract[] newest: CPMMBinaryContract[] score: CPMMBinaryContract[] @@ -193,13 +203,16 @@ function renderSections( return ( <> {sections.map((s) => { - const { id, label } = s - const contracts = - sectionContracts[s.id as keyof typeof sectionContracts] - - if (id === 'daily-movers') { - return <DailyMoversSection key={id} contracts={contracts} /> + const { id, label } = s as { + id: keyof typeof sectionContracts + label: string } + if (id === 'daily-movers') { + return <DailyMoversSection key={id} {...sectionContracts[id]} /> + } + + const contracts = sectionContracts[id] + if (id === 'daily-trending') { return ( <SearchSection @@ -347,58 +360,39 @@ function GroupSection(props: { ) } -function DailyMoversSection(props: { contracts: CPMMBinaryContract[] }) { - const { contracts } = props +function DailyMoversSection(props: { + contracts: CPMMBinaryContract[] + metrics: ContractMetrics[] +}) { + const { contracts, metrics } = props - const changes = contracts.filter((c) => Math.abs(c.probChanges.day) >= 0.01) - - if (changes.length === 0) { + if (contracts.length === 0) { return null } return ( <Col className="gap-2"> <SectionHeader label="Daily movers" href="/daily-movers" /> - <ProbChangeTable changes={changes} /> + <ProfitChangeTable contracts={contracts} metrics={metrics} /> </Col> ) } -function DailyStats(props: { - user: User | null | undefined - className?: string -}) { - const { user, className } = props - - const metrics = usePortfolioHistory(user?.id ?? '', 'daily') ?? [] - const [first, last] = [metrics[0], metrics[metrics.length - 1]] +function DailyStats(props: { user: User | null | undefined }) { + const { user } = props const privateUser = usePrivateUser() const streaks = privateUser?.notificationPreferences?.betting_streaks ?? [] const streaksHidden = streaks.length === 0 - let profit = 0 - let profitPercent = 0 - if (first && last) { - profit = calculatePortfolioProfit(last) - calculatePortfolioProfit(first) - profitPercent = profit / first.investmentValue - } - return ( <Row className={'flex-shrink-0 gap-4'}> - <Col> - <div className="text-gray-500">Daily profit</div> - <Row className={clsx(className, 'items-center text-lg')}> - <span>{formatMoney(profit)}</span>{' '} - <ProfitBadge profitPercent={profitPercent * 100} /> - </Row> - </Col> + <DailyProfit user={user} /> {!streaksHidden && ( <Col> <div className="text-gray-500">Streak</div> <Row className={clsx( - className, 'items-center text-lg', user && !hasCompletedStreakToday(user) && 'grayscale' )} @@ -411,6 +405,39 @@ function DailyStats(props: { ) } +export function DailyProfit(props: { user: User | null | undefined }) { + const { user } = props + + const contractMetricsByProfit = useUserContractMetricsByProfit( + user?.id ?? '_', + 100 + ) + const profit = sum( + contractMetricsByProfit?.metrics.map((m) => + m.from ? m.from.day.profit : 0 + ) ?? [] + ) + + const metrics = usePortfolioHistory(user?.id ?? '', 'daily') ?? [] + const [first, last] = [metrics[0], metrics[metrics.length - 1]] + + let profitPercent = 0 + if (first && last) { + // profit = calculatePortfolioProfit(last) - calculatePortfolioProfit(first) + profitPercent = profit / first.investmentValue + } + + return ( + <SiteLink className="flex flex-col" href="/daily-movers"> + <div className="text-gray-500">Daily profit</div> + <Row className="items-center text-lg"> + <span>{formatMoney(profit)}</span>{' '} + <ProfitBadge profitPercent={profitPercent * 100} /> + </Row> + </SiteLink> + ) +} + export function TrendingGroupsSection(props: { user: User myGroups: Group[]