This commit is contained in:
marsteralex 2022-09-20 12:48:57 -04:00
commit b1ac37ea87
72 changed files with 1371 additions and 1338 deletions

View File

@ -1,4 +1,4 @@
import { maxBy, sortBy, sum, sumBy } from 'lodash'
import { maxBy, partition, sortBy, sum, sumBy } from 'lodash'
import { Bet, LimitBet } from './bet'
import {
calculateCpmmSale,
@ -255,3 +255,43 @@ export function getTopAnswer(
)
return top?.answer
}
export function getLargestPosition(contract: Contract, userBets: Bet[]) {
let yesFloorShares = 0,
yesShares = 0,
noShares = 0,
noFloorShares = 0
if (userBets.length === 0) {
return null
}
if (contract.outcomeType === 'FREE_RESPONSE') {
const answerCounts: { [outcome: string]: number } = {}
for (const bet of userBets) {
if (bet.outcome) {
if (!answerCounts[bet.outcome]) {
answerCounts[bet.outcome] = bet.amount
} else {
answerCounts[bet.outcome] += bet.amount
}
}
}
const majorityAnswer =
maxBy(Object.keys(answerCounts), (outcome) => answerCounts[outcome]) ?? ''
return {
prob: undefined,
shares: answerCounts[majorityAnswer] || 0,
outcome: majorityAnswer,
}
}
const [yesBets, noBets] = partition(userBets, (bet) => bet.outcome === 'YES')
yesShares = sumBy(yesBets, (bet) => bet.shares)
noShares = sumBy(noBets, (bet) => bet.shares)
yesFloorShares = Math.floor(yesShares)
noFloorShares = Math.floor(noShares)
const shares = yesFloorShares || noFloorShares
const outcome = yesFloorShares > noFloorShares ? 'YES' : 'NO'
return { shares, outcome }
}

View File

