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 | ||||
|   creatorVolumeCached: number | ||||
| 
 | ||||
|   followerCountCached: number | ||||
| 
 | ||||
|   followedCategories?: string[] | ||||
| } | ||||
| 
 | ||||
|  |  | |||
|  | @ -72,6 +72,7 @@ export const createUser = functions | |||
|       createdTime: Date.now(), | ||||
|       totalPnLCached: 0, | ||||
|       creatorVolumeCached: 0, | ||||
|       followerCountCached: 0, | ||||
|       followedCategories: DEFAULT_CATEGORIES, | ||||
|     } | ||||
| 
 | ||||
|  |  | |||
|  | @ -25,6 +25,7 @@ export * from './on-create-answer' | |||
| export * from './on-update-contract' | ||||
| export * from './on-create-contract' | ||||
| export * from './on-follow-user' | ||||
| export * from './on-unfollow-user' | ||||
| export * from './on-create-liquidity-provision' | ||||
| 
 | ||||
| // v2
 | ||||
|  |  | |||
|  | @ -1,12 +1,16 @@ | |||
| import * as functions from 'firebase-functions' | ||||
| import * as admin from 'firebase-admin' | ||||
| 
 | ||||
| import { getUser } from './utils' | ||||
| import { createNotification } from './create-notification' | ||||
| import { FieldValue } from 'firebase-admin/firestore' | ||||
| 
 | ||||
| export const onFollowUser = functions.firestore | ||||
|   .document('users/{userId}/follows/{followedUserId}') | ||||
|   .onCreate(async (change, context) => { | ||||
|     const { userId } = context.params as { | ||||
|     const { userId, followedUserId } = context.params as { | ||||
|       userId: string | ||||
|       followedUserId: string | ||||
|     } | ||||
|     const { eventId } = context | ||||
| 
 | ||||
|  | @ -15,6 +19,10 @@ export const onFollowUser = functions.firestore | |||
|     const followingUser = await getUser(userId) | ||||
|     if (!followingUser) throw new Error('Could not find following user') | ||||
| 
 | ||||
|     await firestore.doc(`users/${followedUserId}`).update({ | ||||
|       followerCountCached: FieldValue.increment(1), | ||||
|     }) | ||||
| 
 | ||||
|     await createNotification( | ||||
|       followingUser.id, | ||||
|       'follow', | ||||
|  | @ -27,3 +35,5 @@ export const onFollowUser = functions.firestore | |||
|       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) | ||||
| ) | ||||
| 
 | ||||
| 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() { | ||||
|   return getValues<User>(topCreatorsQuery) | ||||
| } | ||||
|  |  | |||
|  | @ -1,22 +1,29 @@ | |||
| import { Col } from 'web/components/layout/col' | ||||
| import { Leaderboard } from 'web/components/leaderboard' | ||||
| 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 { fromPropz, usePropz } from 'web/hooks/use-propz' | ||||
| import { useTracking } from 'web/hooks/use-tracking' | ||||
| 
 | ||||
| export const getStaticProps = fromPropz(getStaticPropz) | ||||
| export async function getStaticPropz() { | ||||
|   const [topTraders, topCreators] = await Promise.all([ | ||||
|   const [topTraders, topCreators, topFollowed] = await Promise.all([ | ||||
|     getTopTraders().catch(() => {}), | ||||
|     getTopCreators().catch(() => {}), | ||||
|     getTopFollowed().catch(() => {}), | ||||
|   ]) | ||||
| 
 | ||||
|   return { | ||||
|     props: { | ||||
|       topTraders, | ||||
|       topCreators, | ||||
|       topFollowed, | ||||
|     }, | ||||
| 
 | ||||
|     revalidate: 60, // regenerate after a minute
 | ||||
|  | @ -26,12 +33,14 @@ export async function getStaticPropz() { | |||
| export default function Leaderboards(props: { | ||||
|   topTraders: User[] | ||||
|   topCreators: User[] | ||||
|   topFollowed: User[] | ||||
| }) { | ||||
|   props = usePropz(props, getStaticPropz) ?? { | ||||
|     topTraders: [], | ||||
|     topCreators: [], | ||||
|     topFollowed: [], | ||||
|   } | ||||
|   const { topTraders, topCreators } = props | ||||
|   const { topTraders, topCreators, topFollowed } = props | ||||
| 
 | ||||
|   useTracking('view leaderboards') | ||||
| 
 | ||||
|  | @ -59,6 +68,18 @@ export default function Leaderboards(props: { | |||
|           ]} | ||||
|         /> | ||||
|       </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> | ||||
|   ) | ||||
| } | ||||
|  |  | |||
		Loading…
	
		Reference in New Issue
	
	Block a user