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:
parent
7a09365f00
commit
67d0a6c0c2
4
common/follow.ts
Normal file
4
common/follow.ts
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
export type Follow = {
|
||||||
|
userId: string
|
||||||
|
timestamp: number
|
||||||
|
}
|
|
@ -18,6 +18,8 @@ export type User = {
|
||||||
totalPnLCached: number
|
totalPnLCached: number
|
||||||
creatorVolumeCached: number
|
creatorVolumeCached: number
|
||||||
|
|
||||||
|
followerCountCached: number
|
||||||
|
|
||||||
followedCategories?: string[]
|
followedCategories?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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()
|
||||||
|
|
18
functions/src/on-unfollow-user.ts
Normal file
18
functions/src/on-unfollow-user.ts
Normal 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()
|
46
functions/src/scripts/backfill-followers.ts
Normal file
46
functions/src/scripts/backfill-followers.ts
Normal 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)
|
||||||
|
}
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue
Block a user