Merge branch 'main' into referrals
This commit is contained in:
commit
dbb11d6637
2
.github/workflows/check.yml
vendored
2
.github/workflows/check.yml
vendored
|
@ -52,4 +52,4 @@ jobs:
|
|||
- name: Run Typescript checker on cloud functions
|
||||
if: ${{ success() || failure() }}
|
||||
working-directory: functions
|
||||
run: tsc --pretty --project tsconfig.json --noEmit
|
||||
run: tsc -b -v --pretty
|
||||
|
|
5
common/.gitignore
vendored
5
common/.gitignore
vendored
|
@ -1,6 +1,5 @@
|
|||
# Compiled JavaScript files
|
||||
lib/**/*.js
|
||||
lib/**/*.js.map
|
||||
lib/
|
||||
|
||||
# TypeScript v1 declaration files
|
||||
typings/
|
||||
|
@ -10,4 +9,4 @@ node_modules/
|
|||
|
||||
package-lock.json
|
||||
ui-debug.log
|
||||
firebase-debug.log
|
||||
firebase-debug.log
|
||||
|
|
|
@ -516,6 +516,22 @@ The American Civil Liberties Union is our nation's guardian of liberty, working
|
|||
|
||||
The U.S. Constitution and the Bill of Rights trumpet our aspirations for the kind of society that we want to be. But for much of our history, our nation failed to fulfill the promise of liberty for whole groups of people.`,
|
||||
},
|
||||
{
|
||||
name: 'The Center for Election Science',
|
||||
website: 'https://electionscience.org/',
|
||||
photo: 'https://i.imgur.com/WvdHHZa.png',
|
||||
preview:
|
||||
'The Center for Election Science is a nonpartisan nonprofit dedicated to empowering voters with voting methods that strengthen democracy. We believe you deserve a vote that empowers you to impact the world you live in.',
|
||||
description: `Founded in 2011, The Center for Election Science is a national, nonpartisan nonprofit focused on voting reform.
|
||||
|
||||
Our Mission — To empower people with voting methods that strengthen democracy.
|
||||
|
||||
Our Vision — A world where democracies thrive because voters’ voices are heard.
|
||||
|
||||
With an emphasis on approval voting, we bring better elections to people across the country through both advocacy and research.
|
||||
|
||||
The movement for a better way to vote is rapidly gaining momentum as voters grow tired of election results that don’t represent the will of the people. In 2018, we worked with locals in Fargo, ND to help them become the first city in the U.S. to adopt approval voting. And in 2020, we helped grassroots activists empower the 300k people of St. Louis, MO with stronger democracy through approval voting.`,
|
||||
},
|
||||
].map((charity) => {
|
||||
const slug = charity.name.toLowerCase().replace(/\s/g, '-')
|
||||
return {
|
||||
|
|
|
@ -3,7 +3,8 @@
|
|||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"verify": "(cd .. && yarn verify)"
|
||||
"verify": "(cd .. && yarn verify)",
|
||||
"verify:dir": "npx eslint . --max-warnings 0"
|
||||
},
|
||||
"sideEffects": false,
|
||||
"dependencies": {
|
||||
|
|
23
common/stats.ts
Normal file
23
common/stats.ts
Normal file
|
@ -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[]
|
||||
}
|
||||
}
|
|
@ -1,6 +1,8 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": "../",
|
||||
"composite": true,
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "node",
|
||||
"noImplicitReturns": true,
|
||||
"outDir": "lib",
|
||||
|
|
|
@ -398,6 +398,65 @@ Requires no authorization.
|
|||
```
|
||||
- Response type: A `FullMarket` ; same as above.
|
||||
|
||||
### `GET /v0/users`
|
||||
|
||||
Lists all users.
|
||||
|
||||
Requires no authorization.
|
||||
|
||||
- Example request
|
||||
```
|
||||
https://manifold.markets/api/v0/users
|
||||
```
|
||||
- Example response
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id":"igi2zGXsfxYPgB0DJTXVJVmwCOr2",
|
||||
"createdTime":1639011767273,
|
||||
"name":"Austin",
|
||||
"username":"Austin",
|
||||
"url":"https://manifold.markets/Austin",
|
||||
"avatarUrl":"https://lh3.googleusercontent.com/a-/AOh14GiZyl1lBehuBMGyJYJhZd-N-mstaUtgE4xdI22lLw=s96-c",
|
||||
"bio":"I build Manifold! Always happy to chat; reach out on Discord or find a time on https://calendly.com/austinchen/manifold!",
|
||||
"bannerUrl":"https://images.unsplash.com/photo-1501523460185-2aa5d2a0f981?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1531&q=80",
|
||||
"website":"https://blog.austn.io",
|
||||
"twitterHandle":"akrolsmir",
|
||||
"discordHandle":"akrolsmir#4125",
|
||||
"balance":9122.607163564959,
|
||||
"totalDeposits":10339.004780544328,
|
||||
"totalPnLCached":9376.601262721899,
|
||||
"creatorVolumeCached":76078.46984199001
|
||||
}
|
||||
```
|
||||
- Response type: Array of `LiteUser`
|
||||
|
||||
```tsx
|
||||
// Basic information about a user
|
||||
type LiteUser = {
|
||||
id: string // user's unique id
|
||||
createdTime: number
|
||||
|
||||
name: string // display name, may contain spaces
|
||||
username: string // username, used in urls
|
||||
url: string // link to user's profile
|
||||
avatarUrl?: string
|
||||
|
||||
bio?: string
|
||||
bannerUrl?: string
|
||||
website?: string
|
||||
twitterHandle?: string
|
||||
discordHandle?: string
|
||||
|
||||
// Note: the following are here for convenience only and may be removed in the future.
|
||||
balance: number
|
||||
totalDeposits: number
|
||||
totalPnLCached: number
|
||||
creatorVolumeCached: number
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
### `POST /v0/bet`
|
||||
|
||||
Places a new bet on behalf of the authorized user.
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
{
|
||||
"functions": {
|
||||
"predeploy": "npm --prefix \"$RESOURCE_DIR\" run build",
|
||||
"predeploy": "cd functions && yarn build",
|
||||
"runtime": "nodejs16",
|
||||
"source": "functions"
|
||||
"source": "functions/dist"
|
||||
},
|
||||
"firestore": {
|
||||
"rules": "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', 'referredByUserId', 'referredByContractId']);
|
||||
}
|
||||
|
||||
|
||||
match /{somePath=**}/portfolioHistory/{portfolioHistoryId} {
|
||||
allow read;
|
||||
}
|
||||
|
|
6
functions/.gitignore
vendored
6
functions/.gitignore
vendored
|
@ -2,9 +2,11 @@
|
|||
.env*
|
||||
.runtimeconfig.json
|
||||
|
||||
# GCP deployment artifact
|
||||
dist/
|
||||
|
||||
# Compiled JavaScript files
|
||||
lib/**/*.js
|
||||
lib/**/*.js.map
|
||||
lib/
|
||||
|
||||
# TypeScript v1 declaration files
|
||||
typings/
|
||||
|
|
|
@ -5,7 +5,8 @@
|
|||
"firestore": "dev-mantic-markets.appspot.com"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"build": "yarn compile && rm -rf dist && mkdir -p dist/functions && cp -R ../common/lib dist/common && cp -R lib/src dist/functions/src && cp ../yarn.lock dist && cp package.json dist",
|
||||
"compile": "tsc -b",
|
||||
"watch": "tsc -w",
|
||||
"shell": "yarn build && firebase functions:shell",
|
||||
"start": "yarn shell",
|
||||
|
@ -16,9 +17,10 @@
|
|||
"db:backup-local": "firebase emulators:export --force ./firestore_export",
|
||||
"db:rename-remote-backup-folder": "gsutil mv gs://$npm_package_config_firestore/firestore_export gs://$npm_package_config_firestore/firestore_export_$(date +%d-%m-%Y-%H-%M)",
|
||||
"db:backup-remote": "yarn db:rename-remote-backup-folder && gcloud firestore export gs://$npm_package_config_firestore/firestore_export/",
|
||||
"verify": "(cd .. && yarn verify)"
|
||||
"verify": "(cd .. && yarn verify)",
|
||||
"verify:dir": "npx eslint . --max-warnings 0; tsc -b -v --pretty"
|
||||
},
|
||||
"main": "lib/functions/src/index.js",
|
||||
"main": "functions/src/index.js",
|
||||
"dependencies": {
|
||||
"@amplitude/node": "1.10.0",
|
||||
"fetch": "1.1.0",
|
||||
|
|
|
@ -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'
|
||||
|
|
15
functions/src/scripts/update-stats.ts
Normal file
15
functions/src/scripts/update-stats.ts
Normal file
|
@ -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())
|
||||
}
|
316
functions/src/update-stats.ts
Normal file
316
functions/src/update-stats.ts
Normal file
|
@ -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<Bet>(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<Comment>(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<Contract>(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<User>(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<string>()
|
||||
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<string>()
|
||||
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<string>()
|
||||
for (let j = twoWeeksAgo.start; j <= twoWeeksAgo.end; j++) {
|
||||
dailyUserIds[j].forEach((userId) => activeTwoWeeksAgo.add(userId))
|
||||
}
|
||||
const activeLastWeek = new Set<string>()
|
||||
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<string>()
|
||||
for (let j = twoMonthsAgo.start; j <= twoMonthsAgo.end; j++) {
|
||||
dailyUserIds[j].forEach((userId) => activeTwoMonthsAgo.add(userId))
|
||||
}
|
||||
const activeLastMonth = new Set<string>()
|
||||
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)
|
|
@ -1,6 +1,7 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": "../",
|
||||
"composite": true,
|
||||
"module": "commonjs",
|
||||
"noImplicitReturns": true,
|
||||
"outDir": "lib",
|
||||
|
@ -8,6 +9,11 @@
|
|||
"strict": true,
|
||||
"target": "es2017"
|
||||
},
|
||||
"references": [
|
||||
{
|
||||
"path": "../common"
|
||||
}
|
||||
],
|
||||
"compileOnSave": true,
|
||||
"include": ["src", "../common/**/*.ts"]
|
||||
"include": ["src"]
|
||||
}
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
"web"
|
||||
],
|
||||
"scripts": {
|
||||
"verify": "(cd web && npx prettier --check .; yarn lint --max-warnings 0; tsc --pretty --project tsconfig.json --noEmit); (cd common && npx eslint . --max-warnings 0); (cd functions && npx eslint . --max-warnings 0; tsc --pretty --project tsconfig.json --noEmit)"
|
||||
"verify": "(cd web && yarn verify:dir); (cd functions && yarn verify:dir)"
|
||||
},
|
||||
"dependencies": {},
|
||||
"devDependencies": {
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import clsx from 'clsx'
|
||||
import { useState } from 'react'
|
||||
import Textarea from 'react-expanding-textarea'
|
||||
import { findBestMatch } from 'string-similarity'
|
||||
|
||||
import { FreeResponseContract } from 'common/contract'
|
||||
import { BuyAmountInput } from '../amount-input'
|
||||
|
@ -23,6 +24,7 @@ import { firebaseLogin } from 'web/lib/firebase/users'
|
|||
import { Bet } from 'common/bet'
|
||||
import { MAX_ANSWER_LENGTH } from 'common/answer'
|
||||
import { withTracking } from 'web/lib/service/analytics'
|
||||
import { lowerCase } from 'lodash'
|
||||
|
||||
export function CreateAnswerPanel(props: { contract: FreeResponseContract }) {
|
||||
const { contract } = props
|
||||
|
@ -30,9 +32,15 @@ export function CreateAnswerPanel(props: { contract: FreeResponseContract }) {
|
|||
const [text, setText] = useState('')
|
||||
const [betAmount, setBetAmount] = useState<number | undefined>(10)
|
||||
const [amountError, setAmountError] = useState<string | undefined>()
|
||||
const [answerError, setAnswerError] = useState<string | undefined>()
|
||||
const [possibleDuplicateAnswer, setPossibleDuplicateAnswer] = useState<
|
||||
string | undefined
|
||||
>()
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const { answers } = contract
|
||||
|
||||
const canSubmit = text && betAmount && !amountError && !isSubmitting
|
||||
const canSubmit =
|
||||
text && betAmount && !amountError && !isSubmitting && !answerError
|
||||
|
||||
const submitAnswer = async () => {
|
||||
if (canSubmit) {
|
||||
|
@ -54,6 +62,36 @@ export function CreateAnswerPanel(props: { contract: FreeResponseContract }) {
|
|||
}
|
||||
}
|
||||
|
||||
const changeAnswer = (text: string) => {
|
||||
setText(text)
|
||||
const existingAnswer = answers.find(
|
||||
(a) => lowerCase(a.text) === lowerCase(text)
|
||||
)
|
||||
|
||||
if (existingAnswer) {
|
||||
setAnswerError(
|
||||
existingAnswer
|
||||
? `"${existingAnswer.text}" already exists as an answer`
|
||||
: ''
|
||||
)
|
||||
return
|
||||
} else {
|
||||
setAnswerError('')
|
||||
}
|
||||
|
||||
if (answers.length && text) {
|
||||
const matches = findBestMatch(
|
||||
lowerCase(text),
|
||||
answers.map((a) => lowerCase(a.text))
|
||||
)
|
||||
setPossibleDuplicateAnswer(
|
||||
matches.bestMatch.rating > 0.8
|
||||
? answers[matches.bestMatchIndex].text
|
||||
: ''
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const resultProb = getDpmOutcomeProbabilityAfterBet(
|
||||
contract.totalShares,
|
||||
'new',
|
||||
|
@ -79,12 +117,21 @@ export function CreateAnswerPanel(props: { contract: FreeResponseContract }) {
|
|||
<div className="mb-1">Add your answer</div>
|
||||
<Textarea
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onChange={(e) => changeAnswer(e.target.value)}
|
||||
className="textarea textarea-bordered w-full resize-none"
|
||||
placeholder="Type your answer..."
|
||||
rows={1}
|
||||
maxLength={MAX_ANSWER_LENGTH}
|
||||
/>
|
||||
{answerError ? (
|
||||
<AnswerError key={1} level="error" text={answerError} />
|
||||
) : possibleDuplicateAnswer ? (
|
||||
<AnswerError
|
||||
key={2}
|
||||
level="warning"
|
||||
text={`Did you mean to bet on "${possibleDuplicateAnswer}"?`}
|
||||
/>
|
||||
) : undefined}
|
||||
<div />
|
||||
<Col
|
||||
className={clsx(
|
||||
|
@ -163,3 +210,21 @@ export function CreateAnswerPanel(props: { contract: FreeResponseContract }) {
|
|||
</Col>
|
||||
)
|
||||
}
|
||||
|
||||
type answerErrorLevel = 'warning' | 'error'
|
||||
|
||||
const AnswerError = (props: { text: string; level: answerErrorLevel }) => {
|
||||
const { text, level } = props
|
||||
const colorClass =
|
||||
{
|
||||
error: 'text-red-500',
|
||||
warning: 'text-orange-500',
|
||||
}[level] ?? ''
|
||||
return (
|
||||
<div
|
||||
className={`${colorClass} mb-2 mr-auto self-center whitespace-nowrap text-xs font-medium tracking-wide`}
|
||||
>
|
||||
{text}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -8,15 +8,17 @@ import { Spacer } from '../layout/spacer'
|
|||
import { Tabs } from '../layout/tabs'
|
||||
import { Col } from '../layout/col'
|
||||
import { CommentTipMap } from 'web/hooks/use-tip-txns'
|
||||
import { LiquidityProvision } from 'common/liquidity-provision'
|
||||
|
||||
export function ContractTabs(props: {
|
||||
contract: Contract
|
||||
user: User | null | undefined
|
||||
bets: Bet[]
|
||||
liquidityProvisions: LiquidityProvision[]
|
||||
comments: Comment[]
|
||||
tips: CommentTipMap
|
||||
}) {
|
||||
const { contract, user, bets, comments, tips } = props
|
||||
const { contract, user, bets, comments, tips, liquidityProvisions } = props
|
||||
const { outcomeType } = contract
|
||||
|
||||
const userBets = user && bets.filter((bet) => bet.userId === user.id)
|
||||
|
@ -25,6 +27,7 @@ export function ContractTabs(props: {
|
|||
<ContractActivity
|
||||
contract={contract}
|
||||
bets={bets}
|
||||
liquidityProvisions={liquidityProvisions}
|
||||
comments={comments}
|
||||
tips={tips}
|
||||
user={user}
|
||||
|
@ -38,6 +41,7 @@ export function ContractTabs(props: {
|
|||
<ContractActivity
|
||||
contract={contract}
|
||||
bets={bets}
|
||||
liquidityProvisions={liquidityProvisions}
|
||||
comments={comments}
|
||||
tips={tips}
|
||||
user={user}
|
||||
|
@ -55,6 +59,7 @@ export function ContractTabs(props: {
|
|||
<ContractActivity
|
||||
contract={contract}
|
||||
bets={bets}
|
||||
liquidityProvisions={liquidityProvisions}
|
||||
comments={comments}
|
||||
tips={tips}
|
||||
user={user}
|
||||
|
|
|
@ -325,14 +325,6 @@ export function getColor(contract: Contract) {
|
|||
)
|
||||
}
|
||||
|
||||
if (contract.outcomeType === 'NUMERIC') {
|
||||
return 'blue-400'
|
||||
}
|
||||
|
||||
if (contract.outcomeType === 'FREE_RESPONSE') {
|
||||
return 'blue-400'
|
||||
}
|
||||
|
||||
if ((contract.closeTime ?? Infinity) < Date.now()) {
|
||||
return 'gray-400'
|
||||
}
|
||||
|
|
|
@ -7,6 +7,7 @@ import { Comment } from 'common/comment'
|
|||
import { Contract, FreeResponseContract } from 'common/contract'
|
||||
import { User } from 'common/user'
|
||||
import { CommentTipMap } from 'web/hooks/use-tip-txns'
|
||||
import { LiquidityProvision } from 'common/liquidity-provision'
|
||||
|
||||
export type ActivityItem =
|
||||
| DescriptionItem
|
||||
|
@ -17,6 +18,7 @@ export type ActivityItem =
|
|||
| ResolveItem
|
||||
| CommentInputItem
|
||||
| CommentThreadItem
|
||||
| LiquidityItem
|
||||
|
||||
type BaseActivityItem = {
|
||||
id: string
|
||||
|
@ -72,6 +74,14 @@ export type ResolveItem = BaseActivityItem & {
|
|||
type: 'resolve'
|
||||
}
|
||||
|
||||
export type LiquidityItem = BaseActivityItem & {
|
||||
type: 'liquidity'
|
||||
liquidity: LiquidityProvision
|
||||
hideOutcome: boolean
|
||||
smallAvatar: boolean
|
||||
hideComment?: boolean
|
||||
}
|
||||
|
||||
function getAnswerAndCommentInputGroups(
|
||||
contract: FreeResponseContract,
|
||||
bets: Bet[],
|
||||
|
@ -139,6 +149,7 @@ export function getSpecificContractActivityItems(
|
|||
contract: Contract,
|
||||
bets: Bet[],
|
||||
comments: Comment[],
|
||||
liquidityProvisions: LiquidityProvision[],
|
||||
tips: CommentTipMap,
|
||||
user: User | null | undefined,
|
||||
options: {
|
||||
|
@ -146,7 +157,7 @@ export function getSpecificContractActivityItems(
|
|||
}
|
||||
) {
|
||||
const { mode } = options
|
||||
const items = [] as ActivityItem[]
|
||||
let items = [] as ActivityItem[]
|
||||
|
||||
switch (mode) {
|
||||
case 'bets':
|
||||
|
@ -163,6 +174,23 @@ export function getSpecificContractActivityItems(
|
|||
hideComment: true,
|
||||
}))
|
||||
)
|
||||
items.push(
|
||||
...liquidityProvisions.map((liquidity) => ({
|
||||
type: 'liquidity' as const,
|
||||
id: liquidity.id,
|
||||
contract,
|
||||
liquidity,
|
||||
hideOutcome: false,
|
||||
smallAvatar: false,
|
||||
}))
|
||||
)
|
||||
items = sortBy(items, (item) =>
|
||||
item.type === 'bet'
|
||||
? item.bet.createdTime
|
||||
: item.type === 'liquidity'
|
||||
? item.liquidity.createdTime
|
||||
: undefined
|
||||
)
|
||||
break
|
||||
|
||||
case 'comments': {
|
||||
|
|
|
@ -8,11 +8,13 @@ import { FeedItems } from './feed-items'
|
|||
import { User } from 'common/user'
|
||||
import { useContractWithPreload } from 'web/hooks/use-contract'
|
||||
import { CommentTipMap } from 'web/hooks/use-tip-txns'
|
||||
import { LiquidityProvision } from 'common/liquidity-provision'
|
||||
|
||||
export function ContractActivity(props: {
|
||||
contract: Contract
|
||||
bets: Bet[]
|
||||
comments: Comment[]
|
||||
liquidityProvisions: LiquidityProvision[]
|
||||
tips: CommentTipMap
|
||||
user: User | null | undefined
|
||||
mode: 'comments' | 'bets' | 'free-response-comment-answer-groups'
|
||||
|
@ -20,7 +22,8 @@ export function ContractActivity(props: {
|
|||
className?: string
|
||||
betRowClassName?: string
|
||||
}) {
|
||||
const { user, mode, tips, className, betRowClassName } = props
|
||||
const { user, mode, tips, className, betRowClassName, liquidityProvisions } =
|
||||
props
|
||||
|
||||
const contract = useContractWithPreload(props.contract) ?? props.contract
|
||||
|
||||
|
@ -33,6 +36,7 @@ export function ContractActivity(props: {
|
|||
contract,
|
||||
bets,
|
||||
comments,
|
||||
liquidityProvisions,
|
||||
tips,
|
||||
user,
|
||||
{ mode }
|
||||
|
|
|
@ -35,6 +35,7 @@ import {
|
|||
} from 'web/components/feed/feed-comments'
|
||||
import { FeedBet } from 'web/components/feed/feed-bets'
|
||||
import { NumericContract } from 'common/contract'
|
||||
import { FeedLiquidity } from './feed-liquidity'
|
||||
|
||||
export function FeedItems(props: {
|
||||
contract: Contract
|
||||
|
@ -83,6 +84,8 @@ export function FeedItem(props: { item: ActivityItem }) {
|
|||
return <FeedDescription {...item} />
|
||||
case 'bet':
|
||||
return <FeedBet {...item} />
|
||||
case 'liquidity':
|
||||
return <FeedLiquidity {...item} />
|
||||
case 'answergroup':
|
||||
return <FeedAnswerCommentGroup {...item} />
|
||||
case 'close':
|
||||
|
|
85
web/components/feed/feed-liquidity.tsx
Normal file
85
web/components/feed/feed-liquidity.tsx
Normal file
|
@ -0,0 +1,85 @@
|
|||
import dayjs from 'dayjs'
|
||||
import { User } from 'common/user'
|
||||
import { useUser, useUserById } from 'web/hooks/use-user'
|
||||
import { Row } from 'web/components/layout/row'
|
||||
import { Avatar, EmptyAvatar } from 'web/components/avatar'
|
||||
import clsx from 'clsx'
|
||||
import { formatMoney } from 'common/util/format'
|
||||
import { RelativeTimestamp } from 'web/components/relative-timestamp'
|
||||
import React from 'react'
|
||||
import { UserLink } from '../user-page'
|
||||
import { LiquidityProvision } from 'common/liquidity-provision'
|
||||
|
||||
export function FeedLiquidity(props: {
|
||||
liquidity: LiquidityProvision
|
||||
smallAvatar: boolean
|
||||
}) {
|
||||
const { liquidity, smallAvatar } = props
|
||||
const { userId, createdTime } = liquidity
|
||||
|
||||
const isBeforeJune2022 = dayjs(createdTime).isBefore('2022-06-01')
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
const bettor = isBeforeJune2022 ? undefined : useUserById(userId)
|
||||
|
||||
const user = useUser()
|
||||
const isSelf = user?.id === userId
|
||||
|
||||
return (
|
||||
<>
|
||||
<Row className={'flex w-full gap-2 pt-3'}>
|
||||
{isSelf ? (
|
||||
<Avatar
|
||||
className={clsx(smallAvatar && 'ml-1')}
|
||||
size={smallAvatar ? 'sm' : undefined}
|
||||
avatarUrl={user.avatarUrl}
|
||||
username={user.username}
|
||||
/>
|
||||
) : bettor ? (
|
||||
<Avatar
|
||||
className={clsx(smallAvatar && 'ml-1')}
|
||||
size={smallAvatar ? 'sm' : undefined}
|
||||
avatarUrl={bettor.avatarUrl}
|
||||
username={bettor.username}
|
||||
/>
|
||||
) : (
|
||||
<div className="relative px-1">
|
||||
<EmptyAvatar />
|
||||
</div>
|
||||
)}
|
||||
<div className={'min-w-0 flex-1 py-1.5'}>
|
||||
<LiquidityStatusText
|
||||
liquidity={liquidity}
|
||||
isSelf={isSelf}
|
||||
bettor={bettor}
|
||||
/>
|
||||
</div>
|
||||
</Row>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function LiquidityStatusText(props: {
|
||||
liquidity: LiquidityProvision
|
||||
isSelf: boolean
|
||||
bettor?: User
|
||||
}) {
|
||||
const { liquidity, bettor, isSelf } = props
|
||||
const { amount, createdTime } = liquidity
|
||||
|
||||
// TODO: Withdrawn liquidity will never be shown, since liquidity amounts currently are zeroed out upon withdrawal.
|
||||
const bought = amount >= 0 ? 'added' : 'withdrew'
|
||||
const money = formatMoney(Math.abs(amount))
|
||||
|
||||
return (
|
||||
<div className="text-sm text-gray-500">
|
||||
{bettor ? (
|
||||
<UserLink name={bettor.name} username={bettor.username} />
|
||||
) : (
|
||||
<span>{isSelf ? 'You' : 'A trader'}</span>
|
||||
)}{' '}
|
||||
{bought} {money}
|
||||
{' of liquidity'}
|
||||
<RelativeTimestamp time={createdTime} />
|
||||
</div>
|
||||
)
|
||||
}
|
|
@ -8,7 +8,7 @@ import { trackCallback } from 'web/lib/service/analytics'
|
|||
export function ProfileSummary(props: { user: User }) {
|
||||
const { user } = props
|
||||
return (
|
||||
<Link href={`/${user.username}`}>
|
||||
<Link href={`/${user.username}?tab=bets`}>
|
||||
<a
|
||||
onClick={trackCallback('sidebar: profile')}
|
||||
className="group flex flex-row items-center gap-4 rounded-md py-3 text-gray-500 hover:bg-gray-100 hover:text-gray-700"
|
||||
|
|
|
@ -5,7 +5,6 @@ import {
|
|||
DotsHorizontalIcon,
|
||||
CashIcon,
|
||||
HeartIcon,
|
||||
PresentationChartLineIcon,
|
||||
UserGroupIcon,
|
||||
ChevronDownIcon,
|
||||
TrendingUpIcon,
|
||||
|
@ -27,14 +26,9 @@ import { groupPath } from 'web/lib/firebase/groups'
|
|||
import { trackCallback, withTracking } from 'web/lib/service/analytics'
|
||||
import { Group } from 'common/group'
|
||||
|
||||
function getNavigation(username: string) {
|
||||
function getNavigation() {
|
||||
return [
|
||||
{ name: 'Home', href: '/home', icon: HomeIcon },
|
||||
{
|
||||
name: 'Portfolio',
|
||||
href: `/${username}?tab=bets`,
|
||||
icon: PresentationChartLineIcon,
|
||||
},
|
||||
{
|
||||
name: 'Notifications',
|
||||
href: `/notifications`,
|
||||
|
@ -63,12 +57,10 @@ function getMoreNavigation(user?: User | null) {
|
|||
}
|
||||
|
||||
return [
|
||||
{ name: 'Send M$', href: '/links' },
|
||||
{ name: 'Leaderboards', href: '/leaderboards' },
|
||||
{ name: 'Charity', href: '/charity' },
|
||||
{ name: 'Blog', href: 'https://news.manifold.markets' },
|
||||
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },
|
||||
{ name: 'Twitter', href: 'https://twitter.com/ManifoldMarkets' },
|
||||
{ name: 'Statistics', href: '/stats' },
|
||||
{ name: 'About', href: 'https://docs.manifold.markets/$how-to' },
|
||||
{
|
||||
name: 'Sign out',
|
||||
|
@ -106,6 +98,18 @@ const signedInMobileNavigation = [
|
|||
...signedOutMobileNavigation,
|
||||
]
|
||||
|
||||
function getMoreMobileNav() {
|
||||
return [
|
||||
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },
|
||||
{ name: 'Statistics', href: '/stats' },
|
||||
{
|
||||
name: 'Sign out',
|
||||
href: '#',
|
||||
onClick: withTracking(firebaseLogout, 'sign out'),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
export type Item = {
|
||||
name: string
|
||||
href: string
|
||||
|
@ -177,9 +181,7 @@ export default function Sidebar(props: { className?: string }) {
|
|||
const currentPage = router.pathname
|
||||
|
||||
const user = useUser()
|
||||
const navigationOptions = !user
|
||||
? signedOutNavigation
|
||||
: getNavigation(user?.username || 'error')
|
||||
const navigationOptions = !user ? signedOutNavigation : getNavigation()
|
||||
const mobileNavigationOptions = !user
|
||||
? signedOutMobileNavigation
|
||||
: signedInMobileNavigation
|
||||
|
@ -219,29 +221,7 @@ export default function Sidebar(props: { className?: string }) {
|
|||
|
||||
{user && (
|
||||
<MenuButton
|
||||
menuItems={[
|
||||
{
|
||||
name: 'Blog',
|
||||
href: 'https://news.manifold.markets',
|
||||
},
|
||||
{
|
||||
name: 'Discord',
|
||||
href: 'https://discord.gg/eHQBNBqXuh',
|
||||
},
|
||||
{
|
||||
name: 'Twitter',
|
||||
href: 'https://twitter.com/ManifoldMarkets',
|
||||
},
|
||||
{
|
||||
name: 'Statistics',
|
||||
href: '/stats',
|
||||
},
|
||||
{
|
||||
name: 'Sign out',
|
||||
href: '#',
|
||||
onClick: withTracking(firebaseLogout, 'sign out'),
|
||||
},
|
||||
]}
|
||||
menuItems={getMoreMobileNav()}
|
||||
buttonContent={<MoreButton />}
|
||||
/>
|
||||
)}
|
||||
|
|
|
@ -7,8 +7,8 @@ import clsx from 'clsx'
|
|||
import { Comment } from 'common/comment'
|
||||
import { User } from 'common/user'
|
||||
import { formatMoney } from 'common/util/format'
|
||||
import { debounce, sumBy } from 'lodash'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { debounce, sum } from 'lodash'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { CommentTips } from 'web/hooks/use-tip-txns'
|
||||
import { useUser } from 'web/hooks/use-user'
|
||||
import { transact } from 'web/lib/firebase/fn-call'
|
||||
|
@ -16,33 +16,24 @@ import { track } from 'web/lib/service/analytics'
|
|||
import { Row } from './layout/row'
|
||||
import { Tooltip } from './tooltip'
|
||||
|
||||
// xth triangle number * 5 = 5 + 10 + 15 + ... + (x * 5)
|
||||
const quad = (x: number) => (5 / 2) * x * (x + 1)
|
||||
|
||||
// inverse (see https://math.stackexchange.com/questions/2041988/how-to-get-inverse-of-formula-for-sum-of-integers-from-1-to-nsee )
|
||||
const invQuad = (y: number) => Math.sqrt((2 / 5) * y + 1 / 4) - 1 / 2
|
||||
|
||||
export function Tipper(prop: { comment: Comment; tips: CommentTips }) {
|
||||
const { comment, tips } = prop
|
||||
|
||||
const me = useUser()
|
||||
const myId = me?.id ?? ''
|
||||
const savedTip = tips[myId] as number | undefined
|
||||
const savedTip = tips[myId] ?? 0
|
||||
|
||||
// optimistically increase the tip count, but debounce the update
|
||||
const [localTip, setLocalTip] = useState(savedTip ?? 0)
|
||||
const [localTip, setLocalTip] = useState(savedTip)
|
||||
// listen for user being set
|
||||
const initialized = useRef(false)
|
||||
useEffect(() => {
|
||||
if (savedTip && !initialized.current) {
|
||||
setLocalTip(savedTip)
|
||||
if (tips[myId] && !initialized.current) {
|
||||
setLocalTip(tips[myId])
|
||||
initialized.current = true
|
||||
}
|
||||
}, [savedTip])
|
||||
}, [tips, myId])
|
||||
|
||||
const score = useMemo(() => {
|
||||
const tipVals = Object.values({ ...tips, [myId]: localTip })
|
||||
return sumBy(tipVals, invQuad)
|
||||
}, [localTip, tips, myId])
|
||||
const total = sum(Object.values(tips)) - savedTip + localTip
|
||||
|
||||
// declare debounced function only on first render
|
||||
const [saveTip] = useState(() =>
|
||||
|
@ -80,7 +71,7 @@ export function Tipper(prop: { comment: Comment; tips: CommentTips }) {
|
|||
|
||||
const changeTip = (tip: number) => {
|
||||
setLocalTip(tip)
|
||||
me && saveTip(me, tip - (savedTip ?? 0))
|
||||
me && saveTip(me, tip - savedTip)
|
||||
}
|
||||
|
||||
return (
|
||||
|
@ -88,13 +79,13 @@ export function Tipper(prop: { comment: Comment; tips: CommentTips }) {
|
|||
<DownTip
|
||||
value={localTip}
|
||||
onChange={changeTip}
|
||||
disabled={!me || localTip <= 0}
|
||||
disabled={!me || localTip <= savedTip}
|
||||
/>
|
||||
<span className="font-bold">{Math.floor(score)} </span>
|
||||
<span className="font-bold">{Math.floor(total)}</span>
|
||||
<UpTip
|
||||
value={localTip}
|
||||
onChange={changeTip}
|
||||
disabled={!me || me.id === comment.userId}
|
||||
disabled={!me || me.id === comment.userId || me.balance < localTip + 5}
|
||||
/>
|
||||
{localTip === 0 ? (
|
||||
''
|
||||
|
@ -118,16 +109,15 @@ function DownTip(prop: {
|
|||
disabled?: boolean
|
||||
}) {
|
||||
const { onChange, value, disabled } = prop
|
||||
const marginal = 5 * invQuad(value)
|
||||
return (
|
||||
<Tooltip
|
||||
className="tooltip-bottom"
|
||||
text={!disabled && `Refund ${formatMoney(marginal)}`}
|
||||
text={!disabled && `-${formatMoney(5)}`}
|
||||
>
|
||||
<button
|
||||
className="flex h-max items-center hover:text-red-600 disabled:text-gray-300"
|
||||
disabled={disabled}
|
||||
onClick={() => onChange(value - marginal)}
|
||||
onClick={() => onChange(value - 5)}
|
||||
>
|
||||
<ChevronLeftIcon className="h-6 w-6" />
|
||||
</button>
|
||||
|
@ -141,19 +131,18 @@ function UpTip(prop: {
|
|||
disabled?: boolean
|
||||
}) {
|
||||
const { onChange, value, disabled } = prop
|
||||
const marginal = 5 * invQuad(value) + 5
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
className="tooltip-bottom"
|
||||
text={!disabled && `Tip ${formatMoney(marginal)}`}
|
||||
text={!disabled && `Tip ${formatMoney(5)}`}
|
||||
>
|
||||
<button
|
||||
className="hover:text-primary flex h-max items-center disabled:text-gray-300"
|
||||
disabled={disabled}
|
||||
onClick={() => onChange(value + marginal)}
|
||||
onClick={() => onChange(value + 5)}
|
||||
>
|
||||
{value >= quad(2) ? (
|
||||
{value >= 10 ? (
|
||||
<ChevronDoubleRightIcon className="text-primary mx-1 h-6 w-6" />
|
||||
) : value > 0 ? (
|
||||
<ChevronRightIcon className="text-primary h-6 w-6" />
|
||||
|
|
38
web/components/widgets/short-toggle.tsx
Normal file
38
web/components/widgets/short-toggle.tsx
Normal file
|
@ -0,0 +1,38 @@
|
|||
/* This example requires Tailwind CSS v2.0+ */
|
||||
import { Switch } from '@headlessui/react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
export default function ShortToggle(props: {
|
||||
enabled: boolean
|
||||
setEnabled: (enabled: boolean) => void
|
||||
}) {
|
||||
const { enabled, setEnabled } = props
|
||||
|
||||
return (
|
||||
<Switch
|
||||
checked={enabled}
|
||||
onChange={setEnabled}
|
||||
className="group relative inline-flex h-5 w-10 flex-shrink-0 cursor-pointer items-center justify-center rounded-full focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
|
||||
>
|
||||
<span className="sr-only">Use setting</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute h-full w-full rounded-md bg-white"
|
||||
/>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={clsx(
|
||||
enabled ? 'bg-indigo-600' : 'bg-gray-200',
|
||||
'pointer-events-none absolute mx-auto h-4 w-9 rounded-full transition-colors duration-200 ease-in-out'
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={clsx(
|
||||
enabled ? 'translate-x-5' : 'translate-x-0',
|
||||
'pointer-events-none absolute left-0 inline-block h-5 w-5 transform rounded-full border border-gray-200 bg-white shadow ring-0 transition-transform duration-200 ease-in-out'
|
||||
)}
|
||||
/>
|
||||
</Switch>
|
||||
)
|
||||
}
|
|
@ -65,19 +65,18 @@ export const useMemberGroupIds = (user: User | null | undefined) => {
|
|||
export function useMembers(group: Group) {
|
||||
const [members, setMembers] = useState<User[]>([])
|
||||
useEffect(() => {
|
||||
const { memberIds, creatorId } = group
|
||||
if (memberIds.length > 1)
|
||||
// get users via their user ids:
|
||||
Promise.all(
|
||||
memberIds.filter((mId) => mId !== creatorId).map(getUser)
|
||||
).then((users) => {
|
||||
const members = users.filter((user) => user)
|
||||
setMembers(members)
|
||||
})
|
||||
const { memberIds } = group
|
||||
if (memberIds.length > 0) {
|
||||
listMembers(group).then((members) => setMembers(members))
|
||||
}
|
||||
}, [group])
|
||||
return members
|
||||
}
|
||||
|
||||
export async function listMembers(group: Group) {
|
||||
return await Promise.all(group.memberIds.map(getUser))
|
||||
}
|
||||
|
||||
export const useGroupsWithContract = (contractId: string | undefined) => {
|
||||
const [groups, setGroups] = useState<Group[] | null | undefined>()
|
||||
|
||||
|
|
|
@ -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<Bet>(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
|
||||
}
|
||||
|
|
|
@ -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<Comment>(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<Comment>(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'),
|
||||
|
|
|
@ -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<Contract>(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)
|
||||
|
||||
|
|
|
@ -17,7 +17,7 @@ const groupCollection = collection(db, 'groups')
|
|||
|
||||
export function groupPath(
|
||||
groupSlug: string,
|
||||
subpath?: 'edit' | 'questions' | 'details' | 'chat'
|
||||
subpath?: 'edit' | 'questions' | 'about' | 'chat'
|
||||
) {
|
||||
return `/group/${groupSlug}${subpath ? `/${subpath}` : ''}`
|
||||
}
|
||||
|
|
15
web/lib/firebase/stats.ts
Normal file
15
web/lib/firebase/stats.ts
Normal file
|
@ -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<Stats>
|
||||
const statsDoc = doc(statsCollection, 'stats')
|
||||
|
||||
export const getStats = async () => {
|
||||
return (await getDoc(statsDoc)).data()
|
||||
}
|
|
@ -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'
|
||||
|
@ -289,30 +288,6 @@ export function getUsers() {
|
|||
return getValues<User>(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<User>(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<{
|
||||
|
|
|
@ -14,7 +14,8 @@
|
|||
"lint": "next lint",
|
||||
"format": "npx prettier --write .",
|
||||
"postbuild": "next-sitemap",
|
||||
"verify": "(cd .. && yarn verify)"
|
||||
"verify": "(cd .. && yarn verify)",
|
||||
"verify:dir": "npx prettier --check .; yarn lint --max-warnings 0; tsc --pretty --project tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@amplitude/analytics-browser": "0.4.1",
|
||||
|
@ -41,7 +42,8 @@
|
|||
"react-expanding-textarea": "2.3.5",
|
||||
"react-hot-toast": "2.2.0",
|
||||
"react-instantsearch-hooks-web": "6.24.1",
|
||||
"react-query": "3.39.0"
|
||||
"react-query": "3.39.0",
|
||||
"string-similarity": "^4.0.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/forms": "0.4.0",
|
||||
|
@ -50,6 +52,7 @@
|
|||
"@types/lodash": "4.14.178",
|
||||
"@types/node": "16.11.11",
|
||||
"@types/react": "17.0.43",
|
||||
"@types/string-similarity": "^4.0.0",
|
||||
"autoprefixer": "10.2.6",
|
||||
"concurrently": "6.5.1",
|
||||
"critters": "0.0.16",
|
||||
|
|
|
@ -51,6 +51,7 @@ import { CommentTipMap, useTipTxns } from 'web/hooks/use-tip-txns'
|
|||
import { useRouter } from 'next/router'
|
||||
import dayjs from 'dayjs'
|
||||
import { addUserToGroupViaSlug, getGroup } from 'web/lib/firebase/groups'
|
||||
import { useLiquidity } from 'web/hooks/use-liquidity'
|
||||
|
||||
export const getStaticProps = fromPropz(getStaticPropz)
|
||||
export async function getStaticPropz(props: {
|
||||
|
@ -126,6 +127,8 @@ export function ContractPageContent(
|
|||
})
|
||||
|
||||
const bets = useBets(contract.id) ?? props.bets
|
||||
const liquidityProvisions =
|
||||
useLiquidity(contract.id)?.filter((l) => !l.isAnte && l.amount > 0) ?? []
|
||||
// Sort for now to see if bug is fixed.
|
||||
comments.sort((c1, c2) => c1.createdTime - c2.createdTime)
|
||||
|
||||
|
@ -250,6 +253,7 @@ export function ContractPageContent(
|
|||
<ContractTabs
|
||||
contract={contract}
|
||||
user={user}
|
||||
liquidityProvisions={liquidityProvisions}
|
||||
bets={bets}
|
||||
tips={tips}
|
||||
comments={comments}
|
||||
|
|
|
@ -3,7 +3,9 @@ import { Answer } from 'common/answer'
|
|||
import { getOutcomeProbability, getProbability } from 'common/calculate'
|
||||
import { Comment } from 'common/comment'
|
||||
import { Contract } from 'common/contract'
|
||||
import { User } from 'common/user'
|
||||
import { removeUndefinedProps } from 'common/util/object'
|
||||
import { ENV_CONFIG } from 'common/envs/constants'
|
||||
|
||||
export type LiteMarket = {
|
||||
// Unique identifer for this market
|
||||
|
@ -143,3 +145,73 @@ function augmentAnswerWithProbability(
|
|||
probability,
|
||||
}
|
||||
}
|
||||
|
||||
export type LiteUser = {
|
||||
id: string
|
||||
createdTime: number
|
||||
|
||||
name: string
|
||||
username: string
|
||||
url: string
|
||||
avatarUrl?: string
|
||||
|
||||
bio?: string
|
||||
bannerUrl?: string
|
||||
website?: string
|
||||
twitterHandle?: string
|
||||
discordHandle?: string
|
||||
|
||||
balance: number
|
||||
totalDeposits: number
|
||||
|
||||
profitCached: {
|
||||
daily: number
|
||||
weekly: number
|
||||
monthly: number
|
||||
allTime: number
|
||||
}
|
||||
|
||||
creatorVolumeCached: {
|
||||
daily: number
|
||||
weekly: number
|
||||
monthly: number
|
||||
allTime: number
|
||||
}
|
||||
}
|
||||
|
||||
export function toLiteUser(user: User): LiteUser {
|
||||
const {
|
||||
id,
|
||||
createdTime,
|
||||
name,
|
||||
username,
|
||||
avatarUrl,
|
||||
bio,
|
||||
bannerUrl,
|
||||
website,
|
||||
twitterHandle,
|
||||
discordHandle,
|
||||
balance,
|
||||
totalDeposits,
|
||||
profitCached,
|
||||
creatorVolumeCached,
|
||||
} = user
|
||||
|
||||
return removeUndefinedProps({
|
||||
id,
|
||||
createdTime,
|
||||
name,
|
||||
username,
|
||||
url: `https://${ENV_CONFIG.domain}/${username}`,
|
||||
avatarUrl,
|
||||
bio,
|
||||
bannerUrl,
|
||||
website,
|
||||
twitterHandle,
|
||||
discordHandle,
|
||||
balance,
|
||||
totalDeposits,
|
||||
profitCached,
|
||||
creatorVolumeCached,
|
||||
})
|
||||
}
|
||||
|
|
17
web/pages/api/v0/users.ts
Normal file
17
web/pages/api/v0/users.ts
Normal file
|
@ -0,0 +1,17 @@
|
|||
// Next.js API route support: https://vercel.com/docs/concepts/functions/serverless-functions
|
||||
import type { NextApiRequest, NextApiResponse } from 'next'
|
||||
import { listAllUsers } from 'web/lib/firebase/users'
|
||||
import { applyCorsHeaders, CORS_UNRESTRICTED } from 'web/lib/api/cors'
|
||||
import { toLiteUser } from './_types'
|
||||
|
||||
type Data = any[]
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse<Data>
|
||||
) {
|
||||
await applyCorsHeaders(req, res, CORS_UNRESTRICTED)
|
||||
const users = await listAllUsers()
|
||||
res.setHeader('Cache-Control', 'max-age=0')
|
||||
res.status(200).json(users.map(toLiteUser))
|
||||
}
|
|
@ -26,6 +26,7 @@ import { useWarnUnsavedChanges } from 'web/hooks/use-warn-unsaved-changes'
|
|||
import { track } from 'web/lib/service/analytics'
|
||||
import { GroupSelector } from 'web/components/groups/group-selector'
|
||||
import { CATEGORIES } from 'common/categories'
|
||||
import { User } from 'common/user'
|
||||
|
||||
export default function Create() {
|
||||
const [question, setQuestion] = useState('')
|
||||
|
@ -33,7 +34,13 @@ export default function Create() {
|
|||
const router = useRouter()
|
||||
const { groupId } = router.query as { groupId: string }
|
||||
useTracking('view create page')
|
||||
if (!router.isReady) return <div />
|
||||
const creator = useUser()
|
||||
|
||||
useEffect(() => {
|
||||
if (creator === null) router.push('/')
|
||||
}, [creator, router])
|
||||
|
||||
if (!router.isReady || !creator) return <div />
|
||||
|
||||
return (
|
||||
<Page>
|
||||
|
@ -58,7 +65,11 @@ export default function Create() {
|
|||
</div>
|
||||
</form>
|
||||
<Spacer h={6} />
|
||||
<NewContract question={question} groupId={groupId} />
|
||||
<NewContract
|
||||
question={question}
|
||||
groupId={groupId}
|
||||
creator={creator}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Page>
|
||||
|
@ -66,14 +77,12 @@ export default function Create() {
|
|||
}
|
||||
|
||||
// Allow user to create a new contract
|
||||
export function NewContract(props: { question: string; groupId?: string }) {
|
||||
const { question, groupId } = props
|
||||
const creator = useUser()
|
||||
|
||||
useEffect(() => {
|
||||
if (creator === null) router.push('/')
|
||||
}, [creator])
|
||||
|
||||
export function NewContract(props: {
|
||||
creator: User
|
||||
question: string
|
||||
groupId?: string
|
||||
}) {
|
||||
const { creator, question, groupId } = props
|
||||
const [outcomeType, setOutcomeType] = useState<outcomeType>('BINARY')
|
||||
const [initialProb] = useState(50)
|
||||
const [minString, setMinString] = useState('')
|
||||
|
|
|
@ -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<typeof Analytics>[0]) {
|
||||
export default function AnalyticsEmbed() {
|
||||
const [stats, setStats] = useState<Stats | undefined>(undefined)
|
||||
useEffect(() => {
|
||||
getStats().then(setStats)
|
||||
}, [])
|
||||
if (stats == null) {
|
||||
return <></>
|
||||
}
|
||||
return (
|
||||
<Col className="w-full bg-white px-2">
|
||||
<CustomAnalytics {...props} />
|
||||
<CustomAnalytics {...stats} />
|
||||
<Spacer h={8} />
|
||||
<FirebaseAnalytics />
|
||||
</Col>
|
||||
|
|
|
@ -22,7 +22,7 @@ import {
|
|||
import { Spacer } from 'web/components/layout/spacer'
|
||||
import { Col } from 'web/components/layout/col'
|
||||
import { useUser } from 'web/hooks/use-user'
|
||||
import { useGroup, useMembers } from 'web/hooks/use-group'
|
||||
import { listMembers, useGroup, useMembers } from 'web/hooks/use-group'
|
||||
import { useRouter } from 'next/router'
|
||||
import { scoreCreators, scoreTraders } from 'common/scoring'
|
||||
import { Leaderboard } from 'web/components/leaderboard'
|
||||
|
@ -44,12 +44,14 @@ import { checkAgainstQuery } from 'web/hooks/use-sort-and-query-params'
|
|||
import { ChoicesToggleGroup } from 'web/components/choices-toggle-group'
|
||||
import { toast } from 'react-hot-toast'
|
||||
import { useCommentsOnGroup } from 'web/hooks/use-comments'
|
||||
import ShortToggle from 'web/components/widgets/short-toggle'
|
||||
|
||||
export const getStaticProps = fromPropz(getStaticPropz)
|
||||
export async function getStaticPropz(props: { params: { slugs: string[] } }) {
|
||||
const { slugs } = props.params
|
||||
|
||||
const group = await getGroupBySlug(slugs[0])
|
||||
const members = group ? await listMembers(group) : []
|
||||
const creatorPromise = group ? getUser(group.creatorId) : null
|
||||
|
||||
const contracts = group ? await getGroupContracts(group).catch((_) => []) : []
|
||||
|
@ -70,6 +72,7 @@ export async function getStaticPropz(props: { params: { slugs: string[] } }) {
|
|||
return {
|
||||
props: {
|
||||
group,
|
||||
members,
|
||||
creator,
|
||||
traderScores,
|
||||
topTraders,
|
||||
|
@ -96,10 +99,11 @@ async function toTopUsers(userScores: { [userId: string]: number }) {
|
|||
export async function getStaticPaths() {
|
||||
return { paths: [], fallback: 'blocking' }
|
||||
}
|
||||
const groupSubpages = [undefined, 'chat', 'questions', 'details'] as const
|
||||
const groupSubpages = [undefined, 'chat', 'questions', 'about'] as const
|
||||
|
||||
export default function GroupPage(props: {
|
||||
group: Group | null
|
||||
members: User[]
|
||||
creator: User
|
||||
traderScores: { [userId: string]: number }
|
||||
topTraders: User[]
|
||||
|
@ -108,14 +112,21 @@ export default function GroupPage(props: {
|
|||
}) {
|
||||
props = usePropz(props, getStaticPropz) ?? {
|
||||
group: null,
|
||||
members: [],
|
||||
creator: null,
|
||||
traderScores: {},
|
||||
topTraders: [],
|
||||
creatorScores: {},
|
||||
topCreators: [],
|
||||
}
|
||||
const { creator, traderScores, topTraders, creatorScores, topCreators } =
|
||||
props
|
||||
const {
|
||||
creator,
|
||||
members,
|
||||
traderScores,
|
||||
topTraders,
|
||||
creatorScores,
|
||||
topCreators,
|
||||
} = props
|
||||
|
||||
const router = useRouter()
|
||||
const { slugs } = router.query as { slugs: string[] }
|
||||
|
@ -159,20 +170,11 @@ export default function GroupPage(props: {
|
|||
|
||||
const rightSidebar = (
|
||||
<Col className="mt-6 hidden xl:block">
|
||||
<GroupOverview
|
||||
group={group}
|
||||
creator={creator}
|
||||
isCreator={!!isCreator}
|
||||
user={user}
|
||||
/>
|
||||
<YourPerformance
|
||||
traderScores={traderScores}
|
||||
creatorScores={creatorScores}
|
||||
user={user}
|
||||
/>
|
||||
<JoinOrCreateButton group={group} user={user} isMember={!!isMember} />
|
||||
<Spacer h={6} />
|
||||
{contracts && (
|
||||
<div className={'mt-2'}>
|
||||
<div className={'my-2 text-lg text-indigo-700'}>Recent Questions</div>
|
||||
<div className={'my-2 text-gray-500'}>Recent Questions</div>
|
||||
<ContractsGrid
|
||||
contracts={contracts
|
||||
.sort((a, b) => b.createdTime - a.createdTime)
|
||||
|
@ -186,13 +188,22 @@ export default function GroupPage(props: {
|
|||
</Col>
|
||||
)
|
||||
|
||||
const leaderboardsTab = (
|
||||
<Col className="mt-4 gap-8 px-4 md:flex-row">
|
||||
const aboutTab = (
|
||||
<Col>
|
||||
<GroupOverview
|
||||
group={group}
|
||||
creator={creator}
|
||||
isCreator={!!isCreator}
|
||||
user={user}
|
||||
/>
|
||||
<Spacer h={8} />
|
||||
|
||||
<GroupLeaderboards
|
||||
traderScores={traderScores}
|
||||
creatorScores={creatorScores}
|
||||
topTraders={topTraders}
|
||||
topCreators={topCreators}
|
||||
members={members}
|
||||
user={user}
|
||||
/>
|
||||
</Col>
|
||||
|
@ -205,30 +216,27 @@ export default function GroupPage(props: {
|
|||
url={groupPath(group.slug)}
|
||||
/>
|
||||
|
||||
<div className="px-3 lg:px-1">
|
||||
<Row className={' items-center justify-between gap-4 '}>
|
||||
<Col className="px-3 lg:px-1">
|
||||
<Row className={'items-center justify-between gap-4'}>
|
||||
<div className={'mb-1'}>
|
||||
<Title className={'line-clamp-2'} text={group.name} />
|
||||
<span className={'hidden text-gray-700 sm:block'}>
|
||||
{group.about}
|
||||
</span>
|
||||
<Linkify text={group.about} />
|
||||
</div>
|
||||
{isMember && (
|
||||
<CreateQuestionButton
|
||||
<div className="hidden sm:block xl:hidden">
|
||||
<JoinOrCreateButton
|
||||
group={group}
|
||||
user={user}
|
||||
overrideText={'Add a new question'}
|
||||
className={'w-48 flex-shrink-0'}
|
||||
query={`?groupId=${group.id}`}
|
||||
isMember={!!isMember}
|
||||
/>
|
||||
)}
|
||||
{!isMember && group.anyoneCanJoin && (
|
||||
<JoinGroupButton group={group} user={user} />
|
||||
)}
|
||||
</div>
|
||||
</Row>
|
||||
</div>
|
||||
<div className="block sm:hidden">
|
||||
<JoinOrCreateButton group={group} user={user} isMember={!!isMember} />
|
||||
</div>
|
||||
</Col>
|
||||
|
||||
<Tabs
|
||||
defaultIndex={page === 'details' ? 2 : page === 'questions' ? 1 : 0}
|
||||
defaultIndex={page === 'about' ? 2 : page === 'questions' ? 1 : 0}
|
||||
tabs={[
|
||||
{
|
||||
title: 'Chat',
|
||||
|
@ -272,26 +280,9 @@ export default function GroupPage(props: {
|
|||
href: groupPath(group.slug, 'questions'),
|
||||
},
|
||||
{
|
||||
title: 'Details',
|
||||
content: (
|
||||
<>
|
||||
<div className={'xl:hidden'}>
|
||||
<GroupOverview
|
||||
group={group}
|
||||
creator={creator}
|
||||
isCreator={!!isCreator}
|
||||
user={user}
|
||||
/>
|
||||
<YourPerformance
|
||||
traderScores={traderScores}
|
||||
creatorScores={creatorScores}
|
||||
user={user}
|
||||
/>
|
||||
</div>
|
||||
{leaderboardsTab}
|
||||
</>
|
||||
),
|
||||
href: groupPath(group.slug, 'details'),
|
||||
title: 'About',
|
||||
content: aboutTab,
|
||||
href: groupPath(group.slug, 'about'),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
@ -299,6 +290,24 @@ export default function GroupPage(props: {
|
|||
)
|
||||
}
|
||||
|
||||
function JoinOrCreateButton(props: {
|
||||
group: Group
|
||||
user: User | null | undefined
|
||||
isMember: boolean
|
||||
}) {
|
||||
const { group, user, isMember } = props
|
||||
return isMember ? (
|
||||
<CreateQuestionButton
|
||||
user={user}
|
||||
overrideText={'Add a new question'}
|
||||
className={'w-48 flex-shrink-0'}
|
||||
query={`?groupId=${group.id}`}
|
||||
/>
|
||||
) : group.anyoneCanJoin ? (
|
||||
<JoinGroupButton group={group} user={user} />
|
||||
) : null
|
||||
}
|
||||
|
||||
function GroupOverview(props: {
|
||||
group: Group
|
||||
creator: User
|
||||
|
@ -306,7 +315,6 @@ function GroupOverview(props: {
|
|||
isCreator: boolean
|
||||
}) {
|
||||
const { group, creator, isCreator, user } = props
|
||||
const { about } = group
|
||||
const anyoneCanJoinChoices: { [key: string]: string } = {
|
||||
Closed: 'false',
|
||||
Open: 'true',
|
||||
|
@ -325,7 +333,7 @@ function GroupOverview(props: {
|
|||
return (
|
||||
<Col>
|
||||
<Row className="items-center justify-end rounded-t bg-indigo-500 px-4 py-3 text-sm text-white">
|
||||
<Row className="flex-1 justify-start">About group</Row>
|
||||
<Row className="flex-1 justify-start">About {group.name}</Row>
|
||||
{isCreator && <EditGroupButton className={'ml-1'} group={group} />}
|
||||
</Row>
|
||||
<Col className="gap-2 rounded-b bg-white p-4">
|
||||
|
@ -337,7 +345,6 @@ function GroupOverview(props: {
|
|||
username={creator.username}
|
||||
/>
|
||||
</Row>
|
||||
<GroupMembersList group={group} />
|
||||
<Row className={'items-center gap-1'}>
|
||||
<span className={'text-gray-500'}>Membership</span>
|
||||
{user && user.id === creator.id ? (
|
||||
|
@ -356,14 +363,6 @@ function GroupOverview(props: {
|
|||
</span>
|
||||
)}
|
||||
</Row>
|
||||
{about && (
|
||||
<>
|
||||
<Spacer h={2} />
|
||||
<div className="text-gray-500">
|
||||
<Linkify text={about} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Col>
|
||||
</Col>
|
||||
)
|
||||
|
@ -394,40 +393,24 @@ export function GroupMembersList(props: { group: Group }) {
|
|||
)
|
||||
}
|
||||
|
||||
function YourPerformance(props: {
|
||||
traderScores: { [userId: string]: number }
|
||||
creatorScores: { [userId: string]: number }
|
||||
|
||||
user: User | null | undefined
|
||||
function SortedLeaderboard(props: {
|
||||
users: User[]
|
||||
scoreFunction: (user: User) => number
|
||||
title: string
|
||||
header: string
|
||||
}) {
|
||||
const { traderScores, creatorScores, user } = props
|
||||
|
||||
const yourTraderScore = user ? traderScores[user.id] : undefined
|
||||
const yourCreatorScore = user ? creatorScores[user.id] : undefined
|
||||
|
||||
return user ? (
|
||||
<Col>
|
||||
<div className="rounded bg-indigo-500 px-4 py-3 text-sm text-white">
|
||||
Your performance
|
||||
</div>
|
||||
<div className="bg-white p-2">
|
||||
<table className="table-compact table w-full text-gray-500">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Total profit</td>
|
||||
<td>{formatMoney(yourTraderScore ?? 0)}</td>
|
||||
</tr>
|
||||
{yourCreatorScore && (
|
||||
<tr>
|
||||
<td>Total created pool</td>
|
||||
<td>{formatMoney(yourCreatorScore)}</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Col>
|
||||
) : null
|
||||
const { users, scoreFunction, title, header } = props
|
||||
const sortedUsers = users.sort((a, b) => scoreFunction(b) - scoreFunction(a))
|
||||
return (
|
||||
<Leaderboard
|
||||
className="max-w-xl"
|
||||
users={sortedUsers}
|
||||
title={title}
|
||||
columns={[
|
||||
{ header, renderCell: (user) => formatMoney(scoreFunction(user)) },
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function GroupLeaderboards(props: {
|
||||
|
@ -435,41 +418,69 @@ function GroupLeaderboards(props: {
|
|||
creatorScores: { [userId: string]: number }
|
||||
topTraders: User[]
|
||||
topCreators: User[]
|
||||
members: User[]
|
||||
user: User | null | undefined
|
||||
}) {
|
||||
const { traderScores, creatorScores, topTraders, topCreators } = props
|
||||
|
||||
const topTraderScores = topTraders.map((user) => traderScores[user.id])
|
||||
const topCreatorScores = topCreators.map((user) => creatorScores[user.id])
|
||||
const { traderScores, creatorScores, members, topTraders, topCreators } =
|
||||
props
|
||||
const [includeOutsiders, setIncludeOutsiders] = useState(false)
|
||||
|
||||
// Consider hiding M$0
|
||||
return (
|
||||
<>
|
||||
<Leaderboard
|
||||
className="max-w-xl"
|
||||
title="🏅 Top bettors"
|
||||
users={topTraders}
|
||||
columns={[
|
||||
{
|
||||
header: 'Profit',
|
||||
renderCell: (user) =>
|
||||
formatMoney(topTraderScores[topTraders.indexOf(user)]),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Col>
|
||||
<Row className="items-center justify-end gap-4 text-gray-500">
|
||||
Include all users
|
||||
<ShortToggle
|
||||
enabled={includeOutsiders}
|
||||
setEnabled={setIncludeOutsiders}
|
||||
/>
|
||||
</Row>
|
||||
|
||||
<Leaderboard
|
||||
className="max-w-xl"
|
||||
title="🏅 Top creators"
|
||||
users={topCreators}
|
||||
columns={[
|
||||
{
|
||||
header: 'Market volume',
|
||||
renderCell: (user) =>
|
||||
formatMoney(topCreatorScores[topCreators.indexOf(user)]),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
<div className="mt-4 flex flex-col gap-8 px-4 md:flex-row">
|
||||
{!includeOutsiders ? (
|
||||
<>
|
||||
<SortedLeaderboard
|
||||
users={members}
|
||||
scoreFunction={(user) => traderScores[user.id] ?? 0}
|
||||
title="🏅 Top bettors"
|
||||
header="Profit"
|
||||
/>
|
||||
<SortedLeaderboard
|
||||
users={members}
|
||||
scoreFunction={(user) => creatorScores[user.id] ?? 0}
|
||||
title="🏅 Top creators"
|
||||
header="Market volume"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Leaderboard
|
||||
className="max-w-xl"
|
||||
title="🏅 Top bettors"
|
||||
users={topTraders}
|
||||
columns={[
|
||||
{
|
||||
header: 'Profit',
|
||||
renderCell: (user) => formatMoney(traderScores[user.id] ?? 0),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Leaderboard
|
||||
className="max-w-xl"
|
||||
title="🏅 Top creators"
|
||||
users={topCreators}
|
||||
columns={[
|
||||
{
|
||||
header: 'Market volume',
|
||||
renderCell: (user) =>
|
||||
formatMoney(creatorScores[user.id] ?? 0),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Col>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -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<string>()
|
||||
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<string>()
|
||||
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<string>()
|
||||
for (let j = twoWeeksAgo.start; j <= twoWeeksAgo.end; j++) {
|
||||
dailyUserIds[j].forEach((userId) => activeTwoWeeksAgo.add(userId))
|
||||
}
|
||||
const activeLastWeek = new Set<string>()
|
||||
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<string>()
|
||||
for (let j = twoMonthsAgo.start; j <= twoMonthsAgo.end; j++) {
|
||||
dailyUserIds[j].forEach((userId) => activeTwoMonthsAgo.add(userId))
|
||||
}
|
||||
const activeLastMonth = new Set<string>()
|
||||
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<Stats | undefined>(undefined)
|
||||
useEffect(() => {
|
||||
getStats().then(setStats)
|
||||
}, [])
|
||||
if (stats == null) {
|
||||
return <></>
|
||||
}
|
||||
return (
|
||||
<Page>
|
||||
|
@ -275,7 +28,7 @@ export default function Analytics(props: {
|
|||
tabs={[
|
||||
{
|
||||
title: 'Activity',
|
||||
content: <CustomAnalytics {...props} />,
|
||||
content: <CustomAnalytics {...stats} />,
|
||||
},
|
||||
{
|
||||
title: 'Market Stats',
|
||||
|
|
|
@ -16,7 +16,6 @@
|
|||
"jsx": "preserve",
|
||||
"incremental": true
|
||||
},
|
||||
|
||||
"watchOptions": {
|
||||
"excludeDirectories": [".next"]
|
||||
},
|
||||
|
|
15
yarn.lock
15
yarn.lock
|
@ -3148,6 +3148,11 @@
|
|||
dependencies:
|
||||
"@types/node" "*"
|
||||
|
||||
"@types/string-similarity@^4.0.0":
|
||||
version "4.0.0"
|
||||
resolved "https://registry.yarnpkg.com/@types/string-similarity/-/string-similarity-4.0.0.tgz#8cc03d5d1baad2b74530fe6c7d849d5768d391ad"
|
||||
integrity sha512-dMS4S07fbtY1AILG/RhuwmptmzK1Ql8scmAebOTJ/8iBtK/KI17NwGwKzu1uipjj8Kk+3mfPxum56kKZE93mzQ==
|
||||
|
||||
"@types/unist@*", "@types/unist@^2.0.0", "@types/unist@^2.0.2", "@types/unist@^2.0.3":
|
||||
version "2.0.6"
|
||||
resolved "https://registry.yarnpkg.com/@types/unist/-/unist-2.0.6.tgz#250a7b16c3b91f672a24552ec64678eeb1d3a08d"
|
||||
|
@ -8026,11 +8031,6 @@ nanoid@^3.1.23, nanoid@^3.1.30, nanoid@^3.3.4:
|
|||
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.4.tgz#730b67e3cd09e2deacf03c027c81c9d9dbc5e8ab"
|
||||
integrity sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==
|
||||
|
||||
nanoid@^3.3.4:
|
||||
version "3.3.4"
|
||||
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.4.tgz#730b67e3cd09e2deacf03c027c81c9d9dbc5e8ab"
|
||||
integrity sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==
|
||||
|
||||
natural-compare@^1.4.0:
|
||||
version "1.4.0"
|
||||
resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7"
|
||||
|
@ -10320,6 +10320,11 @@ streamsearch@^1.1.0:
|
|||
resolved "https://registry.yarnpkg.com/streamsearch/-/streamsearch-1.1.0.tgz#404dd1e2247ca94af554e841a8ef0eaa238da764"
|
||||
integrity sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==
|
||||
|
||||
string-similarity@^4.0.4:
|
||||
version "4.0.4"
|
||||
resolved "https://registry.yarnpkg.com/string-similarity/-/string-similarity-4.0.4.tgz#42d01ab0b34660ea8a018da8f56a3309bb8b2a5b"
|
||||
integrity sha512-/q/8Q4Bl4ZKAPjj8WerIBJWALKkaPRfrvhfF8k/B23i4nzrlRj2/go1m90In7nG/3XDSbOo0+pu6RvCTM9RGMQ==
|
||||
|
||||
string-width@^4.0.0, string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.2:
|
||||
version "4.2.3"
|
||||
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
|
||||
|
|
Loading…
Reference in New Issue
Block a user