From c6d034545ae39c038c606ade4259710a02ab2bcc Mon Sep 17 00:00:00 2001 From: James Grugett Date: Thu, 22 Sep 2022 16:57:48 -0500 Subject: [PATCH] Home: Prob change cards. Sort by daily score. (#925) * Add dailyScore: product of unique bettors (3 days) and probChanges.day * Increase memory and duration of scoreContracts * Home: Smaller prob change card for groups. Use dailyScore for sort order (algolia) * Add back hover --- common/contract.ts | 1 + functions/src/score-contracts.ts | 27 +++-- web/components/contract/contract-card.tsx | 34 ++++++ web/components/contract/contracts-grid.tsx | 50 ++++---- web/components/contract/prob-change-table.tsx | 24 ++-- web/hooks/use-contracts.ts | 28 ++++- web/hooks/use-group.ts | 2 +- web/hooks/use-prob-changes.tsx | 110 +++++++----------- web/lib/firebase/contracts.ts | 20 +--- web/lib/service/algolia.ts | 10 ++ web/pages/daily-movers.tsx | 7 +- web/pages/home/edit.tsx | 2 +- web/pages/home/index.tsx | 77 +++++++----- 13 files changed, 238 insertions(+), 154 deletions(-) diff --git a/common/contract.ts b/common/contract.ts index 2f71bab7..248c9745 100644 --- a/common/contract.ts +++ b/common/contract.ts @@ -57,6 +57,7 @@ export type Contract = { uniqueBettorIds?: string[] uniqueBettorCount?: number popularityScore?: number + dailyScore?: number followerCount?: number featuredOnHomeRank?: number likedByUserIds?: string[] diff --git a/functions/src/score-contracts.ts b/functions/src/score-contracts.ts index 57976ff2..52ef39d4 100644 --- a/functions/src/score-contracts.ts +++ b/functions/src/score-contracts.ts @@ -1,12 +1,14 @@ import * as functions from 'firebase-functions' import * as admin from 'firebase-admin' -import { Bet } from 'common/bet' import { uniq } from 'lodash' -import { Contract } from 'common/contract' +import { Bet } from '../../common/bet' +import { Contract } from '../../common/contract' import { log } from './utils' +import { removeUndefinedProps } from '../../common/util/object' -export const scoreContracts = functions.pubsub - .schedule('every 1 hours') +export const scoreContracts = functions + .runWith({ memory: '4GB', timeoutSeconds: 540 }) + .pubsub.schedule('every 1 hours') .onRun(async () => { await scoreContractsInternal() }) @@ -44,11 +46,22 @@ async function scoreContractsInternal() { const bettors = bets.docs .map((doc) => doc.data() as Bet) .map((bet) => bet.userId) - const score = uniq(bettors).length - if (contract.popularityScore !== score) + const popularityScore = uniq(bettors).length + + let dailyScore: number | undefined + if (contract.outcomeType === 'BINARY' && contract.mechanism === 'cpmm-1') { + const percentChange = Math.abs(contract.probChanges.day) + dailyScore = popularityScore * percentChange + } + + if ( + contract.popularityScore !== popularityScore || + contract.dailyScore !== dailyScore + ) { await firestore .collection('contracts') .doc(contract.id) - .update({ popularityScore: score }) + .update(removeUndefinedProps({ popularityScore, dailyScore })) + } } } diff --git a/web/components/contract/contract-card.tsx b/web/components/contract/contract-card.tsx index 367a5401..aa130321 100644 --- a/web/components/contract/contract-card.tsx +++ b/web/components/contract/contract-card.tsx @@ -7,6 +7,7 @@ import { Col } from '../layout/col' import { BinaryContract, Contract, + CPMMBinaryContract, FreeResponseContract, MultipleChoiceContract, NumericContract, @@ -32,6 +33,8 @@ import { track } from '@amplitude/analytics-browser' import { trackCallback } from 'web/lib/service/analytics' import { getMappedValue } from 'common/pseudo-numeric' import { Tooltip } from '../tooltip' +import { SiteLink } from '../site-link' +import { ProbChange } from './prob-change-table' export function ContractCard(props: { contract: Contract @@ -379,3 +382,34 @@ export function PseudoNumericResolutionOrExpectation(props: { ) } + +export function ContractCardProbChange(props: { + contract: CPMMBinaryContract + noLinkAvatar?: boolean + className?: string +}) { + const { contract, noLinkAvatar, className } = props + return ( + + + + + {contract.question} + + + + + ) +} diff --git a/web/components/contract/contracts-grid.tsx b/web/components/contract/contracts-grid.tsx index 3da9a5d5..4f573fb8 100644 --- a/web/components/contract/contracts-grid.tsx +++ b/web/components/contract/contracts-grid.tsx @@ -2,7 +2,7 @@ import { Contract } from 'web/lib/firebase/contracts' import { User } from 'web/lib/firebase/users' import { Col } from '../layout/col' import { SiteLink } from '../site-link' -import { ContractCard } from './contract-card' +import { ContractCard, ContractCardProbChange } from './contract-card' import { ShowTime } from './contract-details' import { ContractSearch } from '../contract-search' import { useCallback } from 'react' @@ -10,6 +10,7 @@ import clsx from 'clsx' import { LoadingIndicator } from '../loading-indicator' import { VisibilityObserver } from '../visibility-observer' import Masonry from 'react-masonry-css' +import { CPMMBinaryContract} from 'common/contract' export type ContractHighlightOptions = { contractIds?: string[] @@ -25,6 +26,7 @@ export function ContractsGrid(props: { hideQuickBet?: boolean hideGroupLink?: boolean noLinkAvatar?: boolean + showProbChange?: boolean } highlightOptions?: ContractHighlightOptions trackingPostfix?: string @@ -39,7 +41,8 @@ export function ContractsGrid(props: { highlightOptions, trackingPostfix, } = props - const { hideQuickBet, hideGroupLink, noLinkAvatar } = cardUIOptions || {} + const { hideQuickBet, hideGroupLink, noLinkAvatar, showProbChange } = + cardUIOptions || {} const { contractIds, highlightClassName } = highlightOptions || {} const onVisibilityUpdated = useCallback( (visible) => { @@ -73,24 +76,31 @@ export function ContractsGrid(props: { className="-ml-4 flex w-auto" columnClassName="pl-4 bg-clip-padding" > - {contracts.map((contract) => ( - onContractClick(contract) : undefined - } - noLinkAvatar={noLinkAvatar} - hideQuickBet={hideQuickBet} - hideGroupLink={hideGroupLink} - trackingPostfix={trackingPostfix} - className={clsx( - 'mb-4 break-inside-avoid-column overflow-hidden', // prevent content from wrapping (needs overflow on firefox) - contractIds?.includes(contract.id) && highlightClassName - )} - /> - ))} + {contracts.map((contract) => + showProbChange && contract.mechanism === 'cpmm-1' ? ( + + ) : ( + onContractClick(contract) : undefined + } + noLinkAvatar={noLinkAvatar} + hideQuickBet={hideQuickBet} + hideGroupLink={hideGroupLink} + trackingPostfix={trackingPostfix} + className={clsx( + 'mb-4 break-inside-avoid-column overflow-hidden', // prevent content from wrapping (needs overflow on firefox) + contractIds?.includes(contract.id) && highlightClassName + )} + /> + ) + )} {loadMore && ( - const { positiveChanges, negativeChanges } = changes + const [positiveChanges, negativeChanges] = partition( + changes, + (c) => c.probChanges.day > 0 + ) const threshold = 0.01 const positiveAboveThreshold = positiveChanges.filter( @@ -53,10 +55,18 @@ export function ProbChangeTable(props: { ) } -function ProbChangeRow(props: { contract: CPMMContract }) { - const { contract } = props +export function ProbChangeRow(props: { + contract: CPMMContract + className?: string +}) { + const { contract, className } = props return ( - + { const [contracts, setContracts] = useState() @@ -26,6 +29,29 @@ export const useContracts = () => { return contracts } +export const useContractsByDailyScoreGroups = ( + groupSlugs: string[] | undefined +) => { + const facetFilters = ['isResolved:false'] + + const { data } = useQuery(['daily-score', groupSlugs], () => + Promise.all( + (groupSlugs ?? []).map((slug) => + dailyScoreIndex.search('', { + facetFilters: [...facetFilters, `groupLinks.slug:${slug}`], + }) + ) + ) + ) + if (!groupSlugs || !data || data.length !== groupSlugs.length) + return undefined + + return zipObject( + groupSlugs, + data.map((d) => d.hits.filter((c) => c.dailyScore)) + ) +} + const q = new QueryClient() export const getCachedContracts = async () => q.fetchQuery(['contracts'], () => listAllContracts(1000), { diff --git a/web/hooks/use-group.ts b/web/hooks/use-group.ts index 9bcb59cd..e918aa8c 100644 --- a/web/hooks/use-group.ts +++ b/web/hooks/use-group.ts @@ -104,7 +104,7 @@ export const useMemberGroupIds = (user: User | null | undefined) => { } export function useMemberGroupsSubscription(user: User | null | undefined) { - const cachedGroups = useMemberGroups(user?.id) ?? [] + const cachedGroups = useMemberGroups(user?.id) const [groups, setGroups] = useState(cachedGroups) const userId = user?.id diff --git a/web/hooks/use-prob-changes.tsx b/web/hooks/use-prob-changes.tsx index f3a6eee9..f2f3ce13 100644 --- a/web/hooks/use-prob-changes.tsx +++ b/web/hooks/use-prob-changes.tsx @@ -1,75 +1,47 @@ -import { useFirestoreQueryData } from '@react-query-firebase/firestore' -import { CPMMContract } from 'common/contract' -import { MINUTE_MS } from 'common/util/time' -import { useQuery, useQueryClient } from 'react-query' +import { CPMMBinaryContract } from 'common/contract' +import { sortBy, uniqBy } from 'lodash' +import { useQuery } 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' + probChangeAscendingIndex, + probChangeDescendingIndex, +} 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('', { - facetFilters: ['uniqueBettorIds:' + userId, 'isResolved:false'], - }) - ) - const { data: negativeData } = useQuery( - ['prob-change-day-ascending', userId], - () => - searchClient - .initIndex(getIndexName('prob-change-day-ascending')) - .search('', { - facetFilters: ['uniqueBettorIds:' + userId, 'isResolved:false'], - }) - ) +export const useProbChanges = ( + filters: { bettorId?: string; groupSlugs?: string[] } = {} +) => { + const { bettorId, groupSlugs } = filters - if (!positiveData || !negativeData) { - return undefined + const bettorFilter = bettorId ? `uniqueBettorIds:${bettorId}` : '' + const groupFilters = groupSlugs + ? groupSlugs.map((slug) => `groupLinks.slug:${slug}`) + : [] + + const facetFilters = [ + 'isResolved:false', + 'outcomeType:BINARY', + bettorFilter, + groupFilters, + ] + const searchParams = { + facetFilters, + hitsPerPage: 50, } - 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( - ['prob-changes-day-positive', userId], - getProbChangesPositive(userId) - ) - const { data: negativeChanges } = useFirestoreQueryData( - ['prob-changes-day-negative', userId], - getProbChangesNegative(userId) - ) - - if (!positiveChanges || !negativeChanges) { - return undefined - } - - return { positiveChanges, negativeChanges } -} - -export const usePrefetchProbChanges = (userId: string | undefined) => { - const queryClient = useQueryClient() - if (userId) { - queryClient.prefetchQuery( - ['prob-changes-day-positive', userId], - () => getValues(getProbChangesPositive(userId)), - { staleTime: MINUTE_MS } - ) - queryClient.prefetchQuery( - ['prob-changes-day-negative', userId], - () => getValues(getProbChangesNegative(userId)), - { staleTime: MINUTE_MS } - ) - } + const { data: positiveChanges } = useQuery( + ['prob-change-day', groupSlugs], + () => probChangeDescendingIndex.search('', searchParams) + ) + const { data: negativeChanges } = useQuery( + ['prob-change-day-ascending', groupSlugs], + () => probChangeAscendingIndex.search('', searchParams) + ) + + if (!positiveChanges || !negativeChanges) return undefined + + const hits = uniqBy( + [...positiveChanges.hits, ...negativeChanges.hits], + (c) => c.id + ) + + return sortBy(hits, (c) => Math.abs(c.probChanges.day)).reverse() } diff --git a/web/lib/firebase/contracts.ts b/web/lib/firebase/contracts.ts index 33f6533b..927f7187 100644 --- a/web/lib/firebase/contracts.ts +++ b/web/lib/firebase/contracts.ts @@ -16,7 +16,7 @@ import { import { partition, sortBy, sum, uniqBy } from 'lodash' import { coll, getValues, listenForValue, listenForValues } from './utils' -import { BinaryContract, Contract, CPMMContract } from 'common/contract' +import { BinaryContract, Contract } from 'common/contract' import { chooseRandomSubset } from 'common/util/random' import { formatMoney, formatPercent } from 'common/util/format' import { DAY_MS } from 'common/util/time' @@ -426,21 +426,3 @@ export async function getRecentBetsAndComments(contract: Contract) { recentComments, } } - -export const getProbChangesPositive = (userId: string) => - query( - contracts, - where('uniqueBettorIds', 'array-contains', userId), - where('probChanges.day', '>', 0), - orderBy('probChanges.day', 'desc'), - limit(10) - ) as Query - -export const getProbChangesNegative = (userId: string) => - query( - contracts, - where('uniqueBettorIds', 'array-contains', userId), - where('probChanges.day', '<', 0), - orderBy('probChanges.day', 'asc'), - limit(10) - ) as Query diff --git a/web/lib/service/algolia.ts b/web/lib/service/algolia.ts index 3b6648a1..29cbd6bf 100644 --- a/web/lib/service/algolia.ts +++ b/web/lib/service/algolia.ts @@ -13,3 +13,13 @@ export const searchIndexName = export const getIndexName = (sort: string) => { return `${indexPrefix}contracts-${sort}` } + +export const probChangeDescendingIndex = searchClient.initIndex( + getIndexName('prob-change-day') +) +export const probChangeAscendingIndex = searchClient.initIndex( + getIndexName('prob-change-day-ascending') +) +export const dailyScoreIndex = searchClient.initIndex( + getIndexName('daily-score') +) diff --git a/web/pages/daily-movers.tsx b/web/pages/daily-movers.tsx index 3b709d89..0a17e9e2 100644 --- a/web/pages/daily-movers.tsx +++ b/web/pages/daily-movers.tsx @@ -2,14 +2,17 @@ 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 { useProbChangesAlgolia } from 'web/hooks/use-prob-changes' +import { useProbChanges } from 'web/hooks/use-prob-changes' import { useTracking } from 'web/hooks/use-tracking' import { useUser } from 'web/hooks/use-user' export default function DailyMovers() { const user = useUser() + const bettorId = user?.id ?? undefined - const changes = useProbChangesAlgolia(user?.id ?? '') + const changes = useProbChanges({ bettorId })?.filter( + (c) => Math.abs(c.probChanges.day) >= 0.01 + ) useTracking('view daily movers') diff --git a/web/pages/home/edit.tsx b/web/pages/home/edit.tsx index 48e10c6c..8c5f8ab5 100644 --- a/web/pages/home/edit.tsx +++ b/web/pages/home/edit.tsx @@ -28,7 +28,7 @@ export default function Home() { } const groups = useMemberGroupsSubscription(user) - const { sections } = getHomeItems(groups, homeSections) + const { sections } = getHomeItems(groups ?? [], homeSections) return ( diff --git a/web/pages/home/index.tsx b/web/pages/home/index.tsx index 83bcb15b..f13fc200 100644 --- a/web/pages/home/index.tsx +++ b/web/pages/home/index.tsx @@ -8,6 +8,7 @@ import { import { PlusCircleIcon, XCircleIcon } from '@heroicons/react/outline' import clsx from 'clsx' import { toast, Toaster } from 'react-hot-toast' +import { Dictionary } from 'lodash' import { Page } from 'web/components/page' import { Col } from 'web/components/layout/col' @@ -31,11 +32,10 @@ import { ProbChangeTable } from 'web/components/contract/prob-change-table' import { groupPath, joinGroup, leaveGroup } from 'web/lib/firebase/groups' import { usePortfolioHistory } from 'web/hooks/use-portfolio-history' import { formatMoney } from 'common/util/format' -import { useProbChangesAlgolia } from 'web/hooks/use-prob-changes' +import { useProbChanges } 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' -import { useContractsQuery } from 'web/hooks/use-contracts' import { ContractsGrid } from 'web/components/contract/contracts-grid' import { PillButton } from 'web/components/buttons/pill-button' import { filterDefined } from 'common/util/array' @@ -43,6 +43,8 @@ import { updateUser } from 'web/lib/firebase/users' import { isArray, keyBy } from 'lodash' import { usePrefetch } from 'web/hooks/use-prefetch' import { Title } from 'web/components/title' +import { CPMMBinaryContract } from 'common/contract' +import { useContractsByDailyScoreGroups } from 'web/hooks/use-contracts' export default function Home() { const user = useUser() @@ -54,20 +56,19 @@ export default function Home() { const groups = useMemberGroupsSubscription(user) - const { sections } = getHomeItems(groups, user?.homeSections ?? []) + const { sections } = getHomeItems(groups ?? [], user?.homeSections ?? []) useEffect(() => { - if ( - user && - !user.homeSections && - sections.length > 0 && - groups.length > 0 - ) { + if (user && !user.homeSections && sections.length > 0 && groups) { // Save initial home sections. updateUser(user.id, { homeSections: sections.map((s) => s.id) }) } }, [user, sections, groups]) + const groupContracts = useContractsByDailyScoreGroups( + groups?.map((g) => g.slug) + ) + return ( @@ -81,9 +82,13 @@ export default function Home() { - {sections.map((section) => renderSection(section, user, groups))} + <> + {sections.map((section) => + renderSection(section, user, groups, groupContracts) + )} - + + - + ) } function DailyMoversSection(props: { userId: string | null | undefined }) { const { userId } = props - const changes = useProbChangesAlgolia(userId ?? '') + const changes = useProbChanges({ bettorId: userId ?? undefined })?.filter( + (c) => Math.abs(c.probChanges.day) >= 0.01 + ) - 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 + if (changes && changes.length === 0) { + return null } return ( @@ -332,6 +351,10 @@ export function TrendingGroupsSection(props: { const count = full ? 100 : 25 const chosenGroups = groups.slice(0, count) + if (chosenGroups.length === 0) { + return null + } + return (