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
This commit is contained in:
James Grugett 2022-09-22 16:57:48 -05:00 committed by GitHub
parent eaaa46294a
commit c6d034545a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 238 additions and 154 deletions

View File

@ -57,6 +57,7 @@ export type Contract<T extends AnyContractType = AnyContractType> = {
uniqueBettorIds?: string[]
uniqueBettorCount?: number
popularityScore?: number
dailyScore?: number
followerCount?: number
featuredOnHomeRank?: number
likedByUserIds?: string[]

View File

@ -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 }))
}
}
}

View File

@ -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: {
</Col>
)
}
export function ContractCardProbChange(props: {
contract: CPMMBinaryContract
noLinkAvatar?: boolean
className?: string
}) {
const { contract, noLinkAvatar, className } = props
return (
<Col
className={clsx(
className,
'mb-4 rounded-lg bg-white shadow hover:bg-gray-100 hover:shadow-lg'
)}
>
<AvatarDetails
contract={contract}
className={'px-6 pt-4'}
noLink={noLinkAvatar}
/>
<Row className={clsx('items-start justify-between gap-4 ', className)}>
<SiteLink
className="pl-6 pr-0 pt-2 pb-4 font-semibold text-indigo-700"
href={contractPath(contract)}
>
<span className="line-clamp-3">{contract.question}</span>
</SiteLink>
<ProbChange className="py-2 pr-4" contract={contract} />
</Row>
</Col>
)
}

View File

@ -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) => (
<ContractCard
contract={contract}
key={contract.id}
showTime={showTime}
onClick={
onContractClick ? () => 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' ? (
<ContractCardProbChange
key={contract.id}
contract={contract as CPMMBinaryContract}
/>
) : (
<ContractCard
contract={contract}
key={contract.id}
showTime={showTime}
onClick={
onContractClick ? () => 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
)}
/>
)
)}
</Masonry>
{loadMore && (
<VisibilityObserver

View File

@ -1,4 +1,5 @@
import clsx from 'clsx'
import { partition } from 'lodash'
import { contractPath } from 'web/lib/firebase/contracts'
import { CPMMContract } from 'common/contract'
import { formatPercent } from 'common/util/format'
@ -8,16 +9,17 @@ import { Row } from '../layout/row'
import { LoadingIndicator } from '../loading-indicator'
export function ProbChangeTable(props: {
changes:
| { positiveChanges: CPMMContract[]; negativeChanges: CPMMContract[] }
| undefined
changes: CPMMContract[] | undefined
full?: boolean
}) {
const { changes, full } = props
if (!changes) return <LoadingIndicator />
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 (
<Row className="items-center justify-between gap-4 hover:bg-gray-100">
<Row
className={clsx(
'items-center justify-between gap-4 hover:bg-gray-100',
className
)}
>
<SiteLink
className="p-4 pr-0 font-semibold text-indigo-700"
href={contractPath(contract)}

View File

@ -11,10 +11,13 @@ import {
trendingContractsQuery,
getContractsQuery,
} from 'web/lib/firebase/contracts'
import { QueryClient, useQueryClient } from 'react-query'
import { QueryClient, useQuery, useQueryClient } from 'react-query'
import { MINUTE_MS } from 'common/util/time'
import { query, limit } from 'firebase/firestore'
import { Sort } from 'web/components/contract-search'
import { dailyScoreIndex } from 'web/lib/service/algolia'
import { CPMMBinaryContract } from 'common/contract'
import { zipObject } from 'lodash'
export const useContracts = () => {
const [contracts, setContracts] = useState<Contract[] | undefined>()
@ -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<CPMMBinaryContract>('', {
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), {

View File

@ -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

View File

@ -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<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'],
})
)
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<CPMMBinaryContract>('', searchParams)
)
const { data: negativeChanges } = useQuery(
['prob-change-day-ascending', groupSlugs],
() => probChangeAscendingIndex.search<CPMMBinaryContract>('', 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()
}

View File

@ -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<CPMMContract>
export const getProbChangesNegative = (userId: string) =>
query(
contracts,
where('uniqueBettorIds', 'array-contains', userId),
where('probChanges.day', '<', 0),
orderBy('probChanges.day', 'asc'),
limit(10)
) as Query<CPMMContract>

View File

@ -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')
)

View File

@ -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')

View File

@ -28,7 +28,7 @@ export default function Home() {
}
const groups = useMemberGroupsSubscription(user)
const { sections } = getHomeItems(groups, homeSections)
const { sections } = getHomeItems(groups ?? [], homeSections)
return (
<Page>

View File

@ -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 (
<Page>
<Toaster />
@ -81,9 +82,13 @@ export default function Home() {
<DailyStats user={user} />
</Row>
{sections.map((section) => renderSection(section, user, groups))}
<>
{sections.map((section) =>
renderSection(section, user, groups, groupContracts)
)}
<TrendingGroupsSection user={user} />
<TrendingGroupsSection user={user} />
</>
</Col>
<button
type="button"
@ -134,7 +139,8 @@ export const getHomeItems = (groups: Group[], sections: string[]) => {
function renderSection(
section: { id: string; label: string },
user: User | null | undefined,
groups: Group[]
groups: Group[] | undefined,
groupContracts: Dictionary<CPMMBinaryContract[]> | undefined
) {
const { id, label } = section
if (id === 'daily-movers') {
@ -156,8 +162,23 @@ function renderSection(
<SearchSection key={id} label={label} sort={sort.value} user={user} />
)
const group = groups.find((g) => g.id === id)
if (group) return <GroupSection key={id} group={group} user={user} />
if (groups && groupContracts) {
const group = groups.find((g) => g.id === id)
if (group) {
const contracts = groupContracts[group.slug].filter(
(c) => Math.abs(c.probChanges.day) >= 0.01
)
if (contracts.length === 0) return null
return (
<GroupSection
key={id}
group={group}
user={user}
contracts={contracts}
/>
)
}
}
return null
}
@ -207,7 +228,6 @@ function SearchSection(props: {
defaultPill={pill}
noControls
maxResults={6}
headerClassName="sticky"
persistPrefix={`home-${sort}`}
/>
</Col>
@ -217,10 +237,9 @@ function SearchSection(props: {
function GroupSection(props: {
group: Group
user: User | null | undefined | undefined
contracts: CPMMBinaryContract[]
}) {
const { group, user } = props
const contracts = useContractsQuery('score', 4, { groupSlug: group.slug })
const { group, user, contracts } = props
return (
<Col>
@ -245,22 +264,22 @@ function GroupSection(props: {
<XCircleIcon className={'h-5 w-5 flex-shrink-0'} aria-hidden="true" />
</Button>
</SectionHeader>
<ContractsGrid contracts={contracts} />
<ContractsGrid
contracts={contracts.slice(0, 4)}
cardUIOptions={{ showProbChange: true }}
/>
</Col>
)
}
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 (
<Col className={className}>
<SectionHeader label="Trending groups" href="/explore-groups">