diff --git a/common/group.ts b/common/group.ts index 19f3b7b8..36654101 100644 --- a/common/group.ts +++ b/common/group.ts @@ -12,7 +12,22 @@ export type Group = { aboutPostId?: string chatDisabled?: boolean mostRecentContractAddedTime?: number + /** @deprecated - members and contracts now stored as subcollections*/ + memberIds?: string[] // Deprecated + /** @deprecated - members and contracts now stored as subcollections*/ + contractIds?: string[] // Deprecated + cachedLeaderboard?: { + topTraders: { + userId: string + score: number + }[] + topCreators: { + userId: string + score: number + }[] + } } + export const MAX_GROUP_NAME_LENGTH = 75 export const MAX_ABOUT_LENGTH = 140 export const MAX_ID_LENGTH = 60 diff --git a/functions/src/update-metrics.ts b/functions/src/update-metrics.ts index 430f3d33..273cd098 100644 --- a/functions/src/update-metrics.ts +++ b/functions/src/update-metrics.ts @@ -4,9 +4,11 @@ import { groupBy, isEmpty, keyBy, last, sortBy } from 'lodash' import { getValues, log, logMemory, writeAsync } from './utils' import { Bet } from '../../common/bet' import { Contract, CPMM } from '../../common/contract' + import { PortfolioMetrics, User } from '../../common/user' import { DAY_MS } from '../../common/util/time' import { getLoanUpdates } from '../../common/loans' +import { scoreTraders, scoreCreators } from '../../common/scoring' import { calculateCreatorVolume, calculateNewPortfolioMetrics, @@ -15,6 +17,7 @@ import { computeVolume, } from '../../common/calculate-metrics' import { getProbability } from '../../common/calculate' +import { Group } from 'common/group' const firestore = admin.firestore() @@ -24,16 +27,29 @@ export const updateMetrics = functions .onRun(updateMetricsCore) export async function updateMetricsCore() { - const [users, contracts, bets, allPortfolioHistories] = await Promise.all([ - getValues(firestore.collection('users')), - getValues(firestore.collection('contracts')), - getValues(firestore.collectionGroup('bets')), - getValues( - firestore - .collectionGroup('portfolioHistory') - .where('timestamp', '>', Date.now() - 31 * DAY_MS) // so it includes just over a month ago - ), - ]) + const [users, contracts, bets, allPortfolioHistories, groups] = + await Promise.all([ + getValues(firestore.collection('users')), + getValues(firestore.collection('contracts')), + getValues(firestore.collectionGroup('bets')), + getValues( + firestore + .collectionGroup('portfolioHistory') + .where('timestamp', '>', Date.now() - 31 * DAY_MS) // so it includes just over a month ago + ), + getValues(firestore.collection('groups')), + ]) + + const contractsByGroup = await Promise.all( + groups.map((group) => { + return getValues( + firestore + .collection('groups') + .doc(group.id) + .collection('groupContracts') + ) + }) + ) log( `Loaded ${users.length} users, ${contracts.length} contracts, and ${bets.length} bets.` ) @@ -162,4 +178,40 @@ export async function updateMetricsCore() { 'set' ) log(`Updated metrics for ${users.length} users.`) + + const groupUpdates = groups.map((group, index) => { + const groupContractIds = contractsByGroup[index] as GroupContractDoc[] + const groupContracts = groupContractIds.map( + (e) => contractsById[e.contractId] + ) + const bets = groupContracts.map((e) => { + return betsByContract[e.id] ?? [] + }) + + const creatorScores = scoreCreators(groupContracts) + const traderScores = scoreTraders(groupContracts, bets) + + const topTraderScores = topUserScores(traderScores) + const topCreatorScores = topUserScores(creatorScores) + + return { + doc: firestore.collection('groups').doc(group.id), + fields: { + cachedLeaderboard: { + topTraders: topTraderScores, + topCreators: topCreatorScores, + }, + }, + } + }) + await writeAsync(firestore, groupUpdates) } + +const topUserScores = (scores: { [userId: string]: number }) => { + const top50 = Object.entries(scores) + .sort(([, scoreA], [, scoreB]) => scoreB - scoreA) + .slice(0, 50) + return top50.map(([userId, score]) => ({ userId, score })) +} + +type GroupContractDoc = { contractId: string; createdTime: number } diff --git a/web/pages/group/[...slugs]/index.tsx b/web/pages/group/[...slugs]/index.tsx index ce8accb6..f5d68e57 100644 --- a/web/pages/group/[...slugs]/index.tsx +++ b/web/pages/group/[...slugs]/index.tsx @@ -1,12 +1,10 @@ import React, { useState } from 'react' import Link from 'next/link' import { useRouter } from 'next/router' -import { sortBy, take } from 'lodash' import { toast } from 'react-hot-toast' import { Group, GROUP_CHAT_SLUG } from 'common/group' import { Page } from 'web/components/page' -import { listAllBets } from 'web/lib/firebase/bets' import { Contract, listContractsByGroupSlug } from 'web/lib/firebase/contracts' import { addContractToGroup, @@ -25,7 +23,6 @@ import { useGroupContractIds, useMemberIds, } from 'web/hooks/use-group' -import { scoreCreators, scoreTraders } from 'common/scoring' import { Leaderboard } from 'web/components/leaderboard' import { formatMoney } from 'common/util/format' import { EditGroupButton } from 'web/components/groups/edit-group-button' @@ -72,19 +69,15 @@ export async function getStaticPropz(props: { params: { slugs: string[] } }) { : 'open' const aboutPost = group && group.aboutPostId != null && (await getPost(group.aboutPostId)) - const bets = await Promise.all( - contracts.map((contract: Contract) => listAllBets(contract.id)) - ) const messages = group && (await listAllCommentsOnGroup(group.id)) - const creatorScores = scoreCreators(contracts) - const traderScores = scoreTraders(contracts, bets) - const [topCreators, topTraders] = - (memberIds && [ - await toTopUsers(creatorScores, memberIds), - await toTopUsers(traderScores, memberIds), - ]) ?? - [] + const cachedTopTraderIds = + (group && group.cachedLeaderboard?.topTraders) ?? [] + const cachedTopCreatorIds = + (group && group.cachedLeaderboard?.topCreators) ?? [] + const topTraders = await toTopUsers(cachedTopTraderIds) + + const topCreators = await toTopUsers(cachedTopCreatorIds) const creator = await creatorPromise // Only count unresolved markets @@ -96,9 +89,7 @@ export async function getStaticPropz(props: { params: { slugs: string[] } }) { group, memberIds, creator, - traderScores, topTraders, - creatorScores, topCreators, messages, aboutPost, @@ -108,22 +99,6 @@ export async function getStaticPropz(props: { params: { slugs: string[] } }) { revalidate: 60, // regenerate after a minute } } - -function toTopUsers( - userScores: { [userId: string]: number }, - userIds: string[] -) { - const topUserPairs = take( - sortBy(Object.entries(userScores), ([_, score]) => -1 * score), - 10 - ).filter(([_, score]) => score >= 0.5) - - const topUserIds = topUserPairs - .map(([userId]) => userIds.filter((uid) => uid === userId)[0]) - .filter((userId) => userId != null) as string[] - - return Promise.all(topUserIds.map(getUser)) -} export async function getStaticPaths() { return { paths: [], fallback: 'blocking' } } @@ -140,10 +115,8 @@ export default function GroupPage(props: { group: Group | null memberIds: string[] creator: User - traderScores: { [userId: string]: number } - topTraders: User[] - creatorScores: { [userId: string]: number } - topCreators: User[] + topTraders: { user: User; score: number }[] + topCreators: { user: User; score: number }[] messages: GroupComment[] aboutPost: Post suggestedFilter: 'open' | 'all' @@ -153,22 +126,13 @@ export default function GroupPage(props: { group: null, memberIds: [], creator: null, - traderScores: {}, topTraders: [], - creatorScores: {}, topCreators: [], messages: [], suggestedFilter: 'open', } - const { - contractsCount, - creator, - traderScores, - topTraders, - creatorScores, - topCreators, - suggestedFilter, - } = props + const { contractsCount, creator, topTraders, topCreators, suggestedFilter } = + props const router = useRouter() const { slugs } = router.query as { slugs: string[] } @@ -191,17 +155,24 @@ export default function GroupPage(props: { } const isCreator = user && group && user.id === group.creatorId const isMember = user && memberIds.includes(user.id) + const maxLeaderboardSize = 50 const leaderboard = ( - +
+ + +
) @@ -408,96 +379,32 @@ function GroupOverview(props: { ) } -function SortedLeaderboard(props: { - users: User[] - scoreFunction: (user: User) => number +function GroupLeaderboard(props: { + topUsers: { user: User; score: number }[] title: string + maxToShow: number header: string - maxToShow?: number }) { - const { users, scoreFunction, title, header, maxToShow } = props + const { topUsers, title, maxToShow, header } = props + + const scoresByUser = topUsers.reduce((acc, { user, score }) => { + acc[user.id] = score + return acc + }, {} as { [key: string]: number }) return ( t.user)} title={title} columns={[ - { header, renderCell: (user) => formatMoney(scoreFunction(user)) }, + { header, renderCell: (user) => formatMoney(scoresByUser[user.id]) }, ]} maxToShow={maxToShow} /> ) } -function GroupLeaderboards(props: { - traderScores: { [userId: string]: number } - creatorScores: { [userId: string]: number } - topTraders: User[] - topCreators: User[] - memberIds: string[] - user: User | null | undefined -}) { - const { traderScores, creatorScores, memberIds, topTraders, topCreators } = - props - const maxToShow = 50 - // Consider hiding M$0 - // If it's just one member (curator), show all bettors, otherwise just show members - - return ( - -
- {memberIds.length > 1 ? ( - <> - traderScores[user.id] ?? 0} - title="🏅 Top traders" - header="Profit" - maxToShow={maxToShow} - /> - creatorScores[user.id] ?? 0} - title="🏅 Top creators" - header="Market volume" - maxToShow={maxToShow} - /> - - ) : ( - <> - formatMoney(traderScores[user.id] ?? 0), - }, - ]} - maxToShow={maxToShow} - /> - - formatMoney(creatorScores[user.id] ?? 0), - }, - ]} - maxToShow={maxToShow} - /> - - )} -
- - ) -} - function AddContractButton(props: { group: Group; user: User }) { const { group, user } = props const [open, setOpen] = useState(false) @@ -634,3 +541,15 @@ function JoinGroupButton(props: { ) } + +const toTopUsers = async ( + cachedUserIds: { userId: string; score: number }[] +): Promise<{ user: User; score: number }[]> => + ( + await Promise.all( + cachedUserIds.map(async (e) => { + const user = await getUser(e.userId) + return { user, score: e.score ?? 0 } + }) + ) + ).filter((e) => e.user != null)