betting streaks (#777)

* Parse notif, show betting streaks modal, schedule function

* Ignore streaks of 0

* Pass notifyFills the contract

* Turn 9am into a constant

* Lint

* Up streak reward, fix timing logic

* Change wording
This commit is contained in:
Ian Philips 2022-08-19 11:10:32 -06:00 committed by GitHub
parent 4f3202f90b
commit 00c9fa61c3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 345 additions and 94 deletions

View File

@ -38,6 +38,7 @@ export type notification_source_types =
| 'user'
| 'bonus'
| 'challenge'
| 'betting_streak_bonus'
export type notification_source_update_types =
| 'created'
@ -66,3 +67,4 @@ export type notification_reason_types =
| 'bet_fill'
| 'user_joined_from_your_group_invite'
| 'challenge_accepted'
| 'betting_streak_incremented'

View File

@ -4,3 +4,5 @@ export const NUMERIC_FIXED_VAR = 0.005
export const NUMERIC_GRAPH_COLOR = '#5fa5f9'
export const NUMERIC_TEXT_COLOR = 'text-blue-500'
export const UNIQUE_BETTOR_BONUS_AMOUNT = 10
export const BETTING_STREAK_BONUS_AMOUNT = 5
export const BETTING_STREAK_RESET_HOUR = 9

View File

@ -16,7 +16,13 @@ export type Txn<T extends AnyTxnType = AnyTxnType> = {
amount: number
token: 'M$' // | 'USD' | MarketOutcome
category: 'CHARITY' | 'MANALINK' | 'TIP' | 'REFERRAL' | 'UNIQUE_BETTOR_BONUS'
category:
| 'CHARITY'
| 'MANALINK'
| 'TIP'
| 'REFERRAL'
| 'UNIQUE_BETTOR_BONUS'
| 'BETTING_STREAK_BONUS'
// Any extra data
data?: { [key: string]: any }
@ -57,7 +63,7 @@ type Referral = {
type Bonus = {
fromType: 'BANK'
toType: 'USER'
category: 'UNIQUE_BETTOR_BONUS'
category: 'UNIQUE_BETTOR_BONUS' | 'BETTING_STREAK_BONUS'
}
export type DonationTxn = Txn & Donation

View File

@ -41,6 +41,8 @@ export type User = {
referredByGroupId?: string
lastPingTime?: number
shouldShowWelcome?: boolean
lastBetTime?: number
currentBettingStreak?: number
}
export const STARTING_BALANCE = ENV_CONFIG.startingBalance ?? 1000

View File

@ -504,3 +504,38 @@ export const createChallengeAcceptedNotification = async (
}
return await notificationRef.set(removeUndefinedProps(notification))
}
export const createBettingStreakBonusNotification = async (
user: User,
txnId: string,
bet: Bet,
contract: Contract,
amount: number,
idempotencyKey: string
) => {
const notificationRef = firestore
.collection(`/users/${user.id}/notifications`)
.doc(idempotencyKey)
const notification: Notification = {
id: idempotencyKey,
userId: user.id,
reason: 'betting_streak_incremented',
createdTime: Date.now(),
isSeen: false,
sourceId: txnId,
sourceType: 'betting_streak_bonus',
sourceUpdateType: 'created',
sourceUserName: user.name,
sourceUserUsername: user.username,
sourceUserAvatarUrl: user.avatarUrl,
sourceText: amount.toString(),
sourceSlug: `/${contract.creatorUsername}/${contract.slug}/bets/${bet.id}`,
sourceTitle: 'Betting Streak Bonus',
// Perhaps not necessary, but just in case
sourceContractSlug: contract.slug,
sourceContractId: contract.id,
sourceContractTitle: contract.question,
sourceContractCreatorUsername: contract.creatorUsername,
}
return await notificationRef.set(removeUndefinedProps(notification))
}

View File

@ -25,6 +25,7 @@ export * from './on-create-comment-on-group'
export * from './on-create-txn'
export * from './on-delete-group'
export * from './score-contracts'
export * from './reset-betting-streaks'
// v2
export * from './health'

View File

@ -3,15 +3,20 @@ import * as admin from 'firebase-admin'
import { keyBy, uniq } from 'lodash'
import { Bet, LimitBet } from '../../common/bet'
import { getContract, getUser, getValues, isProd, log } from './utils'
import { getUser, getValues, isProd, log } from './utils'
import {
createBetFillNotification,
createBettingStreakBonusNotification,
createNotification,
} from './create-notification'
import { filterDefined } from '../../common/util/array'
import { Contract } from '../../common/contract'
import { runTxn, TxnData } from './transact'
import { UNIQUE_BETTOR_BONUS_AMOUNT } from '../../common/numeric-constants'
import {
BETTING_STREAK_BONUS_AMOUNT,
BETTING_STREAK_RESET_HOUR,
UNIQUE_BETTOR_BONUS_AMOUNT,
} from '../../common/numeric-constants'
import {
DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
HOUSE_LIQUIDITY_PROVIDER_ID,
@ -38,37 +43,99 @@ export const onCreateBet = functions.firestore
.doc(contractId)
.update({ lastBetTime, lastUpdatedTime: Date.now() })
await notifyFills(bet, contractId, eventId)
await updateUniqueBettorsAndGiveCreatorBonus(
contractId,
eventId,
bet.userId
)
const userContractSnap = await firestore
.collection(`contracts`)
.doc(contractId)
.get()
const contract = userContractSnap.data() as Contract
if (!contract) {
log(`Could not find contract ${contractId}`)
return
}
await updateUniqueBettorsAndGiveCreatorBonus(contract, eventId, bet.userId)
const bettor = await getUser(bet.userId)
if (!bettor) return
await notifyFills(bet, contract, eventId, bettor)
await updateBettingStreak(bettor, bet, contract, eventId)
await firestore.collection('users').doc(bettor.id).update({ lastBetTime })
})
const updateBettingStreak = async (
user: User,
bet: Bet,
contract: Contract,
eventId: string
) => {
const betStreakResetTime = getTodaysBettingStreakResetTime()
const lastBetTime = user?.lastBetTime ?? 0
// If they've already bet after the reset time, or if we haven't hit the reset time yet
if (lastBetTime > betStreakResetTime || bet.createdTime < betStreakResetTime)
return
const newBettingStreak = (user?.currentBettingStreak ?? 0) + 1
// Otherwise, add 1 to their betting streak
await firestore.collection('users').doc(user.id).update({
currentBettingStreak: newBettingStreak,
})
// Send them the bonus times their streak
const bonusAmount = Math.min(
BETTING_STREAK_BONUS_AMOUNT * newBettingStreak,
100
)
const fromUserId = isProd()
? HOUSE_LIQUIDITY_PROVIDER_ID
: DEV_HOUSE_LIQUIDITY_PROVIDER_ID
const bonusTxnDetails = {
currentBettingStreak: newBettingStreak,
}
const result = await firestore.runTransaction(async (trans) => {
const bonusTxn: TxnData = {
fromId: fromUserId,
fromType: 'BANK',
toId: user.id,
toType: 'USER',
amount: bonusAmount,
token: 'M$',
category: 'BETTING_STREAK_BONUS',
description: JSON.stringify(bonusTxnDetails),
}
return await runTxn(trans, bonusTxn)
})
if (!result.txn) {
log("betting streak bonus txn couldn't be made")
return
}
await createBettingStreakBonusNotification(
user,
result.txn.id,
bet,
contract,
bonusAmount,
eventId
)
}
const updateUniqueBettorsAndGiveCreatorBonus = async (
contractId: string,
contract: Contract,
eventId: string,
bettorId: string
) => {
const userContractSnap = await firestore
.collection(`contracts`)
.doc(contractId)
.get()
const contract = userContractSnap.data() as Contract
if (!contract) {
log(`Could not find contract ${contractId}`)
return
}
let previousUniqueBettorIds = contract.uniqueBettorIds
if (!previousUniqueBettorIds) {
const contractBets = (
await firestore.collection(`contracts/${contractId}/bets`).get()
await firestore.collection(`contracts/${contract.id}/bets`).get()
).docs.map((doc) => doc.data() as Bet)
if (contractBets.length === 0) {
log(`No bets for contract ${contractId}`)
log(`No bets for contract ${contract.id}`)
return
}
@ -86,7 +153,7 @@ const updateUniqueBettorsAndGiveCreatorBonus = async (
if (!contract.uniqueBettorIds || isNewUniqueBettor) {
log(`Got ${previousUniqueBettorIds} unique bettors`)
isNewUniqueBettor && log(`And a new unique bettor ${bettorId}`)
await firestore.collection(`contracts`).doc(contractId).update({
await firestore.collection(`contracts`).doc(contract.id).update({
uniqueBettorIds: newUniqueBettorIds,
uniqueBettorCount: newUniqueBettorIds.length,
})
@ -97,7 +164,7 @@ const updateUniqueBettorsAndGiveCreatorBonus = async (
// Create combined txn for all new unique bettors
const bonusTxnDetails = {
contractId: contractId,
contractId: contract.id,
uniqueBettorIds: newUniqueBettorIds,
}
const fromUserId = isProd()
@ -140,14 +207,14 @@ const updateUniqueBettorsAndGiveCreatorBonus = async (
}
}
const notifyFills = async (bet: Bet, contractId: string, eventId: string) => {
const notifyFills = async (
bet: Bet,
contract: Contract,
eventId: string,
user: User
) => {
if (!bet.fills) return
const user = await getUser(bet.userId)
if (!user) return
const contract = await getContract(contractId)
if (!contract) return
const matchedFills = bet.fills.filter((fill) => fill.matchedBetId !== null)
const matchedBets = (
await Promise.all(
@ -180,3 +247,7 @@ const notifyFills = async (bet: Bet, contractId: string, eventId: string) => {
})
)
}
const getTodaysBettingStreakResetTime = () => {
return new Date().setUTCHours(BETTING_STREAK_RESET_HOUR, 0, 0, 0)
}

View File

@ -0,0 +1,38 @@
// check every day if the user has created a bet since 4pm UTC, and if not, reset their streak
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import { User } from '../../common/user'
import { DAY_MS } from '../../common/util/time'
import { BETTING_STREAK_RESET_HOUR } from '../../common/numeric-constants'
const firestore = admin.firestore()
export const resetBettingStreaksForUsers = functions.pubsub
.schedule(`0 ${BETTING_STREAK_RESET_HOUR} * * *`)
.onRun(async () => {
await resetBettingStreaksInternal()
})
const resetBettingStreaksInternal = async () => {
const usersSnap = await firestore.collection('users').get()
const users = usersSnap.docs.map((doc) => doc.data() as User)
for (const user of users) {
await resetBettingStreakForUser(user)
}
}
const resetBettingStreakForUser = async (user: User) => {
const betStreakResetTime = Date.now() - DAY_MS
// if they made a bet within the last day, don't reset their streak
if (
(user.lastBetTime ?? 0 > betStreakResetTime) ||
!user.currentBettingStreak ||
user.currentBettingStreak === 0
)
return
await firestore.collection('users').doc(user.id).update({
currentBettingStreak: 0,
})
}

View File

@ -0,0 +1,32 @@
import { Modal } from 'web/components/layout/modal'
import { Col } from 'web/components/layout/col'
export function BettingStreakModal(props: {
isOpen: boolean
setOpen: (open: boolean) => void
}) {
const { isOpen, setOpen } = props
return (
<Modal open={isOpen} setOpen={setOpen}>
<Col className="items-center gap-4 rounded-md bg-white px-8 py-6">
<span className={'text-8xl'}>🔥</span>
<span>Betting streaks are here!</span>
<Col className={'gap-2'}>
<span className={'text-indigo-700'}> What are they?</span>
<span className={'ml-2'}>
You get a reward for every consecutive day that you place a bet. The
more days you bet in a row, the more you earn!
</span>
<span className={'text-indigo-700'}>
Where can I check my streak?
</span>
<span className={'ml-2'}>
You can see your current streak on the top right of your profile
page.
</span>
</Col>
</Col>
</Modal>
)
}

View File

@ -28,6 +28,7 @@ import { ReferralsButton } from 'web/components/referrals-button'
import { formatMoney } from 'common/util/format'
import { ShareIconButton } from 'web/components/share-icon-button'
import { ENV_CONFIG } from 'common/envs/constants'
import { BettingStreakModal } from 'web/components/profile/betting-streak-modal'
export function UserLink(props: {
name: string
@ -65,10 +66,13 @@ export function UserPage(props: { user: User }) {
const isCurrentUser = user.id === currentUser?.id
const bannerUrl = user.bannerUrl ?? defaultBannerUrl(user.id)
const [showConfetti, setShowConfetti] = useState(false)
const [showBettingStreakModal, setShowBettingStreakModal] = useState(false)
useEffect(() => {
const claimedMana = router.query['claimed-mana'] === 'yes'
setShowConfetti(claimedMana)
const showBettingStreak = router.query['show'] === 'betting-streak'
setShowBettingStreakModal(showBettingStreak)
}, [router])
const profit = user.profitCached.allTime
@ -80,9 +84,14 @@ export function UserPage(props: { user: User }) {
description={user.bio ?? ''}
url={`/${user.username}`}
/>
{showConfetti && (
<FullscreenConfetti recycle={false} numberOfPieces={300} />
)}
{showConfetti ||
(showBettingStreakModal && (
<FullscreenConfetti recycle={false} numberOfPieces={300} />
))}
<BettingStreakModal
isOpen={showBettingStreakModal}
setOpen={setShowBettingStreakModal}
/>
{/* Banner image up top, with an circle avatar overlaid */}
<div
className="h-32 w-full bg-cover bg-center sm:h-40"
@ -114,22 +123,34 @@ export function UserPage(props: { user: User }) {
{/* Profile details: name, username, bio, and link to twitter/discord */}
<Col className="mx-4 -mt-6">
<Row className={'items-center gap-2'}>
<span className="text-2xl font-bold">{user.name}</span>
<span className="mt-1 text-gray-500">
<span
className={clsx(
'text-md',
profit >= 0 ? 'text-green-600' : 'text-red-400'
)}
>
{formatMoney(profit)}
</span>{' '}
profit
</span>
<Row className={'justify-between'}>
<Col>
<span className="text-2xl font-bold">{user.name}</span>
<span className="text-gray-500">@{user.username}</span>
</Col>
<Col className={'justify-center gap-4'}>
<Row>
<Col className={'items-center text-gray-500'}>
<span
className={clsx(
'text-md',
profit >= 0 ? 'text-green-600' : 'text-red-400'
)}
>
{formatMoney(profit)}
</span>
<span>profit</span>
</Col>
<Col
className={'cursor-pointer items-center text-gray-500'}
onClick={() => setShowBettingStreakModal(true)}
>
<span>🔥{user.currentBettingStreak ?? 0}</span>
<span>streak</span>
</Col>
</Row>
</Col>
</Row>
<span className="text-gray-500">@{user.username}</span>
<Spacer h={4} />
{user.bio && (
<>

View File

@ -71,11 +71,15 @@ export function groupNotifications(notifications: Notification[]) {
const notificationsGroupedByDay = notificationGroupsByDay[day]
const incomeNotifications = notificationsGroupedByDay.filter(
(notification) =>
notification.sourceType === 'bonus' || notification.sourceType === 'tip'
notification.sourceType === 'bonus' ||
notification.sourceType === 'tip' ||
notification.sourceType === 'betting_streak_bonus'
)
const normalNotificationsGroupedByDay = notificationsGroupedByDay.filter(
(notification) =>
notification.sourceType !== 'bonus' && notification.sourceType !== 'tip'
notification.sourceType !== 'bonus' &&
notification.sourceType !== 'tip' &&
notification.sourceType !== 'betting_streak_bonus'
)
if (incomeNotifications.length > 0) {
notificationGroups = notificationGroups.concat({

View File

@ -31,7 +31,10 @@ import {
import { TrendingUpIcon } from '@heroicons/react/outline'
import { formatMoney } from 'common/util/format'
import { groupPath } from 'web/lib/firebase/groups'
import { UNIQUE_BETTOR_BONUS_AMOUNT } from 'common/numeric-constants'
import {
BETTING_STREAK_BONUS_AMOUNT,
UNIQUE_BETTOR_BONUS_AMOUNT,
} from 'common/numeric-constants'
import { groupBy, sum, uniq } from 'lodash'
import { track } from '@amplitude/analytics-browser'
import { Pagination } from 'web/components/pagination'
@ -229,39 +232,39 @@ function IncomeNotificationGroupItem(props: {
(n) => n.sourceType
)
for (const sourceType in groupedNotificationsBySourceType) {
// Source title splits by contracts and groups
// Source title splits by contracts, groups, betting streak bonus
const groupedNotificationsBySourceTitle = groupBy(
groupedNotificationsBySourceType[sourceType],
(notification) => {
return notification.sourceTitle ?? notification.sourceContractTitle
}
)
for (const contractId in groupedNotificationsBySourceTitle) {
const notificationsForContractId =
groupedNotificationsBySourceTitle[contractId]
if (notificationsForContractId.length === 1) {
newNotifications.push(notificationsForContractId[0])
for (const sourceTitle in groupedNotificationsBySourceTitle) {
const notificationsForSourceTitle =
groupedNotificationsBySourceTitle[sourceTitle]
if (notificationsForSourceTitle.length === 1) {
newNotifications.push(notificationsForSourceTitle[0])
continue
}
let sum = 0
notificationsForContractId.forEach(
notificationsForSourceTitle.forEach(
(notification) =>
notification.sourceText &&
(sum = parseInt(notification.sourceText) + sum)
)
const uniqueUsers = uniq(
notificationsForContractId.map((notification) => {
notificationsForSourceTitle.map((notification) => {
return notification.sourceUserUsername
})
)
const newNotification = {
...notificationsForContractId[0],
...notificationsForSourceTitle[0],
sourceText: sum.toString(),
sourceUserUsername:
uniqueUsers.length > 1
? MULTIPLE_USERS_KEY
: notificationsForContractId[0].sourceType,
: notificationsForSourceTitle[0].sourceType,
}
newNotifications.push(newNotification)
}
@ -362,7 +365,8 @@ function IncomeNotificationItem(props: {
justSummary?: boolean
}) {
const { notification, justSummary } = props
const { sourceType, sourceUserName, sourceUserUsername } = notification
const { sourceType, sourceUserName, sourceUserUsername, sourceText } =
notification
const [highlighted] = useState(!notification.isSeen)
const { width } = useWindowSize()
const isMobile = (width && width < 768) || false
@ -370,19 +374,74 @@ function IncomeNotificationItem(props: {
setNotificationsAsSeen([notification])
}, [notification])
function getReasonForShowingIncomeNotification(simple: boolean) {
function reasonAndLink(simple: boolean) {
const { sourceText } = notification
let reasonText = ''
if (sourceType === 'bonus' && sourceText) {
reasonText = !simple
? `Bonus for ${
parseInt(sourceText) / UNIQUE_BETTOR_BONUS_AMOUNT
} unique traders`
} unique traders on`
: 'bonus on'
} else if (sourceType === 'tip') {
reasonText = !simple ? `tipped you` : `in tips on`
reasonText = !simple ? `tipped you on` : `in tips on`
} else if (sourceType === 'betting_streak_bonus' && sourceText) {
reasonText = `for your ${
parseInt(sourceText) / BETTING_STREAK_BONUS_AMOUNT
}-day`
}
return reasonText
return (
<>
{reasonText}
{sourceType === 'betting_streak_bonus' ? (
simple ? (
<span className={'ml-1 font-bold'}>Betting Streak</span>
) : (
<SiteLink
className={'ml-1 font-bold'}
href={'/betting-streak-bonus'}
>
Betting Streak
</SiteLink>
)
) : (
<QuestionOrGroupLink
notification={notification}
ignoreClick={isMobile}
/>
)}
</>
)
}
const incomeNotificationLabel = () => {
return sourceText ? (
<span className="text-primary">
{'+' + formatMoney(parseInt(sourceText))}
</span>
) : (
<div />
)
}
const getIncomeSourceUrl = () => {
const {
sourceId,
sourceContractCreatorUsername,
sourceContractSlug,
sourceSlug,
} = notification
if (sourceType === 'tip' && sourceContractSlug)
return `/${sourceContractCreatorUsername}/${sourceContractSlug}#${sourceSlug}`
if (sourceType === 'tip' && sourceSlug) return `${groupPath(sourceSlug)}`
if (sourceType === 'challenge') return `${sourceSlug}`
if (sourceType === 'betting_streak_bonus')
return `/${sourceUserUsername}/?show=betting-streak`
if (sourceContractCreatorUsername && sourceContractSlug)
return `/${sourceContractCreatorUsername}/${sourceContractSlug}#${getSourceIdForLinkComponent(
sourceId ?? '',
sourceType
)}`
}
if (justSummary) {
@ -392,19 +451,9 @@ function IncomeNotificationItem(props: {
<div className={'flex pl-1 sm:pl-0'}>
<div className={'inline-flex overflow-hidden text-ellipsis pl-1'}>
<div className={'mr-1 text-black'}>
<NotificationTextLabel
className={'line-clamp-1'}
notification={notification}
justSummary={true}
/>
{incomeNotificationLabel()}
</div>
<span className={'flex truncate'}>
{getReasonForShowingIncomeNotification(true)}
<QuestionOrGroupLink
notification={notification}
ignoreClick={isMobile}
/>
</span>
<span className={'flex truncate'}>{reasonAndLink(true)}</span>
</div>
</div>
</div>
@ -421,18 +470,16 @@ function IncomeNotificationItem(props: {
>
<div className={'relative'}>
<SiteLink
href={getSourceUrl(notification) ?? ''}
href={getIncomeSourceUrl() ?? ''}
className={'absolute left-0 right-0 top-0 bottom-0 z-0'}
/>
<Row className={'items-center text-gray-500 sm:justify-start'}>
<div className={'line-clamp-2 flex max-w-xl shrink '}>
<div className={'inline'}>
<span className={'mr-1'}>
<NotificationTextLabel notification={notification} />
</span>
<span className={'mr-1'}>{incomeNotificationLabel()}</span>
</div>
<span>
{sourceType != 'bonus' &&
{sourceType === 'tip' &&
(sourceUserUsername === MULTIPLE_USERS_KEY ? (
<span className={'mr-1 truncate'}>Multiple users</span>
) : (
@ -443,8 +490,7 @@ function IncomeNotificationItem(props: {
short={true}
/>
))}
{getReasonForShowingIncomeNotification(false)} {' on'}
<QuestionOrGroupLink notification={notification} />
{reasonAndLink(false)}
</span>
</div>
</Row>
@ -794,9 +840,6 @@ function getSourceUrl(notification: Notification) {
// User referral:
if (sourceType === 'user' && !sourceContractSlug)
return `/${sourceUserUsername}`
if (sourceType === 'tip' && sourceContractSlug)
return `/${sourceContractCreatorUsername}/${sourceContractSlug}#${sourceSlug}`
if (sourceType === 'tip' && sourceSlug) return `${groupPath(sourceSlug)}`
if (sourceType === 'challenge') return `${sourceSlug}`
if (sourceContractCreatorUsername && sourceContractSlug)
return `/${sourceContractCreatorUsername}/${sourceContractSlug}#${getSourceIdForLinkComponent(
@ -885,12 +928,6 @@ function NotificationTextLabel(props: {
return (
<span className="text-blue-400">{formatMoney(parseInt(sourceText))}</span>
)
} else if ((sourceType === 'bonus' || sourceType === 'tip') && sourceText) {
return (
<span className="text-primary">
{'+' + formatMoney(parseInt(sourceText))}
</span>
)
} else if (sourceType === 'bet' && sourceText) {
return (
<>