@ -33,6 +33,11 @@ export type OnContract = {
// denormalized from bet
betAmount?: number
betOutcome?: string
// denormalized based on betting history
commenterPositionProb?: number // binary only
commenterPositionShares?: number
commenterPositionOutcome?: string
}
export type OnGroup = {

View File

@ -148,7 +148,7 @@ export const OUTCOME_TYPES = [
'NUMERIC',
] as const
export const MAX_QUESTION_LENGTH = 480
export const MAX_QUESTION_LENGTH = 240
export const MAX_DESCRIPTION_LENGTH = 16000
export const MAX_TAG_LENGTH = 60

View File

@ -7,7 +7,7 @@ export const FIXED_ANTE = econ?.FIXED_ANTE ?? 100
export const STARTING_BALANCE = econ?.STARTING_BALANCE ?? 1000
// for sus users, i.e. multiple sign ups for same person
export const SUS_STARTING_BALANCE = econ?.SUS_STARTING_BALANCE ?? 10
export const REFERRAL_AMOUNT = econ?.REFERRAL_AMOUNT ?? 500
export const REFERRAL_AMOUNT = econ?.REFERRAL_AMOUNT ?? 250
export const UNIQUE_BETTOR_BONUS_AMOUNT = econ?.UNIQUE_BETTOR_BONUS_AMOUNT ?? 10
export const BETTING_STREAK_BONUS_AMOUNT =

View File

@ -16,4 +16,6 @@ export const DEV_CONFIG: EnvConfig = {
cloudRunId: 'w3txbmd3ba',
cloudRunRegion: 'uc',
amplitudeApiKey: 'fd8cbfd964b9a205b8678a39faae71b3',
// this is Phil's deployment
twitchBotEndpoint: 'https://king-prawn-app-5btyw.ondigitalocean.app',
}

View File

@ -2,6 +2,7 @@ export type EnvConfig = {
domain: string
firebaseConfig: FirebaseConfig
amplitudeApiKey?: string
twitchBotEndpoint?: string
// IDs for v2 cloud functions -- find these by deploying a cloud function and
// examining the URL, https://[name]-[cloudRunId]-[cloudRunRegion].a.run.app
@ -66,6 +67,7 @@ export const PROD_CONFIG: EnvConfig = {
appId: '1:128925704902:web:f61f86944d8ffa2a642dc7',
measurementId: 'G-SSFK1Q138D',
},
twitchBotEndpoint: 'https://twitch-bot-nggbo3neva-uc.a.run.app',
cloudRunId: 'nggbo3neva',
cloudRunRegion: 'uc',
adminEmails: [
@ -82,9 +84,9 @@ export const PROD_CONFIG: EnvConfig = {
visibility: 'PUBLIC',
moneyMoniker: 'M$',
bettor: 'predictor',
pastBet: 'prediction',
presentBet: 'predict',
bettor: 'trader',
pastBet: 'trade',
presentBet: 'trade',
navbarLogoPath: '',
faviconPath: '/favicon.ico',
newQuestionPlaceholders: [

View File

@ -1,20 +1,22 @@
export type Stats = {
startDate: number
dailyActiveUsers: number[]
dailyActiveUsersWeeklyAvg: number[]
weeklyActiveUsers: number[]
monthlyActiveUsers: number[]
d1: number[]
d1WeeklyAvg: number[]
nd1: number[]
nd1WeeklyAvg: number[]
nw1: number[]
dailyBetCounts: number[]
dailyContractCounts: number[]
dailyCommentCounts: number[]
dailySignups: number[]
weekOnWeekRetention: number[]
monthlyRetention: number[]
weeklyActivationRate: number[]
topTenthActions: {
daily: number[]
weekly: number[]
monthly: number[]
}
dailyActivationRate: number[]
dailyActivationRateWeeklyAvg: number[]
manaBet: {
daily: number[]
weekly: number[]

View File

@ -56,11 +56,6 @@ export type PrivateUser = {
username: string // denormalized from User
email?: string
unsubscribedFromResolutionEmails?: boolean
unsubscribedFromCommentEmails?: boolean
unsubscribedFromAnswerEmails?: boolean
unsubscribedFromGenericEmails?: boolean
unsubscribedFromWeeklyTrendingEmails?: boolean
weeklyTrendingEmailSent?: boolean
manaBonusEmailSent?: boolean
initialDeviceToken?: string
@ -86,9 +81,11 @@ export type PortfolioMetrics = {
export const MANIFOLD_USERNAME = 'ManifoldMarkets'
export const MANIFOLD_AVATAR_URL = 'https://manifold.markets/logo-bg-white.png'
export const BETTOR = ENV_CONFIG.bettor ?? 'bettor' // aka predictor
export const BETTORS = ENV_CONFIG.bettor + 's' ?? 'bettors'
export const PRESENT_BET = ENV_CONFIG.presentBet ?? 'bet' // aka predict
export const PRESENT_BETS = ENV_CONFIG.presentBet + 's' ?? 'bets'
export const PAST_BET = ENV_CONFIG.pastBet ?? 'bet' // aka prediction
export const PAST_BETS = ENV_CONFIG.pastBet + 's' ?? 'bets' // aka predictions
// TODO: remove. Hardcoding the strings would be better.
// Different views require different language.
export const BETTOR = ENV_CONFIG.bettor ?? 'trader'
export const BETTORS = ENV_CONFIG.bettor + 's' ?? 'traders'
export const PRESENT_BET = ENV_CONFIG.presentBet ?? 'trade'
export const PRESENT_BETS = ENV_CONFIG.presentBet + 's' ?? 'trades'
export const PAST_BET = ENV_CONFIG.pastBet ?? 'trade'
export const PAST_BETS = ENV_CONFIG.pastBet + 's' ?? 'trades'

View File

@ -27,7 +27,7 @@ service cloud.firestore {
allow read;
allow update: if userId == request.auth.uid
&& request.resource.data.diff(resource.data).affectedKeys()
.hasOnly(['bio', 'bannerUrl', 'website', 'twitterHandle', 'discordHandle', 'followedCategories', 'lastPingTime','shouldShowWelcome', 'hasSeenContractFollowModal']);
.hasOnly(['bio', 'bannerUrl', 'website', 'twitterHandle', 'discordHandle', 'followedCategories', 'lastPingTime','shouldShowWelcome', 'hasSeenContractFollowModal', 'homeSections']);
// User referral rules
allow update: if userId == request.auth.uid
&& request.resource.data.diff(resource.data).affectedKeys()
@ -78,7 +78,7 @@ service cloud.firestore {
allow read: if userId == request.auth.uid || isAdmin();
allow update: if (userId == request.auth.uid || isAdmin())
&& request.resource.data.diff(resource.data).affectedKeys()
.hasOnly(['apiKey', 'unsubscribedFromResolutionEmails', 'unsubscribedFromCommentEmails', 'unsubscribedFromAnswerEmails', 'unsubscribedFromWeeklyTrendingEmails', 'notificationPreferences', 'twitchInfo']);
.hasOnly(['apiKey', 'notificationPreferences', 'twitchInfo']);
}
match /private-users/{userId}/views/{viewId} {
@ -171,33 +171,32 @@ service cloud.firestore {
allow read;
}
match /groups/{groupId} {
allow read;
allow update: if (request.auth.uid == resource.data.creatorId || isAdmin())
&& request.resource.data.diff(resource.data)
.affectedKeys()
.hasOnly(['name', 'about', 'anyoneCanJoin', 'aboutPostId' ]);
allow delete: if request.auth.uid == resource.data.creatorId;
match /groups/{groupId} {
allow read;
allow update: if (request.auth.uid == resource.data.creatorId || isAdmin())
&& request.resource.data.diff(resource.data)
.affectedKeys()
.hasOnly(['name', 'about', 'anyoneCanJoin', 'aboutPostId' ]);
allow delete: if request.auth.uid == resource.data.creatorId;
match /groupContracts/{contractId} {
allow write: if isGroupMember() || request.auth.uid == get(/databases/$(database)/documents/groups/$(groupId)).data.creatorId
}
match /groupContracts/{contractId} {
allow write: if isGroupMember() || request.auth.uid == get(/databases/$(database)/documents/groups/$(groupId)).data.creatorId
}
match /groupMembers/{memberId}{
allow create: if request.auth.uid == get(/databases/$(database)/documents/groups/$(groupId)).data.creatorId || (request.auth.uid == request.resource.data.userId && get(/databases/$(database)/documents/groups/$(groupId)).data.anyoneCanJoin);
allow delete: if request.auth.uid == resource.data.userId;
}
match /groupMembers/{memberId}{
allow create: if request.auth.uid == get(/databases/$(database)/documents/groups/$(groupId)).data.creatorId || (request.auth.uid == request.resource.data.userId && get(/databases/$(database)/documents/groups/$(groupId)).data.anyoneCanJoin);
allow delete: if request.auth.uid == resource.data.userId;
}
function isGroupMember() {
return exists(/databases/$(database)/documents/groups/$(groupId)/groupMembers/$(request.auth.uid));
}
function isGroupMember() {
return exists(/databases/$(database)/documents/groups/$(groupId)/groupMembers/$(request.auth.uid));
}
match /comments/{commentId} {
allow read;
allow create: if request.auth != null && commentMatchesUser(request.auth.uid, request.resource.data) && isGroupMember();
}
}
match /comments/{commentId} {
allow read;
allow create: if request.auth != null && commentMatchesUser(request.auth.uid, request.resource.data) && isGroupMember();
}
}
match /posts/{postId} {
allow read;

View File

@ -65,5 +65,6 @@ Adapted from https://firebase.google.com/docs/functions/get-started
Secrets are strings that shouldn't be checked into Git (eg API keys, passwords). We store these using [Google Secret Manager](https://console.cloud.google.com/security/secret-manager), which provides them as environment variables to functions that require them. Some useful workflows:
- Set a secret: `$ firebase functions:secrets:set stripe.test_secret="THE-API-KEY"`
- Set a secret: `$ firebase functions:secrets:set STRIPE_APIKEY`
- Then, enter the secret in the prompt.
- Read a secret: `$ firebase functions:secrets:access STRIPE_APIKEY`

View File

@ -39,6 +39,7 @@
"lodash": "4.17.21",
"mailgun-js": "0.22.0",
"module-alias": "2.2.2",
"node-fetch": "2",
"react-masonry-css": "1.0.16",
"stripe": "8.194.0",
"zod": "3.17.2"
@ -46,6 +47,7 @@
"devDependencies": {
"@types/mailgun-js": "0.22.12",
"@types/module-alias": "2.0.1",
"@types/node-fetch": "2.6.2",
"firebase-functions-test": "0.3.3"
},
"private": true

View File

@ -9,7 +9,7 @@
<head>
<meta name="viewport" content="width=device-width" />
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<title>New unique predictors on your market</title>
<title>New unique traders on your market</title>
<style type="text/css">
img {
@ -214,14 +214,14 @@
style="line-height: 24px; margin: 10px 0; margin-top: 10px; margin-bottom: 10px;"
data-testid="4XoHRGw1Y"><span
style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">
Your market <a href='{{marketUrl}}'>{{marketTitle}}</a> just got its first prediction from a user!
Your market <a href='{{marketUrl}}'>{{marketTitle}}</a> just got its first trade from a user!
<br/>
<br/>
We sent you a <span style='color: #11b981'>{{bonusString}}</span> bonus for
creating a market that appeals to others, and we'll do so for each new predictor.
creating a market that appeals to others, and we'll do so for each new trader.
<br/>
<br/>
Keep up the good work and check out your newest predictor below!
Keep up the good work and check out your newest trader below!
</span></p>
</div>
</td>

View File

@ -9,7 +9,7 @@
<head>
<meta name="viewport" content="width=device-width" />
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<title>New unique predictors on your market</title>
<title>New unique traders on your market</title>
<style type="text/css">
img {
@ -214,14 +214,14 @@
style="line-height: 24px; margin: 10px 0; margin-top: 10px; margin-bottom: 10px;"
data-testid="4XoHRGw1Y"><span
style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">
Your market <a href='{{marketUrl}}'>{{marketTitle}}</a> got predictions from a total of {{totalPredictors}} users!
Your market <a href='{{marketUrl}}'>{{marketTitle}}</a> has attracted {{totalPredictors}} total traders!
<br/>
<br/>
We sent you a <span style='color: #11b981'>{{bonusString}}</span> bonus for getting {{newPredictors}} new predictors,
and we'll continue to do so for each new predictor, (although we won't send you any more emails about it for this market).
We sent you a <span style='color: #11b981'>{{bonusString}}</span> bonus for getting {{newPredictors}} new traders,
and we'll continue to do so for each new trader, (although we won't send you any more emails about it for this market).
<br/>
<br/>
Keep up the good work and check out your newest predictors below!
Keep up the good work and check out your newest traders below!
</span></p>
</div>
</td>

View File

@ -192,7 +192,7 @@
tips on comments and markets</span></li>
<li style="line-height:23px;"><span
style="font-family:Arial, sans-serif;font-size:18px;">Unique
predictor bonus for each user who predicts on your
trader bonus for each user who trades on your
markets</span></li>
<li style="line-height:23px;"><span style="font-family:Arial, sans-serif;font-size:18px;"><a
class="link-build-content" style="color:inherit;; text-decoration: none;"

View File

@ -210,7 +210,7 @@
class="link-build-content" style="color:inherit;; text-decoration: none;"
target="_blank" href="https://manifold.markets/referrals"><span
style="color:#55575d;font-family:Arial;font-size:18px;"><u>Refer
your friends</u></span></a> and earn M$500 for each signup!</span></li>
your friends</u></span></a> and earn M$250 for each signup!</span></li>
<li style="line-height:23px;"><span style="font-family:Arial, sans-serif;font-size:18px;"><a
class="link-build-content" style="color:inherit;; text-decoration: none;"

View File

@ -3,7 +3,14 @@ import * as admin from 'firebase-admin'
import { keyBy, uniq } from 'lodash'
import { Bet, LimitBet } from '../../common/bet'
import { getUser, getValues, isProd, log } from './utils'
import {
getContractPath,
getUser,
getValues,
isProd,
log,
revalidateStaticProps,
} from './utils'
import {
createBetFillNotification,
createBettingStreakBonusNotification,
@ -24,8 +31,6 @@ import {
} from '../../common/antes'
import { APIError } from '../../common/api'
import { User } from '../../common/user'
import { UNIQUE_BETTOR_LIQUIDITY_AMOUNT } from '../../common/antes'
import { addHouseLiquidity } from './add-liquidity'
import { DAY_MS } from '../../common/util/time'
import { BettingStreakBonusTxn, UniqueBettorBonusTxn } from '../../common/txn'
@ -33,7 +38,7 @@ const firestore = admin.firestore()
const BONUS_START_DATE = new Date('2022-07-13T15:30:00.000Z').getTime()
export const onCreateBet = functions
.runWith({ secrets: ['MAILGUN_KEY'] })
.runWith({ secrets: ['MAILGUN_KEY', 'API_SECRET'] })
.firestore.document('contracts/{contractId}/bets/{betId}')
.onCreate(async (change, context) => {
const { contractId } = context.params as {
@ -73,7 +78,7 @@ export const onCreateBet = functions
await notifyFills(bet, contract, eventId, bettor)
await updateBettingStreak(bettor, bet, contract, eventId)
await firestore.collection('users').doc(bettor.id).update({ lastBetTime })
await revalidateStaticProps(getContractPath(contract))
})
const updateBettingStreak = async (
@ -82,36 +87,42 @@ const updateBettingStreak = async (
contract: Contract,
eventId: string
) => {
const now = Date.now()
const currentDateResetTime = currentDateBettingStreakResetTime()
// if now is before reset time, use yesterday's reset time
const lastDateResetTime = currentDateResetTime - DAY_MS
const betStreakResetTime =
now < currentDateResetTime ? lastDateResetTime : currentDateResetTime
const lastBetTime = user?.lastBetTime ?? 0
const { newBettingStreak } = await firestore.runTransaction(async (trans) => {
const userDoc = firestore.collection('users').doc(user.id)
const bettor = (await trans.get(userDoc)).data() as User
const now = Date.now()
const currentDateResetTime = currentDateBettingStreakResetTime()
// if now is before reset time, use yesterday's reset time
const lastDateResetTime = currentDateResetTime - DAY_MS
const betStreakResetTime =
now < currentDateResetTime ? lastDateResetTime : currentDateResetTime
const lastBetTime = bettor?.lastBetTime ?? 0
// If they've already bet after the reset time
if (lastBetTime > betStreakResetTime) return
// If they've already bet after the reset time
if (lastBetTime > betStreakResetTime) return { newBettingStreak: undefined }
const newBettingStreak = (user?.currentBettingStreak ?? 0) + 1
// Otherwise, add 1 to their betting streak
await firestore.collection('users').doc(user.id).update({
currentBettingStreak: newBettingStreak,
const newBettingStreak = (bettor?.currentBettingStreak ?? 0) + 1
// Otherwise, add 1 to their betting streak
await trans.update(userDoc, {
currentBettingStreak: newBettingStreak,
lastBetTime: bet.createdTime,
})
return { newBettingStreak }
})
// Send them the bonus times their streak
const bonusAmount = Math.min(
BETTING_STREAK_BONUS_AMOUNT * newBettingStreak,
BETTING_STREAK_BONUS_MAX
)
const fromUserId = isProd()
? HOUSE_LIQUIDITY_PROVIDER_ID
: DEV_HOUSE_LIQUIDITY_PROVIDER_ID
const bonusTxnDetails = {
currentBettingStreak: newBettingStreak,
}
// TODO: set the id of the txn to the eventId to prevent duplicates
if (!newBettingStreak) return
const result = await firestore.runTransaction(async (trans) => {
// Send them the bonus times their streak
const bonusAmount = Math.min(
BETTING_STREAK_BONUS_AMOUNT * newBettingStreak,
BETTING_STREAK_BONUS_MAX
)
const fromUserId = isProd()
? HOUSE_LIQUIDITY_PROVIDER_ID
: DEV_HOUSE_LIQUIDITY_PROVIDER_ID
const bonusTxnDetails = {
currentBettingStreak: newBettingStreak,
}
const bonusTxn: TxnData = {
fromId: fromUserId,
fromType: 'BANK',
@ -123,74 +134,80 @@ const updateBettingStreak = async (
description: JSON.stringify(bonusTxnDetails),
data: bonusTxnDetails,
} as Omit<BettingStreakBonusTxn, 'id' | 'createdTime'>
return await runTxn(trans, bonusTxn)
const { message, txn, status } = await runTxn(trans, bonusTxn)
return { message, txn, status, bonusAmount }
})
if (!result.txn) {
if (result.status != 'success') {
log("betting streak bonus txn couldn't be made")
log('status:', result.status)
log('message:', result.message)
return
}
await createBettingStreakBonusNotification(
user,
result.txn.id,
bet,
contract,
bonusAmount,
newBettingStreak,
eventId
)
if (result.txn)
await createBettingStreakBonusNotification(
user,
result.txn.id,
bet,
contract,
result.bonusAmount,
newBettingStreak,
eventId
)
}
const updateUniqueBettorsAndGiveCreatorBonus = async (
contract: Contract,
oldContract: Contract,
eventId: string,
bettor: User
) => {
let previousUniqueBettorIds = contract.uniqueBettorIds
const { newUniqueBettorIds } = await firestore.runTransaction(
async (trans) => {
const contractDoc = firestore.collection(`contracts`).doc(oldContract.id)
const contract = (await trans.get(contractDoc)).data() as Contract
let previousUniqueBettorIds = contract.uniqueBettorIds
if (!previousUniqueBettorIds) {
const contractBets = (
await firestore.collection(`contracts/${contract.id}/bets`).get()
).docs.map((doc) => doc.data() as Bet)
const betsSnap = await trans.get(
firestore.collection(`contracts/${contract.id}/bets`)
)
if (!previousUniqueBettorIds) {
const contractBets = betsSnap.docs.map((doc) => doc.data() as Bet)
if (contractBets.length === 0) {
log(`No bets for contract ${contract.id}`)
return
if (contractBets.length === 0) {
return { newUniqueBettorIds: undefined }
}
previousUniqueBettorIds = uniq(
contractBets
.filter((bet) => bet.createdTime < BONUS_START_DATE)
.map((bet) => bet.userId)
)
}
const isNewUniqueBettor = !previousUniqueBettorIds.includes(bettor.id)
const newUniqueBettorIds = uniq([...previousUniqueBettorIds, bettor.id])
// Update contract unique bettors
if (!contract.uniqueBettorIds || isNewUniqueBettor) {
log(`Got ${previousUniqueBettorIds} unique bettors`)
isNewUniqueBettor && log(`And a new unique bettor ${bettor.id}`)
await trans.update(contractDoc, {
uniqueBettorIds: newUniqueBettorIds,
uniqueBettorCount: newUniqueBettorIds.length,
})
}
// No need to give a bonus for the creator's bet
if (!isNewUniqueBettor || bettor.id == contract.creatorId)
return { newUniqueBettorIds: undefined }
return { newUniqueBettorIds }
}
)
if (!newUniqueBettorIds) return
previousUniqueBettorIds = uniq(
contractBets
.filter((bet) => bet.createdTime < BONUS_START_DATE)
.map((bet) => bet.userId)
)
}
const isNewUniqueBettor = !previousUniqueBettorIds.includes(bettor.id)
const newUniqueBettorIds = uniq([...previousUniqueBettorIds, bettor.id])
// Update contract unique bettors
if (!contract.uniqueBettorIds || isNewUniqueBettor) {
log(`Got ${previousUniqueBettorIds} unique bettors`)
isNewUniqueBettor && log(`And a new unique bettor ${bettor.id}`)
await firestore.collection(`contracts`).doc(contract.id).update({
uniqueBettorIds: newUniqueBettorIds,
uniqueBettorCount: newUniqueBettorIds.length,
})
}
// No need to give a bonus for the creator's bet
if (!isNewUniqueBettor || bettor.id == contract.creatorId) return
if (contract.mechanism === 'cpmm-1') {
await addHouseLiquidity(contract, UNIQUE_BETTOR_LIQUIDITY_AMOUNT)
}
// Create combined txn for all new unique bettors
const bonusTxnDetails = {
contractId: contract.id,
contractId: oldContract.id,
uniqueNewBettorId: bettor.id,
}
const fromUserId = isProd()
@ -199,12 +216,11 @@ const updateUniqueBettorsAndGiveCreatorBonus = async (
const fromSnap = await firestore.doc(`users/${fromUserId}`).get()
if (!fromSnap.exists) throw new APIError(400, 'From user not found.')
const fromUser = fromSnap.data() as User
// TODO: set the id of the txn to the eventId to prevent duplicates
const result = await firestore.runTransaction(async (trans) => {
const bonusTxn: TxnData = {
fromId: fromUser.id,
fromType: 'BANK',
toId: contract.creatorId,
toId: oldContract.creatorId,
toType: 'USER',
amount: UNIQUE_BETTOR_BONUS_AMOUNT,
token: 'M$',
@ -212,21 +228,25 @@ const updateUniqueBettorsAndGiveCreatorBonus = async (
description: JSON.stringify(bonusTxnDetails),
data: bonusTxnDetails,
} as Omit<UniqueBettorBonusTxn, 'id' | 'createdTime'>
return await runTxn(trans, bonusTxn)
const { status, message, txn } = await runTxn(trans, bonusTxn)
return { status, newUniqueBettorIds, message, txn }
})
if (result.status != 'success' || !result.txn) {
log(`No bonus for user: ${contract.creatorId} - status:`, result.status)
log(`No bonus for user: ${oldContract.creatorId} - status:`, result.status)
log('message:', result.message)
} else {
log(`Bonus txn for user: ${contract.creatorId} completed:`, result.txn?.id)
log(
`Bonus txn for user: ${oldContract.creatorId} completed:`,
result.txn?.id
)
await createUniqueBettorBonusNotification(
contract.creatorId,
oldContract.creatorId,
bettor,
result.txn.id,
contract,
oldContract,
result.txn.amount,
newUniqueBettorIds,
result.newUniqueBettorIds,
eventId + '-unique-bettor-bonus'
)
}

View File

@ -1,10 +1,18 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import { compact } from 'lodash'
import { getContract, getUser, getValues } from './utils'
import {
getContract,
getContractPath,
getUser,
getValues,
revalidateStaticProps,
} from './utils'
import { ContractComment } from '../../common/comment'
import { Bet } from '../../common/bet'
import { Answer } from '../../common/answer'
import { getLargestPosition } from '../../common/calculate'
import { maxBy } from 'lodash'
import {
createCommentOrAnswerOrUpdatedContractNotification,
replied_users_info,
@ -32,6 +40,8 @@ export const onCreateCommentOnContract = functions
contractQuestion: contract.question,
})
await revalidateStaticProps(getContractPath(contract))
const comment = change.data() as ContractComment
const lastCommentTime = comment.createdTime
@ -45,6 +55,32 @@ export const onCreateCommentOnContract = functions
.doc(contract.id)
.update({ lastCommentTime, lastUpdatedTime: Date.now() })
const previousBetsQuery = await firestore
.collection('contracts')
.doc(contractId)
.collection('bets')
.where('createdTime', '<', comment.createdTime)
.get()
const previousBets = previousBetsQuery.docs.map((d) => d.data() as Bet)
const position = getLargestPosition(
contract,
previousBets.filter((b) => b.userId === comment.userId && !b.isAnte)
)
if (position) {
const fields: { [k: string]: unknown } = {
commenterPositionShares: position.shares,
commenterPositionOutcome: position.outcome,
}
const previousProb =
contract.outcomeType === 'BINARY'
? maxBy(previousBets, (bet) => bet.createdTime)?.probAfter
: undefined
if (previousProb != null) {
fields.commenterPositionProb = previousProb
}
await change.ref.update(fields)
}
let bet: Bet | undefined
let answer: Answer | undefined
if (comment.answerOutcome) {

View File

@ -4,14 +4,8 @@ import * as utc from 'dayjs/plugin/utc'
dayjs.extend(utc)
import { getPrivateUser } from './utils'
import { User } from 'common/user'
import {
sendCreatorGuideEmail,
sendInterestingMarketsEmail,
sendPersonalFollowupEmail,
sendWelcomeEmail,
} from './emails'
import { getTrendingContracts } from './weekly-markets-emails'
import { User } from '../../common/user'
import { sendPersonalFollowupEmail, sendWelcomeEmail } from './emails'
export const onCreateUser = functions
.runWith({ secrets: ['MAILGUN_KEY'] })
@ -23,23 +17,6 @@ export const onCreateUser = functions
await sendWelcomeEmail(user, privateUser)
const guideSendTime = dayjs().add(28, 'hours').toString()
await sendCreatorGuideEmail(user, privateUser, guideSendTime)
const followupSendTime = dayjs().add(48, 'hours').toString()
await sendPersonalFollowupEmail(user, privateUser, followupSendTime)
// skip email if weekly email is about to go out
const day = dayjs().utc().day()
if (day === 0 || (day === 1 && dayjs().utc().hour() <= 19)) return
const contracts = await getTrendingContracts()
const marketsSendTime = dayjs().add(24, 'hours').toString()
await sendInterestingMarketsEmail(
user,
privateUser,
contracts,
marketsSendTime
)
})

View File

@ -3,19 +3,18 @@ import * as admin from 'firebase-admin'
import { getAllPrivateUsers } from './utils'
export const resetWeeklyEmailsFlag = functions
.runWith({ secrets: ['MAILGUN_KEY'] })
// every Monday at 12 am PT (UTC -07:00) ( 12 hours before the emails will be sent)
.pubsub.schedule('0 7 * * 1')
.runWith({
timeoutSeconds: 300,
memory: '4GB',
})
.pubsub // every Monday at 12 am PT (UTC -07:00) ( 12 hours before the emails will be sent)
.schedule('0 7 * * 1')
.timeZone('Etc/UTC')
.onRun(async () => {
const privateUsers = await getAllPrivateUsers()
// get all users that haven't unsubscribed from weekly emails
const privateUsersToSendEmailsTo = privateUsers.filter((user) => {
return !user.unsubscribedFromWeeklyTrendingEmails
})
const firestore = admin.firestore()
await Promise.all(
privateUsersToSendEmailsTo.map(async (user) => {
privateUsers.map(async (user) => {
return firestore.collection('private-users').doc(user.id).update({
weeklyTrendingEmailSent: false,
})

View File

@ -9,7 +9,7 @@ import {
RESOLUTIONS,
} from '../../common/contract'
import { Bet } from '../../common/bet'
import { getUser, getValues, isProd, log, payUser } from './utils'
import { getContractPath, getUser, getValues, isProd, log, payUser, revalidateStaticProps } from './utils'
import {
getLoanPayouts,
getPayouts,
@ -171,6 +171,8 @@ export const resolvemarket = newEndpoint(opts, async (req, auth) => {
await processPayouts([...payouts, ...loanPayouts])
await undoUniqueBettorRewardsIfCancelResolution(contract, outcome)
await revalidateStaticProps(getContractPath(contract))
const userPayoutsWithoutLoans = groupPayoutsByUser(payouts)
const userInvestments = mapValues(

View File

@ -0,0 +1,92 @@
// Filling in historical bet positions on comments.
// Warning: This just recalculates all of them, rather than trying to
// figure out which ones are out of date, since I'm using it to fill them
// in once in the first place.
import { maxBy } from 'lodash'
import * as admin from 'firebase-admin'
import { filterDefined } from '../../../common/util/array'
import { Bet } from '../../../common/bet'
import { Comment } from '../../../common/comment'
import { Contract } from '../../../common/contract'
import { getLargestPosition } from '../../../common/calculate'
import { initAdmin } from './script-init'
import { DocumentSnapshot } from 'firebase-admin/firestore'
import { log, writeAsync } from '../utils'
initAdmin()
const firestore = admin.firestore()
async function getContractsById() {
const contracts = await firestore.collection('contracts').get()
const results = Object.fromEntries(
contracts.docs.map((doc) => [doc.id, doc.data() as Contract])
)
log(`Found ${contracts.size} contracts.`)
return results
}
async function getCommentsByContractId() {
const comments = await firestore
.collectionGroup('comments')
.where('contractId', '!=', null)
.get()
const results = new Map<string, DocumentSnapshot[]>()
comments.forEach((doc) => {
const contractId = doc.get('contractId')
const contractComments = results.get(contractId) || []
contractComments.push(doc)
results.set(contractId, contractComments)
})
log(`Found ${comments.size} comments on ${results.size} contracts.`)
return results
}
// not in a transaction for speed -- may need to be run more than once
async function denormalize() {
const contractsById = await getContractsById()
const commentsByContractId = await getCommentsByContractId()
for (const [contractId, comments] of commentsByContractId.entries()) {
const betsQuery = await firestore
.collection('contracts')
.doc(contractId)
.collection('bets')
.get()
log(`Loaded ${betsQuery.size} bets for contract ${contractId}.`)
const bets = betsQuery.docs.map((d) => d.data() as Bet)
const updates = comments.map((doc) => {
const comment = doc.data() as Comment
const contract = contractsById[contractId]
const previousBets = bets.filter(
(b) => b.createdTime < comment.createdTime
)
const position = getLargestPosition(
contract,
previousBets.filter((b) => b.userId === comment.userId && !b.isAnte)
)
if (position) {
const fields: { [k: string]: unknown } = {
commenterPositionShares: position.shares,
commenterPositionOutcome: position.outcome,
}
const previousProb =
contract.outcomeType === 'BINARY'
? maxBy(previousBets, (bet) => bet.createdTime)?.probAfter
: undefined
if (previousProb != null) {
fields.commenterPositionProb = previousProb
}
return { doc: doc.ref, fields }
} else {
return undefined
}
})
log(`Updating ${updates.length} comments.`)
await writeAsync(firestore, filterDefined(updates))
}
}
if (require.main === module) {
denormalize().catch((e) => console.error(e))
}

View File

@ -7,6 +7,7 @@ import { Contract } from '../../common/contract'
import { PortfolioMetrics, User } from '../../common/user'
import { getLoanUpdates } from '../../common/loans'
import { createLoanIncomeNotification } from './create-notification'
import { filterDefined } from '../../common/util/array'
const firestore = admin.firestore()
@ -30,16 +31,18 @@ async function updateLoansCore() {
log(
`Loaded ${users.length} users, ${contracts.length} contracts, and ${bets.length} bets.`
)
const userPortfolios = await Promise.all(
users.map(async (user) => {
const portfolio = await getValues<PortfolioMetrics>(
firestore
.collection(`users/${user.id}/portfolioHistory`)
.orderBy('timestamp', 'desc')
.limit(1)
)
return portfolio[0]
})
const userPortfolios = filterDefined(
await Promise.all(
users.map(async (user) => {
const portfolio = await getValues<PortfolioMetrics>(
firestore
.collection(`users/${user.id}/portfolioHistory`)
.orderBy('timestamp', 'desc')
.limit(1)
)
return portfolio[0]
})
)
)
log(`Loaded ${userPortfolios.length} portfolios`)
const portfolioByUser = keyBy(userPortfolios, (portfolio) => portfolio.userId)

View File

@ -22,7 +22,7 @@ import { Group } from 'common/group'
const firestore = admin.firestore()
export const updateMetrics = functions
.runWith({ memory: '2GB', timeoutSeconds: 540 })
.runWith({ memory: '4GB', timeoutSeconds: 540 })
.pubsub.schedule('every 15 minutes')
.onRun(updateMetricsCore)

View File

@ -1,11 +1,18 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import { concat, countBy, sortBy, range, zip, uniq, sum, sumBy } from 'lodash'
import * as dayjs from 'dayjs'
import * as utc from 'dayjs/plugin/utc'
import * as timezone from 'dayjs/plugin/timezone'
dayjs.extend(utc)
dayjs.extend(timezone)
import { 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 { Stats } from '../../common/stats'
import { DAY_MS } from '../../common/util/time'
import { average } from '../../common/util/math'
@ -103,7 +110,7 @@ export async function getDailyNewUsers(
}
export const updateStatsCore = async () => {
const today = Date.now()
const today = dayjs().tz('America/Los_Angeles').startOf('day').valueOf()
const startDate = today - numberOfDays * DAY_MS
log('Fetching data for stats update...')
@ -139,73 +146,128 @@ export const updateStatsCore = async () => {
)
const dailyActiveUsers = dailyUserIds.map((userIds) => userIds.length)
const dailyActiveUsersWeeklyAvg = dailyUserIds.map((_, i) => {
const start = Math.max(0, i - 6)
const end = i + 1
return average(dailyActiveUsers.slice(start, end))
})
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))
const end = i + 1
const uniques = new Set<string>(dailyUserIds.slice(start, end).flat())
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))
const end = i + 1
const uniques = new Set<string>(dailyUserIds.slice(start, end).flat())
return uniques.size
})
const d1 = dailyUserIds.map((userIds, i) => {
if (i === 0) return 0
const uniques = new Set(userIds)
const yesterday = dailyUserIds[i - 1]
const retainedCount = sumBy(yesterday, (userId) =>
uniques.has(userId) ? 1 : 0
)
return retainedCount / uniques.size
})
const d1WeeklyAvg = d1.map((_, i) => {
const start = Math.max(0, i - 6)
const end = i + 1
return average(d1.slice(start, end))
})
const dailyNewUserIds = dailyNewUsers.map((users) => users.map((u) => u.id))
const nd1 = dailyUserIds.map((userIds, i) => {
if (i === 0) return 0
const uniques = new Set(userIds)
const yesterday = dailyNewUserIds[i - 1]
const retainedCount = sumBy(yesterday, (userId) =>
uniques.has(userId) ? 1 : 0
)
return retainedCount / uniques.size
})
const nd1WeeklyAvg = nd1.map((_, i) => {
const start = Math.max(0, i - 6)
const end = i + 1
return average(nd1.slice(start, end))
})
const nw1 = dailyNewUserIds.map((_userIds, i) => {
if (i < 13) return 0
const twoWeeksAgo = {
start: Math.max(0, i - 13),
end: Math.max(0, i - 6),
}
const lastWeek = {
start: Math.max(0, i - 6),
end: i + 1,
}
const newTwoWeeksAgo = new Set<string>(
dailyNewUserIds.slice(twoWeeksAgo.start, twoWeeksAgo.end).flat()
)
const activeLastWeek = new Set<string>(
dailyUserIds.slice(lastWeek.start, lastWeek.end).flat()
)
const retainedCount = sumBy(Array.from(newTwoWeeksAgo), (userId) =>
activeLastWeek.has(userId) ? 1 : 0
)
return retainedCount / newTwoWeeksAgo.size
})
const weekOnWeekRetention = dailyUserIds.map((_userId, i) => {
const twoWeeksAgo = {
start: Math.max(0, i - 13),
end: Math.max(0, i - 7),
end: Math.max(0, i - 6),
}
const lastWeek = {
start: Math.max(0, i - 6),
end: i,
end: i + 1,
}
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 activeTwoWeeksAgo = new Set<string>(
dailyUserIds.slice(twoWeeksAgo.start, twoWeeksAgo.end).flat()
)
const activeLastWeek = new Set<string>(
dailyUserIds.slice(lastWeek.start, lastWeek.end).flat()
)
const retainedCount = sumBy(Array.from(activeTwoWeeksAgo), (userId) =>
activeLastWeek.has(userId) ? 1 : 0
)
const retainedFrac = retainedCount / activeTwoWeeksAgo.size
return Math.round(retainedFrac * 100 * 100) / 100
return retainedCount / activeTwoWeeksAgo.size
})
const monthlyRetention = dailyUserIds.map((_userId, i) => {
const twoMonthsAgo = {
start: Math.max(0, i - 60),
end: Math.max(0, i - 30),
start: Math.max(0, i - 59),
end: Math.max(0, i - 29),
}
const lastMonth = {
start: Math.max(0, i - 30),
end: i,
start: Math.max(0, i - 29),
end: i + 1,
}
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 activeTwoMonthsAgo = new Set<string>(
dailyUserIds.slice(twoMonthsAgo.start, twoMonthsAgo.end).flat()
)
const activeLastMonth = new Set<string>(
dailyUserIds.slice(lastMonth.start, lastMonth.end).flat()
)
const retainedCount = sumBy(Array.from(activeTwoMonthsAgo), (userId) =>
activeLastMonth.has(userId) ? 1 : 0
)
const retainedFrac = retainedCount / activeTwoMonthsAgo.size
return Math.round(retainedFrac * 100 * 100) / 100
if (activeTwoMonthsAgo.size === 0) return 0
return retainedCount / activeTwoMonthsAgo.size
})
const firstBetDict: { [userId: string]: number } = {}
@ -216,52 +278,20 @@ export const updateStatsCore = async () => {
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 dailyActivationRate = dailyNewUsers.map((newUsers, i) => {
const activedCount = sumBy(newUsers, (user) => {
const firstBet = firstBetDict[user.id]
return firstBet === i ? 1 : 0
})
return activedCount / newUsers.length
})
const dailyActivationRateWeeklyAvg = dailyActivationRate.map((_, i) => {
const start = Math.max(0, i - 6)
const end = i + 1
return average(dailyActivationRate.slice(start, end))
})
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))
})
const dailySignups = dailyNewUsers.map((users) => users.length)
// Total mana divided by 100.
const dailyManaBet = dailyBets.map((bets) => {
@ -269,37 +299,39 @@ export const updateStatsCore = async () => {
})
const weeklyManaBet = dailyManaBet.map((_, i) => {
const start = Math.max(0, i - 6)
const end = i
const end = i + 1
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 end = i + 1
const total = sum(dailyManaBet.slice(start, end))
const range = end - start + 1
const range = end - start
if (range < 30) return (total * 30) / range
return total
})
const statsData = {
const statsData: Stats = {
startDate: startDate.valueOf(),
dailyActiveUsers,
dailyActiveUsersWeeklyAvg,
weeklyActiveUsers,
monthlyActiveUsers,
d1,
d1WeeklyAvg,
nd1,
nd1WeeklyAvg,
nw1,
dailyBetCounts,
dailyContractCounts,
dailyCommentCounts,
dailySignups,
weekOnWeekRetention,
weeklyActivationRate,
dailyActivationRate,
dailyActivationRateWeeklyAvg,
monthlyRetention,
topTenthActions: {
daily: dailyTopTenthActions,
weekly: weeklyTopTenthActions,
monthly: monthlyTopTenthActions,
},
manaBet: {
daily: dailyManaBet,
weekly: weeklyManaBet,

View File

@ -1,4 +1,5 @@
import * as admin from 'firebase-admin'
import fetch from 'node-fetch'
import { chunk } from 'lodash'
import { Contract } from '../../common/contract'
@ -17,6 +18,18 @@ export const logMemory = () => {
}
}
export const revalidateStaticProps = async (
// Path after domain: e.g. "/JamesGrugett/will-pete-buttigieg-ever-be-us-pres"
pathToRevalidate: string
) => {
if (isProd()) {
const apiSecret = process.env.API_SECRET as string
const queryStr = `?pathToRevalidate=${pathToRevalidate}&apiSecret=${apiSecret}`
await fetch('https://manifold.markets/api/v0/revalidate' + queryStr)
console.log('Revalidated', pathToRevalidate)
}
}
export type UpdateSpec = {
doc: admin.firestore.DocumentReference
fields: { [k: string]: unknown }
@ -153,3 +166,7 @@ export const chargeUser = (
return updateUserBalance(userId, -charge, isAnte)
}
export const getContractPath = (contract: Contract) => {
return `/${contract.creatorUsername}/${contract.slug}`
}

View File

@ -16,7 +16,7 @@ import { DAY_MS } from '../../common/util/time'
import { filterDefined } from '../../common/util/array'
export const weeklyMarketsEmails = functions
.runWith({ secrets: ['MAILGUN_KEY'] })
.runWith({ secrets: ['MAILGUN_KEY'], memory: '4GB' })
// every minute on Monday for an hour at 12pm PT (UTC -07:00)
.pubsub.schedule('* 19 * * 1')
.timeZone('Etc/UTC')
@ -48,7 +48,7 @@ async function sendTrendingMarketsEmailsToAllUsers() {
// get all users that haven't unsubscribed from weekly emails
const privateUsersToSendEmailsTo = privateUsers.filter((user) => {
return (
!user.unsubscribedFromWeeklyTrendingEmails &&
user.notificationPreferences.trending_markets.includes('email') &&
!user.weeklyTrendingEmailSent
)
})

View File

@ -1,5 +1,6 @@
import { Point, ResponsiveLine } from '@nivo/line'
import clsx from 'clsx'
import { formatPercent } from 'common/util/format'
import dayjs from 'dayjs'
import { zip } from 'lodash'
import { useWindowSize } from 'web/hooks/use-window-size'
@ -63,18 +64,21 @@ export function DailyPercentChart(props: {
startDate: number
dailyPercent: number[]
small?: boolean
excludeFirstDays?: number
}) {
const { dailyPercent, startDate, small } = props
const { dailyPercent, startDate, small, excludeFirstDays } = props
const { width } = useWindowSize()
const dates = dailyPercent.map((_, i) =>
dayjs(startDate).add(i, 'day').toDate()
)
const points = zip(dates, dailyPercent).map(([date, betCount]) => ({
x: date,
y: betCount,
}))
const points = zip(dates, dailyPercent)
.map(([date, percent]) => ({
x: date,
y: percent,
}))
.slice(excludeFirstDays ?? 0)
const data = [{ id: 'Percent', data: points, color: '#11b981' }]
const bottomAxisTicks = width && width < 600 ? 6 : undefined
@ -93,7 +97,7 @@ export function DailyPercentChart(props: {
type: 'time',
}}
axisLeft={{
format: (value) => `${value}%`,
format: formatPercent,
}}
axisBottom={{
tickValues: bottomAxisTicks,
@ -109,15 +113,15 @@ export function DailyPercentChart(props: {
margin={{ top: 20, right: 28, bottom: 22, left: 40 }}
sliceTooltip={({ slice }) => {
const point = slice.points[0]
return <Tooltip point={point} />
return <Tooltip point={point} isPercent />
}}
/>
</div>
)
}
function Tooltip(props: { point: Point }) {
const { point } = props
function Tooltip(props: { point: Point; isPercent?: boolean }) {
const { point, isPercent } = props
return (
<Col className="border border-gray-300 bg-white py-2 px-3">
<div
@ -126,7 +130,8 @@ function Tooltip(props: { point: Point }) {
color: point.serieColor,
}}
>
<strong>{point.serieId}</strong> {point.data.yFormatted}
<strong>{point.serieId}</strong>{' '}
{isPercent ? formatPercent(+point.data.y) : Math.round(+point.data.y)}
</div>
<div>{dayjs(point.data.x).format('MMM DD')}</div>
</Col>

View File

@ -1,6 +1,6 @@
import clsx from 'clsx'
import { sum } from 'lodash'
import { useState } from 'react'
import { useEffect, useState } from 'react'
import { FreeResponseContract, MultipleChoiceContract } from 'common/contract'
import { Col } from '../layout/col'
@ -9,6 +9,7 @@ import { Row } from '../layout/row'
import { ChooseCancelSelector } from '../yes-no-selector'
import { ResolveConfirmationButton } from '../confirmation-button'
import { removeUndefinedProps } from 'common/util/object'
import { BETTOR, PAST_BETS } from 'common/user'
export function AnswerResolvePanel(props: {
isAdmin: boolean
@ -32,6 +33,18 @@ export function AnswerResolvePanel(props: {
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState<string | undefined>(undefined)
const [warning, setWarning] = useState<string | undefined>(undefined)
useEffect(() => {
if (resolveOption === 'CANCEL') {
setWarning(
`All ${PAST_BETS} will be returned. Unique ${BETTOR} bonuses will be
withdrawn from your account.`
)
} else {
setWarning(undefined)
}
}, [resolveOption])
const onResolve = async () => {
if (resolveOption === 'CHOOSE' && answers.length !== 1) return
@ -126,6 +139,7 @@ export function AnswerResolvePanel(props: {
</Col>
{!!error && <div className="text-red-500">{error}</div>}
{!!warning && <div className="text-warning">{warning}</div>}
</Col>
)
}

View File

@ -1,14 +1,22 @@
import clsx from 'clsx'
import { DragDropContext, Droppable, Draggable } from 'react-beautiful-dnd'
import { MenuIcon } from '@heroicons/react/solid'
import { toast } from 'react-hot-toast'
import { Col } from 'web/components/layout/col'
import { Row } from 'web/components/layout/row'
import { Subtitle } from 'web/components/subtitle'
import { keyBy } from 'lodash'
import { XCircleIcon } from '@heroicons/react/outline'
import { Button } from './button'
import { updateUser } from 'web/lib/firebase/users'
import { leaveGroup } from 'web/lib/firebase/groups'
import { User } from 'common/user'
import { useUser } from 'web/hooks/use-user'
import { Group } from 'common/group'
export function ArrangeHome(props: {
sections: { label: string; id: string }[]
sections: { label: string; id: string; group?: Group }[]
setSectionIds: (sections: string[]) => void
}) {
const { sections, setSectionIds } = props
@ -40,8 +48,9 @@ export function ArrangeHome(props: {
function DraggableList(props: {
title: string
items: { id: string; label: string }[]
items: { id: string; label: string; group?: Group }[]
}) {
const user = useUser()
const { title, items } = props
return (
<Droppable droppableId={title.toLowerCase()}>
@ -66,6 +75,7 @@ function DraggableList(props: {
snapshot.isDragging && 'z-[9000] bg-gray-200'
)}
item={item}
user={user}
/>
</div>
)}
@ -79,23 +89,53 @@ function DraggableList(props: {
}
const SectionItem = (props: {
item: { id: string; label: string }
item: { id: string; label: string; group?: Group }
user: User | null | undefined
className?: string
}) => {
const { item, className } = props
const { item, user, className } = props
const { group } = item
return (
<div
<Row
className={clsx(
className,
'flex flex-row items-center gap-4 rounded bg-gray-50 p-2'
'items-center justify-between gap-4 rounded bg-gray-50 p-2'
)}
>
<MenuIcon
className="h-5 w-5 flex-shrink-0 text-gray-500"
aria-hidden="true"
/>{' '}
{item.label}
</div>
<Row className="items-center gap-4">
<MenuIcon
className="h-5 w-5 flex-shrink-0 text-gray-500"
aria-hidden="true"
/>{' '}
{item.label}
</Row>
{group && (
<Button
className="pt-1 pb-1"
color="gray-white"
onClick={() => {
if (user) {
const homeSections = (user.homeSections ?? []).filter(
(id) => id !== group.id
)
updateUser(user.id, { homeSections })
toast.promise(leaveGroup(group, user.id), {
loading: 'Unfollowing group...',
success: `Unfollowed ${group.name}`,
error: "Couldn't unfollow group, try again?",
})
}
}}
>
<XCircleIcon
className={clsx('h-5 w-5 flex-shrink-0')}
aria-hidden="true"
/>
</Button>
)}
</Row>
)
}

View File

@ -10,7 +10,6 @@ import { useSaveBinaryShares } from './use-save-binary-shares'
import { Col } from './layout/col'
import { Button } from 'web/components/button'
import { BetSignUpPrompt } from './sign-up-prompt'
import { PRESENT_BET } from 'common/user'
/** Button that opens BetPanel in a new modal */
export default function BetButton(props: {
@ -42,7 +41,7 @@ export default function BetButton(props: {
)}
onClick={() => setOpen(true)}
>
{PRESENT_BET}
Predict
</Button>
) : (
<BetSignUpPrompt />

View File

@ -756,9 +756,10 @@ function SellButton(props: {
export function ProfitBadge(props: {
profitPercent: number
round?: boolean
className?: string
}) {
const { profitPercent, className } = props
const { profitPercent, round, className } = props
if (!profitPercent) return null
const colors =
profitPercent > 0
@ -773,7 +774,9 @@ export function ProfitBadge(props: {
className
)}
>
{(profitPercent > 0 ? '+' : '') + profitPercent.toFixed(1) + '%'}
{(profitPercent > 0 ? '+' : '') +
profitPercent.toFixed(round ? 0 : 1) +
'%'}
</span>
)
}

View File

@ -1,5 +1,4 @@
/* eslint-disable react-hooks/exhaustive-deps */
import algoliasearch from 'algoliasearch/lite'
import { SearchOptions } from '@algolia/client-search'
import { useRouter } from 'next/router'
import { Contract } from 'common/contract'
@ -11,7 +10,7 @@ import {
import { ShowTime } from './contract/contract-details'
import { Row } from './layout/row'
import { useEffect, useLayoutEffect, useRef, useMemo, ReactNode } from 'react'
import { ENV, IS_PRIVATE_MANIFOLD } from 'common/envs/constants'
import { IS_PRIVATE_MANIFOLD } from 'common/envs/constants'
import { useFollows } from 'web/hooks/use-follows'
import {
historyStore,
@ -28,14 +27,11 @@ import { DEFAULT_CATEGORY_GROUPS } from 'common/categories'
import { Col } from './layout/col'
import clsx from 'clsx'
import { safeLocalStorage } from 'web/lib/util/local'
const searchClient = algoliasearch(
'GJQPAYENIF',
'75c28fc084a80e1129d427d470cf41a3'
)
const indexPrefix = ENV === 'DEV' ? 'dev-' : ''
const searchIndexName = ENV === 'DEV' ? 'dev-contracts' : 'contractsIndex'
import {
getIndexName,
searchClient,
searchIndexName,
} from 'web/lib/service/algolia'
export const SORTS = [
{ label: 'Newest', value: 'newest' },
@ -154,7 +150,7 @@ export function ContractSearch(props: {
if (freshQuery || requestedPage < state.numPages) {
const index = query
? searchIndex
: searchClient.initIndex(`${indexPrefix}contracts-${sort}`)
: searchClient.initIndex(getIndexName(sort))
const numericFilters = query
? []
: [

View File

@ -309,7 +309,7 @@ export function ExtraMobileContractDetails(props: {
<Tooltip
text={`${formatMoney(
volume
)} bet - ${uniqueBettors} unique predictors`}
)} bet - ${uniqueBettors} unique traders`}
>
{volumeTranslation}
</Tooltip>

View File

@ -106,7 +106,6 @@ export function ContractTopTrades(props: {
contract={contract}
comment={commentsById[topCommentId]}
tips={tips[topCommentId]}
betsBySameUser={[betsById[topCommentId]]}
/>
</div>
<Spacer h={16} />

View File

@ -75,7 +75,9 @@ export function ContractTabs(props: {
<>
<FreeResponseContractCommentsActivity
contract={contract}
bets={visibleBets}
betsByCurrentUser={
user ? visibleBets.filter((b) => b.userId === user.id) : []
}
comments={comments}
tips={tips}
user={user}
@ -85,7 +87,9 @@ export function ContractTabs(props: {
<div className={'mb-4 w-full border-b border-gray-200'} />
<ContractCommentsActivity
contract={contract}
bets={generalBets}
betsByCurrentUser={
user ? generalBets.filter((b) => b.userId === user.id) : []
}
comments={generalComments}
tips={tips}
user={user}
@ -95,7 +99,9 @@ export function ContractTabs(props: {
) : (
<ContractCommentsActivity
contract={contract}
bets={visibleBets}
betsByCurrentUser={
user ? visibleBets.filter((b) => b.userId === user.id) : []
}
comments={comments}
tips={tips}
user={user}

View File

@ -19,19 +19,21 @@ export function ProbChangeTable(props: {
const { positiveChanges, negativeChanges } = changes
const threshold = 0.075
const countOverThreshold = Math.max(
positiveChanges.findIndex((c) => c.probChanges.day < threshold) + 1,
negativeChanges.findIndex((c) => c.probChanges.day > -threshold) + 1
const threshold = 0.01
const positiveAboveThreshold = positiveChanges.filter(
(c) => c.probChanges.day > threshold
)
const maxRows = Math.min(positiveChanges.length, negativeChanges.length)
const rows = Math.min(
full ? Infinity : 3,
Math.min(maxRows, countOverThreshold)
const negativeAboveThreshold = negativeChanges.filter(
(c) => c.probChanges.day < threshold
)
const maxRows = Math.min(
positiveAboveThreshold.length,
negativeAboveThreshold.length
)
const rows = full ? maxRows : Math.min(3, maxRows)
const filteredPositiveChanges = positiveChanges.slice(0, rows)
const filteredNegativeChanges = negativeChanges.slice(0, rows)
const filteredPositiveChanges = positiveAboveThreshold.slice(0, rows)
const filteredNegativeChanges = negativeAboveThreshold.slice(0, rows)
if (rows === 0) return <div className="px-4 text-gray-500">None</div>
@ -54,14 +56,14 @@ export function ProbChangeTable(props: {
function ProbChangeRow(props: { contract: CPMMContract }) {
const { contract } = props
return (
<Row className="items-center hover:bg-gray-100">
<ProbChange className="p-4 text-right text-xl" contract={contract} />
<Row className="items-center justify-between gap-4 hover:bg-gray-100">
<SiteLink
className="p-4 pl-2 font-semibold text-indigo-700"
className="p-4 pr-0 font-semibold text-indigo-700"
href={contractPath(contract)}
>
<span className="line-clamp-2">{contract.question}</span>
</SiteLink>
<ProbChange className="py-2 pr-4 text-xl" contract={contract} />
</Row>
)
}
@ -72,19 +74,20 @@ export function ProbChange(props: {
}) {
const { contract, className } = props
const {
prob,
probChanges: { day: change },
} = contract
const color =
change > 0
? 'text-green-500'
: change < 0
? 'text-red-500'
: 'text-gray-600'
const color = change >= 0 ? 'text-green-500' : 'text-red-500'
const str =
change === 0
? '+0%'
: `${change > 0 ? '+' : '-'}${formatPercent(Math.abs(change))}`
return <div className={clsx(className, color)}>{str}</div>
return (
<Col className={clsx('flex flex-col items-end', className)}>
<div className="mb-0.5 mr-0.5 text-2xl">
{formatPercent(Math.round(100 * prob) / 100)}
</div>
<div className={clsx('text-base', color)}>
{(change > 0 ? '+' : '') + (change * 100).toFixed(0) + '%'}
</div>
</Col>
)
}

View File

@ -9,7 +9,6 @@ import { Row } from '../layout/row'
import { ShareEmbedButton } from '../share-embed-button'
import { Title } from '../title'
import { TweetButton } from '../tweet-button'
import { DuplicateContractButton } from '../copy-contract-button'
import { Button } from '../button'
import { copyToClipboard } from 'web/lib/util/copy'
import { track, withTracking } from 'web/lib/service/analytics'
@ -21,6 +20,7 @@ import { REFERRAL_AMOUNT } from 'common/economy'
import { CreateChallengeModal } from 'web/components/challenges/create-challenge-modal'
import { useState } from 'react'
import { CHALLENGES_ENABLED } from 'common/challenge'
import ChallengeIcon from 'web/lib/icons/challenge-icon'
export function ShareModal(props: {
contract: Contract
@ -56,8 +56,8 @@ export function ShareModal(props: {
</p>
<Button
size="2xl"
color="gradient"
className={'flex max-w-xs self-center'}
color="indigo"
className={'mb-2 flex max-w-xs self-center'}
onClick={() => {
copyToClipboard(shareUrl)
toast.success('Link copied!', {
@ -68,38 +68,39 @@ export function ShareModal(props: {
>
{linkIcon} Copy link
</Button>
<Row className={'justify-center'}>or</Row>
{showChallenge && (
<Button
size="2xl"
color="gradient"
className={'mb-2 flex max-w-xs self-center'}
onClick={withTracking(
() => setOpenCreateChallengeModal(true),
'click challenge button'
)}
>
<span> Challenge</span>
<CreateChallengeModal
isOpen={openCreateChallengeModal}
setOpen={(open) => {
if (!open) {
setOpenCreateChallengeModal(false)
setOpen(false)
} else setOpenCreateChallengeModal(open)
}}
user={user}
contract={contract}
/>
</Button>
)}
<Row className="z-0 flex-wrap justify-center gap-4 self-center">
<TweetButton
className="self-start"
tweetText={getTweetText(contract, shareUrl)}
/>
<ShareEmbedButton contract={contract} />
<DuplicateContractButton contract={contract} />
{showChallenge && (
<button
className={
'btn btn-xs flex-nowrap border-2 !border-indigo-500 !bg-white normal-case text-indigo-500'
}
onClick={withTracking(
() => setOpenCreateChallengeModal(true),
'click challenge button'
)}
>
<ChallengeIcon className="mr-1 h-4 w-4" /> Challenge
<CreateChallengeModal
isOpen={openCreateChallengeModal}
setOpen={(open) => {
if (!open) {
setOpenCreateChallengeModal(false)
setOpen(false)
} else setOpenCreateChallengeModal(open)
}}
user={user}
contract={contract}
/>
</button>
)}
</Row>
</Col>
</Modal>

View File

@ -1,7 +1,6 @@
import { useState } from 'react'
import { Contract, FreeResponseContract } from 'common/contract'
import { ContractComment } from 'common/comment'
import { Answer } from 'common/answer'
import { Bet } from 'common/bet'
import { getOutcomeProbability } from 'common/calculate'
import { Pagination } from 'web/components/pagination'
@ -12,7 +11,7 @@ import { FeedCommentThread, ContractCommentInput } from './feed-comments'
import { User } from 'common/user'
import { CommentTipMap } from 'web/hooks/use-tip-txns'
import { LiquidityProvision } from 'common/liquidity-provision'
import { groupBy, sortBy, uniq } from 'lodash'
import { groupBy, sortBy } from 'lodash'
import { Col } from 'web/components/layout/col'
export function ContractBetsActivity(props: {
@ -73,13 +72,12 @@ export function ContractBetsActivity(props: {
export function ContractCommentsActivity(props: {
contract: Contract
bets: Bet[]
betsByCurrentUser: Bet[]
comments: ContractComment[]
tips: CommentTipMap
user: User | null | undefined
}) {
const { bets, contract, comments, user, tips } = props
const betsByUserId = groupBy(bets, (bet) => bet.userId)
const { betsByCurrentUser, contract, comments, user, tips } = props
const commentsByUserId = groupBy(comments, (c) => c.userId)
const commentsByParentId = groupBy(comments, (c) => c.replyToCommentId ?? '_')
const topLevelComments = sortBy(
@ -92,7 +90,7 @@ export function ContractCommentsActivity(props: {
<ContractCommentInput
className="mb-5"
contract={contract}
betsByCurrentUser={(user && betsByUserId[user.id]) ?? []}
betsByCurrentUser={betsByCurrentUser}
commentsByCurrentUser={(user && commentsByUserId[user.id]) ?? []}
/>
{topLevelComments.map((parent) => (
@ -106,8 +104,7 @@ export function ContractCommentsActivity(props: {
(c) => c.createdTime
)}
tips={tips}
bets={bets}
betsByUserId={betsByUserId}
betsByCurrentUser={betsByCurrentUser}
commentsByUserId={commentsByUserId}
/>
))}
@ -117,32 +114,26 @@ export function ContractCommentsActivity(props: {
export function FreeResponseContractCommentsActivity(props: {
contract: FreeResponseContract
bets: Bet[]
betsByCurrentUser: Bet[]
comments: ContractComment[]
tips: CommentTipMap
user: User | null | undefined
}) {
const { bets, contract, comments, user, tips } = props
const { betsByCurrentUser, contract, comments, user, tips } = props
let outcomes = uniq(bets.map((bet) => bet.outcome))
outcomes = sortBy(
outcomes,
(outcome) => -getOutcomeProbability(contract, outcome)
const sortedAnswers = sortBy(
contract.answers,
(answer) => -getOutcomeProbability(contract, answer.number.toString())
)
const answers = outcomes
.map((outcome) => {
return contract.answers.find((answer) => answer.id === outcome) as Answer
})
.filter((answer) => answer != null)
const betsByUserId = groupBy(bets, (bet) => bet.userId)
const commentsByUserId = groupBy(comments, (c) => c.userId)
const commentsByOutcome = groupBy(comments, (c) => c.answerOutcome ?? '_')
const commentsByOutcome = groupBy(
comments,
(c) => c.answerOutcome ?? c.betOutcome ?? '_'
)
return (
<>
{answers.map((answer) => (
{sortedAnswers.map((answer) => (
<div key={answer.id} className={'relative pb-4'}>
<span
className="absolute top-5 left-5 -ml-px h-[calc(100%-2rem)] w-0.5 bg-gray-200"
@ -157,7 +148,7 @@ export function FreeResponseContractCommentsActivity(props: {
(c) => c.createdTime
)}
tips={tips}
betsByUserId={betsByUserId}
betsByCurrentUser={betsByCurrentUser}
commentsByUserId={commentsByUserId}
/>
</div>

View File

@ -27,7 +27,7 @@ export function FeedAnswerCommentGroup(props: {
answer: Answer
answerComments: ContractComment[]
tips: CommentTipMap
betsByUserId: Dictionary<Bet[]>
betsByCurrentUser: Bet[]
commentsByUserId: Dictionary<ContractComment[]>
}) {
const {
@ -35,7 +35,7 @@ export function FeedAnswerCommentGroup(props: {
contract,
answerComments,
tips,
betsByUserId,
betsByCurrentUser,
commentsByUserId,
user,
} = props
@ -48,7 +48,6 @@ export function FeedAnswerCommentGroup(props: {
const router = useRouter()
const answerElementId = `answer-${answer.id}`
const betsByCurrentUser = (user && betsByUserId[user.id]) ?? []
const commentsByCurrentUser = (user && commentsByUserId[user.id]) ?? []
const isFreeResponseContractPage = !!commentsByCurrentUser
const mostRecentCommentableBet = getMostRecentCommentableBet(
@ -166,7 +165,6 @@ export function FeedAnswerCommentGroup(props: {
contract={contract}
comment={comment}
tips={tips[comment.id]}
betsBySameUser={betsByUserId[comment.userId] ?? []}
onReplyClick={scrollAndOpenReplyInput}
/>
))}

View File

@ -1,9 +1,9 @@
import { Bet } from 'common/bet'
import { ContractComment } from 'common/comment'
import { PRESENT_BET, User } from 'common/user'
import { User } from 'common/user'
import { Contract } from 'common/contract'
import React, { useEffect, useState } from 'react'
import { minBy, maxBy, partition, sumBy, Dictionary } from 'lodash'
import { Dictionary } from 'lodash'
import { useUser } from 'web/hooks/use-user'
import { formatMoney } from 'common/util/format'
import { useRouter } from 'next/router'
@ -29,8 +29,7 @@ export function FeedCommentThread(props: {
threadComments: ContractComment[]
tips: CommentTipMap
parentComment: ContractComment
bets: Bet[]
betsByUserId: Dictionary<Bet[]>
betsByCurrentUser: Bet[]
commentsByUserId: Dictionary<ContractComment[]>
}) {
const {
@ -38,8 +37,7 @@ export function FeedCommentThread(props: {
contract,
threadComments,
commentsByUserId,
bets,
betsByUserId,
betsByCurrentUser,
tips,
parentComment,
} = props
@ -64,17 +62,7 @@ export function FeedCommentThread(props: {
contract={contract}
comment={comment}
tips={tips[comment.id]}
betsBySameUser={betsByUserId[comment.userId] ?? []}
onReplyClick={scrollAndOpenReplyInput}
probAtCreatedTime={
contract.outcomeType === 'BINARY'
? minBy(bets, (bet) => {
return bet.createdTime < comment.createdTime
? comment.createdTime - bet.createdTime
: comment.createdTime
})?.probAfter
: undefined
}
/>
))}
{showReply && (
@ -85,7 +73,7 @@ export function FeedCommentThread(props: {
/>
<ContractCommentInput
contract={contract}
betsByCurrentUser={(user && betsByUserId[user.id]) ?? []}
betsByCurrentUser={(user && betsByCurrentUser) ?? []}
commentsByCurrentUser={(user && commentsByUserId[user.id]) ?? []}
parentCommentId={parentComment.id}
replyToUser={replyTo}
@ -104,22 +92,21 @@ export function FeedComment(props: {
contract: Contract
comment: ContractComment
tips: CommentTips
betsBySameUser: Bet[]
indent?: boolean
probAtCreatedTime?: number
onReplyClick?: (comment: ContractComment) => void
}) {
const { contract, comment, tips, indent, onReplyClick } = props
const {
contract,
comment,
tips,
betsBySameUser,
indent,
probAtCreatedTime,
onReplyClick,
} = props
const { text, content, userUsername, userName, userAvatarUrl, createdTime } =
comment
text,
content,
userUsername,
userName,
userAvatarUrl,
commenterPositionProb,
commenterPositionShares,
commenterPositionOutcome,
createdTime,
} = comment
const betOutcome = comment.betOutcome
let bought: string | undefined
let money: string | undefined
@ -136,13 +123,6 @@ export function FeedComment(props: {
}
}, [comment.id, router.asPath])
// Only calculated if they don't have a matching bet
const { userPosition, outcome } = getBettorsLargestPositionBeforeTime(
contract,
comment.createdTime,
comment.betId ? [] : betsBySameUser
)
return (
<Row
id={comment.id}
@ -167,14 +147,17 @@ export function FeedComment(props: {
username={userUsername}
name={userName}
/>{' '}
{!comment.betId != null &&
userPosition > 0 &&
{comment.betId == null &&
commenterPositionProb != null &&
commenterPositionOutcome != null &&
commenterPositionShares != null &&
commenterPositionShares > 0 &&
contract.outcomeType !== 'NUMERIC' && (
<>
{'is '}
<CommentStatus
prob={probAtCreatedTime}
outcome={outcome}
prob={commenterPositionProb}
outcome={commenterPositionOutcome}
contract={contract}
/>
</>
@ -255,7 +238,7 @@ function CommentStatus(props: {
const { contract, outcome, prob } = props
return (
<>
{` ${PRESENT_BET}ing `}
{` predicting `}
<OutcomeLabel outcome={outcome} contract={contract} truncate="short" />
{prob && ' at ' + Math.round(prob * 100) + '%'}
</>
@ -310,56 +293,6 @@ export function ContractCommentInput(props: {
)
}
function getBettorsLargestPositionBeforeTime(
contract: Contract,
createdTime: number,
bets: Bet[]
) {
let yesFloorShares = 0,
yesShares = 0,
noShares = 0,
noFloorShares = 0
const previousBets = bets.filter(
(prevBet) => prevBet.createdTime < createdTime && !prevBet.isAnte
)
if (contract.outcomeType === 'FREE_RESPONSE') {
const answerCounts: { [outcome: string]: number } = {}
for (const bet of previousBets) {
if (bet.outcome) {
if (!answerCounts[bet.outcome]) {
answerCounts[bet.outcome] = bet.amount
} else {
answerCounts[bet.outcome] += bet.amount
}
}
}
const majorityAnswer =
maxBy(Object.keys(answerCounts), (outcome) => answerCounts[outcome]) ?? ''
return {
userPosition: answerCounts[majorityAnswer] || 0,
outcome: majorityAnswer,
}
}
if (bets.length === 0) {
return { userPosition: 0, outcome: '' }
}
const [yesBets, noBets] = partition(
previousBets ?? [],
(bet) => bet.outcome === 'YES'
)
yesShares = sumBy(yesBets, (bet) => bet.shares)
noShares = sumBy(noBets, (bet) => bet.shares)
yesFloorShares = Math.floor(yesShares)
noFloorShares = Math.floor(noShares)
const userPosition = yesFloorShares || noFloorShares
const outcome = yesFloorShares > noFloorShares ? 'YES' : 'NO'
return { userPosition, outcome }
}
function canCommentOnBet(bet: Bet, user?: User | null) {
const { userId, createdTime, isRedemption } = bet
const isSelf = user?.id === userId

View File

@ -6,7 +6,6 @@ import { ConfirmationButton } from '../confirmation-button'
import { Col } from '../layout/col'
import { Spacer } from '../layout/spacer'
import { Title } from '../title'
import { FilterSelectUsers } from 'web/components/filter-select-users'
import { User } from 'common/user'
import { MAX_GROUP_NAME_LENGTH } from 'common/group'
import { createGroup } from 'web/lib/firebase/api'
@ -17,35 +16,30 @@ export function CreateGroupButton(props: {
label?: string
onOpenStateChange?: (isOpen: boolean) => void
goToGroupOnSubmit?: boolean
addGroupIdParamOnSubmit?: boolean
icon?: JSX.Element
}) {
const { user, className, label, onOpenStateChange, goToGroupOnSubmit, icon } =
props
const [defaultName, setDefaultName] = useState(`${user.name}'s group`)
const {
user,
className,
label,
onOpenStateChange,
goToGroupOnSubmit,
addGroupIdParamOnSubmit,
icon,
} = props
const [name, setName] = useState('')
const [memberUsers, setMemberUsers] = useState<User[]>([])
const [isSubmitting, setIsSubmitting] = useState(false)
const [errorText, setErrorText] = useState('')
const router = useRouter()
function updateMemberUsers(users: User[]) {
const usersFirstNames = users.map((user) => user.name.split(' ')[0])
const postFix =
usersFirstNames.length > 3 ? ` & ${usersFirstNames.length - 3} more` : ''
const newName = `${user.name.split(' ')[0]}${
users.length > 0 ? ', ' + usersFirstNames.slice(0, 3).join(', ') : ''
}${postFix}'s group`
setDefaultName(newName)
setMemberUsers(users)
}
const onSubmit = async () => {
setIsSubmitting(true)
const groupName = name !== '' ? name : defaultName
const newGroup = {
name: groupName,
memberIds: memberUsers.map((user) => user.id),
name,
memberIds: [],
anyoneCanJoin: true,
}
const result = await createGroup(newGroup).catch((e) => {
@ -62,12 +56,17 @@ export function CreateGroupButton(props: {
console.log(result.details)
if (result.group) {
updateMemberUsers([])
if (goToGroupOnSubmit)
router.push(groupPath(result.group.slug)).catch((e) => {
console.log(e)
setErrorText(e.message)
})
else if (addGroupIdParamOnSubmit) {
router.replace({
pathname: router.pathname,
query: { ...router.query, groupId: result.group.id },
})
}
setIsSubmitting(false)
return true
} else {
@ -99,41 +98,26 @@ export function CreateGroupButton(props: {
onSubmitWithSuccess={onSubmit}
onOpenChanged={(isOpen) => {
onOpenStateChange?.(isOpen)
updateMemberUsers([])
setName('')
}}
>
<Title className="!my-0" text="Create a group" />
<Col className="gap-1 text-gray-500">
<div>You can add markets and members to your group after creation.</div>
<div>You can add markets to your group after creation.</div>
</Col>
<div className={'text-error'}>{errorText}</div>
{errorText && <div className={'text-error'}>{errorText}</div>}
<div>
<div className="form-control w-full">
<label className="label">
<span className="mb-0">Add members (optional)</span>
</label>
<FilterSelectUsers
setSelectedUsers={updateMemberUsers}
selectedUsers={memberUsers}
ignoreUserIds={[user.id]}
/>
</div>
<div className="form-control w-full">
<label className="label">
<span className="mt-1">Group name (optional)</span>
</label>
<input
placeholder={defaultName}
className="input input-bordered resize-none"
disabled={isSubmitting}
value={name}
maxLength={MAX_GROUP_NAME_LENGTH}
onChange={(e) => setName(e.target.value || '')}
/>
</div>
<div className="form-control w-full">
<label className="mb-2 ml-1 mt-0">Group name</label>
<input
placeholder={'Your group name'}
className="input input-bordered resize-none"
disabled={isSubmitting}
value={name}
maxLength={MAX_GROUP_NAME_LENGTH}
onChange={(e) => setName(e.target.value || '')}
/>
<Spacer h={4} />
</div>

View File

@ -16,7 +16,6 @@ export function EditGroupButton(props: { group: Group; className?: string }) {
const router = useRouter()
const [name, setName] = useState(group.name)
const [about, setAbout] = useState(group.about ?? '')
const [open, setOpen] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false)
const [addMemberUsers, setAddMemberUsers] = useState<User[]>([])
@ -26,8 +25,7 @@ export function EditGroupButton(props: { group: Group; className?: string }) {
setOpen(newOpen)
}
const saveDisabled =
name === group.name && about === group.about && addMemberUsers.length === 0
const saveDisabled = name === group.name && addMemberUsers.length === 0
const onSubmit = async () => {
setIsSubmitting(true)
@ -66,23 +64,6 @@ export function EditGroupButton(props: { group: Group; className?: string }) {
<Spacer h={4} />
<div className="form-control w-full">
<label className="label">
<span className="mb-1">About</span>
</label>
<input
placeholder="Short description (140 characters max)"
className="input input-bordered resize-none"
disabled={isSubmitting}
value={about}
maxLength={140}
onChange={(e) => setAbout(e.target.value || '')}
/>
</div>
<Spacer h={4} />
<div className="form-control w-full">
<label className="label">
<span className="mb-0">Add members</span>

View File

@ -131,7 +131,7 @@ export function GroupSelector(props: {
)}
<span
className={clsx(
'ml-3 mt-1 block flex flex-row justify-between',
'ml-3 mt-1 flex flex-row justify-between',
selected && 'font-semibold'
)}
>
@ -166,7 +166,7 @@ export function GroupSelector(props: {
'w-full justify-start rounded-none border-0 bg-white pl-2 font-normal text-gray-900 hover:bg-indigo-500 hover:text-white'
}
label={'Create a new Group'}
goToGroupOnSubmit={false}
addGroupIdParamOnSubmit
icon={
<PlusCircleIcon className="text-primary mr-2 h-5 w-5" />
}

View File

@ -8,7 +8,8 @@ import {
} from '@heroicons/react/outline'
import { Transition, Dialog } from '@headlessui/react'
import { useState, Fragment } from 'react'
import Sidebar, { Item } from './sidebar'
import Sidebar from './sidebar'
import { Item } from './sidebar-item'
import { useUser } from 'web/hooks/use-user'
import { formatMoney } from 'common/util/format'
import { Avatar } from '../avatar'

View File

@ -1,5 +1,5 @@
import { ClipboardIcon, HomeIcon } from '@heroicons/react/outline'
import { Item } from './sidebar'
import { Item } from './sidebar-item'
import clsx from 'clsx'
import { trackCallback } from 'web/lib/service/analytics'
@ -32,7 +32,7 @@ export function GroupNavBar(props: {
const user = useUser()
return (
<nav className="fixed inset-x-0 bottom-0 z-20 flex justify-between border-t-2 bg-white text-xs text-gray-700 lg:hidden">
<nav className="z-20 flex justify-between border-t-2 bg-white text-xs text-gray-700 lg:hidden">
{mobileGroupNavigation.map((item) => (
<NavBarItem
key={item.name}

View File

@ -7,7 +7,7 @@ import React from 'react'
import TrophyIcon from 'web/lib/icons/trophy-icon'
import { SignInButton } from '../sign-in-button'
import NotificationsIcon from '../notifications-icon'
import { SidebarItem } from './sidebar'
import { SidebarItem } from './sidebar-item'
import { buildArray } from 'common/util/array'
import { User } from 'common/user'
import { Row } from '../layout/row'

View File

@ -0,0 +1,23 @@
import { DotsHorizontalIcon } from '@heroicons/react/outline'
export function MoreButton() {
return <SidebarButton text={'More'} icon={DotsHorizontalIcon} />
}
function SidebarButton(props: {
text: string
icon: React.ComponentType<{ className?: string }>
children?: React.ReactNode
}) {
const { text, children } = props
return (
<a className="group flex items-center rounded-md px-3 py-2 text-sm font-medium text-gray-600 hover:cursor-pointer hover:bg-gray-100">
<props.icon
className="-ml-1 mr-3 h-6 w-6 flex-shrink-0 text-gray-400 group-hover:text-gray-500"
aria-hidden="true"
/>
<span className="truncate">{text}</span>
{children}
</a>
)
}

View File

@ -0,0 +1,63 @@
import React from 'react'
import clsx from 'clsx'
import Link from 'next/link'
import { trackCallback } from 'web/lib/service/analytics'
export type Item = {
name: string
trackingEventName?: string
href?: string
key?: string
icon?: React.ComponentType<{ className?: string }>
}
export function SidebarItem(props: {
item: Item
currentPage: string
onClick?: (key: string) => void
}) {
const { item, currentPage, onClick } = props
const isCurrentPage =
item.href != null ? item.href === currentPage : item.key === currentPage
const sidebarItem = (
<a
onClick={trackCallback('sidebar: ' + item.name)}
className={clsx(
isCurrentPage
? 'bg-gray-200 text-gray-900'
: 'text-gray-600 hover:bg-gray-100',
'group flex items-center rounded-md px-3 py-2 text-sm font-medium'
)}
aria-current={item.href == currentPage ? 'page' : undefined}
>
{item.icon && (
<item.icon
className={clsx(
isCurrentPage
? 'text-gray-500'
: 'text-gray-400 group-hover:text-gray-500',
'-ml-1 mr-3 h-6 w-6 flex-shrink-0'
)}
aria-hidden="true"
/>
)}
<span className="truncate">{item.name}</span>
</a>
)
if (item.href) {
return (
<Link href={item.href} key={item.name}>
{sidebarItem}
</Link>
)
} else {
return onClick ? (
<button onClick={() => onClick(item.key ?? '#')}>{sidebarItem}</button>
) : (
<> </>
)
}
}

View File

@ -1,29 +1,93 @@
import React from 'react'
import {
HomeIcon,
SearchIcon,
BookOpenIcon,
DotsHorizontalIcon,
CashIcon,
HeartIcon,
ChatIcon,
ChartBarIcon,
} from '@heroicons/react/outline'
import clsx from 'clsx'
import Link from 'next/link'
import Router, { useRouter } from 'next/router'
import { useUser } from 'web/hooks/use-user'
import { firebaseLogout, User } from 'web/lib/firebase/users'
import { ManifoldLogo } from './manifold-logo'
import { MenuButton, MenuItem } from './menu'
import { ProfileSummary } from './profile-menu'
import NotificationsIcon from 'web/components/notifications-icon'
import React from 'react'
import { IS_PRIVATE_MANIFOLD } from 'common/envs/constants'
import { CreateQuestionButton } from 'web/components/create-question-button'
import { trackCallback, withTracking } from 'web/lib/service/analytics'
import { withTracking } from 'web/lib/service/analytics'
import { CHALLENGES_ENABLED } from 'common/challenge'
import { buildArray } from 'common/util/array'
import TrophyIcon from 'web/lib/icons/trophy-icon'
import { SignInButton } from '../sign-in-button'
import { SidebarItem } from './sidebar-item'
import { MoreButton } from './more-button'
export default function Sidebar(props: { className?: string }) {
const { className } = props
const router = useRouter()
const currentPage = router.pathname
const user = useUser()
const desktopNavOptions = !user
? signedOutDesktopNavigation
: getDesktopNavigation()
const mobileNavOptions = !user
? signedOutMobileNavigation
: signedInMobileNavigation
const createMarketButton = user && !user.isBannedFromPosting && (
<CreateQuestionButton />
)
return (
<nav
aria-label="Sidebar"
className={clsx('flex max-h-[100vh] flex-col', className)}
>
<ManifoldLogo className="py-6" twoLine />
{!user && <SignInButton className="mb-4" />}
{user && <ProfileSummary user={user} />}
{/* Mobile navigation */}
<div className="flex min-h-0 shrink flex-col gap-1 lg:hidden">
{mobileNavOptions.map((item) => (
<SidebarItem key={item.href} item={item} currentPage={currentPage} />
))}
{user && (
<MenuButton
menuItems={getMoreMobileNav()}
buttonContent={<MoreButton />}
/>
)}
{createMarketButton}
</div>
{/* Desktop navigation */}
<div className="hidden min-h-0 shrink flex-col items-stretch gap-1 lg:flex ">
{desktopNavOptions.map((item) => (
<SidebarItem key={item.href} item={item} currentPage={currentPage} />
))}
<MenuButton
menuItems={getMoreDesktopNavigation(user)}
buttonContent={<MoreButton />}
/>
{createMarketButton}
</div>
</nav>
)
}
const logout = async () => {
// log out, and then reload the page, in case SSR wants to boot them out
@ -32,7 +96,7 @@ const logout = async () => {
await Router.replace(Router.asPath)
}
function getNavigation() {
function getDesktopNavigation() {
return [
{ name: 'Home', href: '/home', icon: HomeIcon },
{ name: 'Search', href: '/search', icon: SearchIcon },
@ -51,10 +115,10 @@ function getNavigation() {
]
}
function getMoreNavigation(user?: User | null) {
function getMoreDesktopNavigation(user?: User | null) {
if (IS_PRIVATE_MANIFOLD) {
return [
{ name: 'Leaderboards', href: '/leaderboards' },
{ name: 'Leaderboards', href: '/leaderboards', icon: ChartBarIcon },
{
name: 'Sign out',
href: '#',
@ -99,7 +163,7 @@ function getMoreNavigation(user?: User | null) {
)
}
const signedOutNavigation = [
const signedOutDesktopNavigation = [
{ name: 'Home', href: '/', icon: HomeIcon },
{ name: 'Explore', href: '/search', icon: SearchIcon },
{
@ -117,11 +181,14 @@ const signedOutMobileNavigation = [
},
{ name: 'Charity', href: '/charity', icon: HeartIcon },
{ name: 'Tournaments', href: '/tournaments', icon: TrophyIcon },
{ name: 'Leaderboards', href: '/leaderboards', icon: ChartBarIcon },
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh', icon: ChatIcon },
]
const signedInMobileNavigation = [
{ name: 'Search', href: '/search', icon: SearchIcon },
{ name: 'Tournaments', href: '/tournaments', icon: TrophyIcon },
{ name: 'Leaderboards', href: '/leaderboards', icon: ChartBarIcon },
...(IS_PRIVATE_MANIFOLD
? []
: [{ name: 'Get M$', href: '/add-funds', icon: CashIcon }]),
@ -145,7 +212,6 @@ function getMoreMobileNav() {
[
{ name: 'Groups', href: '/groups' },
{ name: 'Referrals', href: '/referrals' },
{ name: 'Leaderboards', href: '/leaderboards' },
{ name: 'Charity', href: '/charity' },
{ name: 'Send M$', href: '/links' },
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },
@ -153,136 +219,3 @@ function getMoreMobileNav() {
signOut
)
}
export type Item = {
name: string
trackingEventName?: string
href?: string
key?: string
icon?: React.ComponentType<{ className?: string }>
}
export function SidebarItem(props: {
item: Item
currentPage: string
onClick?: (key: string) => void
}) {
const { item, currentPage, onClick } = props
const isCurrentPage =
item.href != null ? item.href === currentPage : item.key === currentPage
const sidebarItem = (
<a
onClick={trackCallback('sidebar: ' + item.name)}
className={clsx(
isCurrentPage
? 'bg-gray-200 text-gray-900'
: 'text-gray-600 hover:bg-gray-100',
'group flex items-center rounded-md px-3 py-2 text-sm font-medium'
)}
aria-current={item.href == currentPage ? 'page' : undefined}
>
{item.icon && (
<item.icon
className={clsx(
isCurrentPage
? 'text-gray-500'
: 'text-gray-400 group-hover:text-gray-500',
'-ml-1 mr-3 h-6 w-6 flex-shrink-0'
)}
aria-hidden="true"
/>
)}
<span className="truncate">{item.name}</span>
</a>
)
if (item.href) {
return (
<Link href={item.href} key={item.name}>
{sidebarItem}
</Link>
)
} else {
return onClick ? (
<button onClick={() => onClick(item.key ?? '#')}>{sidebarItem}</button>
) : (
<> </>
)
}
}
function SidebarButton(props: {
text: string
icon: React.ComponentType<{ className?: string }>
children?: React.ReactNode
}) {
const { text, children } = props
return (
<a className="group flex items-center rounded-md px-3 py-2 text-sm font-medium text-gray-600 hover:cursor-pointer hover:bg-gray-100">
<props.icon
className="-ml-1 mr-3 h-6 w-6 flex-shrink-0 text-gray-400 group-hover:text-gray-500"
aria-hidden="true"
/>
<span className="truncate">{text}</span>
{children}
</a>
)
}
function MoreButton() {
return <SidebarButton text={'More'} icon={DotsHorizontalIcon} />
}
export default function Sidebar(props: { className?: string }) {
const { className } = props
const router = useRouter()
const currentPage = router.pathname
const user = useUser()
const navigationOptions = !user ? signedOutNavigation : getNavigation()
const mobileNavigationOptions = !user
? signedOutMobileNavigation
: signedInMobileNavigation
return (
<nav
aria-label="Sidebar"
className={clsx('flex max-h-[100vh] flex-col', className)}
>
<ManifoldLogo className="py-6" twoLine />
{!user && <SignInButton className="mb-4" />}
{user && <ProfileSummary user={user} />}
{/* Mobile navigation */}
<div className="flex min-h-0 shrink flex-col gap-1 lg:hidden">
{mobileNavigationOptions.map((item) => (
<SidebarItem key={item.href} item={item} currentPage={currentPage} />
))}
{user && (
<MenuButton
menuItems={getMoreMobileNav()}
buttonContent={<MoreButton />}
/>
)}
</div>
{/* Desktop navigation */}
<div className="hidden min-h-0 shrink flex-col items-stretch gap-1 lg:flex ">
{navigationOptions.map((item) => (
<SidebarItem key={item.href} item={item} currentPage={currentPage} />
))}
<MenuButton
menuItems={getMoreNavigation(user)}
buttonContent={<MoreButton />}
/>
{user && !user.isBannedFromPosting && <CreateQuestionButton />}
</div>
</nav>
)
}

View File

@ -276,6 +276,7 @@ export function NotificationSettings(props: {
<Col className={clsx(expanded ? 'block' : 'hidden', 'gap-2 p-2')}>
{subscriptionTypes.map((subType) => (
<NotificationSettingLine
key={subType}
subscriptionTypeKey={subType as notification_preference}
destinations={getUsersSavedPreference(
subType as notification_preference

View File

@ -10,6 +10,8 @@ import { Modal } from 'web/components/layout/modal'
import { PillButton } from 'web/components/buttons/pill-button'
import { Button } from 'web/components/button'
import { Group } from 'common/group'
import { LoadingIndicator } from '../loading-indicator'
import { withTracking } from 'web/lib/service/analytics'
export default function GroupSelectorDialog(props: {
open: boolean
@ -65,20 +67,26 @@ export default function GroupSelectorDialog(props: {
</p>
<div className="scrollbar-hide items-start gap-2 overflow-x-auto">
{user &&
{!user || displayedGroups.length === 0 ? (
<LoadingIndicator spinnerClassName="h-12 w-12" />
) : (
displayedGroups.map((group) => (
<PillButton
selected={memberGroupIds.includes(group.id)}
onSelect={() =>
memberGroupIds.includes(group.id)
? leaveGroup(group, user.id)
: joinGroup(group, user.id)
}
onSelect={withTracking(
() =>
memberGroupIds.includes(group.id)
? leaveGroup(group, user.id)
: joinGroup(group, user.id),
'toggle group pill',
{ group: group.slug }
)}
className="mr-1 mb-2 max-w-[12rem] truncate"
>
{group.name}
</PillButton>
))}
))
)}
</div>
</Col>
<Col>

View File

@ -40,14 +40,7 @@ export function ShareEmbedButton(props: { contract: Contract }) {
track('copy embed code')
}}
>
<Menu.Button
className="btn btn-xs normal-case"
style={{
backgroundColor: 'white',
border: '2px solid #9ca3af',
color: '#9ca3af', // text-gray-400
}}
>
<Menu.Button className="btn btn-xs border-2 !border-gray-500 !bg-white normal-case text-gray-500">
{codeIcon}
Embed
</Menu.Button>

View File

@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
import { Group } from 'common/group'
import { User } from 'common/user'
import {
getGroup,
getMemberGroups,
GroupMemberDoc,
groupMembers,
@ -102,6 +103,24 @@ export const useMemberGroupIds = (user: User | null | undefined) => {
return memberGroupIds
}
export function useMemberGroupsSubscription(user: User | null | undefined) {
const cachedGroups = useMemberGroups(user?.id) ?? []
const [groups, setGroups] = useState(cachedGroups)
const userId = user?.id
useEffect(() => {
if (userId) {
return listenForMemberGroupIds(userId, (groupIds) => {
Promise.all(groupIds.map((id) => getGroup(id))).then((groups) =>
setGroups(filterDefined(groups))
)
})
}
}, [userId])
return groups
}
export function useMembers(groupId: string | undefined) {
const [members, setMembers] = useState<User[]>([])
useEffect(() => {

View File

@ -1,6 +1,5 @@
import { usePrefetchUserBetContracts } from './use-contracts'
import { usePrefetchPortfolioHistory } from './use-portfolio-history'
import { usePrefetchProbChanges } from './use-prob-changes'
import { usePrefetchUserBets } from './use-user-bets'
export function usePrefetch(userId: string | undefined) {
@ -9,6 +8,5 @@ export function usePrefetch(userId: string | undefined) {
usePrefetchUserBets(maybeUserId),
usePrefetchUserBetContracts(maybeUserId),
usePrefetchPortfolioHistory(maybeUserId, 'weekly'),
usePrefetchProbChanges(userId),
])
}

View File

@ -1,11 +1,45 @@
import { useFirestoreQueryData } from '@react-query-firebase/firestore'
import { CPMMContract } from 'common/contract'
import { MINUTE_MS } from 'common/util/time'
import { useQueryClient } from 'react-query'
import { useQuery, useQueryClient } from 'react-query'
import {
getProbChangesNegative,
getProbChangesPositive,
} from 'web/lib/firebase/contracts'
import { getValues } from 'web/lib/firebase/utils'
import { getIndexName, searchClient } from 'web/lib/service/algolia'
export const useProbChangesAlgolia = (userId: string) => {
const { data: positiveData } = useQuery(['prob-change-day', userId], () =>
searchClient
.initIndex(getIndexName('prob-change-day'))
.search<CPMMContract>('', {
facetFilters: ['uniqueBettorIds:' + userId, 'isResolved:false'],
})
)
const { data: negativeData } = useQuery(
['prob-change-day-ascending', userId],
() =>
searchClient
.initIndex(getIndexName('prob-change-day-ascending'))
.search<CPMMContract>('', {
facetFilters: ['uniqueBettorIds:' + userId, 'isResolved:false'],
})
)
if (!positiveData || !negativeData) {
return undefined
}
return {
positiveChanges: positiveData.hits
.filter((c) => c.probChanges && c.probChanges.day > 0)
.filter((c) => c.outcomeType === 'BINARY'),
negativeChanges: negativeData.hits
.filter((c) => c.probChanges && c.probChanges.day < 0)
.filter((c) => c.outcomeType === 'BINARY'),
}
}
export const useProbChanges = (userId: string) => {
const { data: positiveChanges } = useFirestoreQueryData(

View File

@ -13,7 +13,11 @@ export const useWarnUnsavedChanges = (hasUnsavedChanges: boolean) => {
return confirmationMessage
}
const beforeRouteHandler = () => {
const beforeRouteHandler = (href: string) => {
const pathname = href.split('?')[0]
// Don't warn if the user is navigating to the same page.
if (pathname === location.pathname) return
if (!confirm(confirmationMessage)) {
Router.events.emit('routeChangeError')
throw 'Abort route change. Please ignore this error.'

View File

@ -8,9 +8,9 @@ export default function BoldIcon(props: React.SVGProps<SVGSVGElement>) {
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
<path d="M6 4h8a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z"></path>

View File

@ -8,9 +8,9 @@ export default function ItalicIcon(props: React.SVGProps<SVGSVGElement>) {
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
<line x1="19" y1="4" x2="10" y2="4"></line>

View File

@ -8,9 +8,9 @@ export default function LinkIcon(props: React.SVGProps<SVGSVGElement>) {
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path>

View File

@ -0,0 +1,15 @@
import algoliasearch from 'algoliasearch/lite'
import { ENV } from 'common/envs/constants'
export const searchClient = algoliasearch(
'GJQPAYENIF',
'75c28fc084a80e1129d427d470cf41a3'
)
const indexPrefix = ENV === 'DEV' ? 'dev-' : ''
export const searchIndexName =
ENV === 'DEV' ? 'dev-contracts' : 'contractsIndex'
export const getIndexName = (sort: string) => {
return `${indexPrefix}contracts-${sort}`
}

View File

@ -1,7 +1,6 @@
import { PrivateUser, User } from 'common/user'
import { generateNewApiKey } from '../api/api-key'
const TWITCH_BOT_PUBLIC_URL = 'https://king-prawn-app-5btyw.ondigitalocean.app' // TODO: Add this to env config appropriately
import { ENV_CONFIG } from 'common/envs/constants'
async function postToBot(url: string, body: unknown) {
const result = await fetch(url, {
@ -21,13 +20,16 @@ export async function initLinkTwitchAccount(
manifoldUserID: string,
manifoldUserAPIKey: string
): Promise<[string, Promise<{ twitchName: string; controlToken: string }>]> {
const response = await postToBot(`${TWITCH_BOT_PUBLIC_URL}/api/linkInit`, {
manifoldID: manifoldUserID,
apiKey: manifoldUserAPIKey,
redirectURL: window.location.href,
})
const response = await postToBot(
`${ENV_CONFIG.twitchBotEndpoint}/api/linkInit`,
{
manifoldID: manifoldUserID,
apiKey: manifoldUserAPIKey,
redirectURL: window.location.href,
}
)
const responseFetch = fetch(
`${TWITCH_BOT_PUBLIC_URL}/api/linkResult?userID=${manifoldUserID}`
`${ENV_CONFIG.twitchBotEndpoint}/api/linkResult?userID=${manifoldUserID}`
)
return [response.twitchAuthURL, responseFetch.then((r) => r.json())]
}
@ -50,15 +52,18 @@ export async function updateBotEnabledForUser(
botEnabled: boolean
) {
if (botEnabled) {
return postToBot(`${TWITCH_BOT_PUBLIC_URL}/registerchanneltwitch`, {
return postToBot(`${ENV_CONFIG.twitchBotEndpoint}/registerchanneltwitch`, {
apiKey: privateUser.apiKey,
}).then((r) => {
if (!r.success) throw new Error(r.message)
})
} else {
return postToBot(`${TWITCH_BOT_PUBLIC_URL}/unregisterchanneltwitch`, {
apiKey: privateUser.apiKey,
}).then((r) => {
return postToBot(
`${ENV_CONFIG.twitchBotEndpoint}/unregisterchanneltwitch`,
{
apiKey: privateUser.apiKey,
}
).then((r) => {
if (!r.success) throw new Error(r.message)
})
}
@ -66,10 +71,10 @@ export async function updateBotEnabledForUser(
export function getOverlayURLForUser(privateUser: PrivateUser) {
const controlToken = privateUser?.twitchInfo?.controlToken
return `${TWITCH_BOT_PUBLIC_URL}/overlay?t=${controlToken}`
return `${ENV_CONFIG.twitchBotEndpoint}/overlay?t=${controlToken}`
}
export function getDockURLForUser(privateUser: PrivateUser) {
const controlToken = privateUser?.twitchInfo?.controlToken
return `${TWITCH_BOT_PUBLIC_URL}/dock?t=${controlToken}`
return `${ENV_CONFIG.twitchBotEndpoint}/dock?t=${controlToken}`
}

View File

@ -1,6 +1,6 @@
const API_DOCS_URL = 'https://docs.manifold.markets/api'
const ABOUT_PAGE_URL = 'https://docs.manifold.markets/$how-to'
const ABOUT_PAGE_URL = 'https://help.manifold.markets/'
/** @type {import('next').NextConfig} */
module.exports = {

View File

@ -0,0 +1,42 @@
import type { NextApiRequest, NextApiResponse } from 'next'
import { z } from 'zod'
import { ValidationError } from './_types'
import { validate } from './_validate'
const queryParams = z
.object({
// This secret is stored in both Firebase and Vercel's environment variables, as API_SECRET.
apiSecret: z.string(),
// Path after domain: e.g. "/JamesGrugett/will-pete-buttigieg-ever-be-us-pres"
pathToRevalidate: z.string(),
})
.strict()
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
let params: z.infer<typeof queryParams>
try {
params = validate(queryParams, req.query)
} catch (e) {
if (e instanceof ValidationError) {
return res.status(400).json(e)
}
console.error(`Unknown error during validation: ${e}`)
return res.status(500).json({ error: 'Unknown error during validation' })
}
const { apiSecret, pathToRevalidate } = params
if (apiSecret !== process.env.API_SECRET) {
return res.status(401).json({ message: 'Invalid api secret' })
}
try {
await res.revalidate(pathToRevalidate)
return res.json({ revalidated: true })
} catch (err) {
return res.status(500).send('Error revalidating')
}
}

View File

@ -2,13 +2,13 @@ import { ProbChangeTable } from 'web/components/contract/prob-change-table'
import { Col } from 'web/components/layout/col'
import { Page } from 'web/components/page'
import { Title } from 'web/components/title'
import { useProbChanges } from 'web/hooks/use-prob-changes'
import { useProbChangesAlgolia } from 'web/hooks/use-prob-changes'
import { useUser } from 'web/hooks/use-user'
export default function DailyMovers() {
const user = useUser()
const changes = useProbChanges(user?.id ?? '')
const changes = useProbChangesAlgolia(user?.id ?? '')
return (
<Page>

View File

@ -140,7 +140,10 @@ export default function GroupPage(props: {
const user = useUser()
const isAdmin = useAdmin()
const memberIds = useMemberIds(group?.id ?? null) ?? props.memberIds
const [sidebarIndex, setSidebarIndex] = useState(0)
// Note: Keep in sync with sidebarPages
const [sidebarIndex, setSidebarIndex] = useState(
['markets', 'leaderboards', 'about'].indexOf(page ?? 'markets')
)
useSaveReferral(user, {
defaultReferrerUsername: creator.username,
@ -208,7 +211,7 @@ export default function GroupPage(props: {
<ContractSearch
headerClassName="md:sticky"
user={user}
defaultSort={'newest'}
defaultSort={'score'}
defaultFilter={suggestedFilter}
additionalFilter={{ groupSlug: group.slug }}
persistPrefix={`group-${group.slug}`}
@ -241,6 +244,12 @@ export default function GroupPage(props: {
const onSidebarClick = (key: string) => {
const index = sidebarPages.findIndex((t) => t.key === key)
setSidebarIndex(index)
// Append the page to the URL, e.g. /group/mexifold/markets
router.replace(
{ query: { ...router.query, slugs: [group.slug, key] } },
undefined,
{ shallow: true }
)
}
const joinOrAddQuestionsButton = (
@ -253,7 +262,11 @@ export default function GroupPage(props: {
return (
<>
<TopGroupNavBar group={group} />
<TopGroupNavBar
group={group}
currentPage={sidebarPages[sidebarIndex].key}
onClick={onSidebarClick}
/>
<div>
<div
className={
@ -278,19 +291,19 @@ export default function GroupPage(props: {
{pageContent}
</main>
</div>
<GroupNavBar
currentPage={sidebarPages[sidebarIndex].key}
onClick={onSidebarClick}
/>
</div>
</>
)
}
export function TopGroupNavBar(props: { group: Group }) {
export function TopGroupNavBar(props: {
group: Group
currentPage: string
onClick: (key: string) => void
}) {
return (
<header className="sticky top-0 z-50 w-full pb-2 md:hidden lg:col-span-12">
<div className="flex items-center border-b border-gray-200 bg-white px-4">
<header className="sticky top-0 z-50 w-full border-b border-gray-200 md:hidden lg:col-span-12">
<div className="flex items-center bg-white px-4">
<div className="flex-shrink-0">
<Link href="/">
<a className="text-indigo-700 hover:text-gray-500 ">
@ -304,6 +317,7 @@ export function TopGroupNavBar(props: { group: Group }) {
</h1>
</div>
</div>
<GroupNavBar currentPage={props.currentPage} onClick={props.onClick} />
</header>
)
}

View File

@ -7,12 +7,12 @@ import { Row } from 'web/components/layout/row'
import { Page } from 'web/components/page'
import { SiteLink } from 'web/components/site-link'
import { Title } from 'web/components/title'
import { useMemberGroups } from 'web/hooks/use-group'
import { useMemberGroupsSubscription } from 'web/hooks/use-group'
import { useTracking } from 'web/hooks/use-tracking'
import { useUser } from 'web/hooks/use-user'
import { updateUser } from 'web/lib/firebase/users'
import { track } from 'web/lib/service/analytics'
import { getHomeItems } from '.'
import { getHomeItems, TrendingGroupsSection } from '.'
export default function Home() {
const user = useUser()
@ -27,7 +27,7 @@ export default function Home() {
setHomeSections(newHomeSections)
}
const groups = useMemberGroups(user?.id) ?? []
const groups = useMemberGroupsSubscription(user)
const { sections } = getHomeItems(groups, homeSections)
return (
@ -38,7 +38,15 @@ export default function Home() {
<DoneButton />
</Row>
<ArrangeHome sections={sections} setSectionIds={updateHomeSections} />
<Col className="gap-8 md:flex-row">
<Col className="flex-1">
<ArrangeHome
sections={sections}
setSectionIds={updateHomeSections}
/>
</Col>
<TrendingGroupsSection className="flex-1" user={user} full />
</Col>
</Col>
</Page>
)

View File

@ -1,11 +1,11 @@
import React, { ReactNode, useEffect, useState } from 'react'
import React, { ReactNode, useEffect } from 'react'
import Router from 'next/router'
import {
AdjustmentsIcon,
PencilAltIcon,
ArrowSmRightIcon,
} from '@heroicons/react/solid'
import { XCircleIcon } from '@heroicons/react/outline'
import { PlusCircleIcon, XCircleIcon } from '@heroicons/react/outline'
import clsx from 'clsx'
import { toast, Toaster } from 'react-hot-toast'
@ -22,21 +22,16 @@ import { SiteLink } from 'web/components/site-link'
import { usePrivateUser, useUser } from 'web/hooks/use-user'
import {
useMemberGroupIds,
useMemberGroups,
useMemberGroupsSubscription,
useTrendingGroups,
} from 'web/hooks/use-group'
import { Button } from 'web/components/button'
import { Row } from 'web/components/layout/row'
import { ProbChangeTable } from 'web/components/contract/prob-change-table'
import {
getGroup,
groupPath,
joinGroup,
leaveGroup,
} from 'web/lib/firebase/groups'
import { groupPath, joinGroup, leaveGroup } from 'web/lib/firebase/groups'
import { usePortfolioHistory } from 'web/hooks/use-portfolio-history'
import { formatMoney } from 'common/util/format'
import { useProbChanges } from 'web/hooks/use-prob-changes'
import { useProbChangesAlgolia } from 'web/hooks/use-prob-changes'
import { ProfitBadge } from 'web/components/bets-list'
import { calculatePortfolioProfit } from 'common/calculate-metrics'
import { hasCompletedStreakToday } from 'web/components/profile/betting-streak-modal'
@ -57,27 +52,32 @@ export default function Home() {
useSaveReferral()
usePrefetch(user?.id)
const cachedGroups = useMemberGroups(user?.id) ?? []
const groupIds = useMemberGroupIds(user)
const [groups, setGroups] = useState(cachedGroups)
useEffect(() => {
if (groupIds) {
Promise.all(groupIds.map((id) => getGroup(id))).then((groups) =>
setGroups(filterDefined(groups))
)
}
}, [groupIds])
const groups = useMemberGroupsSubscription(user)
const { sections } = getHomeItems(groups, user?.homeSections ?? [])
useEffect(() => {
if (
user &&
!user.homeSections &&
sections.length > 0 &&
groups.length > 0
) {
// Save initial home sections.
updateUser(user.id, { homeSections: sections.map((s) => s.id) })
}
}, [user, sections, groups])
return (
<Page>
<Toaster />
<Col className="pm:mx-10 gap-4 px-4 pb-12 pt-4 sm:pt-0">
<Row className={'mb-2 w-full items-center justify-between gap-8'}>
<Title className="!mt-0 !mb-0" text="Home" />
<Row className="items-center gap-2">
<Title className="!mt-0 !mb-0" text="Home" />
<CustomizeButton justIcon />
</Row>
<DailyStats user={user} />
</Row>
@ -102,7 +102,7 @@ export default function Home() {
const HOME_SECTIONS = [
{ label: 'Daily movers', id: 'daily-movers' },
{ label: 'Trending', id: 'score' },
{ label: 'New for you', id: 'new-for-you' },
{ label: 'New', id: 'newest' },
{ label: 'Recently updated', id: 'recently-updated-for-you' },
]
@ -110,11 +110,12 @@ export const getHomeItems = (groups: Group[], sections: string[]) => {
// Accommodate old home sections.
if (!isArray(sections)) sections = []
const items = [
const items: { id: string; label: string; group?: Group }[] = [
...HOME_SECTIONS,
...groups.map((g) => ({
label: g.name,
id: g.id,
group: g,
})),
]
const itemsById = keyBy(items, 'id')
@ -139,16 +140,6 @@ function renderSection(
if (id === 'daily-movers') {
return <DailyMoversSection key={id} userId={user?.id} />
}
if (id === 'new-for-you')
return (
<SearchSection
key={id}
label={label}
sort={'newest'}
pill="personal"
user={user}
/>
)
if (id === 'recently-updated-for-you')
return (
<SearchSection
@ -235,7 +226,6 @@ function GroupSection(props: {
<Col>
<SectionHeader label={group.name} href={groupPath(group.slug)}>
<Button
className=""
color="gray-white"
onClick={() => {
if (user) {
@ -252,10 +242,7 @@ function GroupSection(props: {
}
}}
>
<XCircleIcon
className={clsx('h-5 w-5 flex-shrink-0')}
aria-hidden="true"
/>
<XCircleIcon className={'h-5 w-5 flex-shrink-0'} aria-hidden="true" />
</Button>
</SectionHeader>
<ContractsGrid contracts={contracts} />
@ -265,7 +252,16 @@ function GroupSection(props: {
function DailyMoversSection(props: { userId: string | null | undefined }) {
const { userId } = props
const changes = useProbChanges(userId ?? '')
const changes = useProbChangesAlgolia(userId ?? '')
if (changes) {
const { positiveChanges, negativeChanges } = changes
if (
!positiveChanges.find((c) => c.probChanges.day >= 0.01) ||
!negativeChanges.find((c) => c.probChanges.day <= -0.01)
)
return null
}
return (
<Col className="gap-2">
@ -285,8 +281,8 @@ function DailyStats(props: {
const [first, last] = [metrics[0], metrics[metrics.length - 1]]
const privateUser = usePrivateUser()
const streaksHidden =
privateUser?.notificationPreferences.betting_streaks.length === 0
const streaks = privateUser?.notificationPreferences?.betting_streaks ?? []
const streaksHidden = streaks.length === 0
let profit = 0
let profitPercent = 0
@ -322,24 +318,29 @@ function DailyStats(props: {
)
}
function TrendingGroupsSection(props: { user: User | null | undefined }) {
const { user } = props
export function TrendingGroupsSection(props: {
user: User | null | undefined
full?: boolean
className?: string
}) {
const { user, full, className } = props
const memberGroupIds = useMemberGroupIds(user) || []
const groups = useTrendingGroups().filter(
(g) => !memberGroupIds.includes(g.id)
)
const count = 25
const count = full ? 100 : 25
const chosenGroups = groups.slice(0, count)
return (
<Col>
<Col className={className}>
<SectionHeader label="Trending groups" href="/explore-groups">
<CustomizeButton />
{!full && <CustomizeButton className="mb-1" />}
</SectionHeader>
<Row className="flex-wrap gap-2">
{chosenGroups.map((g) => (
<PillButton
className="flex flex-row items-center gap-1"
key={g.id}
selected={memberGroupIds.includes(g.id)}
onSelect={() => {
@ -361,6 +362,11 @@ function TrendingGroupsSection(props: { user: User | null | undefined }) {
}
}}
>
<PlusCircleIcon
className={'h-5 w-5 flex-shrink-0 text-gray-500'}
aria-hidden="true"
/>
{g.name}
</PillButton>
))}
@ -369,10 +375,14 @@ function TrendingGroupsSection(props: { user: User | null | undefined }) {
)
}
function CustomizeButton() {
function CustomizeButton(props: { justIcon?: boolean; className?: string }) {
const { justIcon, className } = props
return (
<SiteLink
className="mb-2 flex flex-row items-center text-xl hover:no-underline"
className={clsx(
className,
'flex flex-row items-center text-xl hover:no-underline'
)}
href="/home/edit"
>
<Button size="lg" color="gray" className={clsx('flex gap-2')}>
@ -380,7 +390,7 @@ function CustomizeButton() {
className={clsx('h-[24px] w-5 text-gray-500')}
aria-hidden="true"
/>
Customize
{!justIcon && 'Customize'}
</Button>
</SiteLink>
)

View File

@ -435,7 +435,7 @@ function IncomeNotificationItem(props: {
reasonText = !simple
? `Bonus for ${
parseInt(sourceText) / UNIQUE_BETTOR_BONUS_AMOUNT
} new predictors on`
} new traders on`
: 'bonus on'
} else if (sourceType === 'tip') {
reasonText = !simple ? `tipped you on` : `in tips on`
@ -556,7 +556,7 @@ function IncomeNotificationItem(props: {
{(isTip || isUniqueBettorBonus) && (
<MultiUserTransactionLink
userInfos={userLinks}
modalLabel={isTip ? 'Who tipped you' : 'Unique predictors'}
modalLabel={isTip ? 'Who tipped you' : 'Unique traders'}
/>
)}
<Row className={'line-clamp-2 flex max-w-xl'}>
@ -971,13 +971,20 @@ function ContractResolvedNotification(props: {
const { sourceText, data } = notification
const { userInvestment, userPayout } = (data as ContractResolutionData) ?? {}
const subtitle = 'resolved the market'
const resolutionDescription = () => {
if (!sourceText) return <div />
if (sourceText === 'YES' || sourceText == 'NO') {
return <BinaryOutcomeLabel outcome={sourceText as any} />
}
if (sourceText.includes('%'))
return <ProbPercentLabel prob={parseFloat(sourceText.replace('%', ''))} />
return (
<ProbPercentLabel
prob={parseFloat(sourceText.replace('%', '')) / 100}
/>
)
if (sourceText === 'CANCEL') return <CancelLabel />
if (sourceText === 'MKT' || sourceText === 'PROB') return <MultiLabel />
@ -996,7 +1003,7 @@ function ContractResolvedNotification(props: {
const description =
userInvestment && userPayout !== undefined ? (
<Row className={'gap-1 '}>
{resolutionDescription()}
Resolved: {resolutionDescription()}
Invested:
<span className={'text-primary'}>{formatMoney(userInvestment)} </span>
Payout:
@ -1013,7 +1020,7 @@ function ContractResolvedNotification(props: {
</span>
</Row>
) : (
<span>{resolutionDescription()}</span>
<span>Resolved {resolutionDescription()}</span>
)
if (justSummary) {

View File

@ -4,6 +4,7 @@ import { ContractSearch } from 'web/components/contract-search'
import { useTracking } from 'web/hooks/use-tracking'
import { useUser } from 'web/hooks/use-user'
import { usePrefetch } from 'web/hooks/use-prefetch'
import { useRouter } from 'next/router'
export default function Search() {
const user = useUser()
@ -11,6 +12,10 @@ export default function Search() {
useTracking('view search')
const { query } = useRouter()
const { q, s, p } = query
const autoFocus = !q && !s && !p
return (
<Page>
<Col className="mx-auto w-full p-2">
@ -18,7 +23,7 @@ export default function Search() {
user={user}
persistPrefix="search"
useQueryUrlParam={true}
autoFocus
autoFocus={autoFocus}
/>
</Col>
</Page>

View File

@ -1,4 +1,3 @@
import dayjs from 'dayjs'
import { useEffect, useState } from 'react'
import {
DailyCountChart,
@ -47,65 +46,38 @@ export default function Analytics() {
)
}
export function CustomAnalytics(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[]
}
}) {
export function CustomAnalytics(props: Stats) {
const {
startDate,
dailyActiveUsers,
dailyActiveUsersWeeklyAvg,
weeklyActiveUsers,
monthlyActiveUsers,
d1,
d1WeeklyAvg,
nd1,
nd1WeeklyAvg,
nw1,
dailyBetCounts,
dailyContractCounts,
dailyCommentCounts,
dailySignups,
weeklyActiveUsers,
monthlyActiveUsers,
weekOnWeekRetention,
monthlyRetention,
weeklyActivationRate,
topTenthActions,
dailyActivationRate,
dailyActivationRateWeeklyAvg,
manaBet,
} = props
const startDate = dayjs(props.startDate).add(12, 'hours').valueOf()
const dailyDividedByWeekly = dailyActiveUsers
.map((dailyActive, i) =>
Math.round((100 * dailyActive) / weeklyActiveUsers[i])
)
.slice(7)
const dailyDividedByMonthly = dailyActiveUsers
.map((dailyActive, i) =>
Math.round((100 * dailyActive) / monthlyActiveUsers[i])
)
.slice(7)
const weeklyDividedByMonthly = weeklyActiveUsers
.map((weeklyActive, i) =>
Math.round((100 * weeklyActive) / monthlyActiveUsers[i])
)
.slice(7)
const oneWeekLaterDate = startDate + 7 * 24 * 60 * 60 * 1000
const dailyDividedByWeekly = dailyActiveUsers.map(
(dailyActive, i) => dailyActive / weeklyActiveUsers[i]
)
const dailyDividedByMonthly = dailyActiveUsers.map(
(dailyActive, i) => dailyActive / monthlyActiveUsers[i]
)
const weeklyDividedByMonthly = weeklyActiveUsers.map(
(weeklyActive, i) => weeklyActive / monthlyActiveUsers[i]
)
return (
<Col className="px-2 sm:px-0">
@ -129,6 +101,16 @@ export function CustomAnalytics(props: {
/>
),
},
{
title: 'Daily (7d avg)',
content: (
<DailyCountChart
dailyCounts={dailyActiveUsersWeeklyAvg}
startDate={startDate}
small
/>
),
},
{
title: 'Weekly',
content: (
@ -153,6 +135,108 @@ export function CustomAnalytics(props: {
/>
<Spacer h={8} />
<Title text="Retention" />
<p className="text-gray-500">
What fraction of active users are still active after the given time
period?
</p>
<Tabs
defaultIndex={1}
tabs={[
{
title: 'D1',
content: (
<DailyPercentChart
dailyPercent={d1}
startDate={startDate}
small
excludeFirstDays={1}
/>
),
},
{
title: 'D1 (7d avg)',
content: (
<DailyPercentChart
dailyPercent={d1WeeklyAvg}
startDate={startDate}
small
excludeFirstDays={7}
/>
),
},
{
title: 'W1',
content: (
<DailyPercentChart
dailyPercent={weekOnWeekRetention}
startDate={startDate}
small
excludeFirstDays={14}
/>
),
},
{
title: 'M1',
content: (
<DailyPercentChart
dailyPercent={monthlyRetention}
startDate={startDate}
small
excludeFirstDays={60}
/>
),
},
]}
/>
<Spacer h={8} />
<Title text="New user retention" />
<p className="text-gray-500">
What fraction of new users are still active after the given time period?
</p>
<Spacer h={4} />
<Tabs
defaultIndex={2}
tabs={[
{
title: 'ND1',
content: (
<DailyPercentChart
dailyPercent={nd1}
startDate={startDate}
excludeFirstDays={1}
small
/>
),
},
{
title: 'ND1 (7d avg)',
content: (
<DailyPercentChart
dailyPercent={nd1WeeklyAvg}
startDate={startDate}
excludeFirstDays={7}
small
/>
),
},
{
title: 'NW1',
content: (
<DailyPercentChart
dailyPercent={nw1}
startDate={startDate}
excludeFirstDays={14}
small
/>
),
},
]}
/>
<Spacer h={8} />
<Title text="Daily activity" />
<Tabs
defaultIndex={0}
@ -202,30 +286,33 @@ export function CustomAnalytics(props: {
<Spacer h={8} />
<Title text="Retention" />
<Title text="Activation rate" />
<p className="text-gray-500">
What fraction of active users are still active after the given time
period?
Out of all new users, how many placed at least one bet?
</p>
<Spacer h={4} />
<Tabs
defaultIndex={0}
defaultIndex={1}
tabs={[
{
title: 'Weekly',
title: 'Daily',
content: (
<DailyPercentChart
dailyPercent={weekOnWeekRetention.slice(7)}
startDate={oneWeekLaterDate}
dailyPercent={dailyActivationRate}
startDate={startDate}
excludeFirstDays={1}
small
/>
),
},
{
title: 'Monthly',
title: 'Daily (7d avg)',
content: (
<DailyPercentChart
dailyPercent={monthlyRetention.slice(7)}
startDate={oneWeekLaterDate}
dailyPercent={dailyActivationRateWeeklyAvg}
startDate={startDate}
excludeFirstDays={7}
small
/>
),
@ -234,17 +321,6 @@ export function CustomAnalytics(props: {
/>
<Spacer h={8} />
<Title text="Weekly activation rate" />
<p className="text-gray-500">
Out of all new users this week, how many placed at least one bet?
</p>
<DailyPercentChart
dailyPercent={weeklyActivationRate.slice(7)}
startDate={oneWeekLaterDate}
small
/>
<Spacer h={8} />
<Title text="Ratio of Active Users" />
<Tabs
defaultIndex={1}
@ -254,8 +330,9 @@ export function CustomAnalytics(props: {
content: (
<DailyPercentChart
dailyPercent={dailyDividedByWeekly}
startDate={oneWeekLaterDate}
startDate={startDate}
small
excludeFirstDays={7}
/>
),
},
@ -264,8 +341,9 @@ export function CustomAnalytics(props: {
content: (
<DailyPercentChart
dailyPercent={dailyDividedByMonthly}
startDate={oneWeekLaterDate}
startDate={startDate}
small
excludeFirstDays={30}
/>
),
},
@ -274,8 +352,9 @@ export function CustomAnalytics(props: {
content: (
<DailyPercentChart
dailyPercent={weeklyDividedByMonthly}
startDate={oneWeekLaterDate}
startDate={startDate}
small
excludeFirstDays={30}
/>
),
},
@ -283,47 +362,6 @@ export function CustomAnalytics(props: {
/>
<Spacer h={8} />
<Title text="Action count of top tenth" />
<p className="text-gray-500">
Number of actions (bets, comments, markets created) taken by the tenth
percentile of top users.
</p>
<Tabs
defaultIndex={1}
tabs={[
{
title: 'Daily',
content: (
<DailyCountChart
dailyCounts={topTenthActions.daily}
startDate={startDate}
small
/>
),
},
{
title: 'Weekly',
content: (
<DailyCountChart
dailyCounts={topTenthActions.weekly}
startDate={startDate}
small
/>
),
},
{
title: 'Monthly',
content: (
<DailyCountChart
dailyCounts={topTenthActions.monthly}
startDate={startDate}
small
/>
),
},
]}
/>
<Title text="Total mana bet" />
<p className="text-gray-500">
Sum of bet amounts. (Divided by 100 to be more readable.)
@ -363,6 +401,7 @@ export function CustomAnalytics(props: {
},
]}
/>
<Spacer h={8} />
</Col>
)
}

File diff suppressed because one or more lines are too long

View File

@ -3398,6 +3398,14 @@
resolved "https://registry.yarnpkg.com/@types/module-alias/-/module-alias-2.0.1.tgz#e5893236ce922152d57c5f3f978f764f4deeb45f"
integrity sha512-DN/CCT1HQG6HquBNJdLkvV+4v5l/oEuwOHUPLxI+Eub0NED+lk0YUfba04WGH90EINiUrNgClkNnwGmbICeWMQ==
"@types/node-fetch@2.6.2":
version "2.6.2"
resolved "https://registry.yarnpkg.com/@types/node-fetch/-/node-fetch-2.6.2.tgz#d1a9c5fd049d9415dce61571557104dec3ec81da"
integrity sha512-DHqhlq5jeESLy19TYhLakJ07kNumXWjcDdxXsLUMJZ6ue8VZJj4kLPQVE/2mdHh3xZziNF1xppu5lwmS53HR+A==
dependencies:
"@types/node" "*"
form-data "^3.0.0"
"@types/node@*", "@types/node@>=12.12.47", "@types/node@>=13.7.0", "@types/node@>=8.1.0":
version "17.0.35"
resolved "https://registry.yarnpkg.com/@types/node/-/node-17.0.35.tgz#635b7586086d51fb40de0a2ec9d1014a5283ba4a"
@ -4777,7 +4785,7 @@ combine-promises@^1.1.0:
resolved "https://registry.yarnpkg.com/combine-promises/-/combine-promises-1.1.0.tgz#72db90743c0ca7aab7d0d8d2052fd7b0f674de71"
integrity sha512-ZI9jvcLDxqwaXEixOhArm3r7ReIivsXkpbyEWyeOhzz1QS0iSgBPnWvEqvIQtYyamGCYA88gFhmUrs9hrrQ0pg==
combined-stream@^1.0.6:
combined-stream@^1.0.6, combined-stream@^1.0.8:
version "1.0.8"
resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f"
integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==
@ -6536,6 +6544,15 @@ form-data@^2.3.3, form-data@^2.5.0:
combined-stream "^1.0.6"
mime-types "^2.1.12"
form-data@^3.0.0:
version "3.0.1"
resolved "https://registry.yarnpkg.com/form-data/-/form-data-3.0.1.tgz#ebd53791b78356a99af9a300d4282c4d5eb9755f"
integrity sha512-RHkBKtLWUVwd7SqRIvCZMEvAMoGUp0XU+seQiZejj0COz3RI3hWP4sCv3gZWWLjJTd7rGwcsF5eKZGii0r/hbg==
dependencies:
asynckit "^0.4.0"
combined-stream "^1.0.8"
mime-types "^2.1.12"
formdata-polyfill@^4.0.10:
version "4.0.10"
resolved "https://registry.yarnpkg.com/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz#24807c31c9d402e002ab3d8c720144ceb8848423"
@ -8666,7 +8683,7 @@ node-emoji@^1.10.0:
dependencies:
lodash "^4.17.21"
node-fetch@2.6.7, node-fetch@^2.6.1, node-fetch@^2.6.7:
node-fetch@2, node-fetch@2.6.7, node-fetch@^2.6.1, node-fetch@^2.6.7:
version "2.6.7"
resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.7.tgz#24de9fba827e3b4ae44dc8b20256a379160052ad"
integrity sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==