Merge branch 'main' into CPM-ui

This commit is contained in:
Austin Chen 2022-04-21 02:21:03 -07:00
commit 4ac1cfa357
50 changed files with 890 additions and 502 deletions

2
.gitignore vendored
View File

@ -1,4 +1,4 @@
.DS_Store
.idea/
.vercel
node_modules

View File

@ -18,7 +18,14 @@ import {
getDpmProbabilityAfterSale,
} from './calculate-dpm'
import { calculateFixedPayout } from './calculate-fixed-payouts'
import { Binary, Contract, CPMM, DPM, FullContract } from './contract'
import {
Binary,
Contract,
CPMM,
DPM,
FreeResponseContract,
FullContract,
} from './contract'
export function getProbability(contract: FullContract<DPM | CPMM, Binary>) {
return contract.mechanism === 'cpmm-1'
@ -170,3 +177,15 @@ export function getContractBetNullMetrics() {
profitPercent: 0,
}
}
export function getTopAnswer(contract: FreeResponseContract) {
const { answers } = contract
const top = _.maxBy(
answers.map((answer) => ({
answer,
prob: getOutcomeProbability(contract, answer.id),
})),
({ prob }) => prob
)
return top?.answer
}

View File

@ -10,3 +10,9 @@ export type ClickEvent = {
contractId: string
timestamp: number
}
export type LatencyEvent = {
type: 'feed' | 'portfolio'
latency: number
timestamp: number
}

View File

