Create Top Followed Users leaderboard (#531)

* Create Top Followed Users leaderboard

* Switch to increment/decrement approach for caching user follower counts

* Backfill script for user follow counts

* Appease ESLint

* Address review comment

Co-authored-by: James Grugett <jahooma@gmail.com>
This commit is contained in:
Ben Congdon 2022-06-22 09:05:54 -07:00 committed by GitHub
parent 7a09365f00
commit 67d0a6c0c2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 118 additions and 4 deletions

4
common/follow.ts Normal file
View File

@ -0,0 +1,4 @@
export type Follow = {
userId: string
timestamp: number
}

View File

@ -18,6 +18,8 @@ export type User = {
totalPnLCached: number totalPnLCached: number
creatorVolumeCached: number creatorVolumeCached: number
followerCountCached: number
followedCategories?: string[] followedCategories?: string[]
} }

View File

@ -72,6 +72,7 @@ export const createUser = functions
createdTime: Date.now(), createdTime: Date.now(),
totalPnLCached: 0, totalPnLCached: 0,
creatorVolumeCached: 0, creatorVolumeCached: 0,
followerCountCached: 0,
followedCategories: DEFAULT_CATEGORIES, followedCategories: DEFAULT_CATEGORIES,
} }

View File

@ -25,6 +25,7 @@ export * from './on-create-answer'
export * from './on-update-contract' export * from './on-update-contract'
export * from './on-create-contract' export * from './on-create-contract'
export * from './on-follow-user' export * from './on-follow-user'
export * from './on-unfollow-user'
export * from './on-create-liquidity-provision' export * from './on-create-liquidity-provision'
// v2 // v2

View File

@ -1,12 +1,16 @@
import * as functions from 'firebase-functions' import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import { getUser } from './utils' import { getUser } from './utils'
import { createNotification } from './create-notification' import { createNotification } from './create-notification'
import { FieldValue } from 'firebase-admin/firestore'
export const onFollowUser = functions.firestore export const onFollowUser = functions.firestore
.document('users/{userId}/follows/{followedUserId}') .document('users/{userId}/follows/{followedUserId}')
.onCreate(async (change, context) => { .onCreate(async (change, context) => {
const { userId } = context.params as { const { userId, followedUserId } = context.params as {
userId: string userId: string
followedUserId: string
} }
const { eventId } = context const { eventId } = context
@ -15,6 +19,10 @@ export const onFollowUser = functions.firestore
const followingUser = await getUser(userId) const followingUser = await getUser(userId)
if (!followingUser) throw new Error('Could not find following user') if (!followingUser) throw new Error('Could not find following user')
await firestore.doc(`users/${followedUserId}`).update({
followerCountCached: FieldValue.increment(1),
})
await createNotification( await createNotification(
followingUser.id, followingUser.id,
'follow', 'follow',
@ -27,3 +35,5 @@ export const onFollowUser = functions.firestore
follow.userId follow.userId
) )
}) })
const firestore = admin.firestore()

View File

@ -0,0 +1,18 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import { FieldValue } from 'firebase-admin/firestore'
export const onUnfollowUser = functions.firestore
.document('users/{userId}/follows/{followedUserId}')
.onDelete(async (change, context) => {
const { followedUserId } = context.params as {
followedUserId: string
}
await firestore.doc(`users/${followedUserId}`).update({
followerCountCached: FieldValue.increment(-1),
})
})
const firestore = admin.firestore()

View File

