diff --git a/functions/src/scripts/backfill-unique-bettors.ts b/functions/src/scripts/backfill-unique-bettors.ts new file mode 100644 index 00000000..35faa54a --- /dev/null +++ b/functions/src/scripts/backfill-unique-bettors.ts @@ -0,0 +1,39 @@ +import * as admin from 'firebase-admin' +import { initAdmin } from './script-init' +import { getValues, log, writeAsync } from '../utils' +import { Bet } from '../../../common/bet' +import { groupBy, mapValues, sortBy, uniq } from 'lodash' + +initAdmin() +const firestore = admin.firestore() + +const getBettorsByContractId = async () => { + const bets = await getValues(firestore.collectionGroup('bets')) + log(`Loaded ${bets.length} bets.`) + const betsByContractId = groupBy(bets, 'contractId') + return mapValues(betsByContractId, (bets) => + uniq(sortBy(bets, 'createdTime').map((bet) => bet.userId)) + ) +} + +const updateUniqueBettors = async () => { + const bettorsByContractId = await getBettorsByContractId() + + const updates = Object.entries(bettorsByContractId).map( + ([contractId, userIds]) => { + const update = { + uniqueBettorIds: userIds, + uniqueBettorCount: userIds.length, + } + const docRef = firestore.collection('contracts').doc(contractId) + return { doc: docRef, fields: update } + } + ) + log(`Updating ${updates.length} contracts.`) + await writeAsync(firestore, updates) + log(`Updated all contracts.`) +} + +if (require.main === module) { + updateUniqueBettors() +} diff --git a/web/components/bets-list.tsx b/web/components/bets-list.tsx index 4ac873b4..3270408b 100644 --- a/web/components/bets-list.tsx +++ b/web/components/bets-list.tsx @@ -1,14 +1,5 @@ import Link from 'next/link' -import { - Dictionary, - keyBy, - groupBy, - mapValues, - sortBy, - partition, - sumBy, - uniq, -} from 'lodash' +import { keyBy, groupBy, mapValues, sortBy, partition, sumBy } from 'lodash' import dayjs from 'dayjs' import { useEffect, useMemo, useState } from 'react' import clsx from 'clsx' @@ -28,7 +19,6 @@ import { Contract, contractPath, getBinaryProbPercent, - getContractFromId, } from 'web/lib/firebase/contracts' import { Row } from './layout/row' import { UserLink } from './user-page' @@ -56,9 +46,9 @@ import { SellSharesModal } from './sell-modal' import { useUnfilledBets } from 'web/hooks/use-bets' import { LimitBet } from 'common/bet' import { floatingEqual } from 'common/util/math' -import { filterDefined } from 'common/util/array' import { Pagination } from './pagination' import { LimitOrderTable } from './limit-bets' +import { useUserBetContracts } from 'web/hooks/use-contracts' type BetSort = 'newest' | 'profit' | 'closeTime' | 'value' type BetFilter = 'open' | 'limit_bet' | 'sold' | 'closed' | 'resolved' | 'all' @@ -72,26 +62,22 @@ export function BetsList(props: { user: User }) { const signedInUser = useUser() const isYourBets = user.id === signedInUser?.id const hideBetsBefore = isYourBets ? 0 : JUNE_1_2022 - const userBets = useUserBets(user.id, { includeRedemptions: true }) - const [contractsById, setContractsById] = useState< - Dictionary | undefined - >() + const userBets = useUserBets(user.id) // Hide bets before 06-01-2022 if this isn't your own profile // NOTE: This means public profits also begin on 06-01-2022 as well. const bets = useMemo( - () => userBets?.filter((bet) => bet.createdTime >= (hideBetsBefore ?? 0)), + () => + userBets?.filter( + (bet) => !bet.isAnte && bet.createdTime >= (hideBetsBefore ?? 0) + ), [userBets, hideBetsBefore] ) - useEffect(() => { - if (bets) { - const contractIds = uniq(bets.map((b) => b.contractId)) - Promise.all(contractIds.map(getContractFromId)).then((contracts) => { - setContractsById(keyBy(filterDefined(contracts), 'id')) - }) - } - }, [bets]) + const contractList = useUserBetContracts(user.id) + const contractsById = useMemo(() => { + return contractList ? keyBy(contractList, 'id') : undefined + }, [contractList]) const [sort, setSort] = useState('newest') const [filter, setFilter] = useState('open') diff --git a/web/components/nav/sidebar.tsx b/web/components/nav/sidebar.tsx index f40cd80e..3e1ecb83 100644 --- a/web/components/nav/sidebar.tsx +++ b/web/components/nav/sidebar.tsx @@ -319,7 +319,7 @@ function GroupsList(props: { currentPage: string; memberItems: Item[] }) { {memberItems.map((item) => ( { - switch (period) { - case 'daily': - return now - 1 * DAY_MS - case 'weekly': - return now - 7 * DAY_MS - case 'monthly': - return now - 30 * DAY_MS - case 'allTime': - default: - return new Date(0) - } -} export const PortfolioValueSection = memo( function PortfolioValueSection(props: { userId: string }) { const { userId } = props const [portfolioPeriod, setPortfolioPeriod] = useState('weekly') - const [portfolioHistory, setUsersPortfolioHistory] = useState< - PortfolioMetrics[] - >([]) + const portfolioHistory = usePortfolioHistory(userId, portfolioPeriod) - useEffect(() => { - const cutoff = periodToCutoff(Date.now(), portfolioPeriod).valueOf() - getPortfolioHistory(userId, cutoff).then(setUsersPortfolioHistory) - }, [portfolioPeriod, userId]) + // Remember the last defined portfolio history. + const portfolioRef = useRef(portfolioHistory) + if (portfolioHistory) portfolioRef.current = portfolioHistory + const currPortfolioHistory = portfolioRef.current - const lastPortfolioMetrics = last(portfolioHistory) - if (portfolioHistory.length === 0 || !lastPortfolioMetrics) { + const lastPortfolioMetrics = last(currPortfolioHistory) + if (!currPortfolioHistory || !lastPortfolioMetrics) { return <> } @@ -64,7 +47,7 @@ export const PortfolioValueSection = memo( diff --git a/web/hooks/use-contracts.ts b/web/hooks/use-contracts.ts index efe30d38..f277a209 100644 --- a/web/hooks/use-contracts.ts +++ b/web/hooks/use-contracts.ts @@ -1,3 +1,4 @@ +import { useFirestoreQueryData } from '@react-query-firebase/firestore' import { isEqual } from 'lodash' import { useEffect, useRef, useState } from 'react' import { @@ -8,6 +9,7 @@ import { listenForHotContracts, listenForInactiveContracts, listenForNewContracts, + getUserBetContractsQuery, } from 'web/lib/firebase/contracts' export const useContracts = () => { @@ -89,3 +91,15 @@ export const useUpdatedContracts = (contracts: Contract[] | undefined) => { ? contracts.map((c) => contractDict.current[c.id]) : undefined } + +export const useUserBetContracts = (userId: string) => { + const result = useFirestoreQueryData( + ['contracts', 'bets', userId], + getUserBetContractsQuery(userId), + { subscribe: true, includeMetadataChanges: true }, + // Temporary workaround for react-query bug: + // https://github.com/invertase/react-query-firebase/issues/25 + { refetchOnMount: 'always' } + ) + return result.data +} diff --git a/web/hooks/use-notifications.ts b/web/hooks/use-notifications.ts index d02d3d30..b2f1701f 100644 --- a/web/hooks/use-notifications.ts +++ b/web/hooks/use-notifications.ts @@ -20,8 +20,9 @@ function useNotifications(privateUser: PrivateUser) { { subscribe: true, includeMetadataChanges: true }, // Temporary workaround for react-query bug: // https://github.com/invertase/react-query-firebase/issues/25 - { cacheTime: 0 } + { refetchOnMount: 'always' } ) + const notifications = useMemo(() => { if (!result.data) return undefined const notifications = result.data as Notification[] diff --git a/web/hooks/use-portfolio-history.ts b/web/hooks/use-portfolio-history.ts new file mode 100644 index 00000000..d5919783 --- /dev/null +++ b/web/hooks/use-portfolio-history.ts @@ -0,0 +1,32 @@ +import { useFirestoreQueryData } from '@react-query-firebase/firestore' +import { DAY_MS, HOUR_MS } from 'common/util/time' +import { getPortfolioHistoryQuery, Period } from 'web/lib/firebase/users' + +export const usePortfolioHistory = (userId: string, period: Period) => { + const nowRounded = Math.round(Date.now() / HOUR_MS) * HOUR_MS + const cutoff = periodToCutoff(nowRounded, period).valueOf() + + const result = useFirestoreQueryData( + ['portfolio-history', userId, cutoff], + getPortfolioHistoryQuery(userId, cutoff), + { subscribe: true, includeMetadataChanges: true }, + // Temporary workaround for react-query bug: + // https://github.com/invertase/react-query-firebase/issues/25 + { refetchOnMount: 'always' } + ) + return result.data +} + +const periodToCutoff = (now: number, period: Period) => { + switch (period) { + case 'daily': + return now - 1 * DAY_MS + case 'weekly': + return now - 7 * DAY_MS + case 'monthly': + return now - 30 * DAY_MS + case 'allTime': + default: + return new Date(0) + } +} diff --git a/web/hooks/use-prefetch.ts b/web/hooks/use-prefetch.ts new file mode 100644 index 00000000..e22e13eb --- /dev/null +++ b/web/hooks/use-prefetch.ts @@ -0,0 +1,11 @@ +import { useUserBetContracts } from './use-contracts' +import { usePortfolioHistory } from './use-portfolio-history' +import { useUserBets } from './use-user-bets' + +export function usePrefetch(userId: string | undefined) { + const maybeUserId = userId ?? '' + + useUserBets(maybeUserId) + useUserBetContracts(maybeUserId) + usePortfolioHistory(maybeUserId, 'weekly') +} diff --git a/web/hooks/use-user-bets.ts b/web/hooks/use-user-bets.ts index b260a406..72a4e5bf 100644 --- a/web/hooks/use-user-bets.ts +++ b/web/hooks/use-user-bets.ts @@ -1,22 +1,21 @@ -import { uniq } from 'lodash' +import { useFirestoreQueryData } from '@react-query-firebase/firestore' import { useEffect, useState } from 'react' import { Bet, - listenForUserBets, + getUserBetsQuery, listenForUserContractBets, } from 'web/lib/firebase/bets' -export const useUserBets = ( - userId: string | undefined, - options: { includeRedemptions: boolean } -) => { - const [bets, setBets] = useState(undefined) - - useEffect(() => { - if (userId) return listenForUserBets(userId, setBets, options) - }, [userId]) - - return bets +export const useUserBets = (userId: string) => { + const result = useFirestoreQueryData( + ['bets', userId], + getUserBetsQuery(userId), + { subscribe: true, includeMetadataChanges: true }, + // Temporary workaround for react-query bug: + // https://github.com/invertase/react-query-firebase/issues/25 + { refetchOnMount: 'always' } + ) + return result.data } export const useUserContractBets = ( @@ -33,36 +32,6 @@ export const useUserContractBets = ( return bets } -export const useUserBetContracts = ( - userId: string | undefined, - options: { includeRedemptions: boolean } -) => { - const [contractIds, setContractIds] = useState() - - useEffect(() => { - if (userId) { - const key = `user-bet-contractIds-${userId}` - - const userBetContractJson = localStorage.getItem(key) - if (userBetContractJson) { - setContractIds(JSON.parse(userBetContractJson)) - } - - return listenForUserBets( - userId, - (bets) => { - const contractIds = uniq(bets.map((bet) => bet.contractId)) - setContractIds(contractIds) - localStorage.setItem(key, JSON.stringify(contractIds)) - }, - options - ) - } - }, [userId]) - - return contractIds -} - export const useGetUserBetContractIds = (userId: string | undefined) => { const [contractIds, setContractIds] = useState() diff --git a/web/lib/firebase/bets.ts b/web/lib/firebase/bets.ts index ef0ab55d..2a095d32 100644 --- a/web/lib/firebase/bets.ts +++ b/web/lib/firebase/bets.ts @@ -11,6 +11,7 @@ import { getDocs, getDoc, DocumentSnapshot, + Query, } from 'firebase/firestore' import { uniq } from 'lodash' @@ -131,24 +132,12 @@ export async function getContractsOfUserBets(userId: string) { return filterDefined(contracts) } -export function listenForUserBets( - userId: string, - setBets: (bets: Bet[]) => void, - options: { includeRedemptions: boolean } -) { - const { includeRedemptions } = options - const userQuery = query( +export function getUserBetsQuery(userId: string) { + return query( collectionGroup(db, 'bets'), where('userId', '==', userId), orderBy('createdTime', 'desc') - ) - return listenForValues(userQuery, (bets) => { - setBets( - bets.filter( - (bet) => (includeRedemptions || !bet.isRedemption) && !bet.isAnte - ) - ) - }) + ) as Query } export function listenForUserContractBets( diff --git a/web/lib/firebase/contracts.ts b/web/lib/firebase/contracts.ts index d3f18f54..2751e9bb 100644 --- a/web/lib/firebase/contracts.ts +++ b/web/lib/firebase/contracts.ts @@ -6,6 +6,7 @@ import { getDocs, limit, orderBy, + Query, query, setDoc, startAfter, @@ -156,6 +157,13 @@ export function listenForUserContracts( return listenForValues(q, setContracts) } +export function getUserBetContractsQuery(userId: string) { + return query( + contracts, + where('uniqueBettorIds', 'array-contains', userId) + ) as Query +} + const activeContractsQuery = query( contracts, where('isResolved', '==', false), diff --git a/web/lib/firebase/users.ts b/web/lib/firebase/users.ts index bad13c8c..c0764f0a 100644 --- a/web/lib/firebase/users.ts +++ b/web/lib/firebase/users.ts @@ -12,6 +12,7 @@ import { deleteDoc, collectionGroup, onSnapshot, + Query, } from 'firebase/firestore' import { getAuth } from 'firebase/auth' import { GoogleAuthProvider, signInWithPopup } from 'firebase/auth' @@ -252,15 +253,13 @@ export async function unfollow(userId: string, unfollowedUserId: string) { await deleteDoc(followDoc) } -export async function getPortfolioHistory(userId: string, since: number) { - return getValues( - query( - collectionGroup(db, 'portfolioHistory'), - where('userId', '==', userId), - where('timestamp', '>=', since), - orderBy('timestamp', 'asc') - ) - ) +export function getPortfolioHistoryQuery(userId: string, since: number) { + return query( + collectionGroup(db, 'portfolioHistory'), + where('userId', '==', userId), + where('timestamp', '>=', since), + orderBy('timestamp', 'asc') + ) as Query } export function listenForFollows( diff --git a/web/pages/[username]/[contractSlug].tsx b/web/pages/[username]/[contractSlug].tsx index 3667511e..2ef472fc 100644 --- a/web/pages/[username]/[contractSlug].tsx +++ b/web/pages/[username]/[contractSlug].tsx @@ -42,6 +42,7 @@ import { } from 'web/components/contract/contract-leaderboard' import { ContractsGrid } from 'web/components/contract/contracts-grid' import { Title } from 'web/components/title' +import { usePrefetch } from 'web/hooks/use-prefetch' export const getStaticProps = fromPropz(getStaticPropz) export async function getStaticPropz(props: { @@ -157,6 +158,7 @@ export function ContractPageContent( const { backToHome, comments, user } = props const contract = useContractWithPreload(props.contract) ?? props.contract + usePrefetch(user?.id) useTracking('view market', { slug: contract.slug, diff --git a/web/pages/home.tsx b/web/pages/home.tsx index 265fd79a..5b6c445c 100644 --- a/web/pages/home.tsx +++ b/web/pages/home.tsx @@ -15,6 +15,7 @@ import { track } from 'web/lib/service/analytics' import { authenticateOnServer } from 'web/lib/firebase/server-auth' import { useSaveReferral } from 'web/hooks/use-save-referral' import { GetServerSideProps } from 'next' +import { usePrefetch } from 'web/hooks/use-prefetch' export const getServerSideProps: GetServerSideProps = async (ctx) => { const creds = await authenticateOnServer(ctx) @@ -30,6 +31,7 @@ const Home = (props: { auth: { user: User } | null }) => { useTracking('view home') useSaveReferral() + usePrefetch(user?.id) return ( <>