@ -30,6 +30,10 @@ service cloud.firestore {
allow create: if userId == request.auth.uid;
}
match /private-users/{userId}/latency/{loadTimeId} {
allow create: if userId == request.auth.uid;
}
match /contracts/{contractId} {
allow read;
allow update: if request.resource.data.diff(resource.data).affectedKeys()

View File

@ -17,16 +17,16 @@ Adapted from https://firebase.google.com/docs/functions/get-started
0. `$ cd functions` to switch to this folder
1. `$ yarn global add firebase-tools` to install the Firebase CLI globally
2. `$ yarn` to install JS dependencies
3. `$ firebase functions:config:get > .runtimeconfig.json` to cache secrets for local dev (TODO: maybe not for Manifold)
3. `$ firebase login` to authenticate the CLI tools to Firebase
4. `$ firebase use dev` to choose the dev project
5. `$ firebase functions:config:get > .runtimeconfig.json` to cache secrets for local dev (TODO: maybe not for Manifold)
## Developing locally
1. `$ firebase login` if you aren't logged into Firebase via commandline yet.
2. `$ yarn dev` to spin up the emulators
0. `$ yarn dev` to spin up the emulators
The Emulator UI is at http://localhost:4000; the functions are hosted on :5001.
Note: You have to kill and restart emulators when you change code; no hot reload =(
3. Connect to emulators by enabling `functions.useEmulator('localhost', 5001)`
1. Connect to emulators by enabling `functions.useEmulator('localhost', 5001)`
## Debugging

View File

@ -1,15 +1,8 @@
import * as admin from 'firebase-admin'
import * as _ from 'lodash'
// Generate your own private key, and set the path below:
// https://console.firebase.google.com/u/0/project/mantic-markets/settings/serviceaccounts/adminsdk
// const serviceAccount = require('../../../../../../Downloads/dev-mantic-markets-firebase-adminsdk-sir5m-b2d27f8970.json')
const serviceAccount = require('../../../../../../Downloads/mantic-markets-firebase-adminsdk-1ep46-351a65eca3.json')
admin.initializeApp({
credential: admin.credential.cert(serviceAccount),
})
import { initAdmin } from './script-init'
initAdmin()
import { getUserByUsername } from '../utils'
import { changeUser } from '../change-user-info'

View File

@ -2,7 +2,7 @@ import * as admin from 'firebase-admin'
import * as _ from 'lodash'
import { initAdmin } from './script-init'
initAdmin('stephen')
initAdmin()
import { Bet } from '../../../common/bet'
import { getDpmProbability } from '../../../common/calculate-dpm'

View File

@ -2,7 +2,7 @@ import * as admin from 'firebase-admin'
import * as _ from 'lodash'
import { initAdmin } from './script-init'
initAdmin('stephen')
initAdmin()
import { PrivateUser, STARTING_BALANCE, User } from '../../../common/user'

View File

@ -3,7 +3,7 @@ import * as _ from 'lodash'
import * as fs from 'fs'
import { initAdmin } from './script-init'
initAdmin('james')
initAdmin()
import { Bet } from '../../../common/bet'
import { Contract } from '../../../common/contract'

View File

@ -2,7 +2,7 @@ import * as admin from 'firebase-admin'
import * as _ from 'lodash'
import { initAdmin } from './script-init'
initAdmin('james')
initAdmin()
import { getValues } from '../utils'
import { Fold } from '../../../common/fold'

View File

@ -2,7 +2,7 @@ import * as admin from 'firebase-admin'
import * as _ from 'lodash'
import { initAdmin } from './script-init'
initAdmin('james')
initAdmin()
import { Contract } from '../../../common/contract'

View File

@ -2,7 +2,7 @@ import * as admin from 'firebase-admin'
import * as _ from 'lodash'
import { initAdmin } from './script-init'
initAdmin('james')
initAdmin()
import { Bet } from '../../../common/bet'
import { Contract } from '../../../common/contract'

View File

@ -2,7 +2,7 @@ import * as admin from 'firebase-admin'
import * as _ from 'lodash'
import { initAdmin } from './script-init'
initAdmin('stephenDev')
initAdmin()
import {
Binary,

View File

@ -2,7 +2,7 @@ import * as admin from 'firebase-admin'
import * as _ from 'lodash'
import { initAdmin } from './script-init'
initAdmin('stephenDev')
initAdmin()
import { Binary, Contract, DPM, FullContract } from '../../../common/contract'
import { Bet } from '../../../common/bet'

View File

@ -2,7 +2,7 @@ import * as admin from 'firebase-admin'
import * as _ from 'lodash'
import { initAdmin } from './script-init'
initAdmin('james')
initAdmin()
import { Bet } from '../../../common/bet'
import { Contract } from '../../../common/contract'

View File

@ -2,7 +2,7 @@ import * as admin from 'firebase-admin'
import * as _ from 'lodash'
import { initAdmin } from './script-init'
initAdmin('james')
initAdmin()
import { Bet } from '../../../common/bet'
import { Contract } from '../../../common/contract'

View File

@ -2,7 +2,7 @@ import * as admin from 'firebase-admin'
import * as _ from 'lodash'
import { initAdmin } from './script-init'
initAdmin('james')
initAdmin()
import { Bet } from '../../../common/bet'
import { Contract } from '../../../common/contract'

View File

@ -2,7 +2,7 @@ import * as admin from 'firebase-admin'
import * as _ from 'lodash'
import { initAdmin } from './script-init'
initAdmin('stephenDev')
initAdmin()
import { Contract } from '../../../common/contract'
import { getValues } from '../utils'

View File

@ -1,32 +1,71 @@
import * as path from 'path'
import * as fs from 'fs'
import * as os from 'os'
import * as admin from 'firebase-admin'
// Generate your own private key, and set the path below:
// Prod:
// https://console.firebase.google.com/u/0/project/mantic-markets/settings/serviceaccounts/adminsdk
// Dev:
// https://console.firebase.google.com/u/0/project/dev-mantic-markets/settings/serviceaccounts/adminsdk
// First, generate a private key from the Google service account management page:
// Prod: https://console.firebase.google.com/u/0/project/mantic-markets/settings/serviceaccounts/adminsdk
// Dev: https://console.firebase.google.com/u/0/project/dev-mantic-markets/settings/serviceaccounts/adminsdk
// Then set GOOGLE_APPLICATION_CREDENTIALS_PROD or GOOGLE_APPLICATION_CREDENTIALS_DEV to the path of the key.
const pathsToPrivateKey = {
james:
'/Users/jahooma/mantic-markets-firebase-adminsdk-1ep46-820891bb87.json',
jamesDev:
'/Users/jahooma/dev-mantic-markets-firebase-adminsdk-sir5m-f38cdbee37.json',
stephen:
'../../../../../../Downloads/mantic-markets-firebase-adminsdk-1ep46-351a65eca3.json',
stephenDev:
'../../../../../../Downloads/dev-mantic-markets-firebase-adminsdk-sir5m-b2d27f8970.json',
// Then, to run a script, make sure you are pointing at the Firebase you intend to:
// $ firebase use dev (or prod)
//
// Followed by, if you have https://github.com/TypeStrong/ts-node installed (recommended):
// $ ts-node my-script.ts
//
// Or compile it and run the compiled version:
// $ yarn build && ../../lib/functions/scripts/src/my-script.js
const getFirebaseProjectRoot = (cwd: string) => {
// see https://github.com/firebase/firebase-tools/blob/master/src/detectProjectRoot.ts
let dir = cwd
while (!fs.existsSync(path.resolve(dir, './firebase.json'))) {
const parentDir = path.dirname(dir)
if (parentDir === dir) {
return null
}
dir = parentDir
}
return dir
}
export const initAdmin = (who: keyof typeof pathsToPrivateKey) => {
const serviceAccount = require(pathsToPrivateKey[who])
const getFirebaseActiveProject = (cwd: string) => {
// firebase uses this configstore package https://github.com/yeoman/configstore/blob/main/index.js#L9
const projectRoot = getFirebaseProjectRoot(cwd)
if (projectRoot == null) {
return null
}
const xdgConfig =
process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config')
const configPath = path.join(xdgConfig, 'configstore', 'firebase-tools.json')
try {
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'))
return config['activeProjects'][projectRoot]
} catch (e) {
return null
}
}
export const initAdmin = (env?: string) => {
env = env || getFirebaseActiveProject(process.cwd())
if (env == null) {
console.error(
"Couldn't find active Firebase project; did you do `firebase use <alias>?`"
)
return
}
const envVar = `GOOGLE_APPLICATION_CREDENTIALS_${env.toUpperCase()}`
const keyPath = process.env[envVar]
if (keyPath == null) {
console.error(
`Please set the ${envVar} environment variable to contain the path to your ${env} environment key file.`
)
return
}
console.log(`Initializing connection to ${env} Firebase...`)
const serviceAccount = require(keyPath)
admin.initializeApp({
credential: admin.credential.cert(serviceAccount),
})
}
// Then:
// yarn watch (or yarn build)
// firebase use dev (or firebase use prod)
// Run script:
// node lib/functions/src/scripts/update-contract-tags.js

View File

@ -2,7 +2,7 @@ import * as admin from 'firebase-admin'
import * as _ from 'lodash'
import { initAdmin } from './script-init'
initAdmin('jamesDev')
initAdmin()
import { Contract } from '../../../common/contract'
import { parseTags } from '../../../common/util/parse'

View File

@ -64,6 +64,7 @@ export function AnswerBetPanel(props: {
if (result?.status === 'success') {
setIsSubmitting(false)
setBetAmount(undefined)
props.closePanel()
} else {
setError(result?.error || 'Error placing bet')
setIsSubmitting(false)

View File

@ -1,6 +1,5 @@
import clsx from 'clsx'
import React, { useEffect, useState } from 'react'
import _ from 'lodash'
import { useUser } from '../hooks/use-user'
import { Binary, CPMM, DPM, FullContract } from '../../common/contract'
@ -19,7 +18,7 @@ import { Bet } from '../../common/bet'
import { placeBet, sellShares } from '../lib/firebase/api-call'
import { BuyAmountInput, SellAmountInput } from './amount-input'
import { InfoTooltip } from './info-tooltip'
import { BinaryOutcomeLabel, OutcomeLabel } from './outcome-label'
import { BinaryOutcomeLabel } from './outcome-label'
import {
calculatePayoutAfterCorrectBet,
calculateShares,
@ -32,61 +31,30 @@ import {
calculateCpmmSale,
getCpmmProbability,
} from '../../common/calculate-cpmm'
import { Modal } from './layout/modal'
import { SellRow } from './sell-row'
import { useSaveShares } from './use-save-shares'
export function BetPanel(props: {
contract: FullContract<DPM | CPMM, Binary>
className?: string
}) {
const { contract, className } = props
const { mechanism } = contract
const user = useUser()
const userBets = useUserContractBets(user?.id, contract.id)
const [showSellModal, setShowSellModal] = useState(false)
const { yesShares, noShares } = useSaveShares(contract, userBets)
const shares = yesShares || noShares
const sharesOutcome = yesShares ? 'YES' : noShares ? 'NO' : undefined
const { yesFloorShares, noFloorShares } = useSaveShares(contract, userBets)
const sharesOutcome = yesFloorShares
? 'YES'
: noFloorShares
? 'NO'
: undefined
return (
<Col className={className}>
{sharesOutcome && user && mechanism === 'cpmm-1' && (
<Col className="rounded-t-md bg-gray-100 px-6 py-6">
<Row className="items-center justify-between gap-2">
<div>
You have {formatWithCommas(Math.floor(shares))}{' '}
<BinaryOutcomeLabel outcome={sharesOutcome} /> shares
</div>
<button
className="btn btn-sm"
style={{
backgroundColor: 'white',
border: '2px solid',
color: '#3D4451',
}}
onClick={() => setShowSellModal(true)}
>
Sell
</button>
{showSellModal && (
<SellSharesModal
contract={contract as FullContract<CPMM, Binary>}
user={user}
userBets={userBets ?? []}
shares={shares}
sharesOutcome={sharesOutcome}
setOpen={setShowSellModal}
/>
)}
</Row>
</Col>
)}
<SellRow
contract={contract}
user={user}
className={'rounded-t-md bg-gray-100 px-6 py-6'}
/>
<Col
className={clsx(
'rounded-b-md bg-white px-8 py-6',
@ -128,10 +96,17 @@ export function BetPanelSwitcher(props: {
const [tradeType, setTradeType] = useState<'BUY' | 'SELL'>('BUY')
const { yesShares, noShares } = useSaveShares(contract, userBets)
const { yesFloorShares, noFloorShares, yesShares, noShares } = useSaveShares(
contract,
userBets
)
const shares = yesShares || noShares
const sharesOutcome = yesShares ? 'YES' : noShares ? 'NO' : undefined
const floorShares = yesFloorShares || noFloorShares
const sharesOutcome = yesFloorShares
? 'YES'
: noFloorShares
? 'NO'
: undefined
useEffect(() => {
// Switch back to BUY if the user has sold all their shares.
@ -146,7 +121,7 @@ export function BetPanelSwitcher(props: {
<Col className="rounded-t-md bg-gray-100 px-6 py-6">
<Row className="items-center justify-between gap-2">
<div>
You have {formatWithCommas(Math.floor(shares))}{' '}
You have {formatWithCommas(floorShares)}{' '}
<BinaryOutcomeLabel outcome={sharesOutcome} /> shares
</div>
@ -394,7 +369,7 @@ function BuyPanel(props: {
)
}
function SellPanel(props: {
export function SellPanel(props: {
contract: FullContract<CPMM, Binary>
userBets: Bet[]
shares: number
@ -493,78 +468,3 @@ function SellPanel(props: {
</>
)
}
const useSaveShares = (
contract: FullContract<CPMM | DPM, Binary>,
userBets: Bet[] | undefined
) => {
const [savedShares, setSavedShares] = useState<
{ yesShares: number; noShares: number } | undefined
>()
const [yesBets, noBets] = _.partition(
userBets ?? [],
(bet) => bet.outcome === 'YES'
)
const [yesShares, noShares] = [
_.sumBy(yesBets, (bet) => bet.shares),
_.sumBy(noBets, (bet) => bet.shares),
]
useEffect(() => {
// Save yes and no shares to local storage.
const savedShares = localStorage.getItem(`${contract.id}-shares`)
if (!userBets && savedShares) {
setSavedShares(JSON.parse(savedShares))
}
if (userBets) {
const updatedShares = { yesShares, noShares }
localStorage.setItem(
`${contract.id}-shares`,
JSON.stringify(updatedShares)
)
}
}, [contract.id, userBets, noShares, yesShares])
if (userBets) return { yesShares, noShares }
return savedShares ?? { yesShares: 0, noShares: 0 }
}
function SellSharesModal(props: {
contract: FullContract<CPMM, Binary>
userBets: Bet[]
shares: number
sharesOutcome: 'YES' | 'NO'
user: User
setOpen: (open: boolean) => void
}) {
const { contract, shares, sharesOutcome, userBets, user, setOpen } = props
return (
<Modal open={true} setOpen={setOpen}>
<Col className="rounded-md bg-white px-8 py-6">
<Title className="!mt-0" text={'Sell shares'} />
<div className="mb-6">
You have {formatWithCommas(Math.floor(shares))}{' '}
<OutcomeLabel
outcome={sharesOutcome}
contract={contract}
truncate="long"
/>{' '}
shares
</div>
<SellPanel
contract={contract}
shares={shares}
sharesOutcome={sharesOutcome}
user={user}
userBets={userBets ?? []}
onSellSuccess={() => setOpen(false)}
/>
</Col>
</Modal>
)
}

View File

@ -1,4 +1,3 @@
import clsx from 'clsx'
import { useState } from 'react'
import { BetPanelSwitcher } from './bet-panel'
@ -6,6 +5,10 @@ import { Row } from './layout/row'
import { YesNoSelector } from './yes-no-selector'
import { Binary, CPMM, DPM, FullContract } from '../../common/contract'
import { Modal } from './layout/modal'
import { SellButton } from './sell-button'
import { useUser } from '../hooks/use-user'
import { useUserContractBets } from '../hooks/use-user-bets'
import { useSaveShares } from './use-save-shares'
// Inline version of a bet panel. Opens BetPanel in a new modal.
export default function BetRow(props: {
@ -13,16 +16,19 @@ export default function BetRow(props: {
className?: string
labelClassName?: string
}) {
const { className, labelClassName } = props
const { className, labelClassName, contract } = props
const [open, setOpen] = useState(false)
const [betChoice, setBetChoice] = useState<'YES' | 'NO' | undefined>(
undefined
)
const user = useUser()
const userBets = useUserContractBets(user?.id, contract.id)
const { yesFloorShares, noFloorShares } = useSaveShares(contract, userBets)
return (
<>
<div className={className}>
<Row className="items-center justify-end gap-2">
<Row className="mt-2 justify-end space-x-3">
{/* <div className={clsx('mr-2 text-gray-400', labelClassName)}>
Place a trade
</div> */}
@ -32,12 +38,22 @@ export default function BetRow(props: {
setOpen(true)
setBetChoice(choice)
}}
replaceNoButton={
yesFloorShares > noFloorShares && yesFloorShares > 0 ? (
<SellButton contract={contract} user={user} />
) : undefined
}
replaceYesButton={
noFloorShares > yesFloorShares && noFloorShares > 0 ? (
<SellButton contract={contract} user={user} />
) : undefined
}
/>
</Row>
<Modal open={open} setOpen={setOpen}>
<BetPanelSwitcher
contract={props.contract}
title={props.contract.question}
contract={contract}
title={contract.question}
selected={betChoice}
onBetSuccess={() => setOpen(false)}
/>

View File

@ -37,6 +37,8 @@ import {
resolvedPayout,
getContractBetNullMetrics,
} from '../../common/calculate'
import { useTimeSinceFirstRender } from '../hooks/use-time-since-first-render'
import { trackLatency } from '../lib/firebase/tracking'
type BetSort = 'newest' | 'profit' | 'closeTime' | 'value'
type BetFilter = 'open' | 'closed' | 'resolved' | 'all'
@ -67,6 +69,14 @@ export function BetsList(props: { user: User }) {
}
}, [bets])
const getTime = useTimeSinceFirstRender()
useEffect(() => {
if (bets && contracts) {
trackLatency('portfolio', getTime())
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [!!bets, !!contracts])
if (!bets || !contracts) {
return <LoadingIndicator />
}

View File

@ -1,27 +1,15 @@
import clsx from 'clsx'
import Link from 'next/link'
import _ from 'lodash'
import { ClockIcon, DatabaseIcon, PencilIcon } from '@heroicons/react/outline'
import { TrendingUpIcon } from '@heroicons/react/solid'
import { Row } from '../layout/row'
import { formatMoney, formatPercent } from '../../../common/util/format'
import { UserLink } from '../user-page'
import { formatPercent } from '../../../common/util/format'
import {
Contract,
contractMetrics,
contractPath,
getBinaryProbPercent,
updateContract,
} from '../../lib/firebase/contracts'
import { Col } from '../layout/col'
import dayjs from 'dayjs'
import { DateTimeTooltip } from '../datetime-tooltip'
import { fromNow } from '../../lib/util/time'
import { Avatar } from '../avatar'
import { Spacer } from '../layout/spacer'
import { useState } from 'react'
import { ContractInfoDialog } from './contract-info-dialog'
import { Bet } from '../../../common/bet'
import {
Binary,
CPMM,
@ -35,7 +23,8 @@ import {
BinaryContractOutcomeLabel,
FreeResponseOutcomeLabel,
} from '../outcome-label'
import { getOutcomeProbability } from '../../../common/calculate'
import { getOutcomeProbability, getTopAnswer } from '../../../common/calculate'
import { AbbrContractDetails } from './contract-details'
export function ContractCard(props: {
contract: Contract
@ -68,7 +57,7 @@ export function ContractCard(props: {
<Row
className={clsx(
'justify-between gap-4',
outcomeType === 'FREE_RESPONSE' && 'flex-col items-start'
outcomeType === 'FREE_RESPONSE' && 'flex-col items-start !gap-2'
)}
>
<p
@ -85,6 +74,7 @@ export function ContractCard(props: {
)}
{outcomeType === 'FREE_RESPONSE' && (
<FreeResponseResolutionOrChance
className="self-end text-gray-600"
contract={contract as FullContract<DPM, FreeResponse>}
truncate="long"
/>
@ -132,29 +122,18 @@ export function BinaryResolutionOrChance(props: {
)
}
function getTopAnswer(contract: FreeResponseContract) {
const { answers } = contract
const top = _.maxBy(
answers.map((answer) => ({
answer,
prob: getOutcomeProbability(contract, answer.id),
})),
({ prob }) => prob
)
return top?.answer
}
export function FreeResponseResolutionOrChance(props: {
contract: FreeResponseContract
truncate: 'short' | 'long' | 'none'
className?: string
}) {
const { contract, truncate } = props
const { contract, truncate, className } = props
const { resolution } = contract
const topAnswer = getTopAnswer(contract)
return (
<Col className="text-xl">
<Col className={clsx(resolution ? 'text-3xl' : 'text-xl', className)}>
{resolution ? (
<>
<div className={clsx('text-base text-gray-500')}>Resolved</div>
@ -162,13 +141,18 @@ export function FreeResponseResolutionOrChance(props: {
contract={contract}
resolution={resolution}
truncate={truncate}
answerClassName="text-xl"
/>
</>
) : (
topAnswer && (
<Row className="flex-1 items-center justify-between gap-6">
<AnswerLabel answer={topAnswer} truncate={truncate} />
<Col className="text-primary">
<Row className="items-center gap-6">
<AnswerLabel
className="!text-gray-600"
answer={topAnswer}
truncate={truncate}
/>
<Col className="text-primary text-3xl">
<div>
{formatPercent(getOutcomeProbability(contract, topAnswer.id))}
</div>
@ -180,209 +164,3 @@ export function FreeResponseResolutionOrChance(props: {
</Col>
)
}
function AbbrContractDetails(props: {
contract: Contract
showHotVolume?: boolean
showCloseTime?: boolean
}) {
const { contract, showHotVolume, showCloseTime } = props
const { volume24Hours, creatorName, creatorUsername, closeTime } = contract
const { volumeLabel } = contractMetrics(contract)
return (
<Col className={clsx('gap-2 text-sm text-gray-500')}>
<Row className="items-center justify-between">
<Row className="items-center gap-2">
<Avatar
username={creatorUsername}
avatarUrl={contract.creatorAvatarUrl}
size={6}
/>
<UserLink
className="whitespace-nowrap"
name={creatorName}
username={creatorUsername}
/>
</Row>
{showHotVolume ? (
<Row className="gap-1">
<TrendingUpIcon className="h-5 w-5" /> {formatMoney(volume24Hours)}
</Row>
) : showCloseTime ? (
<Row className="gap-1">
<ClockIcon className="h-5 w-5" />
{(closeTime || 0) < Date.now() ? 'Closed' : 'Closes'}{' '}
{fromNow(closeTime || 0)}
</Row>
) : (
<Row className="gap-1">
{/* <DatabaseIcon className="h-5 w-5" /> */}
{volumeLabel}
</Row>
)}
</Row>
</Col>
)
}
export function ContractDetails(props: {
contract: Contract
bets: Bet[]
isCreator?: boolean
hideShareButtons?: boolean
}) {
const { contract, bets, isCreator, hideShareButtons } = props
const { closeTime, creatorName, creatorUsername } = contract
const { volumeLabel, createdDate, resolvedDate } = contractMetrics(contract)
return (
<Col className="gap-2 text-sm text-gray-500 sm:flex-row sm:flex-wrap">
<Row className="flex-1 flex-wrap items-center gap-x-4 gap-y-3">
<Row className="items-center gap-2">
<Avatar
username={creatorUsername}
avatarUrl={contract.creatorAvatarUrl}
size={6}
/>
<UserLink
className="whitespace-nowrap"
name={creatorName}
username={creatorUsername}
/>
</Row>
{(!!closeTime || !!resolvedDate) && (
<Row className="items-center gap-1">
<ClockIcon className="h-5 w-5" />
{/* <DateTimeTooltip text="Market created:" time={contract.createdTime}>
{createdDate}
</DateTimeTooltip> */}
{resolvedDate && contract.resolutionTime ? (
<>
{/* {' - '} */}
<DateTimeTooltip
text="Market resolved:"
time={contract.resolutionTime}
>
{resolvedDate}
</DateTimeTooltip>
</>
) : null}
{!resolvedDate && closeTime && (
<>
{/* {' - '}{' '} */}
<EditableCloseDate
closeTime={closeTime}
contract={contract}
isCreator={isCreator ?? false}
/>
</>
)}
</Row>
)}
<Row className="items-center gap-1">
<DatabaseIcon className="h-5 w-5" />
<div className="whitespace-nowrap">{volumeLabel}</div>
</Row>
{!hideShareButtons && (
<ContractInfoDialog contract={contract} bets={bets} />
)}
</Row>
</Col>
)
}
// String version of the above, to send to the OpenGraph image generator
export function contractTextDetails(contract: Contract) {
const { closeTime, tags } = contract
const { createdDate, resolvedDate, volumeLabel } = contractMetrics(contract)
const hashtags = tags.map((tag) => `#${tag}`)
return (
`${resolvedDate ? `${createdDate} - ${resolvedDate}` : createdDate}` +
(closeTime
? `${closeTime > Date.now() ? 'Closes' : 'Closed'} ${dayjs(
closeTime
).format('MMM D, h:mma')}`
: '') +
`${volumeLabel}` +
(hashtags.length > 0 ? `${hashtags.join(' ')}` : '')
)
}
function EditableCloseDate(props: {
closeTime: number
contract: Contract
isCreator: boolean
}) {
const { closeTime, contract, isCreator } = props
const [isEditingCloseTime, setIsEditingCloseTime] = useState(false)
const [closeDate, setCloseDate] = useState(
closeTime && dayjs(closeTime).format('YYYY-MM-DDT23:59')
)
const onSave = () => {
const newCloseTime = dayjs(closeDate).valueOf()
if (newCloseTime === closeTime) setIsEditingCloseTime(false)
else if (newCloseTime > Date.now()) {
const { description } = contract
const formattedCloseDate = dayjs(newCloseTime).format('YYYY-MM-DD h:mm a')
const newDescription = `${description}\n\nClose date updated to ${formattedCloseDate}`
updateContract(contract.id, {
closeTime: newCloseTime,
description: newDescription,
})
setIsEditingCloseTime(false)
}
}
return (
<>
{isEditingCloseTime ? (
<div className="form-control mr-1 items-start">
<input
type="datetime-local"
className="input input-bordered"
onClick={(e) => e.stopPropagation()}
onChange={(e) => setCloseDate(e.target.value || '')}
min={Date.now()}
value={closeDate}
/>
</div>
) : (
<DateTimeTooltip
text={closeTime > Date.now() ? 'Trading ends:' : 'Trading ended:'}
time={closeTime}
>
{dayjs(closeTime).format('MMM D')} ({fromNow(closeTime)})
</DateTimeTooltip>
)}
{isCreator &&
(isEditingCloseTime ? (
<button className="btn btn-xs" onClick={onSave}>
Done
</button>
) : (
<button
className="btn btn-xs btn-ghost"
onClick={() => setIsEditingCloseTime(true)}
>
<PencilIcon className="mr-2 inline h-4 w-4" /> Edit
</button>
))}
</>
)
}

View File

@ -0,0 +1,233 @@
import clsx from 'clsx'
import _ from 'lodash'
import { ClockIcon, DatabaseIcon, PencilIcon } from '@heroicons/react/outline'
import { TrendingUpIcon } from '@heroicons/react/solid'
import { Row } from '../layout/row'
import { formatMoney } from '../../../common/util/format'
import { UserLink } from '../user-page'
import {
Contract,
contractMetrics,
updateContract,
} from '../../lib/firebase/contracts'
import { Col } from '../layout/col'
import dayjs from 'dayjs'
import { DateTimeTooltip } from '../datetime-tooltip'
import { fromNow } from '../../lib/util/time'
import { Avatar } from '../avatar'
import { useState } from 'react'
import { ContractInfoDialog } from './contract-info-dialog'
import { Bet } from '../../../common/bet'
import NewContractBadge from '../new-contract-badge'
export function AbbrContractDetails(props: {
contract: Contract
showHotVolume?: boolean
showCloseTime?: boolean
}) {
const { contract, showHotVolume, showCloseTime } = props
const { volume, volume24Hours, creatorName, creatorUsername, closeTime } =
contract
const { volumeLabel } = contractMetrics(contract)
return (
<Col className={clsx('gap-2 text-sm text-gray-500')}>
<Row className="items-center justify-between">
<Row className="items-center gap-2">
<Avatar
username={creatorUsername}
avatarUrl={contract.creatorAvatarUrl}
size={6}
/>
<UserLink
className="whitespace-nowrap"
name={creatorName}
username={creatorUsername}
/>
</Row>
{showHotVolume ? (
<Row className="gap-1">
<TrendingUpIcon className="h-5 w-5" /> {formatMoney(volume24Hours)}
</Row>
) : showCloseTime ? (
<Row className="gap-1">
<ClockIcon className="h-5 w-5" />
{(closeTime || 0) < Date.now() ? 'Closed' : 'Closes'}{' '}
{fromNow(closeTime || 0)}
</Row>
) : volume > 0 ? (
<Row>{volumeLabel}</Row>
) : (
<NewContractBadge />
)}
</Row>
</Col>
)
}
export function ContractDetails(props: {
contract: Contract
bets: Bet[]
isCreator?: boolean
hideShareButtons?: boolean
}) {
const { contract, bets, isCreator, hideShareButtons } = props
const { closeTime, creatorName, creatorUsername } = contract
const { volumeLabel, createdDate, resolvedDate } = contractMetrics(contract)
return (
<Col className="gap-2 text-sm text-gray-500 sm:flex-row sm:flex-wrap">
<Row className="flex-1 flex-wrap items-center gap-x-4 gap-y-3">
<Row className="items-center gap-2">
<Avatar
username={creatorUsername}
avatarUrl={contract.creatorAvatarUrl}
size={6}
/>
<UserLink
className="whitespace-nowrap"
name={creatorName}
username={creatorUsername}
/>
</Row>
{(!!closeTime || !!resolvedDate) && (
<Row className="items-center gap-1">
<ClockIcon className="h-5 w-5" />
{/* <DateTimeTooltip text="Market created:" time={contract.createdTime}>
{createdDate}
</DateTimeTooltip> */}
{resolvedDate && contract.resolutionTime ? (
<>
{/* {' - '} */}
<DateTimeTooltip
text="Market resolved:"
time={contract.resolutionTime}
>
{resolvedDate}
</DateTimeTooltip>
</>
) : null}
{!resolvedDate && closeTime && (
<>
{/* {' - '}{' '} */}
<EditableCloseDate
closeTime={closeTime}
contract={contract}
isCreator={isCreator ?? false}
/>
</>
)}
</Row>
)}
<Row className="items-center gap-1">
<DatabaseIcon className="h-5 w-5" />
<div className="whitespace-nowrap">{volumeLabel}</div>
</Row>
{!hideShareButtons && (
<ContractInfoDialog contract={contract} bets={bets} />
)}
</Row>
</Col>
)
}
// String version of the above, to send to the OpenGraph image generator
export function contractTextDetails(contract: Contract) {
const { closeTime, tags } = contract
const { createdDate, resolvedDate, volumeLabel } = contractMetrics(contract)
const hashtags = tags.map((tag) => `#${tag}`)
return (
`${resolvedDate ? `${createdDate} - ${resolvedDate}` : createdDate}` +
(closeTime
? `${closeTime > Date.now() ? 'Closes' : 'Closed'} ${dayjs(
closeTime
).format('MMM D, h:mma')}`
: '') +
`${volumeLabel}` +
(hashtags.length > 0 ? `${hashtags.join(' ')}` : '')
)
}
function EditableCloseDate(props: {
closeTime: number
contract: Contract
isCreator: boolean
}) {
const { closeTime, contract, isCreator } = props
const [isEditingCloseTime, setIsEditingCloseTime] = useState(false)
const [closeDate, setCloseDate] = useState(
closeTime && dayjs(closeTime).format('YYYY-MM-DDT23:59')
)
const isSameYear = dayjs(closeTime).isSame(dayjs(), 'year')
const isSameDay = dayjs(closeTime).isSame(dayjs(), 'day')
const onSave = () => {
const newCloseTime = dayjs(closeDate).valueOf()
if (newCloseTime === closeTime) setIsEditingCloseTime(false)
else if (newCloseTime > Date.now()) {
const { description } = contract
const formattedCloseDate = dayjs(newCloseTime).format('YYYY-MM-DD h:mm a')
const newDescription = `${description}\n\nClose date updated to ${formattedCloseDate}`
updateContract(contract.id, {
closeTime: newCloseTime,
description: newDescription,
})
setIsEditingCloseTime(false)
}
}
return (
<>
{isEditingCloseTime ? (
<div className="form-control mr-1 items-start">
<input
type="datetime-local"
className="input input-bordered"
onClick={(e) => e.stopPropagation()}
onChange={(e) => setCloseDate(e.target.value || '')}
min={Date.now()}
value={closeDate}
/>
</div>
) : (
<DateTimeTooltip
text={closeTime > Date.now() ? 'Trading ends:' : 'Trading ended:'}
time={closeTime}
>
{isSameYear
? dayjs(closeTime).format('MMM D')
: dayjs(closeTime).format('MMM D, YYYY')}
{isSameDay && <> ({fromNow(closeTime)})</>}
</DateTimeTooltip>
)}
{isCreator &&
(isEditingCloseTime ? (
<button className="btn btn-xs" onClick={onSave}>
Done
</button>
) : (
<button
className="btn btn-xs btn-ghost"
onClick={() => setIsEditingCloseTime(true)}
>
<PencilIcon className="mr-2 inline h-4 w-4" /> Edit
</button>
))}
</>
)
}

View File

@ -8,7 +8,6 @@ import { Linkify } from '../linkify'
import clsx from 'clsx'
import {
FreeResponseResolutionOrChance,
ContractDetails,
BinaryResolutionOrChance,
} from './contract-card'
import { Bet } from '../../../common/bet'
@ -17,6 +16,7 @@ import BetRow from '../bet-row'
import { AnswersGraph } from '../answers/answers-graph'
import { DPM, FreeResponse, FullContract } from '../../../common/contract'
import { ContractDescription } from './contract-description'
import { ContractDetails } from './contract-details'
export const ContractOverview = (props: {
contract: Contract

View File

@ -9,6 +9,7 @@ import {
UserIcon,
UsersIcon,
XIcon,
SparklesIcon,
} from '@heroicons/react/solid'
import clsx from 'clsx'
import Textarea from 'react-expanding-textarea'
@ -46,6 +47,8 @@ import { useSaveSeenContract } from '../../hooks/use-seen-contracts'
import { User } from '../../../common/user'
import { Modal } from '../layout/modal'
import { trackClick } from '../../lib/firebase/tracking'
import { DAY_MS } from '../../../common/util/time'
import NewContractBadge from '../new-contract-badge'
export function FeedItems(props: {
contract: Contract
@ -307,19 +310,17 @@ export function FeedQuestion(props: {
contractPath?: string
}) {
const { contract, showDescription } = props
const { creatorName, creatorUsername, question, resolution, outcomeType } =
contract
const {
creatorName,
creatorUsername,
question,
outcomeType,
volume,
createdTime,
} = contract
const { volumeLabel } = contractMetrics(contract)
const isBinary = outcomeType === 'BINARY'
// const closeMessage =
// contract.isResolved || !contract.closeTime ? null : (
// <>
// <span className="mx-2">•</span>
// {contract.closeTime > Date.now() ? 'Closes' : 'Closed'}
// <RelativeTimestamp time={contract.closeTime || 0} />
// </>
// )
const isNew = createdTime > Date.now() - DAY_MS
return (
<>
@ -336,10 +337,15 @@ export function FeedQuestion(props: {
/>{' '}
asked
{/* Currently hidden on mobile; ideally we'd fit this in somewhere. */}
<span className="float-right hidden text-gray-400 sm:inline">
{volumeLabel}
{/* {closeMessage} */}
</span>
<div className="relative -top-2 float-right ">
{isNew || volume === 0 ? (
<NewContractBadge />
) : (
<span className="hidden text-gray-400 sm:inline">
{volumeLabel}
</span>
)}
</div>
</div>
<Col className="items-start justify-between gap-2 sm:flex-row sm:gap-4">
<Col>

View File

@ -158,6 +158,12 @@ export default function Sidebar() {
buttonContent={<MoreButton />}
/>
</div>
{user && (
<Link href={'/create'}>
<button className="btn btn-primary btn-md mt-4">Create Market</button>
</Link>
)}
</nav>
)
}

View File

@ -0,0 +1,9 @@
import { SparklesIcon } from '@heroicons/react/solid'
export default function NewContractBadge() {
return (
<span className="inline-flex items-center gap-1 rounded-full bg-blue-100 px-3 py-0.5 text-sm font-medium text-blue-800">
<SparklesIcon className="h-4 w-4" aria-hidden="true" /> New
</span>
)
}

View File

@ -1,3 +1,4 @@
import clsx from 'clsx'
import { Answer } from '../../common/answer'
import { getProbability } from '../../common/calculate'
import {
@ -59,8 +60,9 @@ export function FreeResponseOutcomeLabel(props: {
contract: FreeResponseContract
resolution: string | 'CANCEL' | 'MKT'
truncate: 'short' | 'long' | 'none'
answerClassName?: string
}) {
const { contract, resolution, truncate } = props
const { contract, resolution, truncate, answerClassName } = props
if (resolution === 'CANCEL') return <CancelLabel />
if (resolution === 'MKT') return <MultiLabel />
@ -68,7 +70,13 @@ export function FreeResponseOutcomeLabel(props: {
const { answers } = contract
const chosen = answers?.find((answer) => answer.id === resolution)
if (!chosen) return <AnswerNumberLabel number={resolution} />
return <AnswerLabel answer={chosen} truncate={truncate} />
return (
<AnswerLabel
answer={chosen}
truncate={truncate}
className={answerClassName}
/>
)
}
export function YesLabel() {
@ -103,8 +111,9 @@ export function AnswerNumberLabel(props: { number: string }) {
export function AnswerLabel(props: {
answer: Answer
truncate: 'short' | 'long' | 'none'
className?: string
}) {
const { answer, truncate } = props
const { answer, truncate, className } = props
const { text } = answer
let truncated = text
@ -114,5 +123,5 @@ export function AnswerLabel(props: {
truncated = text.slice(0, 75) + '...'
}
return <span className="text-primary">{truncated}</span>
return <span className={className}>{truncated}</span>
}

View File

@ -0,0 +1,64 @@
import { Binary, CPMM, DPM, FullContract } from '../../common/contract'
import { User } from '../../common/user'
import { useUserContractBets } from '../hooks/use-user-bets'
import { useState } from 'react'
import { useSaveShares } from './use-save-shares'
import { Col } from './layout/col'
import clsx from 'clsx'
import { SellSharesModal } from './sell-modal'
export function SellButton(props: {
contract: FullContract<DPM | CPMM, Binary>
user: User | null | undefined
}) {
const { contract, user } = props
const userBets = useUserContractBets(user?.id, contract.id)
const [showSellModal, setShowSellModal] = useState(false)
const { mechanism } = contract
const { yesFloorShares, noFloorShares, yesShares, noShares } = useSaveShares(
contract,
userBets
)
const floorShares = yesFloorShares || noFloorShares
const sharesOutcome = yesFloorShares
? 'YES'
: noFloorShares
? 'NO'
: undefined
if (sharesOutcome && user && mechanism === 'cpmm-1') {
return (
<Col className={'items-center'}>
<button
className={clsx(
'btn-sm w-24 gap-1',
// from the yes-no-selector:
'flex inline-flex flex-row items-center justify-center rounded-3xl border-2 p-2',
sharesOutcome === 'NO'
? 'hover:bg-primary-focus border-primary hover:border-primary-focus text-primary hover:text-white'
: 'border-red-400 text-red-500 hover:border-red-500 hover:bg-red-500 hover:text-white'
)}
onClick={() => setShowSellModal(true)}
>
{'Sell ' + sharesOutcome}
</button>
<div className={'mt-1 w-24 text-center text-sm text-gray-500'}>
{'(' + floorShares + ' shares)'}
</div>
{showSellModal && (
<SellSharesModal
contract={contract as FullContract<CPMM, Binary>}
user={user}
userBets={userBets ?? []}
shares={yesShares || noShares}
sharesOutcome={sharesOutcome}
setOpen={setShowSellModal}
/>
)}
</Col>
)
}
return <div />
}

View File

@ -0,0 +1,47 @@
import { Binary, CPMM, FullContract } from '../../common/contract'
import { Bet } from '../../common/bet'
import { User } from '../../common/user'
import { Modal } from './layout/modal'
import { Col } from './layout/col'
import { Title } from './title'
import { formatWithCommas } from '../../common/util/format'
import { OutcomeLabel } from './outcome-label'
import { SellPanel } from './bet-panel'
export function SellSharesModal(props: {
contract: FullContract<CPMM, Binary>
userBets: Bet[]
shares: number
sharesOutcome: 'YES' | 'NO'
user: User
setOpen: (open: boolean) => void
}) {
const { contract, shares, sharesOutcome, userBets, user, setOpen } = props
return (
<Modal open={true} setOpen={setOpen}>
<Col className="rounded-md bg-white px-8 py-6">
<Title className="!mt-0" text={'Sell shares'} />
<div className="mb-6">
You have {formatWithCommas(Math.floor(shares))}{' '}
<OutcomeLabel
outcome={sharesOutcome}
contract={contract}
truncate={'short'}
/>{' '}
shares
</div>
<SellPanel
contract={contract}
shares={shares}
sharesOutcome={sharesOutcome}
user={user}
userBets={userBets ?? []}
onSellSuccess={() => setOpen(false)}
/>
</Col>
</Modal>
)
}

View File

@ -0,0 +1,77 @@
import { Binary, CPMM, DPM, FullContract } from '../../common/contract'
import { User } from '../../common/user'
import { useState } from 'react'
import { Col } from './layout/col'
import { Row } from './layout/row'
import { formatWithCommas } from '../../common/util/format'
import { OutcomeLabel } from './outcome-label'
import { useUserContractBets } from '../hooks/use-user-bets'
import { useSaveShares } from './use-save-shares'
import { SellSharesModal } from './sell-modal'
export function SellRow(props: {
contract: FullContract<DPM | CPMM, Binary>
user: User | null | undefined
className?: string
}) {
const { className, contract, user } = props
const userBets = useUserContractBets(user?.id, contract.id)
const [showSellModal, setShowSellModal] = useState(false)
const { mechanism } = contract
const { yesFloorShares, noFloorShares, yesShares, noShares } = useSaveShares(
contract,
userBets
)
const floorShares = yesFloorShares || noFloorShares
const sharesOutcome = yesFloorShares
? 'YES'
: noFloorShares
? 'NO'
: undefined
if (sharesOutcome && user && mechanism === 'cpmm-1') {
return (
<div>
<Col className={className}>
<Row className="items-center justify-between gap-2 ">
<div>
You have {formatWithCommas(floorShares)}{' '}
<OutcomeLabel
outcome={sharesOutcome}
contract={contract}
truncate={'short'}
/>{' '}
shares
</div>
<button
className="btn btn-sm"
style={{
backgroundColor: 'white',
border: '2px solid',
color: '#3D4451',
}}
onClick={() => setShowSellModal(true)}
>
Sell
</button>
</Row>
</Col>
{showSellModal && (
<SellSharesModal
contract={contract as FullContract<CPMM, Binary>}
user={user}
userBets={userBets ?? []}
shares={yesShares || noShares}
sharesOutcome={sharesOutcome}
setOpen={setShowSellModal}
/>
)}
</div>
)
}
return <div />
}

View File

@ -0,0 +1,59 @@
import { Binary, CPMM, DPM, FullContract } from '../../common/contract'
import { Bet } from '../../common/bet'
import { useEffect, useState } from 'react'
import _ from 'lodash'
export const useSaveShares = (
contract: FullContract<CPMM | DPM, Binary>,
userBets: Bet[] | undefined
) => {
const [savedShares, setSavedShares] = useState<
| {
yesShares: number
noShares: number
yesFloorShares: number
noFloorShares: number
}
| undefined
>()
const [yesBets, noBets] = _.partition(
userBets ?? [],
(bet) => bet.outcome === 'YES'
)
const [yesShares, noShares] = [
_.sumBy(yesBets, (bet) => bet.shares),
_.sumBy(noBets, (bet) => bet.shares),
]
const [yesFloorShares, noFloorShares] = [
Math.floor(yesShares),
Math.floor(noShares),
]
useEffect(() => {
// Save yes and no shares to local storage.
const savedShares = localStorage.getItem(`${contract.id}-shares`)
if (!userBets && savedShares) {
setSavedShares(JSON.parse(savedShares))
}
if (userBets) {
const updatedShares = { yesShares, noShares }
localStorage.setItem(
`${contract.id}-shares`,
JSON.stringify(updatedShares)
)
}
}, [contract.id, userBets, noShares, yesShares])
if (userBets) return { yesShares, noShares, yesFloorShares, noFloorShares }
return (
savedShares ?? {
yesShares: 0,
noShares: 0,
yesFloorShares: 0,
noFloorShares: 0,
}
)
}

View File

@ -87,7 +87,12 @@ export function UserPage(props: { user: User; currentUser?: User }) {
<Col className="sm:flex-row sm:gap-4">
{user.website && (
<SiteLink href={user.website}>
<SiteLink
href={
'https://' +
user.website.replace('http://', '').replace('https://', '')
}
>
<Row className="items-center gap-1">
<LinkIcon className="h-4 w-4" />
<span className="text-sm text-gray-500">{user.website}</span>
@ -96,7 +101,13 @@ export function UserPage(props: { user: User; currentUser?: User }) {
)}
{user.twitterHandle && (
<SiteLink href={`https://twitter.com/${user.twitterHandle}`}>
<SiteLink
href={`https://twitter.com/${user.twitterHandle
.replace('https://www.twitter.com/', '')
.replace('https://twitter.com/', '')
.replace('www.twitter.com/', '')
.replace('twitter.com/', '')}`}
>
<Row className="items-center gap-1">
<img
src="/twitter-logo.svg"

View File

@ -9,40 +9,57 @@ export function YesNoSelector(props: {
onSelect: (selected: 'YES' | 'NO') => void
className?: string
btnClassName?: string
replaceYesButton?: React.ReactNode
replaceNoButton?: React.ReactNode
}) {
const { selected, onSelect, className, btnClassName } = props
const {
selected,
onSelect,
className,
btnClassName,
replaceNoButton,
replaceYesButton,
} = props
const commonClassNames =
'inline-flex flex-1 items-center justify-center rounded-3xl border-2 p-2'
return (
<Row className={clsx('space-x-3', className)}>
<button
className={clsx(
commonClassNames,
'hover:bg-primary-focus border-primary hover:border-primary-focus hover:text-white',
selected == 'YES'
? 'bg-primary text-white'
: 'text-primary bg-transparent',
btnClassName
)}
onClick={() => onSelect('YES')}
>
Bet YES
</button>
<button
className={clsx(
commonClassNames,
'border-red-400 hover:border-red-500 hover:bg-red-500 hover:text-white',
selected == 'NO'
? 'bg-red-400 text-white'
: 'bg-transparent text-red-400',
btnClassName
)}
onClick={() => onSelect('NO')}
>
Bet NO
</button>
{replaceYesButton ? (
replaceYesButton
) : (
<button
className={clsx(
commonClassNames,
'hover:bg-primary-focus border-primary hover:border-primary-focus hover:text-white',
selected == 'YES'
? 'bg-primary text-white'
: 'text-primary bg-transparent',
btnClassName
)}
onClick={() => onSelect('YES')}
>
Bet YES
</button>
)}
{replaceNoButton ? (
replaceNoButton
) : (
<button
className={clsx(
commonClassNames,
'border-red-400 hover:border-red-500 hover:bg-red-500 hover:text-white',
selected == 'NO'
? 'bg-red-400 text-white'
: 'bg-transparent text-red-400',
btnClassName
)}
onClick={() => onSelect('NO')}
>
Bet NO
</button>
)}
</Row>
)
}

View File

@ -8,6 +8,14 @@ import { logInterpolation } from '../../common/util/math'
import { getRecommendedContracts } from '../../common/recommended-contracts'
import { useSeenContracts } from './use-seen-contracts'
import { useGetUserBetContractIds, useUserBetContracts } from './use-user-bets'
import { DAY_MS } from '../../common/util/time'
import {
getProbability,
getOutcomeProbability,
getTopAnswer,
} from '../../common/calculate'
import { useTimeSinceFirstRender } from './use-time-since-first-render'
import { trackLatency } from '../lib/firebase/tracking'
const MAX_FEED_CONTRACTS = 75
@ -29,8 +37,15 @@ export const useAlgoFeed = (
const [algoFeed, setAlgoFeed] = useState<Contract[]>([])
const getTime = useTimeSinceFirstRender()
useEffect(() => {
if (initialContracts && initialBets && initialComments) {
if (
initialContracts &&
initialBets &&
initialComments &&
yourBetContractIds
) {
const eligibleContracts = initialContracts.filter(
(c) => !c.isResolved && (c.closeTime ?? Infinity) > Date.now()
)
@ -42,6 +57,7 @@ export const useAlgoFeed = (
seenContracts
)
setAlgoFeed(contracts)
trackLatency('feed', getTime())
}
}, [
initialBets,
@ -49,6 +65,7 @@ export const useAlgoFeed = (
initialContracts,
seenContracts,
yourBetContractIds,
getTime,
])
return algoFeed
@ -120,11 +137,13 @@ function getContractsActivityScores(
)
const scoredContracts = contracts.map((contract) => {
const { outcomeType } = contract
const seenTime = seenContracts[contract.id]
const lastCommentTime = contractMostRecentComment[contract.id]?.createdTime
const hasNewComments =
!seenTime || (lastCommentTime && lastCommentTime > seenTime)
const newCommentScore = hasNewComments ? 1 : 0.75
const newCommentScore = hasNewComments ? 1 : 0.5
const commentCount = contractComments[contract.id]?.length ?? 0
const betCount = contractBets[contract.id]?.length ?? 0
@ -132,25 +151,39 @@ function getContractsActivityScores(
const activityCountScore =
0.5 + 0.5 * logInterpolation(0, 200, activtyCount)
const lastBetTime = contractMostRecentBet[contract.id]?.createdTime
const timeSinceLastBet = !lastBetTime
? contract.createdTime
: Date.now() - lastBetTime
const daysAgo = timeSinceLastBet / oneDayMs
const lastBetTime =
contractMostRecentBet[contract.id]?.createdTime ?? contract.createdTime
const timeSinceLastBet = Date.now() - lastBetTime
const daysAgo = timeSinceLastBet / DAY_MS
const timeAgoScore = 1 - logInterpolation(0, 3, daysAgo)
const score = newCommentScore * activityCountScore * timeAgoScore
let prob = 0.5
if (outcomeType === 'BINARY') {
prob = getProbability(contract)
} else if (outcomeType === 'FREE_RESPONSE') {
const topAnswer = getTopAnswer(contract)
if (topAnswer)
prob = Math.max(0.5, getOutcomeProbability(contract, topAnswer.id))
}
const frac = 1 - Math.abs(prob - 0.5) ** 2 / 0.25
const probScore = 0.5 + frac * 0.5
const score =
newCommentScore * activityCountScore * timeAgoScore * probScore
// Map score to [0.5, 1] since no recent activty is not a deal breaker.
const mappedScore = 0.5 + score / 2
return [contract.id, mappedScore] as [string, number]
const newMappedScore = 0.75 + score / 4
const isNew = Date.now() < contract.createdTime + DAY_MS
const activityScore = isNew ? newMappedScore : mappedScore
return [contract.id, activityScore] as [string, number]
})
return _.fromPairs(scoredContracts)
}
const oneDayMs = 24 * 60 * 60 * 1000
function getSeenContractsScore(
contract: Contract,
seenContracts: { [contractId: string]: number }
@ -160,7 +193,7 @@ function getSeenContractsScore(
return 1
}
const daysAgo = (Date.now() - lastSeen) / oneDayMs
const daysAgo = (Date.now() - lastSeen) / DAY_MS
if (daysAgo < 0.5) {
const frac = logInterpolation(0, 0.5, daysAgo)

View File

@ -6,6 +6,7 @@ import {
listenForContracts,
listenForHotContracts,
listenForInactiveContracts,
listenForNewContracts,
} from '../lib/firebase/contracts'
import { listenForTaggedContracts } from '../lib/firebase/folds'
@ -20,13 +21,22 @@ export const useContracts = () => {
}
export const useActiveContracts = () => {
const [contracts, setContracts] = useState<Contract[] | undefined>()
const [activeContracts, setActiveContracts] = useState<
Contract[] | undefined
>()
const [newContracts, setNewContracts] = useState<Contract[] | undefined>()
useEffect(() => {
return listenForActiveContracts(setContracts)
return listenForActiveContracts(setActiveContracts)
}, [])
return contracts
useEffect(() => {
return listenForNewContracts(setNewContracts)
}, [])
if (!activeContracts || !newContracts) return undefined
return [...activeContracts, ...newContracts]
}
export const useInactiveContracts = () => {

View File

@ -0,0 +1,13 @@
import { useCallback, useEffect, useRef } from 'react'
export function useTimeSinceFirstRender() {
const startTimeRef = useRef(0)
useEffect(() => {
startTimeRef.current = Date.now()
}, [])
return useCallback(() => {
if (!startTimeRef.current) return 0
return Date.now() - startTimeRef.current
}, [])
}

View File

@ -54,13 +54,15 @@ export const useUserBetContracts = (userId: string | undefined) => {
}
export const useGetUserBetContractIds = (userId: string | undefined) => {
const [contractIds, setContractIds] = useState<string[]>([])
const [contractIds, setContractIds] = useState<string[] | undefined>()
useEffect(() => {
const key = `user-bet-contractIds-${userId}`
const userBetContractJson = localStorage.getItem(key)
if (userBetContractJson) {
setContractIds(JSON.parse(userBetContractJson))
if (userId) {
const key = `user-bet-contractIds-${userId}`
const userBetContractJson = localStorage.getItem(key)
if (userBetContractJson) {
setContractIds(JSON.parse(userBetContractJson))
}
}
}, [userId])

View File

@ -22,6 +22,7 @@ import { getDpmProbability } from '../../../common/calculate-dpm'
import { createRNG, shuffle } from '../../../common/util/random'
import { getCpmmProbability } from '../../../common/calculate-cpmm'
import { formatMoney, formatPercent } from '../../../common/util/format'
import { DAY_MS } from '../../../common/util/time'
export type { Contract }
export function contractPath(contract: Contract) {
@ -162,6 +163,19 @@ export function listenForInactiveContracts(
return listenForValues<Contract>(inactiveContractsQuery, setContracts)
}
const newContractsQuery = query(
contractCollection,
where('isResolved', '==', false),
where('volume7Days', '==', 0),
where('createdTime', '>', Date.now() - 7 * DAY_MS)
)
export function listenForNewContracts(
setContracts: (contracts: Contract[]) => void
) {
return listenForValues<Contract>(newContractsQuery, setContracts)
}
export function listenForContract(
contractId: string,
setContract: (contract: Contract | null) => void

View File

@ -2,7 +2,7 @@ import { doc, collection, setDoc } from 'firebase/firestore'
import _ from 'lodash'
import { db } from './init'
import { ClickEvent, View } from '../../../common/tracking'
import { ClickEvent, LatencyEvent, View } from '../../../common/tracking'
import { listenForLogin, User } from './users'
let user: User | null = null
@ -34,3 +34,19 @@ export async function trackClick(contractId: string) {
return await setDoc(ref, clickEvent)
}
export async function trackLatency(
type: 'feed' | 'portfolio',
latency: number
) {
if (!user) return
const ref = doc(collection(db, 'private-users', user.id, 'latency'))
const latencyEvent: LatencyEvent = {
type,
latency,
timestamp: Date.now(),
}
return await setDoc(ref, latencyEvent)
}

View File

@ -4,7 +4,7 @@
"private": true,
"scripts": {
"dev": "concurrently -n NEXT,TS -c magenta,cyan \"next dev -p 3000\" \"yarn ts --watch\"",
"devdev": "NEXT_PUBLIC_FIREBASE_ENV=DEV concurrently -n NEXT,TS -c magenta,cyan \"FIREBASE_ENV=DEV next dev -p 3000\" \"FIREBASE_ENV=DEV yarn ts --watch\"",
"devdev": "NEXT_PUBLIC_FIREBASE_ENV=DEV concurrently -n NEXT,TS -c magenta,cyan \"FIREBASE_ENV=DEV next dev -p 3000\" \"FIREBASE_ENV=DEV yarn ts --watch\" # see https://github.com/vercel/next.js/discussions/33634",
"dev:dev": "yarn devdev",
"dev:the": "NEXT_PUBLIC_FIREBASE_ENV=THEOREMONE concurrently -n NEXT,TS -c magenta,cyan \"FIREBASE_ENV=THEOREMONE next dev -p 3000\" \"FIREBASE_ENV=THEOREMONE yarn ts --watch\"",
"ts": "tsc --noEmit --incremental --preserveWatchOutput --pretty",

View File

@ -19,7 +19,6 @@ export default function Custom404() {
src="https://discord.com/widget?id=915138780216823849&theme=dark"
width="350"
height="500"
allowTransparency={true}
frameBorder="0"
sandbox="allow-popups allow-popups-to-escape-sandbox allow-same-origin allow-scripts"
></iframe>

View File

@ -18,7 +18,6 @@ import {
} from '../../lib/firebase/contracts'
import { SEO } from '../../components/SEO'
import { Page } from '../../components/page'
import { contractTextDetails } from '../../components/contract/contract-card'
import { Bet, listAllBets } from '../../lib/firebase/bets'
import { Comment, listAllComments } from '../../lib/firebase/comments'
import Custom404 from '../404'
@ -33,6 +32,7 @@ import { useUserById } from '../../hooks/use-users'
import { ContractTabs } from '../../components/contract/contract-tabs'
import { FirstArgument } from '../../../common/util/types'
import { DPM, FreeResponse, FullContract } from '../../../common/contract'
import { contractTextDetails } from '../../components/contract/contract-details'
export const getStaticProps = fromPropz(getStaticPropz)
export async function getStaticPropz(props: {

View File

@ -8,10 +8,8 @@ import { Spacer } from '../components/layout/spacer'
import { useUser } from '../hooks/use-user'
import { Contract, contractPath } from '../lib/firebase/contracts'
import { createContract } from '../lib/firebase/api-call'
import { BuyAmountInput } from '../components/amount-input'
import { FIXED_ANTE, MINIMUM_ANTE } from '../../common/antes'
import { InfoTooltip } from '../components/info-tooltip'
import { CREATOR_FEE } from '../../common/fees'
import { Page } from '../components/page'
import { Title } from '../components/title'
import { ProbabilitySelector } from '../components/probability-selector'
@ -39,6 +37,7 @@ export default function Create() {
<Textarea
placeholder="e.g. Will the Democrats win the 2024 US presidential election?"
className="input input-bordered resize-none"
autoFocus
value={question}
onChange={(e) => setQuestion(e.target.value || '')}
/>

View File

@ -9,9 +9,9 @@ import { DOMAIN } from '../../../../common/envs/constants'
import { AnswersGraph } from '../../../components/answers/answers-graph'
import {
BinaryResolutionOrChance,
ContractDetails,
FreeResponseResolutionOrChance,
} from '../../../components/contract/contract-card'
import { ContractDetails } from '../../../components/contract/contract-details'
import { ContractProbGraph } from '../../../components/contract/contract-prob-graph'
import { Col } from '../../../components/layout/col'
import { Row } from '../../../components/layout/row'

View File

@ -1,9 +1,10 @@
const defaultTheme = require('tailwindcss/defaultTheme')
module.exports = {
mode: 'jit',
purge: ['./pages/**/*.{js,ts,jsx,tsx}', './components/**/*.{js,ts,jsx,tsx}'],
darkMode: false, // or 'media' or 'class'
content: [
'./pages/**/*.{js,ts,jsx,tsx}',
'./components/**/*.{js,ts,jsx,tsx}',
],
theme: {
fontFamily: Object.assign(
{ ...defaultTheme.fontFamily },
@ -18,9 +19,6 @@ module.exports = {
},
},
},
variants: {
extend: {},
},
plugins: [
require('@tailwindcss/forms'),
require('@tailwindcss/typography'),

View File

@ -16,5 +16,5 @@
"incremental": true
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "../common/**/*.ts"],
"exclude": ["node_modules"]
"exclude": ["node_modules", ".next"]
}