From 00c9fa61c3ea854ec68fb0934874cf1bc146de1e Mon Sep 17 00:00:00 2001 From: Ian Philips Date: Fri, 19 Aug 2022 11:10:32 -0600 Subject: [PATCH] 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 --- common/notification.ts | 2 + common/numeric-constants.ts | 2 + common/txn.ts | 10 +- common/user.ts | 2 + functions/src/create-notification.ts | 35 +++++ functions/src/index.ts | 1 + functions/src/on-create-bet.ts | 127 ++++++++++++++---- functions/src/reset-betting-streaks.ts | 38 ++++++ .../profile/betting-streak-modal.tsx | 32 +++++ web/components/user-page.tsx | 57 +++++--- web/hooks/use-notifications.ts | 8 +- web/pages/notifications.tsx | 125 +++++++++++------ 12 files changed, 345 insertions(+), 94 deletions(-) create mode 100644 functions/src/reset-betting-streaks.ts create mode 100644 web/components/profile/betting-streak-modal.tsx diff --git a/common/notification.ts b/common/notification.ts index fa4cd90a..99f9d852 100644 --- a/common/notification.ts +++ b/common/notification.ts @@ -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' diff --git a/common/numeric-constants.ts b/common/numeric-constants.ts index f399aa5a..9d41d54f 100644 --- a/common/numeric-constants.ts +++ b/common/numeric-constants.ts @@ -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 diff --git a/common/txn.ts b/common/txn.ts index 701b67fe..00b19570 100644 --- a/common/txn.ts +++ b/common/txn.ts @@ -16,7 +16,13 @@ export type Txn = { 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 diff --git a/common/user.ts b/common/user.ts index 2aeb7122..8ad4c91b 100644 --- a/common/user.ts +++ b/common/user.ts @@ -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 diff --git a/functions/src/create-notification.ts b/functions/src/create-notification.ts index 51b884ad..90250e73 100644 --- a/functions/src/create-notification.ts +++ b/functions/src/create-notification.ts @@ -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)) +} diff --git a/functions/src/index.ts b/functions/src/index.ts index 5cfa27db..c9f62484 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -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' diff --git a/functions/src/on-create-bet.ts b/functions/src/on-create-bet.ts index d33e71dd..c5648293 100644 --- a/functions/src/on-create-bet.ts +++ b/functions/src/on-create-bet.ts @@ -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) +} diff --git a/functions/src/reset-betting-streaks.ts b/functions/src/reset-betting-streaks.ts new file mode 100644 index 00000000..0600fa56 --- /dev/null +++ b/functions/src/reset-betting-streaks.ts @@ -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, + }) +} diff --git a/web/components/profile/betting-streak-modal.tsx b/web/components/profile/betting-streak-modal.tsx new file mode 100644 index 00000000..345d38b1 --- /dev/null +++ b/web/components/profile/betting-streak-modal.tsx @@ -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 ( + + + 🔥 + Betting streaks are here! + + • What are they? + + 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! + + + • Where can I check my streak? + + + You can see your current streak on the top right of your profile + page. + + + + + ) +} diff --git a/web/components/user-page.tsx b/web/components/user-page.tsx index c8a697c3..d3737eea 100644 --- a/web/components/user-page.tsx +++ b/web/components/user-page.tsx @@ -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 && ( - - )} + {showConfetti || + (showBettingStreakModal && ( + + ))} + {/* Banner image up top, with an circle avatar overlaid */}
- - {user.name} - - = 0 ? 'text-green-600' : 'text-red-400' - )} - > - {formatMoney(profit)} - {' '} - profit - + + + {user.name} + @{user.username} + + + + + = 0 ? 'text-green-600' : 'text-red-400' + )} + > + {formatMoney(profit)} + + profit + + setShowBettingStreakModal(true)} + > + 🔥{user.currentBettingStreak ?? 0} + streak + + + - @{user.username} - {user.bio && ( <> diff --git a/web/hooks/use-notifications.ts b/web/hooks/use-notifications.ts index a3ddeb29..9df162bd 100644 --- a/web/hooks/use-notifications.ts +++ b/web/hooks/use-notifications.ts @@ -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({ diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index 7d06c481..c99b226a 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -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 ? ( + Betting Streak + ) : ( + + Betting Streak + + ) + ) : ( + + )} + + ) + } + + const incomeNotificationLabel = () => { + return sourceText ? ( + + {'+' + formatMoney(parseInt(sourceText))} + + ) : ( +
+ ) + } + + 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: {
- + {incomeNotificationLabel()}
- - {getReasonForShowingIncomeNotification(true)} - - + {reasonAndLink(true)}
@@ -421,18 +470,16 @@ function IncomeNotificationItem(props: { >
- - - + {incomeNotificationLabel()}
- {sourceType != 'bonus' && + {sourceType === 'tip' && (sourceUserUsername === MULTIPLE_USERS_KEY ? ( Multiple users ) : ( @@ -443,8 +490,7 @@ function IncomeNotificationItem(props: { short={true} /> ))} - {getReasonForShowingIncomeNotification(false)} {' on'} - + {reasonAndLink(false)}
@@ -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 ( {formatMoney(parseInt(sourceText))} ) - } else if ((sourceType === 'bonus' || sourceType === 'tip') && sourceText) { - return ( - - {'+' + formatMoney(parseInt(sourceText))} - - ) } else if (sourceType === 'bet' && sourceText) { return ( <>