diff --git a/common/.eslintrc.js b/common/.eslintrc.js index 54b878e3..6e7b62cd 100644 --- a/common/.eslintrc.js +++ b/common/.eslintrc.js @@ -9,11 +9,18 @@ module.exports = { { files: ['**/*.ts'], plugins: ['@typescript-eslint'], + extends: ['plugin:@typescript-eslint/recommended'], parser: '@typescript-eslint/parser', + parserOptions: { + tsconfigRootDir: __dirname, + project: ['./tsconfig.json'], + }, + rules: { + '@typescript-eslint/no-explicit-any': 'off', + }, }, ], rules: { - 'no-unused-vars': 'off', 'no-constant-condition': ['error', { checkLoops: false }], 'lodash/import-scope': [2, 'member'], }, diff --git a/common/calculate-dpm.ts b/common/calculate-dpm.ts index 39b348ab..104b5ef7 100644 --- a/common/calculate-dpm.ts +++ b/common/calculate-dpm.ts @@ -170,7 +170,7 @@ export function calculateNumericDpmShares( ([amount]) => amount ).map(([, i]) => i) - for (let i of order) { + for (const i of order) { const [bucket, bet] = bets[i] shares[i] = calculateDpmShares(totalShares, bet, bucket) totalShares = addObjects(totalShares, { [bucket]: shares[i] }) diff --git a/common/envs/constants.ts b/common/envs/constants.ts index db82f014..c03c44bc 100644 --- a/common/envs/constants.ts +++ b/common/envs/constants.ts @@ -5,13 +5,13 @@ import { THEOREMONE_CONFIG } from './theoremone' export const ENV = process.env.NEXT_PUBLIC_FIREBASE_ENV ?? 'PROD' -const CONFIGS = { +const CONFIGS: { [env: string]: EnvConfig } = { PROD: PROD_CONFIG, DEV: DEV_CONFIG, THEOREMONE: THEOREMONE_CONFIG, } -// @ts-ignore -export const ENV_CONFIG: EnvConfig = CONFIGS[ENV] + +export const ENV_CONFIG = CONFIGS[ENV] export function isWhitelisted(email?: string) { if (!ENV_CONFIG.whitelistEmail) { diff --git a/common/new-contract.ts b/common/new-contract.ts index beb48d55..17b958d8 100644 --- a/common/new-contract.ts +++ b/common/new-contract.ts @@ -1,5 +1,4 @@ import { range } from 'lodash' -import { PHANTOM_ANTE } from './antes' import { Binary, Contract, @@ -14,7 +13,6 @@ import { import { User } from './user' import { parseTags } from './util/parse' import { removeUndefinedProps } from './util/object' -import { calcDpmInitialPool } from './calculate-dpm' export function getNewContract( id: string, @@ -86,6 +84,9 @@ export function getNewContract( return contract as Contract } +/* +import { PHANTOM_ANTE } from './antes' +import { calcDpmInitialPool } from './calculate-dpm' const getBinaryDpmProps = (initialProb: number, ante: number) => { const { sharesYes, sharesNo, poolYes, poolNo, phantomYes, phantomNo } = calcDpmInitialPool(initialProb, ante, PHANTOM_ANTE) @@ -102,6 +103,7 @@ const getBinaryDpmProps = (initialProb: number, ante: number) => { return system } +*/ const getBinaryCpmmProps = (initialProb: number, ante: number) => { const pool = { YES: ante, NO: ante } @@ -162,11 +164,3 @@ const getNumericProps = ( return system } - -const getMultiProps = ( - outcomes: string[], - initialProbs: number[], - ante: number -) => { - // Not implemented. -} diff --git a/common/scoring.ts b/common/scoring.ts index d4855851..3f0c44b6 100644 --- a/common/scoring.ts +++ b/common/scoring.ts @@ -4,7 +4,7 @@ import { Bet } from './bet' import { Binary, Contract, FullContract } from './contract' import { getPayouts } from './payouts' -export function scoreCreators(contracts: Contract[], bets: Bet[][]) { +export function scoreCreators(contracts: Contract[]) { const creatorScore = mapValues( groupBy(contracts, ({ creatorId }) => creatorId), (contracts) => sumBy(contracts, ({ pool }) => pool.YES + pool.NO) diff --git a/common/tsconfig.json b/common/tsconfig.json new file mode 100644 index 00000000..158a5218 --- /dev/null +++ b/common/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "baseUrl": "../", + "moduleResolution": "node", + "noImplicitReturns": true, + "outDir": "lib", + "sourceMap": true, + "strict": true, + "target": "es2017" + }, + "include": ["**/*.ts"] +} diff --git a/common/util/object.ts b/common/util/object.ts index 031e674c..c970cb24 100644 --- a/common/util/object.ts +++ b/common/util/object.ts @@ -1,9 +1,9 @@ import { union } from 'lodash' export const removeUndefinedProps = (obj: T): T => { - let newObj: any = {} + const newObj: any = {} - for (let key of Object.keys(obj)) { + for (const key of Object.keys(obj)) { if ((obj as any)[key] !== undefined) newObj[key] = (obj as any)[key] } @@ -17,7 +17,7 @@ export const addObjects = ( const keys = union(Object.keys(obj1), Object.keys(obj2)) const newObj = {} as any - for (let key of keys) { + for (const key of keys) { newObj[key] = (obj1[key] ?? 0) + (obj2[key] ?? 0) } diff --git a/common/util/random.ts b/common/util/random.ts index f52294f1..c26b361b 100644 --- a/common/util/random.ts +++ b/common/util/random.ts @@ -5,7 +5,8 @@ export const randomString = (length = 12) => export function genHash(str: string) { // xmur3 - for (var i = 0, h = 1779033703 ^ str.length; i < str.length; i++) { + let h: number + for (let i = 0, h = 1779033703 ^ str.length; i < str.length; i++) { h = Math.imul(h ^ str.charCodeAt(i), 3432918353) h = (h << 13) | (h >>> 19) } @@ -28,7 +29,7 @@ export function createRNG(seed: string) { b >>>= 0 c >>>= 0 d >>>= 0 - var t = (a + b) | 0 + let t = (a + b) | 0 a = b ^ (b >>> 9) b = (c + (c << 3)) | 0 c = (c << 21) | (c >>> 11) @@ -39,7 +40,7 @@ export function createRNG(seed: string) { } } -export const shuffle = (array: any[], rand: () => number) => { +export const shuffle = (array: unknown[], rand: () => number) => { for (let i = 0; i < array.length; i++) { const swapIndex = Math.floor(rand() * (array.length - i)) ;[array[i], array[swapIndex]] = [array[swapIndex], array[i]] diff --git a/functions/.eslintrc.js b/functions/.eslintrc.js index 749ab4f5..c5b8e16f 100644 --- a/functions/.eslintrc.js +++ b/functions/.eslintrc.js @@ -11,6 +11,7 @@ module.exports = { plugins: ['@typescript-eslint'], parser: '@typescript-eslint/parser', parserOptions: { + tsconfigRootDir: __dirname, project: ['./tsconfig.json'], }, }, diff --git a/functions/src/emails.ts b/functions/src/emails.ts index 8aba4fb5..b5838968 100644 --- a/functions/src/emails.ts +++ b/functions/src/emails.ts @@ -262,7 +262,7 @@ export const sendNewCommentEmail = async ( return const { question, creatorUsername, slug } = contract - const marketUrl = `https://${DOMAIN}/${creatorUsername}/${slug}` + const marketUrl = `https://${DOMAIN}/${creatorUsername}/${slug}#${comment.id}` const unsubscribeUrl = `https://us-central1-${PROJECT_ID}.cloudfunctions.net/unsubscribe?id=${userId}&type=market-comment` diff --git a/web/components/choices-toggle-group.tsx b/web/components/choices-toggle-group.tsx index d2f80d14..0120aea9 100644 --- a/web/components/choices-toggle-group.tsx +++ b/web/components/choices-toggle-group.tsx @@ -1,36 +1,56 @@ import { Row } from './layout/row' +import { RadioGroup } from '@headlessui/react' import clsx from 'clsx' +import React from 'react' export function ChoicesToggleGroup(props: { currentChoice: number | string - setChoice: (p: any) => void //number | string does not work here because of SetStateAction in .tsx - choices: (number | string)[] - titles: string[] + choicesMap: { [key: string]: string | number } isSubmitting?: boolean + setChoice: (p: number | string) => void + className?: string + children?: React.ReactNode }) { - const { currentChoice, setChoice, titles, choices, isSubmitting } = props - const baseButtonClassName = 'btn btn-outline btn-md sm:btn-md normal-case' - const activeClasss = - 'bg-indigo-600 focus:bg-indigo-600 hover:bg-indigo-600 text-white' + const { + currentChoice, + setChoice, + isSubmitting, + choicesMap, + className, + children, + } = props return ( -
- {choices.map((choice, i) => { - return ( - - ) - })} -
+ {choiceKey} + + ))} + {children} + +
) } diff --git a/web/components/contract/contract-details.tsx b/web/components/contract/contract-details.tsx index 42894263..1587f8fc 100644 --- a/web/components/contract/contract-details.tsx +++ b/web/components/contract/contract-details.tsx @@ -213,7 +213,7 @@ function EditableCloseDate(props: { const [isEditingCloseTime, setIsEditingCloseTime] = useState(false) const [closeDate, setCloseDate] = useState( - closeTime && dayjs(closeTime).format('YYYY-MM-DDT23:59') + closeTime && dayjs(closeTime).format('YYYY-MM-DDTHH:mm') ) const isSameYear = dayjs(closeTime).isSame(dayjs(), 'year') diff --git a/web/components/contract/quick-bet.tsx b/web/components/contract/quick-bet.tsx index 04d769f1..c8489c7a 100644 --- a/web/components/contract/quick-bet.tsx +++ b/web/components/contract/quick-bet.tsx @@ -39,16 +39,21 @@ export function QuickBet(props: { contract: Contract }) { const user = useUser() const userBets = useUserContractBets(user?.id, contract.id) + const topAnswer = + contract.outcomeType === 'FREE_RESPONSE' + ? getTopAnswer(contract as FreeResponseContract) + : undefined + + // TODO: yes/no from useSaveShares doesn't work on numeric contracts const { yesFloorShares, noFloorShares } = useSaveShares( - contract as FullContract, - userBets + contract as FullContract, + userBets, + topAnswer?.number.toString() || undefined ) - // TODO: This relies on a hack in useSaveShares, where noFloorShares includes - // all non-YES shares. Ideally, useSaveShares should group by all outcomes const hasUpShares = - contract.outcomeType === 'BINARY' ? yesFloorShares : noFloorShares + yesFloorShares || (noFloorShares && contract.outcomeType === 'NUMERIC') const hasDownShares = - contract.outcomeType === 'BINARY' ? noFloorShares : yesFloorShares + noFloorShares && yesFloorShares <= 0 && contract.outcomeType !== 'NUMERIC' const [upHover, setUpHover] = useState(false) const [downHover, setDownHover] = useState(false) diff --git a/web/components/feed/copy-link-date-time.tsx b/web/components/feed/copy-link-date-time.tsx index 3bd6d21a..354996a4 100644 --- a/web/components/feed/copy-link-date-time.tsx +++ b/web/components/feed/copy-link-date-time.tsx @@ -21,14 +21,11 @@ export function CopyLinkDateTimeComponent(props: { event: React.MouseEvent ) { event.preventDefault() + let elementLocation = `https://${ENV_CONFIG.domain}${contractPath( + contract + )}#${elementId}` - let currentLocation = window.location.href.includes('/home') - ? `https://${ENV_CONFIG.domain}${contractPath(contract)}#${elementId}` - : window.location.href - if (currentLocation.includes('#')) { - currentLocation = currentLocation.split('#')[0] - } - copyToClipboard(`${currentLocation}#${elementId}`) + copyToClipboard(elementLocation) setShowToast(true) setTimeout(() => setShowToast(false), 2000) } diff --git a/web/components/feed/feed-comments.tsx b/web/components/feed/feed-comments.tsx index 55bfe0f6..db55e890 100644 --- a/web/components/feed/feed-comments.tsx +++ b/web/components/feed/feed-comments.tsx @@ -156,7 +156,7 @@ export function FeedComment(props: { { - const utcMidnightToLocalDate = new Date(getUtcFreeMarketResetTime(false)) + const nextUtcResetTime = getUtcFreeMarketResetTime(false) const interval = setInterval(() => { const now = new Date().getTime() - let timeUntil = Math.abs(utcMidnightToLocalDate.getTime() - now) - if (now > utcMidnightToLocalDate.getTime()) { - timeUntil = 24 * 60 * 60 * 1000 - timeUntil - } + let timeUntil = nextUtcResetTime - now const hoursUntil = timeUntil / 1000 / 60 / 60 const minutesUntil = Math.floor((hoursUntil * 60) % 60) const secondsUntil = Math.floor((hoursUntil * 60 * 60) % 60) diff --git a/web/components/use-save-shares.ts b/web/components/use-save-shares.ts index 977c8a97..9bf3e739 100644 --- a/web/components/use-save-shares.ts +++ b/web/components/use-save-shares.ts @@ -1,11 +1,19 @@ -import { Binary, CPMM, DPM, FullContract } from 'common/contract' +import { + Binary, + CPMM, + DPM, + FreeResponseContract, + FullContract, +} from 'common/contract' import { Bet } from 'common/bet' import { useEffect, useState } from 'react' import { partition, sumBy } from 'lodash' +import { safeLocalStorage } from 'web/lib/util/local' export const useSaveShares = ( - contract: FullContract, - userBets: Bet[] | undefined + contract: FullContract, + userBets: Bet[] | undefined, + freeResponseAnswerOutcome?: string ) => { const [savedShares, setSavedShares] = useState< | { @@ -17,9 +25,11 @@ export const useSaveShares = ( | undefined >() - const [yesBets, noBets] = partition( - userBets ?? [], - (bet) => bet.outcome === 'YES' + // TODO: How do we handle numeric yes / no bets? - maybe bet amounts above vs below the highest peak + const [yesBets, noBets] = partition(userBets ?? [], (bet) => + freeResponseAnswerOutcome + ? bet.outcome === freeResponseAnswerOutcome + : bet.outcome === 'YES' ) const [yesShares, noShares] = [ sumBy(yesBets, (bet) => bet.shares), @@ -30,18 +40,16 @@ export const useSaveShares = ( const noFloorShares = Math.round(noShares) === 0 ? 0 : Math.floor(noShares) useEffect(() => { + const local = safeLocalStorage() // Save yes and no shares to local storage. - const savedShares = localStorage.getItem(`${contract.id}-shares`) + const savedShares = local?.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) - ) + local?.setItem(`${contract.id}-shares`, JSON.stringify(updatedShares)) } }, [contract.id, userBets, noShares, yesShares]) diff --git a/web/hooks/use-has-created-contract-today.ts b/web/hooks/use-has-created-contract-today.ts index f63f5ac1..8f05287c 100644 --- a/web/hooks/use-has-created-contract-today.ts +++ b/web/hooks/use-has-created-contract-today.ts @@ -1,17 +1,36 @@ import { listContracts } from 'web/lib/firebase/contracts' import { useEffect, useState } from 'react' import { User } from 'common/user' +import dayjs from 'dayjs' +import utc from 'dayjs/plugin/utc' +dayjs.extend(utc) let sessionCreatedContractToday = true -export function getUtcFreeMarketResetTime(yesterday: boolean) { - // Uses utc time like the server. - const utcFreeMarketResetTime = new Date() - utcFreeMarketResetTime.setUTCDate( - utcFreeMarketResetTime.getUTCDate() - (yesterday ? 1 : 0) - ) - const utcFreeMarketMS = utcFreeMarketResetTime.setUTCHours(16, 0, 0, 0) - return utcFreeMarketMS +export function getUtcFreeMarketResetTime(previous: boolean) { + const localTimeNow = new Date() + const utc4pmToday = dayjs() + .utc() + .set('hour', 16) + .set('minute', 0) + .set('second', 0) + .set('millisecond', 0) + + // if it's after 4pm UTC today + if (localTimeNow.getTime() > utc4pmToday.valueOf()) { + return previous + ? // Return it as it is + utc4pmToday.valueOf() + : // Or add 24 hours to get the next 4pm UTC time: + utc4pmToday.valueOf() + 24 * 60 * 60 * 1000 + } + + // 4pm UTC today is coming up + return previous + ? // Subtract 24 hours to get the previous 4pm UTC time: + utc4pmToday.valueOf() - 24 * 60 * 60 * 1000 + : // Return it as it is + utc4pmToday.valueOf() } export const useHasCreatedContractToday = (user: User | null | undefined) => { diff --git a/web/lib/firebase/fn-call.ts b/web/lib/firebase/fn-call.ts index 72a2a110..3713c4e5 100644 --- a/web/lib/firebase/fn-call.ts +++ b/web/lib/firebase/fn-call.ts @@ -5,6 +5,7 @@ import { User } from 'common/user' import { randomString } from 'common/util/random' import './init' import { functions } from './init' +import { safeLocalStorage } from '../util/local' export const cloudFunction = (name: string) => httpsCallable(functions, name) @@ -48,10 +49,11 @@ export const resolveMarket = cloudFunction< >('resolveMarket') export const createUser: () => Promise = () => { - let deviceToken = window.localStorage.getItem('device-token') + const local = safeLocalStorage() + let deviceToken = local?.getItem('device-token') if (!deviceToken) { deviceToken = randomString() - window.localStorage.setItem('device-token', deviceToken) + local?.setItem('device-token', deviceToken) } return cloudFunction('createUser')({ deviceToken }) diff --git a/web/lib/firebase/users.ts b/web/lib/firebase/users.ts index 4edcddb8..1e316744 100644 --- a/web/lib/firebase/users.ts +++ b/web/lib/firebase/users.ts @@ -27,6 +27,7 @@ import { getValue, getValues, listenForValue, listenForValues } from './utils' import { DAY_MS } from 'common/util/time' import { feed } from 'common/feed' import { CATEGORY_LIST } from 'common/categories' +import { safeLocalStorage } from '../util/local' export type { User } @@ -86,8 +87,9 @@ let createUserPromise: Promise | undefined = undefined const warmUpCreateUser = throttle(createUser, 5000 /* ms */) export function listenForLogin(onUser: (user: User | null) => void) { - const cachedUser = localStorage.getItem(CACHED_USER_KEY) - onUser(cachedUser ? JSON.parse(cachedUser) : null) + const local = safeLocalStorage() + const cachedUser = local?.getItem(CACHED_USER_KEY) + onUser(cachedUser && JSON.parse(cachedUser)) if (!cachedUser) warmUpCreateUser() @@ -106,11 +108,11 @@ export function listenForLogin(onUser: (user: User | null) => void) { // Persist to local storage, to reduce login blink next time. // Note: Cap on localStorage size is ~5mb - localStorage.setItem(CACHED_USER_KEY, JSON.stringify(user)) + local?.setItem(CACHED_USER_KEY, JSON.stringify(user)) } else { // User logged out; reset to null onUser(null) - localStorage.removeItem(CACHED_USER_KEY) + local?.removeItem(CACHED_USER_KEY) createUserPromise = undefined } }) diff --git a/web/lib/util/local.ts b/web/lib/util/local.ts new file mode 100644 index 00000000..0778c0ac --- /dev/null +++ b/web/lib/util/local.ts @@ -0,0 +1,11 @@ +export const safeLocalStorage = () => (isLocalStorage() ? localStorage : null) + +const isLocalStorage = () => { + try { + localStorage.getItem('test') + localStorage.setItem('hi', 'mom') + return true + } catch (e) { + return false + } +} diff --git a/web/pages/analytics.tsx b/web/pages/analytics.tsx index 1b6a5a2c..7dc6671f 100644 --- a/web/pages/analytics.tsx +++ b/web/pages/analytics.tsx @@ -18,7 +18,7 @@ import { getDailyNewUsers } from 'web/lib/firebase/users' export const getStaticProps = fromPropz(getStaticPropz) export async function getStaticPropz() { - const numberOfDays = 45 + const numberOfDays = 90 const today = dayjs(dayjs().format('YYYY-MM-DD')) // Convert from UTC midnight to PT midnight. .add(7, 'hours') diff --git a/web/pages/create.tsx b/web/pages/create.tsx index ab66130a..00d912bd 100644 --- a/web/pages/create.tsx +++ b/web/pages/create.tsx @@ -17,8 +17,6 @@ import { useHasCreatedContractToday } from 'web/hooks/use-has-created-contract-t import { removeUndefinedProps } from 'common/util/object' import { CATEGORIES } from 'common/categories' import { ChoicesToggleGroup } from 'web/components/choices-toggle-group' -import { CalculatorIcon } from '@heroicons/react/outline' -import { write } from 'fs' export default function Create() { const [question, setQuestion] = useState('') @@ -71,8 +69,6 @@ export function NewContract(props: { question: string; tag?: string }) { const [minString, setMinString] = useState('') const [maxString, setMaxString] = useState('') const [description, setDescription] = useState('') - const [showCalendar, setShowCalendar] = useState(false) - const [showNumInput, setShowNumInput] = useState(false) const [category, setCategory] = useState('') // const [tagText, setTagText] = useState(tag ?? '') @@ -93,20 +89,25 @@ export function NewContract(props: { question: string; tag?: string }) { // By default, close the market a week from today const [closeDate, setCloseDate] = useState(weekFrom(dayjs())) const [resolutionDate, setResolutionDate] = useState(weekFrom(closeDate)) - + const [closeHoursMinutes, setCloseHoursMinutes] = useState('23:59') + const [probErrorText, setProbErrorText] = useState('') + const [marketInfoText, setMarketInfoText] = useState('') const [isSubmitting, setIsSubmitting] = useState(false) - const closeTime = closeDate ? dayjs(closeDate).valueOf() : undefined + const closeTime = closeDate + ? dayjs(`${closeDate}T${closeHoursMinutes}`).valueOf() + : undefined const automaticResolutionTime = resolutionDate ? dayjs(resolutionDate).valueOf() : undefined const balance = creator?.balance || 0 const min = minString ? parseFloat(minString) : undefined const max = maxString ? parseFloat(maxString) : undefined + // get days from today until the end of this year: + const daysLeftInTheYear = dayjs().endOf('year').diff(dayjs(), 'day') const isValid = - initialProb > 0 && - initialProb < 100 && + (outcomeType === 'BINARY' ? initialProb >= 5 && initialProb <= 95 : true) && question.length > 0 && ante !== undefined && ante !== null && @@ -130,8 +131,7 @@ export function NewContract(props: { question: string; tag?: string }) { } function setCloseDateInDays(days: number) { - setShowCalendar(days === 0) - const newCloseDate = dayjs().add(days, 'day').format('YYYY-MM-DDT23:59') + const newCloseDate = dayjs().add(days, 'day').format('YYYY-MM-DD') setCloseDate(newCloseDate) } @@ -173,90 +173,83 @@ export function NewContract(props: { question: string; tag?: string }) { return (
- - - - - - + { + if (choice === 'NUMERIC') + setMarketInfoText( + 'Numeric markets are still experimental and subject to major revisions.' + ) + else if (choice === 'FREE_RESPONSE') + setMarketInfoText( + 'Users can submit their own answers to this market.' + ) + else setMarketInfoText('') + setOutcomeType(choice as outcomeType) + }} + choicesMap={{ + 'Yes / No': 'BINARY', + 'Free response': 'FREE_RESPONSE', + Numeric: 'NUMERIC', + }} + isSubmitting={isSubmitting} + className={'col-span-4'} + /> + {marketInfoText && ( +
+ {marketInfoText} +
+ )} {outcomeType === 'BINARY' && (
How likely is it to happen? - setShowNumInput(!showNumInput)} - /> - + { + setProbErrorText('') + setInitialProb(option as number) + }} + choicesMap={{ + Unlikely: 25, + 'Not Sure': 50, + Likely: 75, + }} isSubmitting={isSubmitting} - /> - {showNumInput && ( - <> + className={'col-span-4 sm:col-span-3'} + > + - setInitialProb(parseInt(e.target.value.substring(0, 2))) - } + onChange={(e) => { + // show error if prob is less than 5 or greater than 95: + const prob = parseInt(e.target.value) + setInitialProb(prob) + if (prob < 5 || prob > 95) + setProbErrorText('Probability must be between 5% and 95%') + else setProbErrorText('') + }} /> - % - - )} + % + + + {probErrorText && ( +
{probErrorText}
+ )}
)} @@ -299,7 +292,7 @@ export function NewContract(props: { question: string; tag?: string }) {