@ -0,0 +1,46 @@
import * as admin from 'firebase-admin'
import { initAdmin } from './script-init'
initAdmin()
import { getValues } from '../utils'
import { User } from 'common/user'
import { Follow } from 'common/follow'
const firestore = admin.firestore()
async function backfillFollowers() {
console.log('Backfilling user follower counts')
const followerCounts: { [userId: string]: number } = {}
const users = await getValues<User>(firestore.collection('users'))
console.log(`Loaded ${users.length} users. Calculating follower counts...`)
for (const [idx, user] of users.entries()) {
console.log(`Querying user ${user.id} (${idx + 1}/${users.length})`)
const follows = await getValues<Follow>(
firestore.collection('users').doc(user.id).collection('follows')
)
for (const follow of follows) {
followerCounts[follow.userId] = (followerCounts[follow.userId] || 0) + 1
}
}
console.log(
`Finished calculating follower counts. Persisting cached follower counts...`
)
for (const [idx, user] of users.entries()) {
console.log(`Persisting user ${user.id} (${idx + 1}/${users.length})`)
const followerCount = followerCounts[user.id] || 0
await firestore
.collection('users')
.doc(user.id)
.update({ followerCountCached: followerCount })
}
}
if (require.main === module) {
backfillFollowers()
.then(() => process.exit())
.catch(console.log)
}

View File

@ -195,6 +195,17 @@ const topCreatorsQuery = query(
limit(20) limit(20)
) )
export async function getTopFollowed() {
const users = await getValues<User>(topFollowedQuery)
return users.slice(0, 20)
}
const topFollowedQuery = query(
collection(db, 'users'),
orderBy('followerCountCached', 'desc'),
limit(20)
)
export function getTopCreators() { export function getTopCreators() {
return getValues<User>(topCreatorsQuery) return getValues<User>(topCreatorsQuery)
} }

View File

@ -1,22 +1,29 @@
import { Col } from 'web/components/layout/col' import { Col } from 'web/components/layout/col'
import { Leaderboard } from 'web/components/leaderboard' import { Leaderboard } from 'web/components/leaderboard'
import { Page } from 'web/components/page' import { Page } from 'web/components/page'
import { getTopCreators, getTopTraders, User } from 'web/lib/firebase/users' import {
getTopCreators,
getTopTraders,
getTopFollowed,
User,
} from 'web/lib/firebase/users'
import { formatMoney } from 'common/util/format' import { formatMoney } from 'common/util/format'
import { fromPropz, usePropz } from 'web/hooks/use-propz' import { fromPropz, usePropz } from 'web/hooks/use-propz'
import { useTracking } from 'web/hooks/use-tracking' import { useTracking } from 'web/hooks/use-tracking'
export const getStaticProps = fromPropz(getStaticPropz) export const getStaticProps = fromPropz(getStaticPropz)
export async function getStaticPropz() { export async function getStaticPropz() {
const [topTraders, topCreators] = await Promise.all([ const [topTraders, topCreators, topFollowed] = await Promise.all([
getTopTraders().catch(() => {}), getTopTraders().catch(() => {}),
getTopCreators().catch(() => {}), getTopCreators().catch(() => {}),
getTopFollowed().catch(() => {}),
]) ])
return { return {
props: { props: {
topTraders, topTraders,
topCreators, topCreators,
topFollowed,
}, },
revalidate: 60, // regenerate after a minute revalidate: 60, // regenerate after a minute
@ -26,12 +33,14 @@ export async function getStaticPropz() {
export default function Leaderboards(props: { export default function Leaderboards(props: {
topTraders: User[] topTraders: User[]
topCreators: User[] topCreators: User[]
topFollowed: User[]
}) { }) {
props = usePropz(props, getStaticPropz) ?? { props = usePropz(props, getStaticPropz) ?? {
topTraders: [], topTraders: [],
topCreators: [], topCreators: [],
topFollowed: [],
} }
const { topTraders, topCreators } = props const { topTraders, topCreators, topFollowed } = props
useTracking('view leaderboards') useTracking('view leaderboards')
@ -59,6 +68,18 @@ export default function Leaderboards(props: {
]} ]}
/> />
</Col> </Col>
<Col className="mx-4 my-10 w-1/2 items-center gap-10 lg:mx-0 lg:flex-row">
<Leaderboard
title="👀 Most followed"
users={topFollowed}
columns={[
{
header: 'Number of followers',
renderCell: (user) => user.followerCountCached,
},
]}
/>
</Col>
</Page> </Page>
) )
} }