From 0067bee94be823864d59962126a7cccdeed62032 Mon Sep 17 00:00:00 2001 From: Marshall Polaris Date: Sun, 26 Jun 2022 14:42:42 -0700 Subject: [PATCH] Compute stats in Firebase instead of Vercel (#584) * Add stats updating cloud function * Read stats from database on client instead of computing them * Improve logging for stats updater * Tidying up --- common/stats.ts | 23 ++ firestore.rules | 6 +- functions/src/index.ts | 1 + functions/src/scripts/update-stats.ts | 15 ++ functions/src/update-stats.ts | 316 ++++++++++++++++++++++++++ web/lib/firebase/bets.ts | 23 +- web/lib/firebase/comments.ts | 28 --- web/lib/firebase/contracts.ts | 31 +-- web/lib/firebase/stats.ts | 15 ++ web/lib/firebase/users.ts | 27 +-- web/pages/embed/analytics.tsx | 23 +- web/pages/stats.tsx | 269 +--------------------- 12 files changed, 402 insertions(+), 375 deletions(-) create mode 100644 common/stats.ts create mode 100644 functions/src/scripts/update-stats.ts create mode 100644 functions/src/update-stats.ts create mode 100644 web/lib/firebase/stats.ts diff --git a/common/stats.ts b/common/stats.ts new file mode 100644 index 00000000..152a6eae --- /dev/null +++ b/common/stats.ts @@ -0,0 +1,23 @@ +export type Stats = { + startDate: number + dailyActiveUsers: number[] + weeklyActiveUsers: number[] + monthlyActiveUsers: number[] + dailyBetCounts: number[] + dailyContractCounts: number[] + dailyCommentCounts: number[] + dailySignups: number[] + weekOnWeekRetention: number[] + monthlyRetention: number[] + weeklyActivationRate: number[] + topTenthActions: { + daily: number[] + weekly: number[] + monthly: number[] + } + manaBet: { + daily: number[] + weekly: number[] + monthly: number[] + } +} diff --git a/firestore.rules b/firestore.rules index 7f02a43d..176cc71e 100644 --- a/firestore.rules +++ b/firestore.rules @@ -12,13 +12,17 @@ service cloud.firestore { || request.auth.uid == 'IPTOzEqrpkWmEzh6hwvAyY9PqFb2' // Manifold } + match /stats/stats { + allow read; + } + match /users/{userId} { allow read; allow update: if resource.data.id == request.auth.uid && request.resource.data.diff(resource.data).affectedKeys() .hasOnly(['bio', 'bannerUrl', 'website', 'twitterHandle', 'discordHandle', 'followedCategories']); } - + match /{somePath=**}/portfolioHistory/{portfolioHistoryId} { allow read; } diff --git a/functions/src/index.ts b/functions/src/index.ts index 0a538ff8..dcd50e66 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -15,6 +15,7 @@ export * from './on-create-comment' export * from './on-view' export * from './unsubscribe' export * from './update-metrics' +export * from './update-stats' export * from './backup-db' export * from './change-user-info' export * from './market-close-notifications' diff --git a/functions/src/scripts/update-stats.ts b/functions/src/scripts/update-stats.ts new file mode 100644 index 00000000..105230ba --- /dev/null +++ b/functions/src/scripts/update-stats.ts @@ -0,0 +1,15 @@ +import { initAdmin } from './script-init' +initAdmin() + +import { log, logMemory } from '../utils' +import { updateStatsCore } from '../update-stats' + +async function updateStats() { + logMemory() + log('Updating stats...') + await updateStatsCore() +} + +if (require.main === module) { + updateStats().then(() => process.exit()) +} diff --git a/functions/src/update-stats.ts b/functions/src/update-stats.ts new file mode 100644 index 00000000..f99458ef --- /dev/null +++ b/functions/src/update-stats.ts @@ -0,0 +1,316 @@ +import * as functions from 'firebase-functions' +import * as admin from 'firebase-admin' +import { concat, countBy, sortBy, range, zip, uniq, sum, sumBy } from 'lodash' +import { getValues, log, logMemory } from './utils' +import { Bet } from '../../common/bet' +import { Contract } from '../../common/contract' +import { Comment } from '../../common/comment' +import { User } from '../../common/user' +import { DAY_MS } from '../../common/util/time' +import { average } from '../../common/util/math' + +const firestore = admin.firestore() + +const numberOfDays = 90 + +const getBetsQuery = (startTime: number, endTime: number) => + firestore + .collectionGroup('bets') + .where('createdTime', '>=', startTime) + .where('createdTime', '<', endTime) + .orderBy('createdTime', 'asc') + +export async function getDailyBets(startTime: number, numberOfDays: number) { + const query = getBetsQuery(startTime, startTime + DAY_MS * numberOfDays) + const bets = await getValues(query) + + const betsByDay = range(0, numberOfDays).map(() => [] as Bet[]) + for (const bet of bets) { + const dayIndex = Math.floor((bet.createdTime - startTime) / DAY_MS) + betsByDay[dayIndex].push(bet) + } + + return betsByDay +} + +const getCommentsQuery = (startTime: number, endTime: number) => + firestore + .collectionGroup('comments') + .where('createdTime', '>=', startTime) + .where('createdTime', '<', endTime) + .orderBy('createdTime', 'asc') + +export async function getDailyComments( + startTime: number, + numberOfDays: number +) { + const query = getCommentsQuery(startTime, startTime + DAY_MS * numberOfDays) + const comments = await getValues(query) + + const commentsByDay = range(0, numberOfDays).map(() => [] as Comment[]) + for (const comment of comments) { + const dayIndex = Math.floor((comment.createdTime - startTime) / DAY_MS) + commentsByDay[dayIndex].push(comment) + } + + return commentsByDay +} + +const getContractsQuery = (startTime: number, endTime: number) => + firestore + .collection('contracts') + .where('createdTime', '>=', startTime) + .where('createdTime', '<', endTime) + .orderBy('createdTime', 'asc') + +export async function getDailyContracts( + startTime: number, + numberOfDays: number +) { + const query = getContractsQuery(startTime, startTime + DAY_MS * numberOfDays) + const contracts = await getValues(query) + + const contractsByDay = range(0, numberOfDays).map(() => [] as Contract[]) + for (const contract of contracts) { + const dayIndex = Math.floor((contract.createdTime - startTime) / DAY_MS) + contractsByDay[dayIndex].push(contract) + } + + return contractsByDay +} + +const getUsersQuery = (startTime: number, endTime: number) => + firestore + .collection('users') + .where('createdTime', '>=', startTime) + .where('createdTime', '<', endTime) + .orderBy('createdTime', 'asc') + +export async function getDailyNewUsers( + startTime: number, + numberOfDays: number +) { + const query = getUsersQuery(startTime, startTime + DAY_MS * numberOfDays) + const users = await getValues(query) + + const usersByDay = range(0, numberOfDays).map(() => [] as User[]) + for (const user of users) { + const dayIndex = Math.floor((user.createdTime - startTime) / DAY_MS) + usersByDay[dayIndex].push(user) + } + + return usersByDay +} + +export const updateStatsCore = async () => { + const today = Date.now() + const startDate = today - numberOfDays * DAY_MS + + log('Fetching data for stats update...') + const [dailyBets, dailyContracts, dailyComments, dailyNewUsers] = + await Promise.all([ + getDailyBets(startDate.valueOf(), numberOfDays), + getDailyContracts(startDate.valueOf(), numberOfDays), + getDailyComments(startDate.valueOf(), numberOfDays), + getDailyNewUsers(startDate.valueOf(), numberOfDays), + ]) + logMemory() + + const dailyBetCounts = dailyBets.map((bets) => bets.length) + const dailyContractCounts = dailyContracts.map( + (contracts) => contracts.length + ) + const dailyCommentCounts = dailyComments.map((comments) => comments.length) + + const dailyUserIds = zip(dailyContracts, dailyBets, dailyComments).map( + ([contracts, bets, comments]) => { + const creatorIds = (contracts ?? []).map((c) => c.creatorId) + const betUserIds = (bets ?? []).map((bet) => bet.userId) + const commentUserIds = (comments ?? []).map((comment) => comment.userId) + return uniq([...creatorIds, ...betUserIds, ...commentUserIds]) + } + ) + log( + `Fetched ${sum(dailyBetCounts)} bets, ${sum( + dailyContractCounts + )} contracts, ${sum(dailyComments)} comments, from ${sum( + dailyNewUsers + )} unique users.` + ) + + const dailyActiveUsers = dailyUserIds.map((userIds) => userIds.length) + + const weeklyActiveUsers = dailyUserIds.map((_, i) => { + const start = Math.max(0, i - 6) + const end = i + const uniques = new Set() + for (let j = start; j <= end; j++) + dailyUserIds[j].forEach((userId) => uniques.add(userId)) + return uniques.size + }) + + const monthlyActiveUsers = dailyUserIds.map((_, i) => { + const start = Math.max(0, i - 29) + const end = i + const uniques = new Set() + for (let j = start; j <= end; j++) + dailyUserIds[j].forEach((userId) => uniques.add(userId)) + return uniques.size + }) + + const weekOnWeekRetention = dailyUserIds.map((_userId, i) => { + const twoWeeksAgo = { + start: Math.max(0, i - 13), + end: Math.max(0, i - 7), + } + const lastWeek = { + start: Math.max(0, i - 6), + end: i, + } + + const activeTwoWeeksAgo = new Set() + for (let j = twoWeeksAgo.start; j <= twoWeeksAgo.end; j++) { + dailyUserIds[j].forEach((userId) => activeTwoWeeksAgo.add(userId)) + } + const activeLastWeek = new Set() + for (let j = lastWeek.start; j <= lastWeek.end; j++) { + dailyUserIds[j].forEach((userId) => activeLastWeek.add(userId)) + } + const retainedCount = sumBy(Array.from(activeTwoWeeksAgo), (userId) => + activeLastWeek.has(userId) ? 1 : 0 + ) + const retainedFrac = retainedCount / activeTwoWeeksAgo.size + return Math.round(retainedFrac * 100 * 100) / 100 + }) + + const monthlyRetention = dailyUserIds.map((_userId, i) => { + const twoMonthsAgo = { + start: Math.max(0, i - 60), + end: Math.max(0, i - 30), + } + const lastMonth = { + start: Math.max(0, i - 30), + end: i, + } + + const activeTwoMonthsAgo = new Set() + for (let j = twoMonthsAgo.start; j <= twoMonthsAgo.end; j++) { + dailyUserIds[j].forEach((userId) => activeTwoMonthsAgo.add(userId)) + } + const activeLastMonth = new Set() + for (let j = lastMonth.start; j <= lastMonth.end; j++) { + dailyUserIds[j].forEach((userId) => activeLastMonth.add(userId)) + } + const retainedCount = sumBy(Array.from(activeTwoMonthsAgo), (userId) => + activeLastMonth.has(userId) ? 1 : 0 + ) + const retainedFrac = retainedCount / activeTwoMonthsAgo.size + return Math.round(retainedFrac * 100 * 100) / 100 + }) + + const firstBetDict: { [userId: string]: number } = {} + for (let i = 0; i < dailyBets.length; i++) { + const bets = dailyBets[i] + for (const bet of bets) { + if (bet.userId in firstBetDict) continue + firstBetDict[bet.userId] = i + } + } + const weeklyActivationRate = dailyNewUsers.map((_, i) => { + const start = Math.max(0, i - 6) + const end = i + let activatedCount = 0 + let newUsers = 0 + for (let j = start; j <= end; j++) { + const userIds = dailyNewUsers[j].map((user) => user.id) + newUsers += userIds.length + for (const userId of userIds) { + const dayIndex = firstBetDict[userId] + if (dayIndex !== undefined && dayIndex <= end) { + activatedCount++ + } + } + } + const frac = activatedCount / (newUsers || 1) + return Math.round(frac * 100 * 100) / 100 + }) + const dailySignups = dailyNewUsers.map((users) => users.length) + + const dailyTopTenthActions = zip( + dailyContracts, + dailyBets, + dailyComments + ).map(([contracts, bets, comments]) => { + const userIds = concat( + contracts?.map((c) => c.creatorId) ?? [], + bets?.map((b) => b.userId) ?? [], + comments?.map((c) => c.userId) ?? [] + ) + const counts = Object.values(countBy(userIds)) + const sortedCounts = sortBy(counts, (count) => count).reverse() + if (sortedCounts.length === 0) return 0 + const tenthPercentile = sortedCounts[Math.floor(sortedCounts.length * 0.1)] + return tenthPercentile + }) + const weeklyTopTenthActions = dailyTopTenthActions.map((_, i) => { + const start = Math.max(0, i - 6) + const end = i + return average(dailyTopTenthActions.slice(start, end)) + }) + const monthlyTopTenthActions = dailyTopTenthActions.map((_, i) => { + const start = Math.max(0, i - 29) + const end = i + return average(dailyTopTenthActions.slice(start, end)) + }) + + // Total mana divided by 100. + const dailyManaBet = dailyBets.map((bets) => { + return Math.round(sumBy(bets, (bet) => bet.amount) / 100) + }) + const weeklyManaBet = dailyManaBet.map((_, i) => { + const start = Math.max(0, i - 6) + const end = i + const total = sum(dailyManaBet.slice(start, end)) + if (end - start < 7) return (total * 7) / (end - start) + return total + }) + const monthlyManaBet = dailyManaBet.map((_, i) => { + const start = Math.max(0, i - 29) + const end = i + const total = sum(dailyManaBet.slice(start, end)) + const range = end - start + 1 + if (range < 30) return (total * 30) / range + return total + }) + + const statsData = { + startDate: startDate.valueOf(), + dailyActiveUsers, + weeklyActiveUsers, + monthlyActiveUsers, + dailyBetCounts, + dailyContractCounts, + dailyCommentCounts, + dailySignups, + weekOnWeekRetention, + weeklyActivationRate, + monthlyRetention, + topTenthActions: { + daily: dailyTopTenthActions, + weekly: weeklyTopTenthActions, + monthly: monthlyTopTenthActions, + }, + manaBet: { + daily: dailyManaBet, + weekly: weeklyManaBet, + monthly: monthlyManaBet, + }, + } + log('Computed stats: ', statsData) + await firestore.doc('stats/stats').set(statsData) +} + +export const updateStats = functions + .runWith({ memory: '1GB', timeoutSeconds: 540 }) + .pubsub.schedule('every 60 minutes') + .onRun(updateStatsCore) diff --git a/web/lib/firebase/bets.ts b/web/lib/firebase/bets.ts index 5311cee4..c442ff73 100644 --- a/web/lib/firebase/bets.ts +++ b/web/lib/firebase/bets.ts @@ -5,7 +5,7 @@ import { where, orderBy, } from 'firebase/firestore' -import { range, uniq } from 'lodash' +import { uniq } from 'lodash' import { db } from './init' import { Bet } from 'common/bet' @@ -136,24 +136,3 @@ export function withoutAnteBets(contract: Contract, bets?: Bet[]) { return bets?.filter((bet) => !bet.isAnte) ?? [] } - -const getBetsQuery = (startTime: number, endTime: number) => - query( - collectionGroup(db, 'bets'), - where('createdTime', '>=', startTime), - where('createdTime', '<', endTime), - orderBy('createdTime', 'asc') - ) - -export async function getDailyBets(startTime: number, numberOfDays: number) { - const query = getBetsQuery(startTime, startTime + DAY_IN_MS * numberOfDays) - const bets = await getValues(query) - - const betsByDay = range(0, numberOfDays).map(() => [] as Bet[]) - for (const bet of bets) { - const dayIndex = Math.floor((bet.createdTime - startTime) / DAY_IN_MS) - betsByDay[dayIndex].push(bet) - } - - return betsByDay -} diff --git a/web/lib/firebase/comments.ts b/web/lib/firebase/comments.ts index 9d418507..3093f764 100644 --- a/web/lib/firebase/comments.ts +++ b/web/lib/firebase/comments.ts @@ -7,7 +7,6 @@ import { setDoc, where, } from 'firebase/firestore' -import { range } from 'lodash' import { getValues, listenForValues } from './utils' import { db } from './init' @@ -136,33 +135,6 @@ export function listenForRecentComments( return listenForValues(recentCommentsQuery, setComments) } -const getCommentsQuery = (startTime: number, endTime: number) => - query( - collectionGroup(db, 'comments'), - where('createdTime', '>=', startTime), - where('createdTime', '<', endTime), - orderBy('createdTime', 'asc') - ) - -export async function getDailyComments( - startTime: number, - numberOfDays: number -) { - const query = getCommentsQuery( - startTime, - startTime + DAY_IN_MS * numberOfDays - ) - const comments = await getValues(query) - - const commentsByDay = range(0, numberOfDays).map(() => [] as Comment[]) - for (const comment of comments) { - const dayIndex = Math.floor((comment.createdTime - startTime) / DAY_IN_MS) - commentsByDay[dayIndex].push(comment) - } - - return commentsByDay -} - const getUsersCommentsQuery = (userId: string) => query( collectionGroup(db, 'comments'), diff --git a/web/lib/firebase/contracts.ts b/web/lib/firebase/contracts.ts index 184135f0..f177d841 100644 --- a/web/lib/firebase/contracts.ts +++ b/web/lib/firebase/contracts.ts @@ -14,7 +14,7 @@ import { limit, startAfter, } from 'firebase/firestore' -import { range, sortBy, sum } from 'lodash' +import { sortBy, sum } from 'lodash' import { app } from './init' import { getValues, listenForValue, listenForValues } from './utils' @@ -303,35 +303,6 @@ export async function getClosingSoonContracts() { ) } -const getContractsQuery = (startTime: number, endTime: number) => - query( - collection(db, 'contracts'), - where('createdTime', '>=', startTime), - where('createdTime', '<', endTime), - orderBy('createdTime', 'asc') - ) - -const DAY_IN_MS = 24 * 60 * 60 * 1000 - -export async function getDailyContracts( - startTime: number, - numberOfDays: number -) { - const query = getContractsQuery( - startTime, - startTime + DAY_IN_MS * numberOfDays - ) - const contracts = await getValues(query) - - const contractsByDay = range(0, numberOfDays).map(() => [] as Contract[]) - for (const contract of contracts) { - const dayIndex = Math.floor((contract.createdTime - startTime) / DAY_IN_MS) - contractsByDay[dayIndex].push(contract) - } - - return contractsByDay -} - export async function getRecentBetsAndComments(contract: Contract) { const contractDoc = doc(db, 'contracts', contract.id) diff --git a/web/lib/firebase/stats.ts b/web/lib/firebase/stats.ts new file mode 100644 index 00000000..994645ca --- /dev/null +++ b/web/lib/firebase/stats.ts @@ -0,0 +1,15 @@ +import { + CollectionReference, + doc, + collection, + getDoc, +} from 'firebase/firestore' +import { db } from 'web/lib/firebase/init' +import { Stats } from 'common/stats' + +const statsCollection = collection(db, 'stats') as CollectionReference +const statsDoc = doc(statsCollection, 'stats') + +export const getStats = async () => { + return (await getDoc(statsDoc)).data() +} diff --git a/web/lib/firebase/users.ts b/web/lib/firebase/users.ts index a3bba98a..e9fcbb93 100644 --- a/web/lib/firebase/users.ts +++ b/web/lib/firebase/users.ts @@ -21,13 +21,12 @@ import { GoogleAuthProvider, signInWithPopup, } from 'firebase/auth' -import { range, throttle, zip } from 'lodash' +import { throttle, zip } from 'lodash' import { app } from './init' import { PortfolioMetrics, PrivateUser, User } from 'common/user' import { createUser } from './fn-call' import { getValue, getValues, listenForValue, listenForValues } from './utils' -import { DAY_MS } from 'common/util/time' import { feed } from 'common/feed' import { CATEGORY_LIST } from 'common/categories' import { safeLocalStorage } from '../util/local' @@ -214,30 +213,6 @@ export function getUsers() { return getValues(collection(db, 'users')) } -const getUsersQuery = (startTime: number, endTime: number) => - query( - collection(db, 'users'), - where('createdTime', '>=', startTime), - where('createdTime', '<', endTime), - orderBy('createdTime', 'asc') - ) - -export async function getDailyNewUsers( - startTime: number, - numberOfDays: number -) { - const query = getUsersQuery(startTime, startTime + DAY_MS * numberOfDays) - const users = await getValues(query) - - const usersByDay = range(0, numberOfDays).map(() => [] as User[]) - for (const user of users) { - const dayIndex = Math.floor((user.createdTime - startTime) / DAY_MS) - usersByDay[dayIndex].push(user) - } - - return usersByDay -} - export async function getUserFeed(userId: string) { const feedDoc = doc(db, 'private-users', userId, 'cache', 'feed') const userFeed = await getValue<{ diff --git a/web/pages/embed/analytics.tsx b/web/pages/embed/analytics.tsx index be850fdf..68101d7c 100644 --- a/web/pages/embed/analytics.tsx +++ b/web/pages/embed/analytics.tsx @@ -1,18 +1,21 @@ +import { useState, useEffect } from 'react' import { Col } from 'web/components/layout/col' import { Spacer } from 'web/components/layout/spacer' -import { fromPropz } from 'web/hooks/use-propz' -import Analytics, { - CustomAnalytics, - FirebaseAnalytics, - getStaticPropz, -} from '../stats' +import { CustomAnalytics, FirebaseAnalytics } from '../stats' +import { getStats } from 'web/lib/firebase/stats' +import { Stats } from 'common/stats' -export const getStaticProps = fromPropz(getStaticPropz) - -export default function AnalyticsEmbed(props: Parameters[0]) { +export default function AnalyticsEmbed() { + const [stats, setStats] = useState(undefined) + useEffect(() => { + getStats().then(setStats) + }, []) + if (stats == null) { + return <> + } return ( - + diff --git a/web/pages/stats.tsx b/web/pages/stats.tsx index 401749b6..c81bc3ff 100644 --- a/web/pages/stats.tsx +++ b/web/pages/stats.tsx @@ -1,5 +1,5 @@ import dayjs from 'dayjs' -import { zip, uniq, sumBy, concat, countBy, sortBy, sum } from 'lodash' +import { useEffect, useState } from 'react' import { DailyCountChart, DailyPercentChart, @@ -9,265 +9,18 @@ import { Spacer } from 'web/components/layout/spacer' import { Tabs } from 'web/components/layout/tabs' import { Page } from 'web/components/page' import { Title } from 'web/components/title' -import { fromPropz, usePropz } from 'web/hooks/use-propz' -import { getDailyBets } from 'web/lib/firebase/bets' -import { getDailyComments } from 'web/lib/firebase/comments' -import { getDailyContracts } from 'web/lib/firebase/contracts' -import { getDailyNewUsers } from 'web/lib/firebase/users' import { SiteLink } from 'web/components/site-link' import { Linkify } from 'web/components/linkify' -import { average } from 'common/util/math' +import { getStats } from 'web/lib/firebase/stats' +import { Stats } from 'common/stats' -export const getStaticProps = fromPropz(getStaticPropz) -export async function getStaticPropz() { - const numberOfDays = 90 - const today = dayjs(dayjs().format('YYYY-MM-DD')) - // Convert from UTC midnight to PT midnight. - .add(7, 'hours') - - const startDate = today.subtract(numberOfDays, 'day') - - const [dailyBets, dailyContracts, dailyComments, dailyNewUsers] = - await Promise.all([ - getDailyBets(startDate.valueOf(), numberOfDays), - getDailyContracts(startDate.valueOf(), numberOfDays), - getDailyComments(startDate.valueOf(), numberOfDays), - getDailyNewUsers(startDate.valueOf(), numberOfDays), - ]) - - const dailyBetCounts = dailyBets.map((bets) => bets.length) - const dailyContractCounts = dailyContracts.map( - (contracts) => contracts.length - ) - const dailyCommentCounts = dailyComments.map((comments) => comments.length) - - const dailyUserIds = zip(dailyContracts, dailyBets, dailyComments).map( - ([contracts, bets, comments]) => { - const creatorIds = (contracts ?? []).map((c) => c.creatorId) - const betUserIds = (bets ?? []).map((bet) => bet.userId) - const commentUserIds = (comments ?? []).map((comment) => comment.userId) - return uniq([...creatorIds, ...betUserIds, ...commentUserIds]) - } - ) - - const dailyActiveUsers = dailyUserIds.map((userIds) => userIds.length) - - const weeklyActiveUsers = dailyUserIds.map((_, i) => { - const start = Math.max(0, i - 6) - const end = i - const uniques = new Set() - for (let j = start; j <= end; j++) - dailyUserIds[j].forEach((userId) => uniques.add(userId)) - return uniques.size - }) - - const monthlyActiveUsers = dailyUserIds.map((_, i) => { - const start = Math.max(0, i - 29) - const end = i - const uniques = new Set() - for (let j = start; j <= end; j++) - dailyUserIds[j].forEach((userId) => uniques.add(userId)) - return uniques.size - }) - - const weekOnWeekRetention = dailyUserIds.map((_userId, i) => { - const twoWeeksAgo = { - start: Math.max(0, i - 13), - end: Math.max(0, i - 7), - } - const lastWeek = { - start: Math.max(0, i - 6), - end: i, - } - - const activeTwoWeeksAgo = new Set() - for (let j = twoWeeksAgo.start; j <= twoWeeksAgo.end; j++) { - dailyUserIds[j].forEach((userId) => activeTwoWeeksAgo.add(userId)) - } - const activeLastWeek = new Set() - for (let j = lastWeek.start; j <= lastWeek.end; j++) { - dailyUserIds[j].forEach((userId) => activeLastWeek.add(userId)) - } - const retainedCount = sumBy(Array.from(activeTwoWeeksAgo), (userId) => - activeLastWeek.has(userId) ? 1 : 0 - ) - const retainedFrac = retainedCount / activeTwoWeeksAgo.size - return Math.round(retainedFrac * 100 * 100) / 100 - }) - - const monthlyRetention = dailyUserIds.map((_userId, i) => { - const twoMonthsAgo = { - start: Math.max(0, i - 60), - end: Math.max(0, i - 30), - } - const lastMonth = { - start: Math.max(0, i - 30), - end: i, - } - - const activeTwoMonthsAgo = new Set() - for (let j = twoMonthsAgo.start; j <= twoMonthsAgo.end; j++) { - dailyUserIds[j].forEach((userId) => activeTwoMonthsAgo.add(userId)) - } - const activeLastMonth = new Set() - for (let j = lastMonth.start; j <= lastMonth.end; j++) { - dailyUserIds[j].forEach((userId) => activeLastMonth.add(userId)) - } - const retainedCount = sumBy(Array.from(activeTwoMonthsAgo), (userId) => - activeLastMonth.has(userId) ? 1 : 0 - ) - const retainedFrac = retainedCount / activeTwoMonthsAgo.size - return Math.round(retainedFrac * 100 * 100) / 100 - }) - - const firstBetDict: { [userId: string]: number } = {} - for (let i = 0; i < dailyBets.length; i++) { - const bets = dailyBets[i] - for (const bet of bets) { - if (bet.userId in firstBetDict) continue - firstBetDict[bet.userId] = i - } - } - const weeklyActivationRate = dailyNewUsers.map((_, i) => { - const start = Math.max(0, i - 6) - const end = i - let activatedCount = 0 - let newUsers = 0 - for (let j = start; j <= end; j++) { - const userIds = dailyNewUsers[j].map((user) => user.id) - newUsers += userIds.length - for (const userId of userIds) { - const dayIndex = firstBetDict[userId] - if (dayIndex !== undefined && dayIndex <= end) { - activatedCount++ - } - } - } - const frac = activatedCount / (newUsers || 1) - return Math.round(frac * 100 * 100) / 100 - }) - const dailySignups = dailyNewUsers.map((users) => users.length) - - const dailyTopTenthActions = zip( - dailyContracts, - dailyBets, - dailyComments - ).map(([contracts, bets, comments]) => { - const userIds = concat( - contracts?.map((c) => c.creatorId) ?? [], - bets?.map((b) => b.userId) ?? [], - comments?.map((c) => c.userId) ?? [] - ) - const counts = Object.values(countBy(userIds)) - const sortedCounts = sortBy(counts, (count) => count).reverse() - if (sortedCounts.length === 0) return 0 - const tenthPercentile = sortedCounts[Math.floor(sortedCounts.length * 0.1)] - return tenthPercentile - }) - const weeklyTopTenthActions = dailyTopTenthActions.map((_, i) => { - const start = Math.max(0, i - 6) - const end = i - return average(dailyTopTenthActions.slice(start, end)) - }) - const monthlyTopTenthActions = dailyTopTenthActions.map((_, i) => { - const start = Math.max(0, i - 29) - const end = i - return average(dailyTopTenthActions.slice(start, end)) - }) - - // Total mana divided by 100. - const dailyManaBet = dailyBets.map((bets) => { - return Math.round(sumBy(bets, (bet) => bet.amount) / 100) - }) - const weeklyManaBet = dailyManaBet.map((_, i) => { - const start = Math.max(0, i - 6) - const end = i - const total = sum(dailyManaBet.slice(start, end)) - if (end - start < 7) return (total * 7) / (end - start) - return total - }) - const monthlyManaBet = dailyManaBet.map((_, i) => { - const start = Math.max(0, i - 29) - const end = i - const total = sum(dailyManaBet.slice(start, end)) - const range = end - start + 1 - if (range < 30) return (total * 30) / range - return total - }) - - return { - props: { - startDate: startDate.valueOf(), - dailyActiveUsers, - weeklyActiveUsers, - monthlyActiveUsers, - dailyBetCounts, - dailyContractCounts, - dailyCommentCounts, - dailySignups, - weekOnWeekRetention, - weeklyActivationRate, - monthlyRetention, - topTenthActions: { - daily: dailyTopTenthActions, - weekly: weeklyTopTenthActions, - monthly: monthlyTopTenthActions, - }, - manaBet: { - daily: dailyManaBet, - weekly: weeklyManaBet, - monthly: monthlyManaBet, - }, - }, - revalidate: 60 * 60, // Regenerate after an hour - } -} - -export default function Analytics(props: { - startDate: number - dailyActiveUsers: number[] - weeklyActiveUsers: number[] - monthlyActiveUsers: number[] - dailyBetCounts: number[] - dailyContractCounts: number[] - dailyCommentCounts: number[] - dailySignups: number[] - weekOnWeekRetention: number[] - monthlyRetention: number[] - weeklyActivationRate: number[] - topTenthActions: { - daily: number[] - weekly: number[] - monthly: number[] - } - manaBet: { - daily: number[] - weekly: number[] - monthly: number[] - } -}) { - props = usePropz(props, getStaticPropz) ?? { - startDate: 0, - dailyActiveUsers: [], - weeklyActiveUsers: [], - monthlyActiveUsers: [], - dailyBetCounts: [], - dailyContractCounts: [], - dailyCommentCounts: [], - dailySignups: [], - weekOnWeekRetention: [], - monthlyRetention: [], - weeklyActivationRate: [], - topTenthActions: { - daily: [], - weekly: [], - monthly: [], - }, - manaBet: { - daily: [], - weekly: [], - monthly: [], - }, +export default function Analytics() { + const [stats, setStats] = useState(undefined) + useEffect(() => { + getStats().then(setStats) + }, []) + if (stats == null) { + return <> } return ( @@ -275,7 +28,7 @@ export default function Analytics(props: { tabs={[ { title: 'Activity', - content: , + content: , }, { title: 'Market Stats',