From 7ba19c274bb28cfb6116bdc453f830b003916750 Mon Sep 17 00:00:00 2001 From: Barak Gila Date: Tue, 27 Sep 2022 10:02:03 -0700 Subject: [PATCH 001/135] basic sprig integration with possible page URL events (#932) * basic sprig integration with possible page URL events * iteration 0 * iteration 1 * run prettier; attempt to remove expect error * readd expect error messages * typescript comment fixes * add identify * remove package-lock.json * extract to separate file * fix linting * fix lint * fix lint * fix missing config --- common/envs/dev.ts | 1 + common/envs/prod.ts | 2 ++ web/lib/service/analytics.ts | 4 ++++ web/lib/service/sprig.ts | 33 +++++++++++++++++++++++++++++++++ 4 files changed, 40 insertions(+) create mode 100644 web/lib/service/sprig.ts diff --git a/common/envs/dev.ts b/common/envs/dev.ts index 96ec4dc2..ff3fd37d 100644 --- a/common/envs/dev.ts +++ b/common/envs/dev.ts @@ -18,4 +18,5 @@ export const DEV_CONFIG: EnvConfig = { amplitudeApiKey: 'fd8cbfd964b9a205b8678a39faae71b3', // this is Phil's deployment twitchBotEndpoint: 'https://king-prawn-app-5btyw.ondigitalocean.app', + sprigEnvironmentId: 'Tu7kRZPm7daP', } diff --git a/common/envs/prod.ts b/common/envs/prod.ts index 3014f4e3..d0469d84 100644 --- a/common/envs/prod.ts +++ b/common/envs/prod.ts @@ -3,6 +3,7 @@ export type EnvConfig = { firebaseConfig: FirebaseConfig amplitudeApiKey?: string twitchBotEndpoint?: string + sprigEnvironmentId?: string // IDs for v2 cloud functions -- find these by deploying a cloud function and // examining the URL, https://[name]-[cloudRunId]-[cloudRunRegion].a.run.app @@ -56,6 +57,7 @@ type FirebaseConfig = { export const PROD_CONFIG: EnvConfig = { domain: 'manifold.markets', amplitudeApiKey: '2d6509fd4185ebb8be29709842752a15', + sprigEnvironmentId: 'sQcrq9TDqkib', firebaseConfig: { apiKey: 'AIzaSyDp3J57vLeAZCzxLD-vcPaGIkAmBoGOSYw', diff --git a/web/lib/service/analytics.ts b/web/lib/service/analytics.ts index 3ac58055..780d9ba4 100644 --- a/web/lib/service/analytics.ts +++ b/web/lib/service/analytics.ts @@ -6,6 +6,8 @@ import { Identify, } from '@amplitude/analytics-browser' +import * as Sprig from 'web/lib/service/sprig' + import { ENV_CONFIG } from 'common/envs/constants' init(ENV_CONFIG.amplitudeApiKey ?? '', undefined, { includeReferrer: true }) @@ -33,10 +35,12 @@ export const withTracking = export async function identifyUser(userId: string) { setUserId(userId) + Sprig.setUserId(userId) } export async function setUserProperty(property: string, value: string) { const identifyObj = new Identify() identifyObj.set(property, value) await identify(identifyObj) + Sprig.setAttributes({ [property]: value }) } diff --git a/web/lib/service/sprig.ts b/web/lib/service/sprig.ts new file mode 100644 index 00000000..ee6052b7 --- /dev/null +++ b/web/lib/service/sprig.ts @@ -0,0 +1,33 @@ +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-nocheck +// Integrate Sprig + +import { ENV_CONFIG } from 'common/envs/constants' + +try { + ;(function (l, e, a, p) { + if (window.Sprig) return + window.Sprig = function (...args) { + S._queue.push(args) + } + const S = window.Sprig + S.appId = a + S._queue = [] + window.UserLeap = S + a = l.createElement('script') + a.async = 1 + a.src = e + '?id=' + S.appId + p = l.getElementsByTagName('script')[0] + p.parentNode.insertBefore(a, p) + })(document, 'https://cdn.sprig.com/shim.js', ENV_CONFIG.sprigEnvironmentId) +} catch (error) { + console.log('Error initializing Sprig, please complain to Barak', error) +} + +export function setUserId(userId: string): void { + window.Sprig.setUserId(userId) +} + +export function setAttributes(attributes: Record): void { + window.Sprig.setAttributes(attributes) +} From 723d9dbece2924c7091721d5be4f46d6fe6349ca Mon Sep 17 00:00:00 2001 From: mantikoros <95266179+mantikoros@users.noreply.github.com> Date: Tue, 27 Sep 2022 12:09:54 -0500 Subject: [PATCH 002/135] Better bet summary (#936) * show position, expected value, profit instead of "invested" * move bet summary outside trades on market page * refactor * pass in userbets * hide only if no bets; show invested on desktop * various --- web/components/bet-summary.tsx | 120 +++++++++++++++++ web/components/bets-list.tsx | 155 +--------------------- web/components/contract/contract-tabs.tsx | 64 ++++----- web/components/profit-badge.tsx | 28 ++++ web/pages/[username]/[contractSlug].tsx | 17 ++- web/pages/home/index.tsx | 2 +- 6 files changed, 192 insertions(+), 194 deletions(-) create mode 100644 web/components/bet-summary.tsx create mode 100644 web/components/profit-badge.tsx diff --git a/web/components/bet-summary.tsx b/web/components/bet-summary.tsx new file mode 100644 index 00000000..aa64da43 --- /dev/null +++ b/web/components/bet-summary.tsx @@ -0,0 +1,120 @@ +import { sumBy } from 'lodash' +import clsx from 'clsx' + +import { Bet } from 'web/lib/firebase/bets' +import { formatMoney, formatWithCommas } from 'common/util/format' +import { Col } from './layout/col' +import { Contract } from 'web/lib/firebase/contracts' +import { Row } from './layout/row' +import { YesLabel, NoLabel } from './outcome-label' +import { + calculatePayout, + getContractBetMetrics, + getProbability, +} from 'common/calculate' +import { InfoTooltip } from './info-tooltip' +import { ProfitBadge } from './profit-badge' + +export function BetsSummary(props: { + contract: Contract + userBets: Bet[] + className?: string +}) { + const { contract, className } = props + const { resolution, outcomeType } = contract + const isBinary = outcomeType === 'BINARY' + + const bets = props.userBets.filter((b) => !b.isAnte) + const { profitPercent, payout, profit, invested } = getContractBetMetrics( + contract, + bets + ) + + const excludeSales = bets.filter((b) => !b.isSold && !b.sale) + const yesWinnings = sumBy(excludeSales, (bet) => + calculatePayout(contract, bet, 'YES') + ) + const noWinnings = sumBy(excludeSales, (bet) => + calculatePayout(contract, bet, 'NO') + ) + + const position = yesWinnings - noWinnings + + const prob = isBinary ? getProbability(contract) : 0 + const expectation = prob * yesWinnings + (1 - prob) * noWinnings + + if (bets.length === 0) return <> + + return ( + + + {resolution ? ( + +
Payout
+
+ {formatMoney(payout)}{' '} + +
+ + ) : isBinary ? ( + +
+ Position{' '} + +
+
+ {position > 1e-7 ? ( + <> + {formatWithCommas(position)} + + ) : position < -1e-7 ? ( + <> + {formatWithCommas(-position)} + + ) : ( + 'โ€”โ€”' + )} +
+ + ) : ( + +
+ Expectation{''} + +
+
{formatMoney(payout)}
+ + )} + + +
+ Invested{' '} + +
+
{formatMoney(invested)}
+ + + {isBinary && !resolution && ( + +
+ Expectation{' '} + +
+
{formatMoney(expectation)}
+ + )} + + +
+ Profit{' '} + +
+
+ {formatMoney(profit)} + +
+ +
+ + ) +} diff --git a/web/components/bets-list.tsx b/web/components/bets-list.tsx index dbb2db56..5a95f22f 100644 --- a/web/components/bets-list.tsx +++ b/web/components/bets-list.tsx @@ -22,7 +22,7 @@ import { import { Row } from './layout/row' import { sellBet } from 'web/lib/firebase/api' import { ConfirmationButton } from './confirmation-button' -import { OutcomeLabel, YesLabel, NoLabel } from './outcome-label' +import { OutcomeLabel } from './outcome-label' import { LoadingIndicator } from './loading-indicator' import { SiteLink } from './site-link' import { @@ -38,14 +38,14 @@ import { NumericContract } from 'common/contract' import { formatNumericProbability } from 'common/pseudo-numeric' import { useUser } from 'web/hooks/use-user' import { useUserBets } from 'web/hooks/use-user-bets' -import { SellSharesModal } from './sell-modal' import { useUnfilledBets } from 'web/hooks/use-bets' import { LimitBet } from 'common/bet' -import { floatingEqual } from 'common/util/math' import { Pagination } from './pagination' import { LimitOrderTable } from './limit-bets' import { UserLink } from 'web/components/user-link' import { useUserBetContracts } from 'web/hooks/use-contracts' +import { BetsSummary } from './bet-summary' +import { ProfitBadge } from './profit-badge' type BetSort = 'newest' | 'profit' | 'closeTime' | 'value' type BetFilter = 'open' | 'limit_bet' | 'sold' | 'closed' | 'resolved' | 'all' @@ -337,8 +337,7 @@ function ContractBets(props: { {contract.mechanism === 'cpmm-1' && limitBets.length > 0 && ( @@ -364,125 +363,6 @@ function ContractBets(props: { ) } -export function BetsSummary(props: { - contract: Contract - bets: Bet[] - isYourBets: boolean - className?: string -}) { - const { contract, isYourBets, className } = props - const { resolution, closeTime, outcomeType, mechanism } = contract - const isBinary = outcomeType === 'BINARY' - const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC' - const isCpmm = mechanism === 'cpmm-1' - const isClosed = closeTime && Date.now() > closeTime - - const bets = props.bets.filter((b) => !b.isAnte) - const { hasShares, invested, profitPercent, payout, profit, totalShares } = - getContractBetMetrics(contract, bets) - - const excludeSales = bets.filter((b) => !b.isSold && !b.sale) - const yesWinnings = sumBy(excludeSales, (bet) => - calculatePayout(contract, bet, 'YES') - ) - const noWinnings = sumBy(excludeSales, (bet) => - calculatePayout(contract, bet, 'NO') - ) - - const [showSellModal, setShowSellModal] = useState(false) - const user = useUser() - - const sharesOutcome = floatingEqual(totalShares.YES ?? 0, 0) - ? floatingEqual(totalShares.NO ?? 0, 0) - ? undefined - : 'NO' - : 'YES' - - const canSell = - isYourBets && - isCpmm && - (isBinary || isPseudoNumeric) && - !isClosed && - !resolution && - hasShares && - sharesOutcome && - user - - return ( - - - -
- Invested -
-
{formatMoney(invested)}
- - -
Profit
-
- {formatMoney(profit)} -
- - {canSell && ( - <> - - {showSellModal && ( - - )} - - )} -
- - {resolution ? ( - -
Payout
-
- {formatMoney(payout)}{' '} - -
- - ) : isBinary ? ( - <> - -
- Payout if -
-
- {formatMoney(yesWinnings)} -
- - -
- Payout if -
-
{formatMoney(noWinnings)}
- - - ) : ( - -
- Expected value -
-
{formatMoney(payout)}
- - )} -
- - ) -} - export function ContractBetsTable(props: { contract: Contract bets: Bet[] @@ -750,30 +630,3 @@ function SellButton(props: { ) } - -export function ProfitBadge(props: { - profitPercent: number - round?: boolean - className?: string -}) { - const { profitPercent, round, className } = props - if (!profitPercent) return null - const colors = - profitPercent > 0 - ? 'bg-green-100 text-green-800' - : 'bg-red-100 text-red-800' - - return ( - - {(profitPercent > 0 ? '+' : '') + - profitPercent.toFixed(round ? 0 : 1) + - '%'} - - ) -} diff --git a/web/components/contract/contract-tabs.tsx b/web/components/contract/contract-tabs.tsx index 17471796..bd3204ed 100644 --- a/web/components/contract/contract-tabs.tsx +++ b/web/components/contract/contract-tabs.tsx @@ -9,7 +9,7 @@ import { groupBy, sortBy } from 'lodash' import { Bet } from 'common/bet' import { Contract } from 'common/contract' import { PAST_BETS } from 'common/user' -import { ContractBetsTable, BetsSummary } from '../bets-list' +import { ContractBetsTable } from '../bets-list' import { Spacer } from '../layout/spacer' import { Tabs } from '../layout/tabs' import { Col } from '../layout/col' @@ -17,59 +17,45 @@ import { LoadingIndicator } from 'web/components/loading-indicator' import { useComments } from 'web/hooks/use-comments' import { useLiquidity } from 'web/hooks/use-liquidity' import { useTipTxns } from 'web/hooks/use-tip-txns' -import { useUser } from 'web/hooks/use-user' import { capitalize } from 'lodash' import { DEV_HOUSE_LIQUIDITY_PROVIDER_ID, HOUSE_LIQUIDITY_PROVIDER_ID, } from 'common/antes' -import { useIsMobile } from 'web/hooks/use-is-mobile' +import { buildArray } from 'common/util/array' -export function ContractTabs(props: { contract: Contract; bets: Bet[] }) { - const { contract, bets } = props - - const isMobile = useIsMobile() - const user = useUser() - const userBets = - user && bets.filter((bet) => !bet.isAnte && bet.userId === user.id) +export function ContractTabs(props: { + contract: Contract + bets: Bet[] + userBets: Bet[] +}) { + const { contract, bets, userBets } = props const yourTrades = (
- - +
) + const tabs = buildArray( + { + title: 'Comments', + content: , + }, + { + title: capitalize(PAST_BETS), + content: , + }, + userBets.length > 0 && { + title: 'Your trades', + content: yourTrades, + } + ) + return ( - , - }, - { - title: capitalize(PAST_BETS), - content: , - }, - ...(!user || !userBets?.length - ? [] - : [ - { - title: isMobile ? `You` : `Your ${PAST_BETS}`, - content: yourTrades, - }, - ]), - ]} - /> + ) } diff --git a/web/components/profit-badge.tsx b/web/components/profit-badge.tsx new file mode 100644 index 00000000..f82159e6 --- /dev/null +++ b/web/components/profit-badge.tsx @@ -0,0 +1,28 @@ +import clsx from 'clsx' + +export function ProfitBadge(props: { + profitPercent: number + round?: boolean + className?: string +}) { + const { profitPercent, round, className } = props + if (!profitPercent) return null + const colors = + profitPercent > 0 + ? 'bg-green-100 text-green-800' + : 'bg-red-100 text-red-800' + + return ( + + {(profitPercent > 0 ? '+' : '') + + profitPercent.toFixed(round ? 0 : 1) + + '%'} + + ) +} diff --git a/web/pages/[username]/[contractSlug].tsx b/web/pages/[username]/[contractSlug].tsx index 38df2fbf..1dde2f95 100644 --- a/web/pages/[username]/[contractSlug].tsx +++ b/web/pages/[username]/[contractSlug].tsx @@ -1,5 +1,6 @@ import React, { memo, useEffect, useMemo, useState } from 'react' import { ArrowLeftIcon } from '@heroicons/react/outline' +import dayjs from 'dayjs' import { useContractWithPreload } from 'web/hooks/use-contract' import { ContractOverview } from 'web/components/contract/contract-overview' @@ -44,8 +45,7 @@ import { useAdmin } from 'web/hooks/use-admin' import { BetSignUpPrompt } from 'web/components/sign-up-prompt' import { PlayMoneyDisclaimer } from 'web/components/play-money-disclaimer' import BetButton from 'web/components/bet-button' - -import dayjs from 'dayjs' +import { BetsSummary } from 'web/components/bet-summary' export const getStaticProps = fromPropz(getStaticPropz) export async function getStaticPropz(props: { @@ -167,6 +167,10 @@ export function ContractPageContent( [bets] ) + const userBets = user + ? bets.filter((bet) => !bet.isAnte && bet.userId === user.id) + : [] + const [showConfetti, setShowConfetti] = useState(false) useEffect(() => { @@ -248,7 +252,14 @@ export function ContractPageContent( )} - + + + + {!user ? ( diff --git a/web/pages/home/index.tsx b/web/pages/home/index.tsx index b42b37bb..ba2851bf 100644 --- a/web/pages/home/index.tsx +++ b/web/pages/home/index.tsx @@ -33,7 +33,6 @@ import { groupPath, joinGroup, leaveGroup } from 'web/lib/firebase/groups' import { usePortfolioHistory } from 'web/hooks/use-portfolio-history' import { formatMoney } from 'common/util/format' 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 { ContractsGrid } from 'web/components/contract/contracts-grid' @@ -45,6 +44,7 @@ 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' +import { ProfitBadge } from 'web/components/profit-badge' import { LoadingIndicator } from 'web/components/loading-indicator' export default function Home() { From 5e34b5a911314ff46369765a018ea43599e65567 Mon Sep 17 00:00:00 2001 From: mantikoros Date: Tue, 27 Sep 2022 13:15:13 -0400 Subject: [PATCH 003/135] greyscale bet button if outcome is undefined --- web/components/bet-panel.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/components/bet-panel.tsx b/web/components/bet-panel.tsx index 1b9bddbe..90918283 100644 --- a/web/components/bet-panel.tsx +++ b/web/components/bet-panel.tsx @@ -401,7 +401,7 @@ export function BuyPanel(props: { isSubmitting={isSubmitting} openModalButtonClass={clsx( 'btn mb-2 flex-1', - betDisabled + betDisabled || outcome === undefined ? 'btn-disabled bg-greyscale-2' : outcome === 'NO' ? 'border-none bg-red-400 hover:bg-red-500' From e2047210b74e3c7e772679e50128722ac1e1d9fa Mon Sep 17 00:00:00 2001 From: Barak Gila Date: Tue, 27 Sep 2022 12:13:11 -0700 Subject: [PATCH 004/135] add to queue rather than invoking sprig object directly, as it's still being setup (#940) --- web/lib/service/sprig.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/lib/service/sprig.ts b/web/lib/service/sprig.ts index ee6052b7..9353020d 100644 --- a/web/lib/service/sprig.ts +++ b/web/lib/service/sprig.ts @@ -25,9 +25,9 @@ try { } export function setUserId(userId: string): void { - window.Sprig.setUserId(userId) + window.Sprig('setUserId', userId) } export function setAttributes(attributes: Record): void { - window.Sprig.setAttributes(attributes) + window.Sprig('setAttributes', attributes) } From 419c7ab636361238940c88c6dbcb53a33ccaf126 Mon Sep 17 00:00:00 2001 From: Austin Chen Date: Tue, 27 Sep 2022 17:16:48 -0400 Subject: [PATCH 005/135] Navigate to ?tab=portfolio --- web/components/nav/bottom-nav-bar.tsx | 4 +--- web/components/nav/profile-menu.tsx | 3 +-- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/web/components/nav/bottom-nav-bar.tsx b/web/components/nav/bottom-nav-bar.tsx index f906b21d..922e6646 100644 --- a/web/components/nav/bottom-nav-bar.tsx +++ b/web/components/nav/bottom-nav-bar.tsx @@ -20,8 +20,6 @@ import { useIsIframe } from 'web/hooks/use-is-iframe' import { trackCallback } from 'web/lib/service/analytics' import { User } from 'common/user' -import { PAST_BETS } from 'common/user' - function getNavigation() { return [ { name: 'Home', href: '/home', icon: HomeIcon }, @@ -42,7 +40,7 @@ const signedOutNavigation = [ export const userProfileItem = (user: User) => ({ name: formatMoney(user.balance), trackingEventName: 'profile', - href: `/${user.username}?tab=${PAST_BETS}`, + href: `/${user.username}?tab=portfolio`, icon: () => ( + Date: Tue, 27 Sep 2022 17:30:07 -0500 Subject: [PATCH 006/135] Date docs on Manifold (#941) * Date docs * Create date doc * Create and show a date market as well * Move url to date-docs * Date doc individual page * Add share button * Edit date docs * Layout * Add comments for create-post * Add comments and back nav * Fix urls * Tweaks --- common/post.ts | 8 ++ functions/src/api.ts | 2 +- functions/src/create-market.ts | 27 +++-- functions/src/create-post.ts | 39 +++++- web/components/site-link.tsx | 4 +- web/lib/firebase/posts.ts | 24 +++- web/pages/date-docs/[username].tsx | 159 ++++++++++++++++++++++++ web/pages/date-docs/create.tsx | 179 ++++++++++++++++++++++++++++ web/pages/date-docs/index.tsx | 72 +++++++++++ web/pages/post/[...slugs]/index.tsx | 19 +-- web/posts/post-comments.tsx | 2 - 11 files changed, 500 insertions(+), 35 deletions(-) create mode 100644 web/pages/date-docs/[username].tsx create mode 100644 web/pages/date-docs/create.tsx create mode 100644 web/pages/date-docs/index.tsx diff --git a/common/post.ts b/common/post.ts index 05eab685..13a90821 100644 --- a/common/post.ts +++ b/common/post.ts @@ -9,4 +9,12 @@ export type Post = { slug: string } +export type DateDoc = Post & { + bounty: number + birthday: number + photoUrl: string + type: 'date-doc' + contractSlug: string +} + export const MAX_POST_TITLE_LENGTH = 480 diff --git a/functions/src/api.ts b/functions/src/api.ts index 7440f16a..7134c8d8 100644 --- a/functions/src/api.ts +++ b/functions/src/api.ts @@ -14,7 +14,7 @@ import { export { APIError } from '../../common/api' type Output = Record -type AuthedUser = { +export type AuthedUser = { uid: string creds: JwtCredentials | (KeyCredentials & { privateUser: PrivateUser }) } diff --git a/functions/src/create-market.ts b/functions/src/create-market.ts index 300d91f2..d1483ca4 100644 --- a/functions/src/create-market.ts +++ b/functions/src/create-market.ts @@ -16,7 +16,7 @@ import { slugify } from '../../common/util/slugify' import { randomString } from '../../common/util/random' import { chargeUser, getContract, isProd } from './utils' -import { APIError, newEndpoint, validate, zTimestamp } from './api' +import { APIError, AuthedUser, newEndpoint, validate, zTimestamp } from './api' import { FIXED_ANTE, FREE_MARKETS_PER_USER_MAX } from '../../common/economy' import { @@ -92,7 +92,11 @@ const multipleChoiceSchema = z.object({ answers: z.string().trim().min(1).array().min(2), }) -export const createmarket = newEndpoint({}, async (req, auth) => { +export const createmarket = newEndpoint({}, (req, auth) => { + return createMarketHelper(req.body, auth) +}) + +export async function createMarketHelper(body: any, auth: AuthedUser) { const { question, description, @@ -101,16 +105,13 @@ export const createmarket = newEndpoint({}, async (req, auth) => { outcomeType, groupId, visibility = 'public', - } = validate(bodySchema, req.body) + } = validate(bodySchema, body) let min, max, initialProb, isLogScale, answers if (outcomeType === 'PSEUDO_NUMERIC' || outcomeType === 'NUMERIC') { let initialValue - ;({ min, max, initialValue, isLogScale } = validate( - numericSchema, - req.body - )) + ;({ min, max, initialValue, isLogScale } = validate(numericSchema, body)) if (max - min <= 0.01 || initialValue <= min || initialValue >= max) throw new APIError(400, 'Invalid range.') @@ -126,11 +127,11 @@ export const createmarket = newEndpoint({}, async (req, auth) => { } if (outcomeType === 'BINARY') { - ;({ initialProb } = validate(binarySchema, req.body)) + ;({ initialProb } = validate(binarySchema, body)) } if (outcomeType === 'MULTIPLE_CHOICE') { - ;({ answers } = validate(multipleChoiceSchema, req.body)) + ;({ answers } = validate(multipleChoiceSchema, body)) } const userDoc = await firestore.collection('users').doc(auth.uid).get() @@ -186,17 +187,17 @@ export const createmarket = newEndpoint({}, async (req, auth) => { // convert string descriptions into JSONContent const newDescription = - typeof description === 'string' + !description || typeof description === 'string' ? { type: 'doc', content: [ { type: 'paragraph', - content: [{ type: 'text', text: description }], + content: [{ type: 'text', text: description || ' ' }], }, ], } - : description ?? {} + : description const contract = getNewContract( contractRef.id, @@ -323,7 +324,7 @@ export const createmarket = newEndpoint({}, async (req, auth) => { } return contract -}) +} const getSlug = async (question: string) => { const proposedSlug = slugify(question) diff --git a/functions/src/create-post.ts b/functions/src/create-post.ts index 113a34bd..a342dc05 100644 --- a/functions/src/create-post.ts +++ b/functions/src/create-post.ts @@ -7,6 +7,9 @@ import { Post, MAX_POST_TITLE_LENGTH } from '../../common/post' import { APIError, newEndpoint, validate } from './api' import { JSONContent } from '@tiptap/core' import { z } from 'zod' +import { removeUndefinedProps } from '../../common/util/object' +import { createMarketHelper } from './create-market' +import { DAY_MS } from '../../common/util/time' const contentSchema: z.ZodType = z.lazy(() => z.intersection( @@ -35,11 +38,21 @@ const postSchema = z.object({ title: z.string().min(1).max(MAX_POST_TITLE_LENGTH), content: contentSchema, groupId: z.string().optional(), + + // Date doc fields: + bounty: z.number().optional(), + birthday: z.number().optional(), + photoUrl: z.string().optional(), + type: z.string().optional(), + question: z.string().optional(), }) export const createpost = newEndpoint({}, async (req, auth) => { const firestore = admin.firestore() - const { title, content, groupId } = validate(postSchema, req.body) + const { title, content, groupId, question, ...otherProps } = validate( + postSchema, + req.body + ) const creator = await getUser(auth.uid) if (!creator) @@ -51,14 +64,34 @@ export const createpost = newEndpoint({}, async (req, auth) => { const postRef = firestore.collection('posts').doc() - const post: Post = { + // If this is a date doc, create a market for it. + let contractSlug + if (question) { + const closeTime = Date.now() + DAY_MS * 30 * 3 + + const result = await createMarketHelper( + { + question, + closeTime, + outcomeType: 'BINARY', + visibility: 'unlisted', + initialProb: 50, + }, + auth + ) + contractSlug = result.slug + } + + const post: Post = removeUndefinedProps({ + ...otherProps, id: postRef.id, creatorId: creator.id, slug, title, createdTime: Date.now(), content: content, - } + contractSlug, + }) await postRef.create(post) if (groupId) { diff --git a/web/components/site-link.tsx b/web/components/site-link.tsx index f395e6a9..2b97f07d 100644 --- a/web/components/site-link.tsx +++ b/web/components/site-link.tsx @@ -6,13 +6,15 @@ export const linkClass = 'z-10 break-anywhere hover:underline hover:decoration-indigo-400 hover:decoration-2' export const SiteLink = (props: { - href: string + href: string | undefined children?: ReactNode onClick?: () => void className?: string }) => { const { href, children, onClick, className } = props + if (!href) return <>{children} + return ( ('posts') @@ -44,3 +45,22 @@ export async function listPosts(postIds?: string[]) { if (postIds === undefined) return [] return Promise.all(postIds.map(getPost)) } + +export async function getDateDocs() { + const q = query(posts, where('type', '==', 'date-doc')) + return getValues(q) +} + +export async function getDateDoc(username: string) { + const user = await getUserByUsername(username) + if (!user) return null + + const q = query( + posts, + where('type', '==', 'date-doc'), + where('creatorId', '==', user.id) + ) + const docs = await getValues(q) + const post = docs.length === 0 ? null : docs[0] + return { post, user } +} diff --git a/web/pages/date-docs/[username].tsx b/web/pages/date-docs/[username].tsx new file mode 100644 index 00000000..d6ac37cd --- /dev/null +++ b/web/pages/date-docs/[username].tsx @@ -0,0 +1,159 @@ +import { getDateDoc } from 'web/lib/firebase/posts' +import { ArrowLeftIcon, LinkIcon } from '@heroicons/react/outline' +import { Page } from 'web/components/page' +import dayjs from 'dayjs' + +import { DateDoc } from 'common/post' +import { Content } from 'web/components/editor' +import { Col } from 'web/components/layout/col' +import { Row } from 'web/components/layout/row' +import { SiteLink } from 'web/components/site-link' +import { User } from 'web/lib/firebase/users' +import { DOMAIN } from 'common/envs/constants' +import Custom404 from '../404' +import { ShareIcon } from '@heroicons/react/solid' +import clsx from 'clsx' +import { Button } from 'web/components/button' +import { track } from '@amplitude/analytics-browser' +import toast from 'react-hot-toast' +import { copyToClipboard } from 'web/lib/util/copy' +import { useUser } from 'web/hooks/use-user' +import { PostCommentsActivity, RichEditPost } from '../post/[...slugs]' +import { usePost } from 'web/hooks/use-post' +import { useTipTxns } from 'web/hooks/use-tip-txns' +import { useCommentsOnPost } from 'web/hooks/use-comments' + +export async function getStaticProps(props: { params: { username: string } }) { + const { username } = props.params + const { user: creator, post } = (await getDateDoc(username)) ?? { + creator: null, + post: null, + } + + return { + props: { + creator, + post, + }, + revalidate: 5, // regenerate after five seconds + } +} + +export async function getStaticPaths() { + return { paths: [], fallback: 'blocking' } +} + +export default function DateDocPageHelper(props: { + creator: User | null + post: DateDoc | null +}) { + const { creator, post } = props + + if (!creator || !post) return + + return +} + +function DateDocPage(props: { creator: User; post: DateDoc }) { + const { creator, post } = props + + const tips = useTipTxns({ postId: post.id }) + const comments = useCommentsOnPost(post.id) ?? [] + + return ( + + + + + + + + +
Add your endorsement of {creator.name}!
+ + + +
+ ) +} + +export function DateDocPost(props: { + dateDoc: DateDoc + creator: User + link?: boolean +}) { + const { dateDoc, creator, link } = props + const { content, birthday, photoUrl, contractSlug } = dateDoc + const { name, username } = creator + + const user = useUser() + const post = usePost(dateDoc.id) ?? dateDoc + + const age = dayjs().diff(birthday, 'year') + const shareUrl = `https://${DOMAIN}/date-docs/${username}` + const marketUrl = `https://${DOMAIN}/${username}/${contractSlug}` + + return ( + + + + +
+ {name}, {age} +
+ + + + +
+ {name} + +
+ {user && user.id === creator.id ? ( + + ) : ( + + )} +
+ +
+ + ) +} diff --git a/web/pages/date-docs/create.tsx b/web/pages/date-docs/create.tsx new file mode 100644 index 00000000..5d72da42 --- /dev/null +++ b/web/pages/date-docs/create.tsx @@ -0,0 +1,179 @@ +import Router from 'next/router' +import { useEffect, useState } from 'react' +import Textarea from 'react-expanding-textarea' + +import { DateDoc } from 'common/post' +import { useTextEditor, TextEditor } from 'web/components/editor' +import { Page } from 'web/components/page' +import { Title } from 'web/components/title' +import { useUser } from 'web/hooks/use-user' +import { createPost } from 'web/lib/firebase/api' +import { Row } from 'web/components/layout/row' +import { Button } from 'web/components/button' +import dayjs from 'dayjs' +import { MINUTE_MS } from 'common/util/time' +import { Col } from 'web/components/layout/col' +import { uploadImage } from 'web/lib/firebase/storage' +import { LoadingIndicator } from 'web/components/loading-indicator' +import { MAX_QUESTION_LENGTH } from 'common/contract' + +export default function CreateDateDocPage() { + const user = useUser() + + useEffect(() => { + if (user === null) Router.push('/date') + }) + + const title = `${user?.name}'s Date Doc` + const [birthday, setBirthday] = useState(undefined) + const [photoUrl, setPhotoUrl] = useState('') + const [avatarLoading, setAvatarLoading] = useState(false) + const [question, setQuestion] = useState( + 'Will I find a partner in the next 3 months?' + ) + + const [isSubmitting, setIsSubmitting] = useState(false) + + const { editor, upload } = useTextEditor({ + disabled: isSubmitting, + }) + + const birthdayTime = birthday ? dayjs(birthday).valueOf() : undefined + const isValid = + user && + birthday && + photoUrl && + editor && + editor.isEmpty === false && + question + + const fileHandler = async (event: any) => { + if (!user) return + + const file = event.target.files[0] + + setAvatarLoading(true) + + await uploadImage(user.username, file) + .then(async (url) => { + setPhotoUrl(url) + setAvatarLoading(false) + }) + .catch(() => { + setAvatarLoading(false) + setPhotoUrl('') + }) + } + + async function saveDateDoc() { + if (!user || !editor || !birthdayTime) return + + const newPost: Omit< + DateDoc, + 'id' | 'creatorId' | 'createdTime' | 'slug' | 'contractSlug' + > & { question: string } = { + title, + content: editor.getJSON(), + bounty: 0, + birthday: birthdayTime, + photoUrl, + type: 'date-doc', + question, + } + + const result = await createPost(newPost) + + if (result.post) { + await Router.push(`/date-docs/${user.username}`) + } + } + + return ( + +
+
+ + + <Button + type="submit" + disabled={isSubmitting || !isValid || upload.isLoading} + onClick={async () => { + setIsSubmitting(true) + await saveDateDoc() + setIsSubmitting(false) + }} + color="blue" + > + {isSubmitting ? 'Publishing...' : 'Publish'} + </Button> + </Row> + + <Col className="gap-8"> + <Col className="max-w-[160px] justify-start gap-4"> + <div className="">Birthday</div> + <input + type={'date'} + className="input input-bordered" + onClick={(e) => e.stopPropagation()} + onChange={(e) => setBirthday(e.target.value)} + max={Math.round(Date.now() / MINUTE_MS) * MINUTE_MS} + disabled={isSubmitting} + value={birthday} + /> + </Col> + + <Col className="gap-4"> + <div className="">Photo</div> + <Row className="items-center gap-4"> + {avatarLoading ? ( + <LoadingIndicator /> + ) : ( + <> + {photoUrl && ( + <img + src={photoUrl} + width={80} + height={80} + className="flex h-[80px] w-[80px] items-center justify-center rounded-lg bg-gray-400 object-cover" + /> + )} + <input + className="text-sm text-gray-500" + type="file" + name="file" + accept="image/*" + onChange={fileHandler} + /> + </> + )} + </Row> + </Col> + + <Col className="gap-4"> + <div className=""> + Tell us about you! What are you looking for? + </div> + <TextEditor editor={editor} upload={upload} /> + </Col> + + <Col className="gap-4"> + <div className=""> + Finally, we'll create an (unlisted) prediction market! + </div> + + <Col className="gap-2"> + <Textarea + className="input input-bordered resize-none" + maxLength={MAX_QUESTION_LENGTH} + value={question} + onChange={(e) => setQuestion(e.target.value || '')} + /> + <div className="ml-2 text-gray-500">Cost: M$100</div> + </Col> + </Col> + </Col> + </div> + </div> + </Page> + ) +} diff --git a/web/pages/date-docs/index.tsx b/web/pages/date-docs/index.tsx new file mode 100644 index 00000000..d2dd874c --- /dev/null +++ b/web/pages/date-docs/index.tsx @@ -0,0 +1,72 @@ +import { Page } from 'web/components/page' +import { PlusCircleIcon } from '@heroicons/react/outline' + +import { getDateDocs } from 'web/lib/firebase/posts' +import type { DateDoc } from 'common/post' +import { Title } from 'web/components/title' +import { Spacer } from 'web/components/layout/spacer' +import { Col } from 'web/components/layout/col' +import { useUser } from 'web/hooks/use-user' +import { Row } from 'web/components/layout/row' +import { Button } from 'web/components/button' +import { SiteLink } from 'web/components/site-link' +import { getUser, User } from 'web/lib/firebase/users' +import { DateDocPost } from './[username]' + +export async function getStaticProps() { + const dateDocs = await getDateDocs() + const docCreators = await Promise.all( + dateDocs.map((d) => getUser(d.creatorId)) + ) + + return { + props: { + dateDocs, + docCreators, + }, + + revalidate: 60, // regenerate after a minute + } +} + +export default function DatePage(props: { + dateDocs: DateDoc[] + docCreators: User[] +}) { + const { dateDocs, docCreators } = props + const user = useUser() + + const hasDoc = dateDocs.some((d) => d.creatorId === user?.id) + + return ( + <Page> + <div className="mx-auto w-full max-w-xl"> + <Row className="items-center justify-between"> + <Title className="!my-0 px-2 text-blue-500" text="Date docs" /> + {!hasDoc && ( + <SiteLink href="/date-docs/create" className="!no-underline"> + <Button className="flex flex-row gap-1" color="blue"> + <PlusCircleIcon + className={'h-5 w-5 flex-shrink-0 text-white'} + aria-hidden="true" + /> + New + </Button> + </SiteLink> + )} + </Row> + <Spacer h={6} /> + <Col className="gap-4"> + {dateDocs.map((dateDoc, i) => ( + <DateDocPost + key={dateDoc.id} + dateDoc={dateDoc} + creator={docCreators[i]} + link + /> + ))} + </Col> + </div> + </Page> + ) +} diff --git a/web/pages/post/[...slugs]/index.tsx b/web/pages/post/[...slugs]/index.tsx index 6cd4408f..b71b7cca 100644 --- a/web/pages/post/[...slugs]/index.tsx +++ b/web/pages/post/[...slugs]/index.tsx @@ -34,9 +34,9 @@ export async function getStaticProps(props: { params: { slugs: string[] } }) { return { props: { - post: post, - creator: creator, - comments: comments, + post, + creator, + comments, }, revalidate: 60, // regenerate after a minute @@ -117,12 +117,7 @@ export default function PostPage(props: { <Spacer h={4} /> <div className="rounded-lg bg-white px-6 py-4 sm:py-0"> - <PostCommentsActivity - post={post} - comments={comments} - tips={tips} - user={creator} - /> + <PostCommentsActivity post={post} comments={comments} tips={tips} /> </div> </div> </Page> @@ -133,9 +128,8 @@ export function PostCommentsActivity(props: { post: Post comments: PostComment[] tips: CommentTipMap - user: User | null | undefined }) { - const { post, comments, user, tips } = props + const { post, comments, tips } = props const commentsByUserId = groupBy(comments, (c) => c.userId) const commentsByParentId = groupBy(comments, (c) => c.replyToCommentId ?? '_') const topLevelComments = sortBy( @@ -149,7 +143,6 @@ export function PostCommentsActivity(props: { {topLevelComments.map((parent) => ( <PostCommentThread key={parent.id} - user={user} post={post} parentComment={parent} threadComments={sortBy( @@ -164,7 +157,7 @@ export function PostCommentsActivity(props: { ) } -function RichEditPost(props: { post: Post }) { +export function RichEditPost(props: { post: Post }) { const { post } = props const [editing, setEditing] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false) diff --git a/web/posts/post-comments.tsx b/web/posts/post-comments.tsx index b98887bb..f1d50a29 100644 --- a/web/posts/post-comments.tsx +++ b/web/posts/post-comments.tsx @@ -3,7 +3,6 @@ import { Editor } from '@tiptap/core' import clsx from 'clsx' import { PostComment } from 'common/comment' import { Post } from 'common/post' -import { User } from 'common/user' import { Dictionary } from 'lodash' import { useRouter } from 'next/router' import { useEffect, useState } from 'react' @@ -21,7 +20,6 @@ import { createCommentOnPost } from 'web/lib/firebase/comments' import { firebaseLogin } from 'web/lib/firebase/users' export function PostCommentThread(props: { - user: User | null | undefined post: Post threadComments: PostComment[] tips: CommentTipMap From 80d4bffc95390e5455f088f4237b0a14c6718cf9 Mon Sep 17 00:00:00 2001 From: mantikoros <95266179+mantikoros@users.noreply.github.com> Date: Tue, 27 Sep 2022 17:50:43 -0500 Subject: [PATCH 007/135] US Elections map (#943) * usa map * state election map * senate midterms * iframe * fix * /midterms * listen for updates --- web/components/usa-map/data.tsx | 302 ++++++++++++++++++ web/components/usa-map/state-election-map.tsx | 85 +++++ web/components/usa-map/usa-map.tsx | 106 ++++++ web/components/usa-map/usa-state.tsx | 34 ++ web/pages/midterms.tsx | 92 ++++++ 5 files changed, 619 insertions(+) create mode 100644 web/components/usa-map/data.tsx create mode 100644 web/components/usa-map/state-election-map.tsx create mode 100644 web/components/usa-map/usa-map.tsx create mode 100644 web/components/usa-map/usa-state.tsx create mode 100644 web/pages/midterms.tsx diff --git a/web/components/usa-map/data.tsx b/web/components/usa-map/data.tsx new file mode 100644 index 00000000..b198c4f0 --- /dev/null +++ b/web/components/usa-map/data.tsx @@ -0,0 +1,302 @@ +export const DATA = { + AK: { + dimensions: + 'M161.1,453.7 l-0.3,85.4 1.6,1 3.1,0.2 1.5,-1.1 h2.6 l0.2,2.9 7,6.8 0.5,2.6 3.4,-1.9 0.6,-0.2 0.3,-3.1 1.5,-1.6 1.1,-0.2 1.9,-1.5 3.1,2.1 0.6,2.9 1.9,1.1 1.1,2.4 3.9,1.8 3.4,6 2.7,3.9 2.3,2.7 1.5,3.7 5,1.8 5.2,2.1 1,4.4 0.5,3.1 -1,3.4 -1.8,2.3 -1.6,-0.8 -1.5,-3.1 -2.7,-1.5 -1.8,-1.1 -0.8,0.8 1.5,2.7 0.2,3.7 -1.1,0.5 -1.9,-1.9 -2.1,-1.3 0.5,1.6 1.3,1.8 -0.8,0.8 c0,0 -0.8,-0.3 -1.3,-1 -0.5,-0.6 -2.1,-3.4 -2.1,-3.4 l-1,-2.3 c0,0 -0.3,1.3 -1,1 -0.6,-0.3 -1.3,-1.5 -1.3,-1.5 l1.8,-1.9 -1.5,-1.5 v-5 h-0.8 l-0.8,3.4 -1.1,0.5 -1,-3.7 -0.6,-3.7 -0.8,-0.5 0.3,5.7 v1.1 l-1.5,-1.3 -3.6,-6 -2.1,-0.5 -0.6,-3.7 -1.6,-2.9 -1.6,-1.1 v-2.3 l2.1,-1.3 -0.5,-0.3 -2.6,0.6 -3.4,-2.4 -2.6,-2.9 -4.8,-2.6 -4,-2.6 1.3,-3.2 v-1.6 l-1.8,1.6 -2.9,1.1 -3.7,-1.1 -5.7,-2.4 h-5.5 l-0.6,0.5 -6.5,-3.9 -2.1,-0.3 -2.7,-5.8 -3.6,0.3 -3.6,1.5 0.5,4.5 1.1,-2.9 1,0.3 -1.5,4.4 3.2,-2.7 0.6,1.6 -3.9,4.4 -1.3,-0.3 -0.5,-1.9 -1.3,-0.8 -1.3,1.1 -2.7,-1.8 -3.1,2.1 -1.8,2.1 -3.4,2.1 -4.7,-0.2 -0.5,-2.1 3.7,-0.6 v-1.3 l-2.3,-0.6 1,-2.4 2.3,-3.9 v-1.8 l0.2,-0.8 4.4,-2.3 1,1.3 h2.7 l-1.3,-2.6 -3.7,-0.3 -5,2.7 -2.4,3.4 -1.8,2.6 -1.1,2.3 -4.2,1.5 -3.1,2.6 -0.3,1.6 2.3,1 0.8,2.1 -2.7,3.2 -6.5,4.2 -7.8,4.2 -2.1,1.1 -5.3,1.1 -5.3,2.3 1.8,1.3 -1.5,1.5 -0.5,1.1 -2.7,-1 -3.2,0.2 -0.8,2.3 h-1 l0.3,-2.4 -3.6,1.3 -2.9,1 -3.4,-1.3 -2.9,1.9 h-3.2 l-2.1,1.3 -1.6,0.8 -2.1,-0.3 -2.6,-1.1 -2.3,0.6 -1,1 -1.6,-1.1 v-1.9 l3.1,-1.3 6.3,0.6 4.4,-1.6 2.1,-2.1 2.9,-0.6 1.8,-0.8 2.7,0.2 1.6,1.3 1,-0.3 2.3,-2.7 3.1,-1 3.4,-0.6 1.3,-0.3 0.6,0.5 h0.8 l1.3,-3.7 4,-1.5 1.9,-3.7 2.3,-4.5 1.6,-1.5 0.3,-2.6 -1.6,1.3 -3.4,0.6 -0.6,-2.4 -1.3,-0.3 -1,1 -0.2,2.9 -1.5,-0.2 -1.5,-5.8 -1.3,1.3 -1.1,-0.5 -0.3,-1.9 -4,0.2 -2.1,1.1 -2.6,-0.3 1.5,-1.5 0.5,-2.6 -0.6,-1.9 1.5,-1 1.3,-0.2 -0.6,-1.8 v-4.4 l-1,-1 -0.8,1.5 h-6.1 l-1.5,-1.3 -0.6,-3.9 -2.1,-3.6 v-1 l2.1,-0.8 0.2,-2.1 1.1,-1.1 -0.8,-0.5 -1.3,0.5 -1.1,-2.7 1,-5 4.5,-3.2 2.6,-1.6 1.9,-3.7 2.7,-1.3 2.6,1.1 0.3,2.4 2.4,-0.3 3.2,-2.4 1.6,0.6 1,0.6 h1.6 l2.3,-1.3 0.8,-4.4 c0,0 0.3,-2.9 1,-3.4 0.6,-0.5 1,-1 1,-1 l-1.1,-1.9 -2.6,0.8 -3.2,0.8 -1.9,-0.5 -3.6,-1.8 -5,-0.2 -3.6,-3.7 0.5,-3.9 0.6,-2.4 -2.1,-1.8 -1.9,-3.7 0.5,-0.8 6.8,-0.5 h2.1 l1,1 h0.6 l-0.2,-1.6 3.9,-0.6 2.6,0.3 1.5,1.1 -1.5,2.1 -0.5,1.5 2.7,1.6 5,1.8 1.8,-1 -2.3,-4.4 -1,-3.2 1,-0.8 -3.4,-1.9 -0.5,-1.1 0.5,-1.6 -0.8,-3.9 -2.9,-4.7 -2.4,-4.2 2.9,-1.9 h3.2 l1.8,0.6 4.2,-0.2 3.7,-3.6 1.1,-3.1 3.7,-2.4 1.6,1 2.7,-0.6 3.7,-2.1 1.1,-0.2 1,0.8 4.5,-0.2 2.7,-3.1 h1.1 l3.6,2.4 1.9,2.1 -0.5,1.1 0.6,1.1 1.6,-1.6 3.9,0.3 0.3,3.7 1.9,1.5 7.1,0.6 6.3,4.2 1.5,-1 5.2,2.6 2.1,-0.6 1.9,-0.8 4.8,1.9z m-115.1,28.9 2.1,5.3 -0.2,1 -2.9,-0.3 -1.8,-4 -1.8,-1.5 h-2.4 l-0.2,-2.6 1.8,-2.4 1.1,2.4 1.5,1.5z m-2.6,33.5 3.7,0.8 3.7,1 0.8,1 -1.6,3.7 -3.1,-0.2 -3.4,-3.6z m-20.7,-14.1 1.1,2.6 1.1,1.6 -1.1,0.8 -2.1,-3.1 v-1.9z m-13.7,73.1 3.4,-2.3 3.4,-1 2.6,0.3 0.5,1.6 1.9,0.5 1.9,-1.9 -0.3,-1.6 2.7,-0.6 2.9,2.6 -1.1,1.8 -4.4,1.1 -2.7,-0.5 -3.7,-1.1 -4.4,1.5 -1.6,0.3z m48.9,-4.5 1.6,1.9 2.1,-1.6 -1.5,-1.3z m2.9,3 1.1,-2.3 2.1,0.3 -0.8,1.9 h-2.4z m23.6,-1.9 1.5,1.8 1,-1.1 -0.8,-1.9z m8.8,-12.5 1.1,5.8 2.9,0.8 5,-2.9 4.4,-2.6 -1.6,-2.4 0.5,-2.4 -2.1,1.3 -2.9,-0.8 1.6,-1.1 1.9,0.8 3.9,-1.8 0.5,-1.5 -2.4,-0.8 0.8,-1.9 -2.7,1.9 -4.7,3.6 -4.8,2.9z m42.3,-19.8 2.4,-1.5 -1,-1.8 -1.8,1z', + abbreviation: 'AK', + name: 'Alaska', + }, + HI: { + dimensions: + 'M233.1,519.3 l1.9,-3.6 2.3,-0.3 0.3,0.8 -2.1,3.1z m10.2,-3.7 6.1,2.6 2.1,-0.3 1.6,-3.9 -0.6,-3.4 -4.2,-0.5 -4,1.8z m30.7,10 3.7,5.5 2.4,-0.3 1.1,-0.5 1.5,1.3 3.7,-0.2 1,-1.5 -2.9,-1.8 -1.9,-3.7 -2.1,-3.6 -5.8,2.9z m20.2,8.9 1.3,-1.9 4.7,1 0.6,-0.5 6.1,0.6 -0.3,1.3 -2.6,1.5 -4.4,-0.3z m5.3,5.2 1.9,3.9 3.1,-1.1 0.3,-1.6 -1.6,-2.1 -3.7,-0.3z m7,-1.2 2.3,-2.9 4.7,2.4 4.4,1.1 4.4,2.7 v1.9 l-3.6,1.8 -4.8,1 -2.4,-1.5z m16.6,15.6 1.6,-1.3 3.4,1.6 7.6,3.6 3.4,2.1 1.6,2.4 1.9,4.4 4,2.6 -0.3,1.3 -3.9,3.2 -4.2,1.5 -1.5,-0.6 -3.1,1.8 -2.4,3.2 -2.3,2.9 -1.8,-0.2 -3.6,-2.6 -0.3,-4.5 0.6,-2.4 -1.6,-5.7 -2.1,-1.8 -0.2,-2.6 2.3,-1 2.1,-3.1 0.5,-1 -1.6,-1.8z', + abbreviation: 'HI', + name: 'Hawaii', + }, + AL: { + dimensions: + 'M628.5,466.4 l0.6,0.2 1.3,-2.7 1.5,-4.4 2.3,0.6 3.1,6 v1 l-2.7,1.9 2.7,0.3 5.2,-2.5 -0.3,-7.6 -2.5,-1.8 -2,-2 0.4,-4 10.5,-1.5 25.7,-2.9 6.7,-0.6 5.6,0.1 -0.5,-2.2 -1.5,-0.8 -0.9,-1.1 1,-2.6 -0.4,-5.2 -1.6,-4.5 0.8,-5.1 1.7,-4.8 -0.2,-1.7 -1.8,-0.7 -0.5,-3.6 -2.7,-3.4 -2,-6.5 -1.4,-6.7 -1.8,-5 -3.8,-16 -3.5,-7.9 -0.8,-5.6 0.1,-2.2 -9,0.8 -23.4,2.2 -12.2,0.8 -0.2,6.4 0.2,16.7 -0.7,31 -0.3,14.1 2.8,18.8 1.6,14.7z', + abbreviation: 'AL', + name: 'Alabama', + }, + AR: { + dimensions: + 'M587.3,346.1 l-6.4,-0.7 0.9,-3.1 3.1,-2.6 0.6,-2.3 -1.8,-2.9 -31.9,1.2 -23.3,0.7 -23.6,0.3 1.5,6.9 0.1,8.5 1.4,10.9 0.3,38.2 2.1,1.6 3,-1.2 2.9,1.2 0.4,10.1 25.2,-0.2 26.8,-0.8 0.9,-1.9 -0.3,-3.8 -1.7,-3.1 1.5,-1.4 -1.4,-2.2 0.7,-2.4 1.1,-5.9 2.7,-2.3 -0.8,-2.2 4,-5.6 2.5,-1.1 -0.1,-1.7 -0.5,-1.7 2.9,-5.8 2.5,-1.1 0.2,-3.3 2.1,-1.4 0.9,-4.1 -1.4,-4 4.2,-2.4 0.3,-2.1 1.2,-4.2 0.9,-3.1z', + abbreviation: 'AR', + name: 'Arkansas', + }, + AZ: { + dimensions: + 'M135.1,389.7 l-0.3,1.5 0.5,1 18.9,10.7 12.1,7.6 14.7,8.6 16.8,10 12.3,2.4 25.4,2.7 6,-39.6 7,-53.1 4.4,-31 -24.6,-3.6 -60.7,-11 -0.2,1.1 -2.6,16.5 -2.1,3.8 -2.8,-0.2 -1.2,-2.6 -2.6,-0.4 -1.2,-1.1 -1.1,0.1 -2.1,1.7 -0.3,6.8 -0.3,1.5 -0.5,12.5 -1.5,2.4 -0.4,3.3 2.8,5 1.1,5.5 0.7,1.1 1.1,0.9 -0.4,2.4 -1.7,1.2 -3.4,1.6 -1.6,1.8 -1.6,3.6 -0.5,4.9 -3,2.9 -1.9,0.9 -0.1,5.8 -0.6,1.6 0.5,0.8 3.9,0.4 -0.9,3 -1.7,2.4 -3.7,0.4z', + abbreviation: 'AZ', + name: 'Arizona', + }, + CA: { + dimensions: + 'M122.7,385.9 l-19.7,-2.7 -10,-1.5 -0.5,-1.8 v-9.4 l-0.3,-3.2 -2.6,-4.2 -0.8,-2.3 -3.9,-4.2 -2.9,-4.7 -2.7,-0.2 -3.2,-0.8 -0.3,-1 1.5,-0.6 -0.6,-3.2 -1.5,-2.1 -4.8,-0.8 -3.9,-2.1 -1.1,-2.3 -2.6,-4.8 -2.9,-3.1 h-2.9 l-3.9,-2.1 -4.5,-1.8 -4.2,-0.5 -2.4,-2.7 0.5,-1.9 1.8,-7.1 0.8,-1.9 v-2.4 l-1.6,-1 -0.5,-2.9 -1.5,-2.6 -3.4,-5.8 -1.3,-3.1 -1.5,-4.7 -1.6,-5.3 -3.2,-4.4 -0.5,-2.9 0.8,-3.9 h1.1 l2.1,-1.6 1.1,-3.6 -1,-2.7 -2.7,-0.5 -1.9,-2.6 -2.1,-3.7 -0.2,-8.2 0.6,-1.9 0.6,-2.3 0.5,-2.4 -5.7,-6.3 v-2.1 l0.3,-0.5 0.3,-3.2 -1.3,-4 -2.3,-4.8 -2.7,-4.5 -1.8,-3.9 1,-3.7 0.6,-5.8 1.8,-3.1 0.3,-6.5 -1.1,-3.6 -1.6,-4.2 -2.7,-4.2 0.8,-3.2 1.5,-4.2 1.8,-0.8 0.3,-1.1 3.1,-2.6 5.2,-11.8 0.2,-7.4 1.69,-4.9 38.69,11.8 25.6,6.6 -8,31.3 -8.67,33.1 12.63,19.2 42.16,62.3 17.1,26.1 -0.4,3.1 2.8,5.2 1.1,5.4 1,1.5 0.7,0.6 -0.2,1.4 -1.4,1 -3.4,1.6 -1.9,2.1 -1.7,3.9 -0.5,4.7 -2.6,2.5 -2.3,1.1 -0.1,6.2 -0.6,1.9 1,1.7 3,0.3 -0.4,1.6 -1.4,2 -3.9,0.6z m-73.9,-48.9 1.3,1.5 -0.2,1.3 -3.2,-0.1 -0.6,-1.2 -0.6,-1.5z m1.9,0 1.2,-0.6 3.6,2.1 3.1,1.2 -0.9,0.6 -4.5,-0.2 -1.6,-1.6z m20.7,19.8 1.8,2.3 0.8,1 1.5,0.6 0.6,-1.5 -1,-1.8 -2.7,-2 -1.1,0.2 v1.2z m-1.4,8.7 1.8,3.2 1.2,1.9 -1.5,0.2 -1.3,-1.2 c0,0 -0.7,-1.5 -0.7,-1.9 0,-0.4 0,-2.2 0,-2.2z', + abbreviation: 'CA', + name: 'California', + }, + CO: { + dimensions: + 'M380.2,235.5 l-36,-3.5 -79.1,-8.6 -2.2,22.1 -7,50.4 -1.9,13.7 34,3.9 37.5,4.4 34.7,3 14.3,0.6z', + abbreviation: 'CO', + name: 'Colorado', + }, + CT: { + dimensions: + 'M852,190.9 l3.6,-3.2 1.9,-2.1 0.8,0.6 2.7,-1.5 5.2,-1.1 7,-3.5 -0.6,-4.2 -0.8,-4.4 -1.6,-6 -4.3,1.1 -21.8,4.7 0.6,3.1 1.5,7.3 v8.3 l-0.9,2.1 1.7,2.2z', + abbreviation: 'CT', + name: 'Connecticut', + }, + DE: { + dimensions: + 'M834.4,247.2 l-1,0.5 -3.6,-2.4 -1.8,-4.7 -1.9,-3.6 -2.3,-1 -2.1,-3.6 0.5,-2 0.5,-2.3 0.1,-1.1 -0.6,0.1 -1.7,1 -2,1.7 -0.2,0.3 1.4,4.1 2.3,5.6 3.7,16.1 5,-0.3 6,-1.1z', + abbreviation: 'DE', + name: 'Delaware', + }, + FL: { + dimensions: + 'M750.2,445.2 l-5.2,-0.7 -0.7,0.8 1.5,4.4 -0.4,5.2 -4.1,-1 -0.2,-2.8 h-4.1 l-5.3,0.7 -32.4,1.9 -8.2,-0.3 -1.7,-1.7 -2.5,-4.2 h-5.9 l-6.6,0.5 -35.4,4.2 -0.3,2.8 1.6,1.6 2.9,2 0.3,8.4 3.3,-0.6 6,-2.1 6,-0.5 4.4,-0.6 7.6,1.8 8.1,3.9 1.6,1.5 2.9,1.1 1.6,1.9 0.3,2.7 3.2,-1.3 h3.9 l3.6,-1.9 3.7,-3.6 3.1,0.2 0.5,-1.1 -0.8,-1 0.2,-1.9 4,-0.8 h2.6 l2.9,1.5 4.2,1.5 2.4,3.7 2.7,1 1.1,3.4 3.4,1.6 1.6,2.6 1.9,0.6 5.2,1.3 1.3,3.1 3,3.7 v9.5 l-1.5,4.7 0.3,2.7 1.3,4.8 1.8,4 0.8,-0.5 1.5,-4.5 -2.6,-1 -0.3,-0.6 1.6,-0.6 4.5,1 0.2,1.6 -3.2,5.5 -2.1,2.4 3.6,3.7 2.6,3.1 2.9,5.3 2.9,3.9 2.1,5 1.8,0.3 1.6,-2.1 1.8,1.1 2.6,4 0.6,3.6 3.1,4.4 0.8,-1.3 3.9,0.3 3.6,2.3 3.4,5.2 0.8,3.4 0.3,2.9 1.1,1 1.3,0.5 2.4,-1 1.5,-1.6 3.9,-0.2 3.1,-1.5 2.7,-3.2 -0.5,-1.9 -0.3,-2.4 0.6,-1.9 -0.3,-1.9 2.4,-1.3 0.3,-3.4 -0.6,-1.8 -0.5,-12 -1.3,-7.6 -4.5,-8.2 -3.6,-5.8 -2.6,-5.3 -2.9,-2.9 -2.9,-7.4 0.7,-1.4 1.1,-1.3 -1.6,-2.9 -4,-3.7 -4.8,-5.5 -3.7,-6.3 -5.3,-9.4 -3.7,-9.7 -2.3,-7.3z m17.7,132.7 2.4,-0.6 1.3,-0.2 1.5,-2.3 2.3,-1.6 1.3,0.5 1.7,0.3 0.4,1.1 -3.5,1.2 -4.2,1.5 -2.3,1.2z m13.5,-5 1.2,1.1 2.7,-2.1 5.3,-4.2 3.7,-3.9 2.5,-6.6 1,-1.7 0.2,-3.4 -0.7,0.5 -1,2.8 -1.5,4.6 -3.2,5.3 -4.4,4.2 -3.4,1.9z', + abbreviation: 'FL', + name: 'Florida', + }, + GA: { + dimensions: + 'M750.2,444.2 l-5.6,-0.7 -1.4,1.6 1.6,4.7 -0.3,3.9 -2.2,-0.6 -0.2,-3 h-5.2 l-5.3,0.7 -32.3,1.9 -7.7,-0.3 -1.4,-1.2 -2.5,-4.3 -0.8,-3.3 -1.6,-0.9 -0.5,-0.5 0.9,-2.2 -0.4,-5.5 -1.6,-4.5 0.8,-4.9 1.7,-4.8 -0.2,-2.5 -1.9,-0.7 -0.4,-3.2 -2.8,-3.5 -1.9,-6.2 -1.5,-7 -1.7,-4.8 -3.8,-16 -3.5,-8 -0.8,-5.3 0.1,-2.3 3.3,-0.3 13.6,-1.6 18.6,-2 6.3,-1.1 0.5,1.4 -2.2,0.9 -0.9,2.2 0.4,2 1.4,1.6 4.3,2.7 3.2,-0.1 3.2,4.7 0.6,1.6 2.3,2.8 0.5,1.7 4.7,1.8 3,2.2 2.3,3 2.3,1.3 2,1.8 1.4,2.7 2.1,1.9 4.1,1.8 2.7,6 1.7,5.1 2.8,0.7 2.1,1.9 2,5.7 2.9,1.6 1.7,-0.8 0.4,1.2 -3.3,6.2 0.5,2.6 -1.5,4.2 -2.3,10 0.8,6.3z', + abbreviation: 'GA', + name: 'Georgia', + }, + IA: { + dimensions: + 'M556.8,183.6 l2.1,2.1 0.3,0.7 -2,3 0.3,4 2.6,4.1 3.1,1.6 2.4,0.3 0.9,1.8 0.2,2.4 2.5,1 0.9,1.1 0.5,1.6 3.8,3.3 0.6,1.9 -0.7,3 -1.7,3.7 -0.6,2.4 -2.1,1.6 -1.6,0.5 -5.7,1.5 -1.6,4.8 0.8,1.8 1.7,1.5 -0.2,3.5 -1.9,1.4 -0.7,1.8 v2.4 l-1.4,0.4 -1.7,1.4 -0.5,1.7 0.4,1.7 -1.3,1 -2.3,-2.7 -1.4,-2.8 -8.3,0.8 -10,0.6 -49.2,1.2 -1.6,-4.3 -0.4,-6.7 -1.4,-4.2 -0.7,-5.2 -2.2,-3.7 -1,-4.6 -2.7,-7.8 -1.1,-5.6 -1.4,-1.9 -1.3,-2.9 1.7,-3.8 1.2,-6.1 -2.7,-2.2 -0.3,-2.4 0.7,-2.4 1.8,-0.3 61.1,-0.6 21.2,-0.7z', + abbreviation: 'IA', + name: 'Iowa', + }, + ID: { + dimensions: + 'M175.3,27.63 l-4.8,17.41 -4.5,20.86 -3.4,16.22 -0.4,9.67 1.2,4.44 3.5,2.66 -0.2,3.91 -3.9,4.4 -4.5,6.6 -0.9,2.9 -1.2,1.1 -1.8,0.8 -4.3,5.3 -0.4,3.1 -0.4,1.1 0.6,1 2.6,-0.1 1.1,2.3 -2.4,5.8 -1.2,4.2 -8.8,35.3 20.7,4.5 39.5,7.9 34.8,6.1 4.9,-29.2 3.8,-24.1 -2.7,-2.4 -0.4,-2.6 -0.8,-1.1 -2.1,1 -0.7,2.6 -3.2,0.5 -3.9,-1.6 -3.8,0.1 -2.5,0.7 -3.4,-1.5 -2.4,0.2 -2.4,2 -2,-1.1 -0.7,-4 0.7,-2.9 -2.5,-2.9 -3.3,-2.6 -2.7,-13.1 -0.1,-4.7 -0.3,-0.1 -0.2,0.4 -5.1,3.5 -1.7,-0.2 -2.9,-3.4 -0.2,-3.1 7,-17.13 -0.4,-1.94 -3.4,-1.15 -0.6,-1.18 -2.6,-3.46 -4.6,-10.23 -3.2,-1.53 -2,-4.95 1.3,-4.63 -3.2,-7.58 4.4,-21.52z', + abbreviation: 'ID', + name: 'Idaho', + }, + IL: { + dimensions: + 'M618.7,214.3 l-0.8,-2.6 -1.3,-3.7 -1.6,-1.8 -1.5,-2.6 -0.4,-5.5 -15.9,1.8 -17.4,1 h-12.3 l0.2,2.1 2.2,0.9 1.1,1.4 0.4,1.4 3.9,3.4 0.7,2.4 -0.7,3.3 -1.7,3.7 -0.8,2.7 -2.4,1.9 -1.9,0.6 -5.2,1.3 -1.3,4.1 0.6,1.1 1.9,1.8 -0.2,4.3 -2.1,1.6 -0.5,1.3 v2.8 l-1.8,0.6 -1.4,1.2 -0.4,1.2 0.4,2 -1.6,1.3 -0.9,2.8 0.3,3.9 2.3,7 7,7.6 5.7,3.7 v4.4 l0.7,1.2 6.6,0.6 2.7,1.4 -0.7,3.5 -2.2,6.2 -0.8,3 2,3.7 6.4,5.3 4.8,0.8 2.2,5.1 2,3.4 -0.9,2.8 1.5,3.8 1.7,2.1 1.6,-0.3 1,-2.2 2.4,-1.7 2.8,-1 6.1,2.5 0.5,-0.2 v-1.1 l-1.2,-2.7 0.4,-2.8 2.4,-1.6 3.4,-1.2 -0.5,-1.3 -0.8,-2 1.2,-1.3 1,-2.7 v-4 l0.4,-4.9 2.5,-3 1.8,-3.8 2.5,-4 -0.5,-5.3 -1.8,-3.2 -0.3,-3.3 0.8,-5.3 -0.7,-7.2 -1.1,-15.8 -1.4,-15.3 -0.9,-11.7z', + abbreviation: 'IL', + name: 'Illinois', + }, + IN: { + dimensions: + 'M622.9,216.1 l1.5,1 1.1,-0.3 2.1,-1.9 2.5,-1.8 14.3,-1.1 18.4,-1.8 1.6,15.5 4.9,42.6 -0.6,2.9 1.3,1.6 0.2,1.3 -2.3,1.6 -3.6,1.7 -3.2,0.4 -0.5,4.8 -4.7,3.6 -2.9,4 0.2,2.4 -0.5,1.4 h-3.5 l-1.4,-1.7 -5.2,3 0.2,3.1 -0.9,0.2 -0.5,-0.9 -2.4,-1.7 -3.6,1.5 -1.4,2.9 -1.2,-0.6 -1.6,-1.8 -4.4,0.5 -5.7,1 -2.5,1.3 v-2.6 l0.4,-4.7 2.3,-2.9 1.8,-3.9 2.7,-4.2 -0.5,-5.8 -1.8,-3.1 -0.3,-3.2 0.8,-5.3 -0.7,-7.1 -0.9,-12.6 -2.5,-30.1z', + abbreviation: 'IN', + name: 'Indiana', + }, + KS: { + dimensions: + 'M485.9,259.5 l-43.8,-0.6 -40.6,-1.2 -21.7,-0.9 -4.3,64.8 24.3,1 44.7,2.1 46.3,0.6 12.6,-0.3 0.7,-35 -1.2,-11.1 -2.5,-2 -2.4,-3 -2.3,-3.6 0.6,-3 1.7,-1.4 v-2.1 l-0.8,-0.7 -2.6,-0.2 -3.5,-3.4z', + abbreviation: 'KS', + name: 'Kansas', + }, + KY: { + dimensions: + 'M607.2,331.8 l12.6,-0.7 0.1,-4.1 h4.3 l30.4,-3.2 45.1,-4.3 5.6,-3.6 3.9,-2.1 0.1,-1.9 6,-7.8 4.1,-3.6 2.1,-2.4 -3.3,-2 -2.5,-2.7 -3,-3.8 -0.5,-2.2 -2.6,-1.4 -0.9,-1.9 -0.2,-6.1 -2.6,-2 -1.9,-1.1 -0.5,-2.3 -1.3,0.2 -2,1.2 -2.5,2.7 -1.9,-1.7 -2.5,-0.5 -2.4,1.4 h-2.3 l-1.8,-2 -5.6,-0.1 -1.8,-4.5 -2.9,-1.5 -2.1,0.8 -4.2,0.2 -0.5,2.1 1.2,1.5 0.3,2.1 -2.8,2 -3.8,1.8 -2.6,0.4 -0.5,4.5 -4.9,3.6 -2.6,3.7 0.2,2.2 -0.9,2.3 -4.5,-0.1 -1.3,-1.3 -3.9,2.2 0.2,3.3 -2.4,0.6 -0.8,-1.4 -1.7,-1.2 -2.7,1.1 -1.8,3.5 -2.2,-1 -1.4,-1.6 -3.7,0.4 -5.6,1 -2.8,1.3 -1.2,3.4 -1,1 1.5,3.7 -4.2,1.4 -1.9,1.4 -0.4,2.2 1.2,2.4 v2.2 l-1.6,0.4 -6.1,-2.5 -2.3,0.9 -2,1.4 -0.8,1.8 1.7,2.4 -0.9,1.8 -0.1,3.3 -2.4,1.3 -2.1,1.7z', + abbreviation: 'KY', + name: 'Kentucky', + }, + LA: { + dimensions: + 'M526.9,485.9 l8.1,-0.3 10.3,3.6 6.5,1.1 3.7,-1.5 3.2,1.1 3.2,1 0.8,-2.1 -3.2,-1.1 -2.6,0.5 -2.7,-1.6 0.8,-1.5 3.1,-1 1.8,1.5 1.8,-1 3.2,0.6 1.5,2.4 0.3,2.3 4.5,0.3 1.8,1.8 -0.8,1.6 -1.3,0.8 1.6,1.6 8.4,3.6 3.6,-1.3 1,-2.4 2.6,-0.6 1.8,-1.5 1.3,1 0.8,2.9 -2.3,0.8 0.6,0.6 3.4,-1.3 2.3,-3.4 0.8,-0.5 -2.1,-0.3 0.8,-1.6 -0.2,-1.5 2.1,-0.5 1.1,-1.3 0.6,0.8 0.6,3.1 4.2,0.6 4,1.9 1,1.5 h2.9 l1.1,1 2.3,-3.1 v-1.5 h-1.3 l-3.4,-2.7 -5.8,-0.8 -3.2,-2.3 1.1,-2.7 2.3,0.3 0.2,-0.6 -1.8,-1 v-0.5 h3.2 l1.8,-3.1 -1.3,-1.9 -0.3,-2.7 -1.5,0.2 -1.9,2.1 -0.6,2.6 -3.1,-0.6 -1,-1.8 1.8,-1.9 1.9,-1.7 -2.2,-6.5 -3.4,-3.4 1,-7.3 -0.2,-0.5 -1.3,0.2 -33.1,1.4 -0.8,-2.4 0.8,-8.5 8.6,-14.8 -0.9,-2.6 1.4,-0.4 0.4,-2 -2.2,-2 0.1,-1.9 -2,-4.5 -0.4,-5.1 0.1,-0.7 -26.4,0.8 -25.2,0.1 0.4,9.7 0.7,9.5 0.5,3.7 2.6,4.5 0.9,4.4 4.3,6 0.3,3.1 0.6,0.8 -0.7,8.3 -2.8,4.6 1.2,2.4 -0.5,2.6 -0.8,7.3 -1.3,3 0.2,3.7z', + abbreviation: 'LA', + name: 'Louisiana', + }, + MA: { + dimensions: + 'M887.5,172.5 l-0.5,-2.3 0.8,-1.5 2.9,-1.5 0.8,3.1 -0.5,1.8 -2.4,1.5 v1 l1.9,-1.5 3.9,-4.5 3.9,-1.9 4.2,-1.5 -0.3,-2.4 -1,-2.9 -1.9,-2.4 -1.8,-0.8 -2.1,0.2 -0.5,0.5 1,1.3 1.5,-0.8 2.1,1.6 0.8,2.7 -1.8,1.8 -2.3,1 -3.6,-0.5 -3.9,-6 -2.3,-2.6 h-1.8 l-1.1,0.8 -1.9,-2.6 0.3,-1.5 2.4,-5.2 -2.9,-4.4 -3.7,1.8 -1.8,2.9 -18.3,4.7 -13.8,2.5 -0.6,10.6 0.7,4.9 22,-4.8 11.2,-2.8 2,1.6 3.4,4.3 2.9,4.7z m12.5,1.4 2.2,-0.7 0.5,-1.7 1,0.1 1,2.3 -1.3,0.5 -3.9,0.1z m-9.4,0.8 2.3,-2.6 h1.6 l1.8,1.5 -2.4,1 -2.2,1z', + abbreviation: 'MA', + name: 'Massachusetts', + }, + MD: { + dimensions: + 'M834.8,264.1 l1.7,-3.8 0.5,-4.8 -6.3,1.1 -5.8,0.3 -3.8,-16.8 -2.3,-5.5 -1.5,-4.6 -22.2,4.3 -37.6,7.6 2,10.4 4.8,-4.9 2.5,-0.7 1.4,-1.5 1.8,-2.7 1.6,0.7 2.6,-0.2 2.6,-2.1 2,-1.5 2.1,-0.6 1.5,1.1 2.7,1.4 1.9,1.8 1.3,1.4 4.8,1.6 -0.6,2.9 5.8,2.1 2.1,-2.6 3.7,2.5 -2.1,3.3 -0.7,3.3 -1.8,2.6 v2.1 l0.3,0.8 2,1.3 3.4,1.1 4.3,-0.1 3.1,1 2.1,0.3 1,-2.1 -1.5,-2.1 v-1.8 l-2.4,-2.1 -2.1,-5.5 1.3,-5.3 -0.2,-2.1 -1.3,-1.3 c0,0 1.5,-1.6 1.5,-2.3 0,-0.6 0.5,-2.1 0.5,-2.1 l1.9,-1.3 1.9,-1.6 0.5,1 -1.5,1.6 -1.3,3.7 0.3,1.1 1.8,0.3 0.5,5.5 -2.1,1 0.3,3.6 0.5,-0.2 1.1,-1.9 1.6,1.8 -1.6,1.3 -0.3,3.4 2.6,3.4 3.9,0.5 1.6,-0.8 3.2,4.2 1,0.4z m-14.5,0.2 1.1,2.5 0.2,1.8 1.1,1.9 c0,0 0.9,-0.9 0.9,-1.2 0,-0.3 -0.7,-3.1 -0.7,-3.1 l-0.7,-2.3z', + abbreviation: 'MD', + name: 'Maryland', + }, + ME: { + dimensions: + 'M865.8,91.9 l1.5,0.4 v-2.6 l0.8,-5.5 2.6,-4.7 1.5,-4 -1.9,-2.4 v-6 l0.8,-1 0.8,-2.7 -0.2,-1.5 -0.2,-4.8 1.8,-4.8 2.9,-8.9 2.1,-4.2 h1.3 l1.3,0.2 v1.1 l1.3,2.3 2.7,0.6 0.8,-0.8 v-1 l4,-2.9 1.8,-1.8 1.5,0.2 6,2.4 1.9,1 9.1,29.9 h6 l0.8,1.9 0.2,4.8 2.9,2.3 h0.8 l0.2,-0.5 -0.5,-1.1 2.8,-0.5 1.9,2.1 2.3,3.7 v1.9 l-2.1,4.7 -1.9,0.6 -3.4,3.1 -4.8,5.5 c0,0 -0.6,0 -1.3,0 -0.6,0 -1,-2.1 -1,-2.1 l-1.8,0.2 -1,1.5 -2.4,1.5 -1,1.5 1.6,1.5 -0.5,0.6 -0.5,2.7 -1.9,-0.2 v-1.6 l-0.3,-1.3 -1.5,0.3 -1.8,-3.2 -2.1,1.3 1.3,1.5 0.3,1.1 -0.8,1.3 0.3,3.1 0.2,1.6 -1.6,2.6 -2.9,0.5 -0.3,2.9 -5.3,3.1 -1.3,0.5 -1.6,-1.5 -3.1,3.6 1,3.2 -1.5,1.3 -0.2,4.4 -1.1,6.3 -2.2,-0.9 -0.5,-3.1 -4,-1.1 -0.2,-2.5 -11.7,-37.43z m36.5,15.6 1.5,-1.5 1.4,1.1 0.6,2.4 -1.7,0.9z m6.7,-5.9 1.8,1.9 c0,0 1.3,0.1 1.3,-0.2 0,-0.3 0.2,-2 0.2,-2 l0.9,-0.8 -0.8,-1.8 -2,0.7z', + abbreviation: 'ME', + name: 'Maine', + }, + MI: { + dimensions: + 'M644.5,211 l19.1,-1.9 0.2,1.1 9.9,-1.5 12,-1.7 0.1,-0.6 0.2,-1.5 2.1,-3.7 2,-1.7 -0.2,-5.1 1.6,-1.6 1.1,-0.3 0.2,-3.6 1.5,-3 1.1,0.6 0.2,0.6 0.8,0.2 1.9,-1 -0.4,-9.1 -3.2,-8.2 -2.3,-9.1 -2.4,-3.2 -2.6,-1.8 -1.6,1.1 -3.9,1.8 -1.9,5 -2.7,3.7 -1.1,0.6 -1.5,-0.6 c0,0 -2.6,-1.5 -2.4,-2.1 0.2,-0.6 0.5,-5 0.5,-5 l3.4,-1.3 0.8,-3.4 0.6,-2.6 2.4,-1.6 -0.3,-10 -1.6,-2.3 -1.3,-0.8 -0.8,-2.1 0.8,-0.8 1.6,0.3 0.2,-1.6 -2.6,-2.2 -1.3,-2.6 h-2.6 l-4.5,-1.5 -5.5,-3.4 h-2.7 l-0.6,0.6 -1,-0.5 -3.1,-2.3 -2.9,1.8 -2.9,2.3 0.3,3.6 1,0.3 2.1,0.5 0.5,0.8 -2.6,0.8 -2.6,0.3 -1.5,1.8 -0.3,2.1 0.3,1.6 0.3,5.5 -3.6,2.1 -0.6,-0.2 v-4.2 l1.3,-2.4 0.6,-2.4 -0.8,-0.8 -1.9,0.8 -1,4.2 -2.7,1.1 -1.8,1.9 -0.2,1 0.6,0.8 -0.6,2.6 -2.3,0.5 v1.1 l0.8,2.4 -1.1,6.1 -1.6,4 0.6,4.7 0.5,1.1 -0.8,2.4 -0.3,0.8 -0.3,2.7 3.6,6 2.9,6.5 1.5,4.8 -0.8,4.7 -1,6 -2.4,5.2 -0.3,2.7 -3.2,3.1z m-33.3,-72.4 -1.3,-1.1 -1.8,-10.4 -3.7,-1.3 -1.7,-2.3 -12.6,-2.8 -2.8,-1.1 -8.1,-2.2 -7.8,-1 -3.9,-5.3 0.7,-0.5 2.7,-0.8 3.6,-2.3 v-1 l0.6,-0.6 6,-1 2.4,-1.9 4.4,-2.1 0.2,-1.3 1.9,-2.9 1.8,-0.8 1.3,-1.8 2.3,-2.3 4.4,-2.4 4.7,-0.5 1.1,1.1 -0.3,1 -3.7,1 -1.5,3.1 -2.3,0.8 -0.5,2.4 -2.4,3.2 -0.3,2.6 0.8,0.5 1,-1.1 3.6,-2.9 1.3,1.3 h2.3 l3.2,1 1.5,1.1 1.5,3.1 2.7,2.7 3.9,-0.2 1.5,-1 1.6,1.3 1.6,0.5 1.3,-0.8 h1.1 l1.6,-1 4,-3.6 3.4,-1.1 6.6,-0.3 4.5,-1.9 2.6,-1.3 1.5,0.2 v5.7 l0.5,0.3 2.9,0.8 1.9,-0.5 6.1,-1.6 1.1,-1.1 1.5,0.5 v7 l3.2,3.1 1.3,0.6 1.3,1 -1.3,0.3 -0.8,-0.3 -3.7,-0.5 -2.1,0.6 -2.3,-0.2 -3.2,1.5 h-1.8 l-5.8,-1.3 -5.2,0.2 -1.9,2.6 -7,0.6 -2.4,0.8 -1.1,3.1 -1.3,1.1 -0.5,-0.2 -1.5,-1.6 -4.5,2.4 h-0.6 l-1.1,-1.6 -0.8,0.2 -1.9,4.4 -1,4 -3.2,6.9z m-29.6,-56.5 1.8,-2.1 2.2,-0.8 5.4,-3.9 2.3,-0.6 0.5,0.5 -5.1,5.1 -3.3,1.9 -2.1,0.9z m86.2,32.1 0.6,2.5 3.2,0.2 1.3,-1.2 c0,0 -0.1,-1.5 -0.4,-1.6 -0.3,-0.2 -1.6,-1.9 -1.6,-1.9 l-2.2,0.2 -1.6,0.2 -0.3,1.1z', + abbreviation: 'MI', + name: 'Michigan', + }, + MN: { + dimensions: + 'M464.6,66.79 l-0.6,3.91 v10.27 l1.6,5.03 1.9,3.32 0.5,9.93 1.8,13.45 1.8,7.3 0.4,6.4 v5.3 l-1.6,1.8 -1.8,1.3 v1.5 l0.9,1.7 4.1,3.5 0.7,3.2 v35.9 l60.3,-0.6 21.2,-0.7 -0.5,-6 -1.8,-2.1 -7.2,-4.6 -3.6,-5.3 -3.4,-0.9 -2,-2.8 h-3.2 l-3.5,-3.8 -0.5,-7 0.1,-3.9 1.5,-3 -0.7,-2.7 -2.8,-3.1 2.2,-6.1 5.4,-4 1.2,-1.4 -0.2,-8 0.2,-3 2.6,-3 3.8,-2.9 1.3,-0.2 4.5,-5 1.8,-0.8 2.3,-3.9 2.4,-3.6 3.1,-2.6 4.8,-2 9.2,-4.1 3.9,-1.8 0.6,-2.3 -4.4,0.4 -0.7,1.1 h-0.6 l-1.8,-3.1 -8.9,0.3 -1,0.8 h-1 l-0.5,-1.3 -0.8,-1.8 -2.6,0.5 -3.2,3.2 -1.6,0.8 h-3.1 l-2.6,-1 v-2.1 l-1.3,-0.2 -0.5,0.5 -2.6,-1.3 -0.5,-2.9 -1.5,0.5 -0.5,1 -2.4,-0.5 -5.3,-2.4 -3.9,-2.6 h-2.9 l-1.3,-1 -2.3,0.6 -1.1,1.1 -0.3,1.3 h-4.8 v-2.1 l-6.3,-0.3 -0.3,-1.5 h-4.8 l-1.6,-1.6 -1.5,-6.1 -0.8,-5.5 -1.9,-0.8 -2.3,-0.5 -0.6,0.2 -0.3,8.2 -30.1,-0.03z', + abbreviation: 'MN', + name: 'Minnesota', + }, + MO: { + dimensions: + 'M593.1,338.7 l0.5,-5.9 4.2,-3.4 1.9,-1 v-2.9 l0.7,-1.6 -1.1,-1.6 -2.4,0.3 -2.1,-2.5 -1.7,-4.5 0.9,-2.6 -2,-3.2 -1.8,-4.6 -4.6,-0.7 -6.8,-5.6 -2.2,-4.2 0.8,-3.3 2.2,-6 0.6,-3 -1.9,-1 -6.9,-0.6 -1.1,-1.9 v-4.1 l-5.3,-3.5 -7.2,-7.8 -2.3,-7.3 -0.5,-4.2 0.7,-2.4 -2.6,-3.1 -1.2,-2.4 -7.7,0.8 -10,0.6 -48.8,1.2 1.3,2.6 -0.1,2.2 2.3,3.6 3,3.9 3.1,3 2.6,0.2 1.4,1.1 v2.9 l-1.8,1.6 -0.5,2.3 2.1,3.2 2.4,3 2.6,2.1 1.3,11.6 -0.8,40 0.5,5.7 23.7,-0.2 23.3,-0.7 32.5,-1.3 2.2,3.7 -0.8,3.1 -3.1,2.5 -0.5,1.8 5.2,0.5 4.1,-1.1z', + abbreviation: 'MO', + name: 'Missouri', + }, + MS: { + dimensions: + 'M604.3,472.5 l2.6,-4.2 1.8,0.8 6.8,-1.9 2.1,0.3 1.5,0.8 h5.2 l0.4,-1.6 -1.7,-14.8 -2.8,-19 1,-45.1 -0.2,-16.7 0.2,-6.3 -4.8,0.3 -19.6,1.6 -13,0.4 -0.2,3.2 -2.8,1.3 -2.6,5.1 0.5,1.6 0.1,2.4 -2.9,1.1 -3.5,5.1 0.8,2.3 -3,2.5 -1,5.7 -0.6,1.9 1.6,2.5 -1.5,1.4 1.5,2.8 0.3,4.2 -1.2,2.5 -0.2,0.9 0.4,5 2,4.5 -0.1,1.7 2.3,2 -0.7,3.1 -0.9,0.3 0.6,1.9 -8.6,15 -0.8,8.2 0.5,1.5 24.2,-0.7 8.2,-0.7 1.9,-0.3 0.6,1.4 -1,7.1 3.3,3.3 2.2,6.4z', + abbreviation: 'MS', + name: 'Mississippi', + }, + MT: { + dimensions: + 'M361.1,70.77 l-5.3,57.13 -1.3,15.2 -59.1,-6.6 -49,-7.1 -1.4,11.2 -1.9,-1.7 -0.4,-2.5 -1.3,-1.9 -3.3,1.5 -0.7,2.5 -2.3,0.3 -3.8,-1.6 -4.1,0.1 -2.4,0.7 -3.2,-1.5 -3,0.2 -2.1,1.9 -0.9,-0.6 -0.7,-3.4 0.7,-3.2 -2.7,-3.2 -3.3,-2.5 -2.5,-12.6 -0.1,-5.3 -1.6,-0.8 -0.6,1 -4.5,3.2 -1.2,-0.1 -2.3,-2.8 -0.2,-2.8 7,-17.15 -0.6,-2.67 -3.5,-1.12 -0.4,-0.91 -2.7,-3.5 -4.6,-10.41 -3.2,-1.58 -1.8,-4.26 1.3,-4.63 -3.2,-7.57 4.4,-21.29 32.7,6.89 18.4,3.4 32.3,5.3 29.3,4 29.2,3.5 30.8,3.07z', + abbreviation: 'MT', + name: 'Montana', + }, + NC: { + dimensions: + 'M786.7,357.7 l-12.7,-7.7 -3.1,-0.8 -16.6,2.1 -1.6,-3 -2.8,-2.2 -16.7,0.5 -7.4,0.9 -9.2,4.5 -6.8,2.7 -6.5,1.2 -13.4,1.4 0.1,-4.1 1.7,-1.3 2.7,-0.7 0.7,-3.8 3.9,-2.5 3.9,-1.5 4.5,-3.7 4.4,-2.3 0.7,-3.2 4.1,-3.8 0.7,1 2.5,0.2 2.4,-3.6 1.7,-0.4 2.6,0.3 1.8,-4 2.5,-2.4 0.5,-1.8 0.1,-3.5 4.4,0.1 38.5,-5.6 57.5,-12.3 2,4.8 3.6,6.5 2.4,2.4 0.6,2.3 -2.4,0.2 0.8,0.6 -0.3,4.2 -2.6,1.3 -0.6,2.1 -1.3,2.9 -3.7,1.6 -2.4,-0.3 -1.5,-0.2 -1.6,-1.3 0.3,1.3 v1 h1.9 l0.8,1.3 -1.9,6.3 h4.2 l0.6,1.6 2.3,-2.3 1.3,-0.5 -1.9,3.6 -3.1,4.8 h-1.3 l-1.1,-0.5 -2.7,0.6 -5.2,2.4 -6.5,5.3 -3.4,4.7 -1.9,6.5 -0.5,2.4 -4.7,0.5 -5.1,1.5z m49.3,-26.2 2.6,-2.5 3.2,-2.6 1.5,-0.6 0.2,-2 -0.6,-6.1 -1.5,-2.3 -0.6,-1.9 0.7,-0.2 2.7,5.5 0.4,4.4 -0.2,3.4 -3.4,1.5 -2.8,2.4 -1.1,1.2z', + abbreviation: 'NC', + name: 'North Carolina', + }, + ND: { + dimensions: + 'M471,126.4 l-0.4,-6.2 -1.8,-7.3 -1.8,-13.61 -0.5,-9.7 -1.9,-3.18 -1.6,-5.32 v-10.41 l0.6,-3.85 -1.8,-5.54 -28.6,-0.59 -18.6,-0.6 -26.5,-1.3 -25.2,-2.16 -0.9,14.42 -4.7,50.94 56.8,3.9 56.9,1.7z', + abbreviation: 'ND', + name: 'North Dakota', + }, + NE: { + dimensions: + 'M470.3,204.3 l-1,-2.3 -0.5,-1.6 -2.9,-1.6 -4.8,-1.5 -2.2,-1.2 -2.6,0.1 -3.7,0.4 -4.2,1.2 -6,-4.1 -2.2,-2 -10.7,0.6 -41.5,-2.4 -35.6,-2.2 -4.3,43.7 33.1,3.3 -1.4,21.1 21.7,1 40.6,1.2 43.8,0.6 h4.5 l-2.2,-3 -2.6,-3.9 0.1,-2.3 -1.4,-2.7 -1.9,-5.2 -0.4,-6.7 -1.4,-4.1 -0.5,-5 -2.3,-3.7 -1,-4.7 -2.8,-7.9 -1,-5.3z', + abbreviation: 'NE', + name: 'Nebraska', + }, + NH: { + dimensions: + 'M881.7,141.3 l1.1,-3.2 -2.7,-1.2 -0.5,-3.1 -4.1,-1.1 -0.3,-3 -11.7,-37.48 -0.7,0.08 -0.6,1.6 -0.6,-0.5 -1,-1 -1.5,1.9 -0.2,2.29 0.5,8.41 1.9,2.8 v4.3 l-3.9,4.8 -2.4,0.9 v0.7 l1.1,1.9 v8.6 l-0.8,9.2 -0.2,4.7 1,1.4 -0.2,4.7 -0.5,1.5 1,1.1 5.1,-1.2 13.8,-3.5 1.7,-2.9 4,-1.9z', + abbreviation: 'NH', + name: 'New Hampshire', + }, + NJ: { + dimensions: + 'M823.7,228.3 l0.1,-1.5 2.7,-1.3 1.7,-2.8 1.7,-2.4 3.3,-3.2 v-1.2 l-6.1,-4.1 -1,-2.7 -2.7,-0.3 -0.1,-0.9 -0.7,-2.2 2.2,-1.1 0.2,-2.9 -1.3,-1.3 0.2,-1.2 1.9,-3.1 v-3.1 l2.5,-3.1 5.6,2.5 6.4,1.9 2.5,1.2 0.1,1.8 -0.5,2.7 0.4,4.5 -2.1,1.9 -1.1,1 0.5,0.5 2.7,-0.3 1.1,-0.8 1.6,3.4 0.2,9.4 0.6,1.1 -1.1,5.5 -3.1,6.5 -2.7,4 -0.8,4.8 -2.1,2.4 h-0.8 l-0.3,-2.7 0.8,-1 -0.2,-1.5 -4,-0.6 -4.8,-2.3 -3.2,-2.9 -1,-2z', + abbreviation: 'NJ', + name: 'New Jersey', + }, + NM: { + dimensions: + 'M270.2,429.4 l-16.7,-2.6 -1.2,9.6 -15.8,-2 6,-39.7 7,-53.2 4.4,-30.9 34,3.9 37.4,4.4 32,2.8 -0.3,10.8 -1.4,-0.1 -7.4,97.7 -28.4,-1.8 -38.1,-3.7 0.7,6.3z', + abbreviation: 'NM', + name: 'New Mexico', + }, + NV: { + dimensions: + 'M123.1,173.6 l38.7,8.5 26,5.2 -10.6,53.1 -5.4,29.8 -3.3,15.5 -2.1,11.1 -2.6,16.4 -1.7,3.1 -1.6,-0.1 -1.2,-2.6 -2.8,-0.5 -1.3,-1.1 -1.8,0.1 -0.9,0.8 -1.8,1.3 -0.3,7.3 -0.3,1.5 -0.5,12.4 -1.1,1.8 -16.7,-25.5 -42.1,-62.1 -12.43,-19 8.55,-32.6 8.01,-31.3z', + abbreviation: 'NV', + name: 'Nevada', + }, + NY: { + dimensions: + 'M843.4,200 l0.5,-2.7 -0.2,-2.4 -3,-1.5 -6.5,-2 -6,-2.6 -0.6,-0.4 -2.7,-0.3 -2,-1.5 -2.1,-5.9 -3.3,-0.5 -2.4,-2.4 -38.4,8.1 -31.6,6 -0.5,-6.5 1.6,-1.2 1.3,-1.1 1,-1.6 1.8,-1.1 1.9,-1.8 0.5,-1.6 2.1,-2.7 1.1,-1 -0.2,-1 -1.3,-3.1 -1.8,-0.2 -1.9,-6.1 2.9,-1.8 4.4,-1.5 4,-1.3 3.2,-0.5 6.3,-0.2 1.9,1.3 1.6,0.2 2.1,-1.3 2.6,-1.1 5.2,-0.5 2.1,-1.8 1.8,-3.2 1.6,-1.9 h2.1 l1.9,-1.1 0.2,-2.3 -1.5,-2.1 -0.3,-1.5 1.1,-2.1 v-1.5 h-1.8 l-1.8,-0.8 -0.8,-1.1 -0.2,-2.6 5.8,-5.5 0.6,-0.8 1.5,-2.9 2.9,-4.5 2.7,-3.7 2.1,-2.4 2.4,-1.8 3.1,-1.2 5.5,-1.3 3.2,0.2 4.5,-1.5 7.4,-2.2 0.7,4.9 2.4,6.5 0.8,5 -1,4.2 2.6,4.5 0.8,2 -0.9,3.2 3.7,1.7 2.7,10.2 v5.8 l-0.6,10.9 0.8,5.4 0.7,3.6 1.5,7.3 v8.1 l-1.1,2.3 2.1,2.7 0.5,0.9 -1.9,1.8 0.3,1.3 1.3,-0.3 1.5,-1.3 2.3,-2.6 1.1,-0.6 1.6,0.6 2.3,0.2 7.9,-3.9 2.9,-2.7 1.3,-1.5 4.2,1.6 -3.4,3.6 -3.9,2.9 -7.1,5.3 -2.6,1 -5.8,1.9 -4,1.1 -1,-0.4z', + abbreviation: 'NY', + name: 'New York', + }, + OH: { + dimensions: + 'M663.8,211.2 l1.7,15.5 4.8,41.1 3.9,-0.2 2.3,-0.8 3.6,1.8 1.7,4.2 5.4,0.1 1.8,2 h1.7 l2.4,-1.4 3.1,0.5 1.5,1.3 1.8,-2 2.3,-1.4 2.4,-0.4 0.6,2.7 1.6,1 2.6,2 0.8,0.2 2,-0.1 1.2,-0.6 v-2.1 l1.7,-1.5 0.1,-4.8 1.1,-4.2 1.9,-1.3 1,0.7 1,1.1 0.7,0.2 0.4,-0.4 -0.9,-2.7 v-2.2 l1.1,-1.4 2.5,-3.6 1.3,-1.5 2.2,0.5 2.1,-1.5 3,-3.3 2.2,-3.7 0.2,-5.4 0.5,-5 v-4.6 l-1.2,-3.2 1.2,-1.8 1.3,-1.2 -0.6,-2.8 -4.3,-25.6 -6.2,3.7 -3.9,2.3 -3.4,3.7 -4,3.9 -3.2,0.8 -2.9,0.5 -5.5,2.6 -2.1,0.2 -3.4,-3.1 -5.2,0.6 -2.6,-1.5 -2.2,-1.3z', + abbreviation: 'OH', + name: 'Ohio', + }, + OK: { + dimensions: + 'M411.9,334.9 l-1.8,24.3 -0.9,18 0.2,1.6 4,3.6 1.7,0.9 h0.9 l0.9,-2.1 1.5,1.9 1.6,0.1 0.3,-0.2 0.2,-1.1 2.8,1.4 -0.4,3.5 3.8,0.5 2.5,1 4.2,0.6 2.3,1.6 2.5,-1.7 3.5,0.7 2.2,3.1 1.2,0.1 v2.3 l2.1,0.7 2.5,-2.1 1.8,0.6 2.7,0.1 0.7,2.3 4.4,1.8 1.7,-0.3 1.9,-4.2 h1.3 l1.1,2.1 4.2,0.8 3.4,1.3 3,0.8 1.6,-0.7 0.7,-2.7 h4.5 l1.9,0.9 2.7,-1.9 h1.4 l0.6,1.4 h3.6 l2,-1.8 2.3,0.6 1.7,2.2 3,1.7 3.4,0.9 1.9,1.2 -0.3,-37.6 -1.4,-10.9 -0.1,-8.6 -1.5,-6.6 -0.6,-6.8 0.1,-4.3 -12.6,0.3 -46.3,-0.5 -44.7,-2.1 -41.5,-1.8 -0.4,10.7z', + abbreviation: 'OK', + name: 'Oklahoma', + }, + OR: { + dimensions: + 'M67.44,158.9 l28.24,7.2 27.52,6.5 17,3.7 8.8,-35.1 1.2,-4.4 2.4,-5.5 -0.7,-1.3 -2.5,0.1 -1.3,-1.8 0.6,-1.5 0.4,-3.3 4.7,-5.7 1.9,-0.9 0.9,-0.8 0.7,-2.7 0.8,-1.1 3.9,-5.7 3.7,-4 0.2,-3.26 -3.4,-2.49 -1.2,-4.55 -13.1,-3.83 -15.3,-3.47 -14.8,0.37 -1.1,-1.31 -5.1,1.84 -4.5,-0.48 -2.4,-1.58 -1.3,0.54 -4.68,-0.29 -1.96,-1.43 -4.84,-1.77 -1.1,-0.07 -4.45,-1.27 -1.76,1.52 -6.26,-0.24 -5.31,-3.85 0.21,-9.28 -2.05,-3.5 -4.1,-0.6 -0.7,-2.5 -2.4,-0.5 -5.8,2.1 -2.3,6.5 -3.2,10 -3.2,6.5 -5,14.1 -6.5,13.6 -8.1,12.6 -1.9,2.9 -0.8,8.6 -1.3,6 2.71,3.5z', + abbreviation: 'OR', + name: 'Oregon', + }, + PA: { + dimensions: + 'M736.6,192.2 l1.3,-0.5 5.7,-5.5 0.7,6.9 33.5,-6.5 36.9,-7.8 2.3,2.3 3.1,0.4 2,5.6 2.4,1.9 2.8,0.4 0.1,0.1 -2.6,3.2 v3.1 l-1.9,3.1 -0.2,1.9 1.3,1.3 -0.2,1.9 -2.4,1.1 1,3.4 0.2,1.1 2.8,0.3 0.9,2.5 5.9,3.9 v0.4 l-3.1,3 -1.5,2.2 -1.7,2.8 -2.7,1.2 -1.4,0.3 -2.1,1.3 -1.6,1.4 -22.4,4.3 -38.7,7.8 -11.3,1.4 -3.9,0.7 -5.1,-22.4 -4.3,-25.9z', + abbreviation: 'PA', + name: 'Pennsylvania', + }, + RI: { + dimensions: + 'M873.6,175.7 l-0.8,-4.4 -1.6,-6 5.7,-1.5 1.5,1.3 3.4,4.3 2.8,4.4 -2.8,1.4 -1.3,-0.2 -1.1,1.8 -2.4,1.9 -2.8,1.1z', + abbreviation: 'RI', + name: 'Rhode Island', + }, + SC: { + dimensions: + 'M759,413.6 l-2.1,-1 -1.9,-5.6 -2.5,-2.3 -2.5,-0.5 -1.5,-4.6 -3,-6.5 -4.2,-1.8 -1.9,-1.8 -1.2,-2.6 -2.4,-2 -2.3,-1.3 -2.2,-2.9 -3.2,-2.4 -4.4,-1.7 -0.4,-1.4 -2.3,-2.8 -0.5,-1.5 -3.8,-5.4 -3.4,0.1 -3.9,-2.5 -1.2,-1.2 -0.2,-1.4 0.6,-1.6 2.7,-1.3 -0.8,-2 6.4,-2.7 9.2,-4.5 7.1,-0.9 16.4,-0.5 2.3,1.9 1.8,3.5 4.6,-0.8 12.6,-1.5 2.7,0.8 12.5,7.4 10.1,8.3 -5.3,5.4 -2.6,6.1 -0.5,6.3 -1.6,0.8 -1.1,2.7 -2.4,0.6 -2.1,3.6 -2.7,2.7 -2.3,3.4 -1.6,0.8 -3.6,3.4 -2.9,0.2 1,3.2 -5,5.3 -2.3,1.6z', + abbreviation: 'SC', + name: 'South Carolina', + }, + SD: { + dimensions: + 'M471,181.1 l-0.9,3.2 0.4,3 2.6,2 -1.2,5.4 -1.8,4.1 1.5,3.3 0.7,1.1 -1.3,0.1 -0.7,-1.6 -0.6,-2 -3.3,-1.8 -4.8,-1.5 -2.5,-1.3 -2.9,0.1 -3.9,0.4 -3.8,1.2 -5.3,-3.8 -2.7,-2.4 -10.9,0.8 -41.5,-2.4 -35.6,-2.2 1.5,-24.8 2.8,-34 0.4,-5 56.9,3.9 56.9,1.7 v2.7 l-1.3,1.5 -2,1.5 -0.1,2.2 1.1,2.2 4.1,3.4 0.5,2.7 v35.9z', + abbreviation: 'SD', + name: 'South Dakota', + }, + TN: { + dimensions: + 'M670.8,359.6 l-13.1,1.2 -23.3,2.2 -37.6,2.7 -11.8,0.4 0.9,-0.6 0.9,-4.5 -1.2,-3.6 3.9,-2.3 0.4,-2.5 1.2,-4.3 3,-9.5 0.5,-5.6 0.3,-0.2 12.3,-0.2 13.6,-0.8 0.1,-3.9 3.5,-0.1 30.4,-3.3 54,-5.2 10.3,-1.5 7.6,-0.2 2.4,-1.9 1.3,0.3 -0.1,3.3 -0.4,1.6 -2.4,2.2 -1.6,3.6 -2,-0.4 -2.4,0.9 -2.2,3.3 -1.4,-0.2 -0.8,-1.2 -1.1,0.4 -4.3,4 -0.8,3.1 -4.2,2.2 -4.3,3.6 -3.8,1.5 -4.4,2.8 -0.6,3.6 -2.5,0.5 -2,1.7 -0.2,4.8z', + abbreviation: 'TN', + name: 'Tennessee', + }, + TX: { + dimensions: + 'M282.8,425.6 l37,3.6 29.3,1.9 7.4,-97.7 54.4,2.4 -1.7,23.3 -1,18 0.2,2 4.4,4.1 2,1.1 h1.8 l0.5,-1.2 0.7,0.9 2.4,0.2 1.1,-0.6 v-0.2 l1,0.5 -0.4,3.7 4.5,0.7 2.4,0.9 4.2,0.7 2.6,1.8 2.8,-1.9 2.7,0.6 2.2,3.1 0.8,0.1 v2.1 l3.3,1.1 2.5,-2.1 1.5,0.5 2.1,0.1 0.6,2.1 5.2,2 2.3,-0.5 1.9,-4 h0.1 l1.1,1.9 4.6,0.9 3.4,1.3 3.2,1 2.4,-1.2 0.7,-2.3 h3.6 l2.1,1 3,-2 h0.4 l0.5,1.4 h4.7 l1.9,-1.8 1.3,0.4 1.7,2.1 3.3,1.9 3.4,1 2.5,1.4 2.7,2 3.1,-1.2 2.1,0.8 0.7,20 0.7,9.5 0.6,4.1 2.6,4.4 0.9,4.5 4.2,5.9 0.3,3.1 0.6,0.8 -0.7,7.7 -2.9,4.8 1.3,2.6 -0.5,2.4 -0.8,7.2 -1.3,3 0.3,4.2 -5.6,1.6 -9.9,4.5 -1,1.9 -2.6,1.9 -2.1,1.5 -1.3,0.8 -5.7,5.3 -2.7,2.1 -5.3,3.2 -5.7,2.4 -6.3,3.4 -1.8,1.5 -5.8,3.6 -3.4,0.6 -3.9,5.5 -4,0.3 -1,1.9 2.3,1.9 -1.5,5.5 -1.3,4.5 -1.1,3.9 -0.8,4.5 0.8,2.4 1.8,7 1,6.1 1.8,2.7 -1,1.5 -3.1,1.9 -5.7,-3.9 -5.5,-1.1 -1.3,0.5 -3.2,-0.6 -4.2,-3.1 -5.2,-1.1 -7.6,-3.4 -2.1,-3.9 -1.3,-6.5 -3.2,-1.9 -0.6,-2.3 0.6,-0.6 0.3,-3.4 -1.3,-0.6 -0.6,-1 1.3,-4.4 -1.6,-2.3 -3.2,-1.3 -3.4,-4.4 -3.6,-6.6 -4.2,-2.6 0.2,-1.9 -5.3,-12.3 -0.8,-4.2 -1.8,-1.9 -0.2,-1.5 -6,-5.3 -2.6,-3.1 v-1.1 l-2.6,-2.1 -6.8,-1.1 -7.4,-0.6 -3.1,-2.3 -4.5,1.8 -3.6,1.5 -2.3,3.2 -1,3.7 -4.4,6.1 -2.4,2.4 -2.6,-1 -1.8,-1.1 -1.9,-0.6 -3.9,-2.3 v-0.6 l-1.8,-1.9 -5.2,-2.1 -7.4,-7.8 -2.3,-4.7 v-8.1 l-3.2,-6.5 -0.5,-2.7 -1.6,-1 -1.1,-2.1 -5,-2.1 -1.3,-1.6 -7.1,-7.9 -1.3,-3.2 -4.7,-2.3 -1.5,-4.4 -2.6,-2.9 -1.7,-0.5z m174.4,141.7 -0.6,-7.1 -2.7,-7.2 -0.6,-7 1.5,-8.2 3.3,-6.9 3.5,-5.4 3.2,-3.6 0.6,0.2 -4.8,6.6 -4.4,6.5 -2,6.6 -0.3,5.2 0.9,6.1 2.6,7.2 0.5,5.2 0.2,1.5z', + abbreviation: 'TX', + name: 'Texas', + }, + UT: { + dimensions: + 'M228.4,305.9 l24.6,3.6 1.9,-13.7 7,-50.5 2.3,-22 -32.2,-3.5 2.2,-13.1 1.8,-10.6 -34.7,-6.1 -12.5,-2.5 -10.6,52.9 -5.4,30 -3.3,15.4 -1.7,9.2z', + abbreviation: 'UT', + name: 'Utah', + }, + VA: { + dimensions: + 'M834.7,265.2 l-0.2,2.8 -2.9,3.8 -0.4,4.6 0.5,3.4 -1.8,5 -2.2,1.9 -1.5,-4.6 0.4,-5.4 1.6,-4.2 0.7,-3.3 -0.1,-1.7z m-60.3,44.6 -38.6,5.6 -4.8,-0.1 -2.2,-0.3 -2.5,1.9 -7.3,0.1 -10.3,1.6 -6.7,0.6 4.1,-2.6 4.1,-2.3 v-2.1 l5.7,-7.3 4.1,-3.7 2.2,-2.5 3.6,4.3 3.8,0.9 2.7,-1 2,-1.5 2.4,1.2 4.6,-1.3 1.7,-4.4 2.4,0.7 3.2,-2.3 1.6,0.4 2.8,-3.2 0.2,-2.7 -0.8,-1.2 4.8,-10.5 1.8,-5.2 0.5,-4.7 0.7,-0.2 1.1,1.7 1.5,1.2 3.9,-0.2 1.7,-8.1 3,-0.6 0.8,-2.6 2.8,-2.2 1.1,-2.1 1.8,-4.3 0.1,-4.6 3.6,1.4 6.6,3.1 0.3,-5.2 3.4,1.2 -0.6,2.9 8.6,3.1 1.4,1.8 -0.8,3.3 -1.3,1.3 -0.5,1.7 0.5,2.4 2,1.3 3.9,1.4 2.9,1 4.9,0.9 2.2,2.1 3.2,0.4 0.9,1.2 -0.4,4.7 1.4,1.1 -0.5,1.9 1.2,0.8 -0.2,1.4 -2.7,-0.1 0.1,1.6 2.3,1.5 0.1,1.4 1.8,1.8 0.5,2.5 -2.6,1.4 1.6,1.5 5.8,-1.7 3.7,6.2z', + abbreviation: 'VA', + name: 'Virginia', + }, + VT: { + dimensions: + 'M832.7,111.3 l2.4,6.5 0.8,5.3 -1,3.9 2.5,4.4 0.9,2.3 -0.7,2.6 3.3,1.5 2.9,10.8 v5.3 l11.5,-2.1 -1,-1.1 0.6,-1.9 0.2,-4.3 -1,-1.4 0.2,-4.7 0.8,-9.3 v-8.5 l-1.1,-1.8 v-1.6 l2.8,-1.1 3.5,-4.4 v-3.6 l-1.9,-2.7 -0.3,-5.79 -26.1,6.79z', + abbreviation: 'VT', + name: 'Vermont', + }, + WA: { + dimensions: + 'M74.5,67.7 l-2.3,-4.3 -4.1,-0.7 -0.4,-2.4 -2.5,-0.6 -2.9,-0.5 -1.8,1 -2.3,-2.9 0.3,-2.9 2.7,-0.3 1.6,-4 -2.6,-1.1 0.2,-3.7 4.4,-0.6 -2.7,-2.7 -1.5,-7.1 0.6,-2.9 v-7.9 l-1.8,-3.2 2.3,-9.4 2.1,0.5 2.4,2.9 2.7,2.6 3.2,1.9 4.5,2.1 3.1,0.6 2.9,1.5 3.4,1 2.3,-0.2 v-2.4 l1.3,-1.1 2.1,-1.3 0.3,1.1 0.3,1.8 -2.3,0.5 -0.3,2.1 1.8,1.5 1.1,2.4 0.6,1.9 1.5,-0.2 0.2,-1.3 -1,-1.3 -0.5,-3.2 0.8,-1.8 -0.6,-1.5 v-2.6 l1.8,-3.6 -1.1,-2.6 -2.4,-4.8 0.3,-0.8 1.4,-0.8 4.4,1.5 9.7,2.7 8.6,1.9 20,5.7 23,5.7 15,3.49 -4.8,17.56 -4.5,20.83 -3.4,16.25 -0.4,9.18 v0 l-12.9,-3.72 -15.3,-3.47 -14.5,0.32 -1.1,-1.53 -5.7,2.09 -3.9,-0.42 -2.6,-1.79 -1.7,0.65 -4.15,-0.25 -1.72,-1.32 -5.16,-1.82 -1.18,-0.16 -4.8,-1.39 -1.92,1.65 -5.65,-0.25 -4.61,-3.35z m9.6,-55.4 2,-0.2 0.5,1.4 1.5,-1.6 h2.3 l0.8,1.5 -1.5,1.7 0.6,0.8 -0.7,2 -1.4,0.4 c0,0 -0.9,0.1 -0.9,-0.2 0,-0.3 1.5,-2.6 1.5,-2.6 l-1.7,-0.6 -0.3,1.5 -0.7,0.6 -1.5,-2.3z', + abbreviation: 'WA', + name: 'Washington', + }, + WI: { + dimensions: + 'M541.4,109.9 l2.9,0.5 2.9,-0.6 7.4,-3.2 2.9,-1.9 2.1,-0.8 1.9,1.5 -1.1,1.1 -1.9,3.1 -0.6,1.9 1,0.6 1.8,-1 1.1,-0.2 2.7,0.8 0.6,1.1 1.1,0.2 0.6,-1.1 4,5.3 8.2,1.2 8.2,2.2 2.6,1.1 12.3,2.6 1.6,2.3 3.6,1.2 1.7,10.2 1.6,1.4 1.5,0.9 -1.1,2.3 -1.8,1.6 -2.1,4.7 -1.3,2.4 0.2,1.8 1.5,0.3 1.1,-1.9 1.5,-0.8 0.8,-2.3 1.9,-1.8 2.7,-4 4.2,-6.3 0.8,-0.5 0.3,1 -0.2,2.3 -2.9,6.8 -2.7,5.7 -0.5,3.2 -0.6,2.6 0.8,1.3 -0.2,2.7 -1.9,2.4 -0.5,1.8 0.6,3.6 0.6,3.4 -1.5,2.6 -0.8,2.9 -1,3.1 1.1,2.4 0.6,6.1 1.6,4.5 -0.2,3 -15.9,1.8 -17.5,1 h-12.7 l-0.7,-1.5 -2.9,-0.4 -2.6,-1.3 -2.3,-3.7 -0.3,-3.6 2,-2.9 -0.5,-1.4 -2.1,-2.2 -0.8,-3.3 -0.6,-6.8 -2.1,-2.5 -7,-4.5 -3.8,-5.4 -3.4,-1 -2.2,-2.8 h-3.2 l-2.9,-3.3 -0.5,-6.5 0.1,-3.8 1.5,-3.1 -0.8,-3.2 -2.5,-2.8 1.8,-5.4 5.2,-3.8 1.6,-1.9 -0.2,-8.1 0.2,-2.8 2.4,-2.8z', + abbreviation: 'WI', + name: 'Wisconsin', + }, + WV: { + dimensions: + 'M758.9,254.3 l5.8,-6 2.6,-0.8 1.6,-1.5 1.5,-2.2 1.1,0.3 3.1,-0.2 4.6,-3.6 1.5,-0.5 1.3,1 2.6,1.2 3,3 -0.4,4.3 -5.4,-2.6 -4.8,-1.8 -0.1,5.9 -2.6,5.7 -2.9,2.4 -0.8,2.3 -3,0.5 -1.7,8.1 -2.8,0.2 -1.1,-1 -1.2,-2 -2.2,0.5 -0.5,5.1 -1.8,5.1 -5,11 0.9,1.4 -0.1,2 -2.2,2.5 -1.6,-0.4 -3.1,2.3 -2.8,-0.8 -1.8,4.9 -3.8,1 -2.5,-1.3 -2.5,1.9 -2.3,0.7 -3.2,-0.8 -3.8,-4.5 -3.5,-2.2 -2.5,-2.5 -2.9,-3.7 -0.5,-2.3 -2.8,-1.7 -0.6,-1.3 -0.2,-5.6 0.3,0.1 2.4,-0.2 1.8,-1 v-2.2 l1.7,-1.5 0.1,-5.2 0.9,-3.6 1.1,-0.7 0.4,0.3 1,1.1 1.7,0.5 1.1,-1.3 -1,-3.1 v-1.6 l3.1,-4.6 1.2,-1.3 2,0.5 2.6,-1.8 3.1,-3.4 2.4,-4.1 0.2,-5.6 0.5,-4.8 v-4.9 l-1.1,-3 0.9,-1.3 0.8,-0.7 4.3,19.3 4.3,-0.8 11.2,-1.3z', + abbreviation: 'WV', + name: 'West Virginia', + }, + WY: { + dimensions: + 'M353,161.9 l-1.5,25.4 -4.4,44 -2.7,-0.3 -83.3,-9.1 -27.9,-3 2,-12 6.9,-41 3.8,-24.2 1.3,-11.2 48.2,7 59.1,6.5z', + abbreviation: 'WY', + name: 'Wyoming', + }, +} diff --git a/web/components/usa-map/state-election-map.tsx b/web/components/usa-map/state-election-map.tsx new file mode 100644 index 00000000..8f7bb284 --- /dev/null +++ b/web/components/usa-map/state-election-map.tsx @@ -0,0 +1,85 @@ +import { zip } from 'lodash' +import Router from 'next/router' +import { useEffect, useState } from 'react' + +import { getProbability } from 'common/calculate' +import { Contract, CPMMBinaryContract } from 'common/contract' +import { Customize, USAMap } from './usa-map' +import { + getContractFromSlug, + listenForContract, +} from 'web/lib/firebase/contracts' + +export interface StateElectionMarket { + creatorUsername: string + slug: string + isWinRepublican: boolean + state: string +} + +export function StateElectionMap(props: { markets: StateElectionMarket[] }) { + const { markets } = props + + const contracts = useContracts(markets.map((m) => m.slug)) + const probs = contracts.map((c) => + c ? getProbability(c as CPMMBinaryContract) : 0.5 + ) + const marketsWithProbs = zip(markets, probs) as [ + StateElectionMarket, + number + ][] + + const stateInfo = marketsWithProbs.map(([market, prob]) => [ + market.state, + { + fill: probToColor(prob, market.isWinRepublican), + clickHandler: () => + Router.push(`/${market.creatorUsername}/${market.slug}`), + }, + ]) + + const config = Object.fromEntries(stateInfo) as Customize + + return <USAMap customize={config} /> +} + +const probToColor = (prob: number, isWinRepublican: boolean) => { + const p = isWinRepublican ? prob : 1 - prob + const hue = p > 0.5 ? 350 : 240 + const saturation = 100 + const lightness = 100 - 50 * Math.abs(p - 0.5) + return `hsl(${hue}, ${saturation}%, ${lightness}%)` +} + +const useContracts = (slugs: string[]) => { + const [contracts, setContracts] = useState<(Contract | undefined)[]>( + slugs.map(() => undefined) + ) + + useEffect(() => { + Promise.all(slugs.map((slug) => getContractFromSlug(slug))).then( + (contracts) => setContracts(contracts) + ) + }, [slugs]) + + useEffect(() => { + if (contracts.some((c) => c === undefined)) return + + // listen to contract updates + const unsubs = (contracts as Contract[]).map((c, i) => + listenForContract( + c.id, + (newC) => newC && setContracts(setAt(contracts, i, newC)) + ) + ) + return () => unsubs.forEach((u) => u()) + }, [contracts]) + + return contracts +} + +function setAt<T>(arr: T[], i: number, val: T) { + const newArr = [...arr] + newArr[i] = val + return newArr +} diff --git a/web/components/usa-map/usa-map.tsx b/web/components/usa-map/usa-map.tsx new file mode 100644 index 00000000..2841e04c --- /dev/null +++ b/web/components/usa-map/usa-map.tsx @@ -0,0 +1,106 @@ +// https://github.com/jb-1980/usa-map-react +// MIT License + +import { DATA } from './data' +import { USAState } from './usa-state' + +export type ClickHandler<E = SVGPathElement | SVGCircleElement, R = any> = ( + e: React.MouseEvent<E, MouseEvent> +) => R +export type GetClickHandler = (stateKey: string) => ClickHandler | undefined +export type CustomizeObj = { + fill?: string + clickHandler?: ClickHandler +} +export interface Customize { + [key: string]: CustomizeObj +} + +export type StatesProps = { + hideStateTitle?: boolean + fillStateColor: (stateKey: string) => string + stateClickHandler: GetClickHandler +} +const States = ({ + hideStateTitle, + fillStateColor, + stateClickHandler, +}: StatesProps) => + Object.entries(DATA).map(([stateKey, data]) => ( + <USAState + key={stateKey} + hideStateTitle={hideStateTitle} + stateName={data.name} + dimensions={data.dimensions} + state={stateKey} + fill={fillStateColor(stateKey)} + onClickState={stateClickHandler(stateKey)} + /> + )) + +type USAMapPropTypes = { + onClick?: ClickHandler + width?: number + height?: number + title?: string + defaultFill?: string + customize?: Customize + hideStateTitle?: boolean + className?: string +} + +export const USAMap = ({ + onClick = (e) => { + console.log(e.currentTarget.dataset.name) + }, + width = 959, + height = 593, + title = 'US states map', + defaultFill = '#d3d3d3', + customize, + hideStateTitle, + className, +}: USAMapPropTypes) => { + const fillStateColor = (state: string) => + customize?.[state]?.fill ? (customize[state].fill as string) : defaultFill + + const stateClickHandler = (state: string) => customize?.[state]?.clickHandler + + return ( + <svg + className={className} + xmlns="http://www.w3.org/2000/svg" + width={width} + height={height} + viewBox="0 0 959 593" + > + <title>{title} + + {States({ + hideStateTitle, + fillStateColor, + stateClickHandler, + })} + + + + + + + ) +} diff --git a/web/components/usa-map/usa-state.tsx b/web/components/usa-map/usa-state.tsx new file mode 100644 index 00000000..9bebd027 --- /dev/null +++ b/web/components/usa-map/usa-state.tsx @@ -0,0 +1,34 @@ +import clsx from 'clsx' +import { ClickHandler } from './usa-map' + +type USAStateProps = { + state: string + dimensions: string + fill: string + onClickState?: ClickHandler + stateName: string + hideStateTitle?: boolean +} +export const USAState = ({ + state, + dimensions, + fill, + onClickState, + stateName, + hideStateTitle, +}: USAStateProps) => { + return ( + + {hideStateTitle ? null : {stateName}} + + ) +} diff --git a/web/pages/midterms.tsx b/web/pages/midterms.tsx new file mode 100644 index 00000000..1ae72f7a --- /dev/null +++ b/web/pages/midterms.tsx @@ -0,0 +1,92 @@ +import { Col } from 'web/components/layout/col' +import { Page } from 'web/components/page' +import { Title } from 'web/components/title' +import { + StateElectionMarket, + StateElectionMap, +} from 'web/components/usa-map/state-election-map' + +const senateMidterms: StateElectionMarket[] = [ + { + state: 'AZ', + creatorUsername: 'BTE', + slug: 'will-blake-masters-win-the-arizona', + isWinRepublican: true, + }, + { + state: 'OH', + creatorUsername: 'BTE', + slug: 'will-jd-vance-win-the-ohio-senate-s', + isWinRepublican: true, + }, + { + state: 'WI', + creatorUsername: 'BTE', + slug: 'will-ron-johnson-be-reelected-in-th', + isWinRepublican: true, + }, + { + state: 'FL', + creatorUsername: 'BTE', + slug: 'will-marco-rubio-be-reelected-to-th', + isWinRepublican: true, + }, + { + state: 'PA', + creatorUsername: 'MattP', + slug: 'will-dr-oz-be-elected-to-the-us-sen', + isWinRepublican: true, + }, + { + state: 'GA', + creatorUsername: 'NcyRocks', + slug: 'will-a-democrat-win-the-2022-us-sen-3d2432ba6d79', + isWinRepublican: false, + }, + { + state: 'NV', + creatorUsername: 'NcyRocks', + slug: 'will-a-democrat-win-the-2022-us-sen', + isWinRepublican: false, + }, + { + state: 'NC', + creatorUsername: 'NcyRocks', + slug: 'will-a-democrat-win-the-2022-us-sen-6f1a901e1fcf', + isWinRepublican: false, + }, + { + state: 'NH', + creatorUsername: 'NcyRocks', + slug: 'will-a-democrat-win-the-2022-us-sen-23194a72f1b7', + isWinRepublican: false, + }, + { + state: 'UT', + creatorUsername: 'SG', + slug: 'will-mike-lee-win-the-2022-utah-sen', + isWinRepublican: true, + }, +] + +const App = () => { + return ( + + + + <StateElectionMap markets={senateMidterms} /> + + <iframe + src="https://manifold.markets/embed/NathanpmYoung/will-the-democrats-control-the-sena" + title="Will the Democrats control the Senate after the Midterms?" + frameBorder="0" + width={800} + height={400} + className="mt-8" + ></iframe> + </Col> + </Page> + ) +} + +export default App From 3ed29877cee1b50ff0a488c4fd394d2cfdfdcec2 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Tue, 27 Sep 2022 18:55:07 -0400 Subject: [PATCH 008/135] Add dating docs to menu bar --- web/components/nav/sidebar.tsx | 2 ++ web/pages/date-docs/index.tsx | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/web/components/nav/sidebar.tsx b/web/components/nav/sidebar.tsx index b0a9862b..71842559 100644 --- a/web/components/nav/sidebar.tsx +++ b/web/components/nav/sidebar.tsx @@ -164,6 +164,7 @@ function getMoreDesktopNavigation(user?: User | null) { { name: 'Charity', href: '/charity' }, { name: 'Send M$', href: '/links' }, { name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' }, + { name: 'Dating docs', href: '/date-docs' }, { name: 'Help & About', href: 'https://help.manifold.markets/' }, { name: 'Sign out', @@ -226,6 +227,7 @@ function getMoreMobileNav() { { name: 'Charity', href: '/charity' }, { name: 'Send M$', href: '/links' }, { name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' }, + { name: 'Dating docs', href: '/date-docs' }, ], signOut ) diff --git a/web/pages/date-docs/index.tsx b/web/pages/date-docs/index.tsx index d2dd874c..9ddeb57f 100644 --- a/web/pages/date-docs/index.tsx +++ b/web/pages/date-docs/index.tsx @@ -41,7 +41,7 @@ export default function DatePage(props: { return ( <Page> <div className="mx-auto w-full max-w-xl"> - <Row className="items-center justify-between"> + <Row className="items-center justify-between p-4 sm:p-0"> <Title className="!my-0 px-2 text-blue-500" text="Date docs" /> {!hasDoc && ( <SiteLink href="/date-docs/create" className="!no-underline"> From b87e29d7c0ea5097e01a1f58fed35731cd5839e0 Mon Sep 17 00:00:00 2001 From: Austin Chen <akrolsmir@gmail.com> Date: Tue, 27 Sep 2022 17:34:48 -0400 Subject: [PATCH 009/135] Rename script --- .../scripts/contest/{create-markets.ts => bulk-create-markets.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename functions/src/scripts/contest/{create-markets.ts => bulk-create-markets.ts} (100%) diff --git a/functions/src/scripts/contest/create-markets.ts b/functions/src/scripts/contest/bulk-create-markets.ts similarity index 100% rename from functions/src/scripts/contest/create-markets.ts rename to functions/src/scripts/contest/bulk-create-markets.ts From 14c008234a57e084ef51ab6652ddef3c9d030923 Mon Sep 17 00:00:00 2001 From: Austin Chen <akrolsmir@gmail.com> Date: Tue, 27 Sep 2022 17:38:30 -0400 Subject: [PATCH 010/135] Script: Add liquidity to all markets in a group --- docs/docs/api.md | 9 +++- .../src/scripts/contest/bulk-add-liquidity.ts | 52 +++++++++++++++++++ web/pages/api/v0/market/[id]/add-liquidity.ts | 28 ++++++++++ 3 files changed, 87 insertions(+), 2 deletions(-) create mode 100644 functions/src/scripts/contest/bulk-add-liquidity.ts create mode 100644 web/pages/api/v0/market/[id]/add-liquidity.ts diff --git a/docs/docs/api.md b/docs/docs/api.md index 007f6fa6..5fc95a4a 100644 --- a/docs/docs/api.md +++ b/docs/docs/api.md @@ -55,6 +55,7 @@ Returns the authenticated user. Gets all groups, in no particular order. Parameters: + - `availableToUserId`: Optional. if specified, only groups that the user can join and groups they've already joined will be returned. @@ -81,7 +82,6 @@ Gets a group's markets by its unique ID. Requires no authorization. Note: group is singular in the URL. - ### `GET /v0/markets` Lists all markets, ordered by creation date descending. @@ -582,12 +582,17 @@ $ curl https://manifold.markets/api/v0/market -X POST -H 'Content-Type: applicat "initialProb":25}' ``` +### `POST /v0/market/[marketId]/add-liquidity` + +Adds a specified amount of liquidity into the market. + +- `amount`: Required. The amount of liquidity to add, in M$. ### `POST /v0/market/[marketId]/close` Closes a market on behalf of the authorized user. -- `closeTime`: Optional. Milliseconds since the epoch to close the market at. If not provided, the market will be closed immediately. Cannot provide close time in past. +- `closeTime`: Optional. Milliseconds since the epoch to close the market at. If not provided, the market will be closed immediately. Cannot provide close time in past. ### `POST /v0/market/[marketId]/resolve` diff --git a/functions/src/scripts/contest/bulk-add-liquidity.ts b/functions/src/scripts/contest/bulk-add-liquidity.ts new file mode 100644 index 00000000..99d5f12b --- /dev/null +++ b/functions/src/scripts/contest/bulk-add-liquidity.ts @@ -0,0 +1,52 @@ +// Run with `npx ts-node src/scripts/contest/resolve-markets.ts` + +const DOMAIN = 'http://localhost:3000' +// Dev API key for Cause Exploration Prizes (@CEP) +// const API_KEY = '188f014c-0ba2-4c35-9e6d-88252e281dbf' +// DEV API key for Criticism and Red Teaming (@CARTBot) +const API_KEY = '6ff1f78a-32fe-43b2-b31b-9e3c78c5f18c' + +// Warning: Checking these in can be dangerous! +// Prod API key for @CEPBot + +// Can just curl /v0/group/{slug} to get a group +async function getGroupBySlug(slug: string) { + const resp = await fetch(`${DOMAIN}/api/v0/group/${slug}`) + return await resp.json() +} + +async function getMarketsByGroupId(id: string) { + // API structure: /v0/group/by-id/[id]/markets + const resp = await fetch(`${DOMAIN}/api/v0/group/by-id/${id}/markets`) + return await resp.json() +} + +async function addLiquidityById(id: string, amount: number) { + const resp = await fetch(`${DOMAIN}/api/v0/market/${id}/add-liquidity`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Key ${API_KEY}`, + }, + body: JSON.stringify({ + amount: amount, + }), + }) + return await resp.json() +} + +async function main() { + const group = await getGroupBySlug('cart-contest') + const markets = await getMarketsByGroupId(group.id) + + // Count up some metrics + console.log('Number of markets', markets.length) + + // Resolve each market to NO + for (const market of markets.slice(0, 3)) { + console.log(market.slug, market.totalLiquidity) + const resp = await addLiquidityById(market.id, 200) + console.log(resp) + } +} +main() diff --git a/web/pages/api/v0/market/[id]/add-liquidity.ts b/web/pages/api/v0/market/[id]/add-liquidity.ts new file mode 100644 index 00000000..3e1a3ed9 --- /dev/null +++ b/web/pages/api/v0/market/[id]/add-liquidity.ts @@ -0,0 +1,28 @@ +import { NextApiRequest, NextApiResponse } from 'next' +import { + CORS_ORIGIN_MANIFOLD, + CORS_ORIGIN_LOCALHOST, +} from 'common/envs/constants' +import { applyCorsHeaders } from 'web/lib/api/cors' +import { fetchBackend, forwardResponse } from 'web/lib/api/proxy' + +export const config = { api: { bodyParser: true } } + +export default async function route(req: NextApiRequest, res: NextApiResponse) { + await applyCorsHeaders(req, res, { + origin: [CORS_ORIGIN_MANIFOLD, CORS_ORIGIN_LOCALHOST], + methods: 'POST', + }) + + const { id } = req.query + const contractId = id as string + + if (req.body) req.body.contractId = contractId + try { + const backendRes = await fetchBackend(req, 'addliquidity') + await forwardResponse(res, backendRes) + } catch (err) { + console.error('Error talking to cloud function: ', err) + res.status(500).json({ message: 'Error communicating with backend.' }) + } +} From 13dad9a10c821c4248145d920c3e1028aeb88235 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Tue, 27 Sep 2022 19:03:14 -0400 Subject: [PATCH 011/135] Date doc: Remove photo as first-class feature --- common/post.ts | 1 - functions/src/create-post.ts | 1 - web/pages/date-docs/[username].tsx | 7 +--- web/pages/date-docs/create.tsx | 57 +----------------------------- 4 files changed, 2 insertions(+), 64 deletions(-) diff --git a/common/post.ts b/common/post.ts index 13a90821..45503b22 100644 --- a/common/post.ts +++ b/common/post.ts @@ -12,7 +12,6 @@ export type Post = { export type DateDoc = Post & { bounty: number birthday: number - photoUrl: string type: 'date-doc' contractSlug: string } diff --git a/functions/src/create-post.ts b/functions/src/create-post.ts index a342dc05..675ce3e1 100644 --- a/functions/src/create-post.ts +++ b/functions/src/create-post.ts @@ -42,7 +42,6 @@ const postSchema = z.object({ // Date doc fields: bounty: z.number().optional(), birthday: z.number().optional(), - photoUrl: z.string().optional(), type: z.string().optional(), question: z.string().optional(), }) diff --git a/web/pages/date-docs/[username].tsx b/web/pages/date-docs/[username].tsx index d6ac37cd..2b6c4909 100644 --- a/web/pages/date-docs/[username].tsx +++ b/web/pages/date-docs/[username].tsx @@ -85,7 +85,7 @@ export function DateDocPost(props: { link?: boolean }) { const { dateDoc, creator, link } = props - const { content, birthday, photoUrl, contractSlug } = dateDoc + const { content, birthday, contractSlug } = dateDoc const { name, username } = creator const user = useUser() @@ -133,11 +133,6 @@ export function DateDocPost(props: { </Button> </Col> </Row> - <img - className="w-full max-w-lg rounded-lg object-cover" - src={photoUrl} - alt={name} - /> </Col> </SiteLink> {user && user.id === creator.id ? ( diff --git a/web/pages/date-docs/create.tsx b/web/pages/date-docs/create.tsx index 5d72da42..6065796d 100644 --- a/web/pages/date-docs/create.tsx +++ b/web/pages/date-docs/create.tsx @@ -13,8 +13,6 @@ import { Button } from 'web/components/button' import dayjs from 'dayjs' import { MINUTE_MS } from 'common/util/time' import { Col } from 'web/components/layout/col' -import { uploadImage } from 'web/lib/firebase/storage' -import { LoadingIndicator } from 'web/components/loading-indicator' import { MAX_QUESTION_LENGTH } from 'common/contract' export default function CreateDateDocPage() { @@ -26,8 +24,6 @@ export default function CreateDateDocPage() { const title = `${user?.name}'s Date Doc` const [birthday, setBirthday] = useState<undefined | string>(undefined) - const [photoUrl, setPhotoUrl] = useState('') - const [avatarLoading, setAvatarLoading] = useState(false) const [question, setQuestion] = useState( 'Will I find a partner in the next 3 months?' ) @@ -40,30 +36,7 @@ export default function CreateDateDocPage() { const birthdayTime = birthday ? dayjs(birthday).valueOf() : undefined const isValid = - user && - birthday && - photoUrl && - editor && - editor.isEmpty === false && - question - - const fileHandler = async (event: any) => { - if (!user) return - - const file = event.target.files[0] - - setAvatarLoading(true) - - await uploadImage(user.username, file) - .then(async (url) => { - setPhotoUrl(url) - setAvatarLoading(false) - }) - .catch(() => { - setAvatarLoading(false) - setPhotoUrl('') - }) - } + user && birthday && editor && editor.isEmpty === false && question async function saveDateDoc() { if (!user || !editor || !birthdayTime) return @@ -76,7 +49,6 @@ export default function CreateDateDocPage() { content: editor.getJSON(), bounty: 0, birthday: birthdayTime, - photoUrl, type: 'date-doc', question, } @@ -122,33 +94,6 @@ export default function CreateDateDocPage() { /> </Col> - <Col className="gap-4"> - <div className="">Photo</div> - <Row className="items-center gap-4"> - {avatarLoading ? ( - <LoadingIndicator /> - ) : ( - <> - {photoUrl && ( - <img - src={photoUrl} - width={80} - height={80} - className="flex h-[80px] w-[80px] items-center justify-center rounded-lg bg-gray-400 object-cover" - /> - )} - <input - className="text-sm text-gray-500" - type="file" - name="file" - accept="image/*" - onChange={fileHandler} - /> - </> - )} - </Row> - </Col> - <Col className="gap-4"> <div className=""> Tell us about you! What are you looking for? From a7abdbb1dbf934b0893ff679f02044a3f35ecc30 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Tue, 27 Sep 2022 19:10:35 -0400 Subject: [PATCH 012/135] Add to dating group --- functions/src/create-post.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/functions/src/create-post.ts b/functions/src/create-post.ts index 675ce3e1..e9d6ae8f 100644 --- a/functions/src/create-post.ts +++ b/functions/src/create-post.ts @@ -75,6 +75,8 @@ export const createpost = newEndpoint({}, async (req, auth) => { outcomeType: 'BINARY', visibility: 'unlisted', initialProb: 50, + // Dating group! + groupId: 'j3ZE8fkeqiKmRGumy3O1', }, auth ) From 9dc0d1696e2b15ada208f5aa49e67add42ed1081 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Tue, 27 Sep 2022 19:36:32 -0400 Subject: [PATCH 013/135] Fix bug --- web/pages/date-docs/[username].tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/web/pages/date-docs/[username].tsx b/web/pages/date-docs/[username].tsx index 2b6c4909..17a41445 100644 --- a/web/pages/date-docs/[username].tsx +++ b/web/pages/date-docs/[username].tsx @@ -98,13 +98,13 @@ export function DateDocPost(props: { return ( <Col className="gap-6 rounded-lg bg-white px-6 py-6"> <SiteLink href={link ? `/date-docs/${creator.username}` : undefined}> - <Col className="gap-6 self-center"> - <Row className="relative items-center justify-between gap-4 text-2xl"> + <Col className="gap-6"> + <Row className="relative justify-between gap-4 text-2xl"> <div> {name}, {age} </div> - <Col className="absolute right-0 px-2"> + <Col className={clsx(link && 'absolute', 'right-0 px-2')}> <Button size="lg" color="gray-white" From e0d9b4d3354ad082df0dca90483c158df8393d6a Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Tue, 27 Sep 2022 20:24:42 -0700 Subject: [PATCH 014/135] Rewrite contract graphs (#935) * Fiddle around with everything, WIP FR charts * Implement numeric chart * Reorganize everything into neat little files * Add `AreaWithTopStroke` helper * Tidying, don't gratuitously use d3.format * Remove duplicate code * Better tooltip bisection * `NumericPoint` -> `DistributionPoint` * Add numeric market tooltip * Make numeric chart bucket points less wrong * Clean up numeric bucket computation * Clean up a bunch of tooltip stuff, add FR legend tooltips * Fix a dumb bug * Implement basic time selection * Fix fishy Date.now inconsistency bugs * Might as well show all the FR outcomes now * Make tooltips accurate on curveStepAfter charts * Make log scale PN charts work properly * Adjust x-axis tick count * Display tooltip on charts only for mouse * Fix up deps * Tighter chart tooltips * Adjustments to chart time range management * Better date formatting * Continue tweaking time selection handling to be perfect * Make FR charts taller by default --- web/components/answers/answers-graph.tsx | 238 --------- web/components/charts/contract/binary.tsx | 65 +++ web/components/charts/contract/choice.tsx | 170 ++++++ web/components/charts/contract/index.tsx | 34 ++ web/components/charts/contract/numeric.tsx | 52 ++ .../charts/contract/pseudo-numeric.tsx | 86 +++ web/components/charts/generic-charts.tsx | 393 ++++++++++++++ web/components/charts/helpers.tsx | 207 ++++++++ web/components/contract/contract-overview.tsx | 17 +- .../contract/contract-prob-graph.tsx | 203 ------- web/components/contract/numeric-graph.tsx | 99 ---- web/hooks/use-element-width.tsx | 17 + web/package.json | 4 +- web/pages/embed/[username]/[contractSlug].tsx | 21 +- yarn.lock | 500 +++++++++++++++++- 15 files changed, 1533 insertions(+), 573 deletions(-) delete mode 100644 web/components/answers/answers-graph.tsx create mode 100644 web/components/charts/contract/binary.tsx create mode 100644 web/components/charts/contract/choice.tsx create mode 100644 web/components/charts/contract/index.tsx create mode 100644 web/components/charts/contract/numeric.tsx create mode 100644 web/components/charts/contract/pseudo-numeric.tsx create mode 100644 web/components/charts/generic-charts.tsx create mode 100644 web/components/charts/helpers.tsx delete mode 100644 web/components/contract/contract-prob-graph.tsx delete mode 100644 web/components/contract/numeric-graph.tsx create mode 100644 web/hooks/use-element-width.tsx diff --git a/web/components/answers/answers-graph.tsx b/web/components/answers/answers-graph.tsx deleted file mode 100644 index e4167d11..00000000 --- a/web/components/answers/answers-graph.tsx +++ /dev/null @@ -1,238 +0,0 @@ -import { DatumValue } from '@nivo/core' -import { ResponsiveLine } from '@nivo/line' -import dayjs from 'dayjs' -import { groupBy, sortBy, sumBy } from 'lodash' -import { memo } from 'react' - -import { Bet } from 'common/bet' -import { FreeResponseContract, MultipleChoiceContract } from 'common/contract' -import { getOutcomeProbability } from 'common/calculate' -import { useWindowSize } from 'web/hooks/use-window-size' - -const NUM_LINES = 6 - -export const AnswersGraph = memo(function AnswersGraph(props: { - contract: FreeResponseContract | MultipleChoiceContract - bets: Bet[] - height?: number -}) { - const { contract, bets, height } = props - const { createdTime, resolutionTime, closeTime, answers } = contract - const now = Date.now() - - const { probsByOutcome, sortedOutcomes } = computeProbsByOutcome( - bets, - contract - ) - - const isClosed = !!closeTime && now > closeTime - const latestTime = dayjs( - resolutionTime && isClosed - ? Math.min(resolutionTime, closeTime) - : isClosed - ? closeTime - : resolutionTime ?? now - ) - - const { width } = useWindowSize() - - const isLargeWidth = !width || width > 800 - const labelLength = isLargeWidth ? 50 : 20 - - // Add a fake datapoint so the line continues to the right - const endTime = latestTime.valueOf() - - const times = sortBy([ - createdTime, - ...bets.map((bet) => bet.createdTime), - endTime, - ]) - const dateTimes = times.map((time) => new Date(time)) - - const data = sortedOutcomes.map((outcome) => { - const betProbs = probsByOutcome[outcome] - // Add extra point for contract start and end. - const probs = [0, ...betProbs, betProbs[betProbs.length - 1]] - - const points = probs.map((prob, i) => ({ - x: dateTimes[i], - y: Math.round(prob * 100), - })) - - const answer = - answers?.find((answer) => answer.id === outcome)?.text ?? 'None' - const answerText = - answer.slice(0, labelLength) + (answer.length > labelLength ? '...' : '') - - return { id: answerText, data: points } - }) - - data.reverse() - - const yTickValues = [0, 25, 50, 75, 100] - - const numXTickValues = isLargeWidth ? 5 : 2 - const startDate = dayjs(contract.createdTime) - const endDate = startDate.add(1, 'hour').isAfter(latestTime) - ? latestTime.add(1, 'hours') - : latestTime - const includeMinute = endDate.diff(startDate, 'hours') < 2 - - const multiYear = !startDate.isSame(latestTime, 'year') - const lessThanAWeek = startDate.add(1, 'week').isAfter(latestTime) - - return ( - <div - className="w-full" - style={{ height: height ?? (isLargeWidth ? 350 : 250) }} - > - <ResponsiveLine - data={data} - yScale={{ min: 0, max: 100, type: 'linear', stacked: true }} - yFormat={formatPercent} - gridYValues={yTickValues} - axisLeft={{ - tickValues: yTickValues, - format: formatPercent, - }} - xScale={{ - type: 'time', - min: startDate.toDate(), - max: endDate.toDate(), - }} - xFormat={(d) => - formatTime(now, +d.valueOf(), multiYear, lessThanAWeek, lessThanAWeek) - } - axisBottom={{ - tickValues: numXTickValues, - format: (time) => - formatTime(now, +time, multiYear, lessThanAWeek, includeMinute), - }} - colors={[ - '#fca5a5', // red-300 - '#a5b4fc', // indigo-300 - '#86efac', // green-300 - '#fef08a', // yellow-200 - '#fdba74', // orange-300 - '#c084fc', // purple-400 - ]} - pointSize={0} - curve="stepAfter" - enableSlices="x" - enableGridX={!!width && width >= 800} - enableArea - areaOpacity={1} - margin={{ top: 20, right: 20, bottom: 25, left: 40 }} - legends={[ - { - anchor: 'top-left', - direction: 'column', - justify: false, - translateX: isLargeWidth ? 5 : 2, - translateY: 0, - itemsSpacing: 0, - itemTextColor: 'black', - itemDirection: 'left-to-right', - itemWidth: isLargeWidth ? 288 : 138, - itemHeight: 20, - itemBackground: 'white', - itemOpacity: 0.9, - symbolSize: 12, - effects: [ - { - on: 'hover', - style: { - itemBackground: 'rgba(255, 255, 255, 1)', - itemOpacity: 1, - }, - }, - ], - }, - ]} - /> - </div> - ) -}) - -function formatPercent(y: DatumValue) { - return `${Math.round(+y.toString())}%` -} - -function formatTime( - now: number, - time: number, - includeYear: boolean, - includeHour: boolean, - includeMinute: boolean -) { - const d = dayjs(time) - if (d.add(1, 'minute').isAfter(now) && d.subtract(1, 'minute').isBefore(now)) - return 'Now' - - let format: string - if (d.isSame(now, 'day')) { - format = '[Today]' - } else if (d.add(1, 'day').isSame(now, 'day')) { - format = '[Yesterday]' - } else { - format = 'MMM D' - } - - if (includeMinute) { - format += ', h:mma' - } else if (includeHour) { - format += ', ha' - } else if (includeYear) { - format += ', YYYY' - } - - return d.format(format) -} - -const computeProbsByOutcome = ( - bets: Bet[], - contract: FreeResponseContract | MultipleChoiceContract -) => { - const { totalBets, outcomeType } = contract - - const betsByOutcome = groupBy(bets, (bet) => bet.outcome) - const outcomes = Object.keys(betsByOutcome).filter((outcome) => { - const maxProb = Math.max( - ...betsByOutcome[outcome].map((bet) => bet.probAfter) - ) - return ( - (outcome !== '0' || outcomeType === 'MULTIPLE_CHOICE') && - maxProb > 0.02 && - totalBets[outcome] > 0.000000001 - ) - }) - - const trackedOutcomes = sortBy( - outcomes, - (outcome) => -1 * getOutcomeProbability(contract, outcome) - ).slice(0, NUM_LINES) - - const probsByOutcome = Object.fromEntries( - trackedOutcomes.map((outcome) => [outcome, [] as number[]]) - ) - const sharesByOutcome = Object.fromEntries( - Object.keys(betsByOutcome).map((outcome) => [outcome, 0]) - ) - - for (const bet of bets) { - const { outcome, shares } = bet - sharesByOutcome[outcome] += shares - - const sharesSquared = sumBy( - Object.values(sharesByOutcome).map((shares) => shares ** 2) - ) - - for (const outcome of trackedOutcomes) { - probsByOutcome[outcome].push( - sharesByOutcome[outcome] ** 2 / sharesSquared - ) - } - } - - return { probsByOutcome, sortedOutcomes: trackedOutcomes } -} diff --git a/web/components/charts/contract/binary.tsx b/web/components/charts/contract/binary.tsx new file mode 100644 index 00000000..f7ed406b --- /dev/null +++ b/web/components/charts/contract/binary.tsx @@ -0,0 +1,65 @@ +import { useMemo, useRef } from 'react' +import { sortBy } from 'lodash' +import { scaleTime, scaleLinear } from 'd3' + +import { Bet } from 'common/bet' +import { getInitialProbability, getProbability } from 'common/calculate' +import { BinaryContract } from 'common/contract' +import { useIsMobile } from 'web/hooks/use-is-mobile' +import { MARGIN_X, MARGIN_Y, MAX_DATE, getDateRange } from '../helpers' +import { SingleValueHistoryChart } from '../generic-charts' +import { useElementWidth } from 'web/hooks/use-element-width' + +const getBetPoints = (bets: Bet[]) => { + return sortBy(bets, (b) => b.createdTime).map( + (b) => [new Date(b.createdTime), b.probAfter] as const + ) +} + +const getStartPoint = (contract: BinaryContract, start: Date) => { + return [start, getInitialProbability(contract)] as const +} + +const getEndPoint = (contract: BinaryContract, end: Date) => { + return [end, getProbability(contract)] as const +} + +export const BinaryContractChart = (props: { + contract: BinaryContract + bets: Bet[] + height?: number +}) => { + const { contract, bets } = props + const [contractStart, contractEnd] = getDateRange(contract) + const betPoints = useMemo(() => getBetPoints(bets), [bets]) + const data = useMemo( + () => [ + getStartPoint(contract, contractStart), + ...betPoints, + getEndPoint(contract, contractEnd ?? MAX_DATE), + ], + [contract, betPoints, contractStart, contractEnd] + ) + const visibleRange = [contractStart, contractEnd ?? Date.now()] + const isMobile = useIsMobile(800) + const containerRef = useRef<HTMLDivElement>(null) + const width = useElementWidth(containerRef) ?? 0 + const height = props.height ?? (isMobile ? 250 : 350) + const xScale = scaleTime(visibleRange, [0, width - MARGIN_X]) + const yScale = scaleLinear([0, 1], [height - MARGIN_Y, 0]) + return ( + <div ref={containerRef}> + {width && ( + <SingleValueHistoryChart + w={width} + h={height} + xScale={xScale} + yScale={yScale} + data={data} + color="#11b981" + pct + /> + )} + </div> + ) +} diff --git a/web/components/charts/contract/choice.tsx b/web/components/charts/contract/choice.tsx new file mode 100644 index 00000000..4b4fa34d --- /dev/null +++ b/web/components/charts/contract/choice.tsx @@ -0,0 +1,170 @@ +import { useMemo, useRef } from 'react' +import { sum, sortBy, groupBy } from 'lodash' +import { scaleTime, scaleLinear } from 'd3' + +import { Bet } from 'common/bet' +import { Answer } from 'common/answer' +import { FreeResponseContract, MultipleChoiceContract } from 'common/contract' +import { getOutcomeProbability } from 'common/calculate' +import { useIsMobile } from 'web/hooks/use-is-mobile' +import { MARGIN_X, MARGIN_Y, MAX_DATE, getDateRange } from '../helpers' +import { MultiPoint, MultiValueHistoryChart } from '../generic-charts' +import { useElementWidth } from 'web/hooks/use-element-width' + +// thanks to https://observablehq.com/@jonhelfman/optimal-orders-for-choosing-categorical-colors +const CATEGORY_COLORS = [ + '#00b8dd', + '#eecafe', + '#874c62', + '#6457ca', + '#f773ba', + '#9c6bbc', + '#a87744', + '#af8a04', + '#bff9aa', + '#f3d89d', + '#c9a0f5', + '#ff00e5', + '#9dc6f7', + '#824475', + '#d973cc', + '#bc6808', + '#056e70', + '#677932', + '#00b287', + '#c8ab6c', + '#a2fb7a', + '#f8db68', + '#14675a', + '#8288f4', + '#fe1ca0', + '#ad6aff', + '#786306', + '#9bfbaf', + '#b00cf7', + '#2f7ec5', + '#4b998b', + '#42fa0e', + '#5b80a1', + '#962d9d', + '#3385ff', + '#48c5ab', + '#b2c873', + '#4cf9a4', + '#00ffff', + '#3cca73', + '#99ae17', + '#7af5cf', + '#52af45', + '#fbb80f', + '#29971b', + '#187c9a', + '#00d539', + '#bbfa1a', + '#61f55c', + '#cabc03', + '#ff9000', + '#779100', + '#bcfd6f', + '#70a560', +] + +const getTrackedAnswers = ( + contract: FreeResponseContract | MultipleChoiceContract, + topN: number +) => { + const { answers, outcomeType, totalBets } = contract + const validAnswers = answers.filter((answer) => { + return ( + (answer.id !== '0' || outcomeType === 'MULTIPLE_CHOICE') && + totalBets[answer.id] > 0.000000001 + ) + }) + return sortBy( + validAnswers, + (answer) => -1 * getOutcomeProbability(contract, answer.id) + ).slice(0, topN) +} + +const getStartPoint = (answers: Answer[], start: Date) => { + return [start, answers.map((_) => 0)] as const +} + +const getEndPoint = ( + answers: Answer[], + contract: FreeResponseContract | MultipleChoiceContract, + end: Date +) => { + return [ + end, + answers.map((a) => getOutcomeProbability(contract, a.id)), + ] as const +} + +const getBetPoints = (answers: Answer[], bets: Bet[]) => { + const sortedBets = sortBy(bets, (b) => b.createdTime) + const betsByOutcome = groupBy(sortedBets, (bet) => bet.outcome) + const sharesByOutcome = Object.fromEntries( + Object.keys(betsByOutcome).map((outcome) => [outcome, 0]) + ) + const points: MultiPoint[] = [] + for (const bet of sortedBets) { + const { outcome, shares } = bet + sharesByOutcome[outcome] += shares + + const sharesSquared = sum( + Object.values(sharesByOutcome).map((shares) => shares ** 2) + ) + points.push([ + new Date(bet.createdTime), + answers.map((answer) => sharesByOutcome[answer.id] ** 2 / sharesSquared), + ]) + } + return points +} + +export const ChoiceContractChart = (props: { + contract: FreeResponseContract | MultipleChoiceContract + bets: Bet[] + height?: number +}) => { + const { contract, bets } = props + const [contractStart, contractEnd] = getDateRange(contract) + const answers = useMemo( + () => getTrackedAnswers(contract, CATEGORY_COLORS.length), + [contract] + ) + const betPoints = useMemo(() => getBetPoints(answers, bets), [answers, bets]) + const data = useMemo( + () => [ + getStartPoint(answers, contractStart), + ...betPoints, + getEndPoint(answers, contract, contractEnd ?? MAX_DATE), + ], + [answers, contract, betPoints, contractStart, contractEnd] + ) + const visibleRange = [contractStart, contractEnd ?? Date.now()] + + const isMobile = useIsMobile(800) + const containerRef = useRef<HTMLDivElement>(null) + const width = useElementWidth(containerRef) ?? 0 + const height = props.height ?? (isMobile ? 150 : 250) + const xScale = scaleTime(visibleRange, [0, width - MARGIN_X]) + const yScale = scaleLinear([0, 1], [height - MARGIN_Y, 0]) + return ( + <div ref={containerRef}> + {width && ( + <MultiValueHistoryChart + w={width} + h={height} + xScale={xScale} + yScale={yScale} + data={data} + colors={CATEGORY_COLORS} + labels={answers.map((answer) => answer.text)} + pct + /> + )} + </div> + ) +} diff --git a/web/components/charts/contract/index.tsx b/web/components/charts/contract/index.tsx new file mode 100644 index 00000000..1f580bae --- /dev/null +++ b/web/components/charts/contract/index.tsx @@ -0,0 +1,34 @@ +import { Contract } from 'common/contract' +import { Bet } from 'common/bet' +import { BinaryContractChart } from './binary' +import { PseudoNumericContractChart } from './pseudo-numeric' +import { ChoiceContractChart } from './choice' +import { NumericContractChart } from './numeric' + +export const ContractChart = (props: { + contract: Contract + bets: Bet[] + height?: number +}) => { + const { contract } = props + switch (contract.outcomeType) { + case 'BINARY': + return <BinaryContractChart {...{ ...props, contract }} /> + case 'PSEUDO_NUMERIC': + return <PseudoNumericContractChart {...{ ...props, contract }} /> + case 'FREE_RESPONSE': + case 'MULTIPLE_CHOICE': + return <ChoiceContractChart {...{ ...props, contract }} /> + case 'NUMERIC': + return <NumericContractChart {...{ ...props, contract }} /> + default: + return null + } +} + +export { + BinaryContractChart, + PseudoNumericContractChart, + ChoiceContractChart, + NumericContractChart, +} diff --git a/web/components/charts/contract/numeric.tsx b/web/components/charts/contract/numeric.tsx new file mode 100644 index 00000000..6adc52f0 --- /dev/null +++ b/web/components/charts/contract/numeric.tsx @@ -0,0 +1,52 @@ +import { useMemo, useRef } from 'react' +import { max, range } from 'lodash' +import { scaleLinear } from 'd3' + +import { getDpmOutcomeProbabilities } from 'common/calculate-dpm' +import { NumericContract } from 'common/contract' +import { NUMERIC_GRAPH_COLOR } from 'common/numeric-constants' +import { useIsMobile } from 'web/hooks/use-is-mobile' +import { MARGIN_X, MARGIN_Y } from '../helpers' +import { SingleValueDistributionChart } from '../generic-charts' +import { useElementWidth } from 'web/hooks/use-element-width' + +const getNumericChartData = (contract: NumericContract) => { + const { totalShares, bucketCount, min, max } = contract + const step = (max - min) / bucketCount + const bucketProbs = getDpmOutcomeProbabilities(totalShares) + return range(bucketCount).map( + (i) => [min + step * (i + 0.5), bucketProbs[`${i}`]] as const + ) +} + +export const NumericContractChart = (props: { + contract: NumericContract + height?: number +}) => { + const { contract } = props + const data = useMemo(() => getNumericChartData(contract), [contract]) + const isMobile = useIsMobile(800) + const containerRef = useRef<HTMLDivElement>(null) + const width = useElementWidth(containerRef) ?? 0 + const height = props.height ?? (isMobile ? 150 : 250) + const maxY = max(data.map((d) => d[1])) as number + const xScale = scaleLinear( + [contract.min, contract.max], + [0, width - MARGIN_X] + ) + const yScale = scaleLinear([0, maxY], [height - MARGIN_Y, 0]) + return ( + <div ref={containerRef}> + {width && ( + <SingleValueDistributionChart + w={width} + h={height} + xScale={xScale} + yScale={yScale} + data={data} + color={NUMERIC_GRAPH_COLOR} + /> + )} + </div> + ) +} diff --git a/web/components/charts/contract/pseudo-numeric.tsx b/web/components/charts/contract/pseudo-numeric.tsx new file mode 100644 index 00000000..9b67a169 --- /dev/null +++ b/web/components/charts/contract/pseudo-numeric.tsx @@ -0,0 +1,86 @@ +import { useMemo, useRef } from 'react' +import { sortBy } from 'lodash' +import { scaleTime, scaleLog, scaleLinear } from 'd3' + +import { Bet } from 'common/bet' +import { getInitialProbability, getProbability } from 'common/calculate' +import { PseudoNumericContract } from 'common/contract' +import { NUMERIC_GRAPH_COLOR } from 'common/numeric-constants' +import { useIsMobile } from 'web/hooks/use-is-mobile' +import { MARGIN_X, MARGIN_Y, MAX_DATE, getDateRange } from '../helpers' +import { SingleValueHistoryChart } from '../generic-charts' +import { useElementWidth } from 'web/hooks/use-element-width' + +// mqp: note that we have an idiosyncratic version of 'log scale' +// contracts. the values are stored "linearly" and can include zero. +// as a result, we have to do some weird-looking stuff in this code + +const getY = (p: number, contract: PseudoNumericContract) => { + const { min, max, isLogScale } = contract + return isLogScale + ? 10 ** (p * Math.log10(max - min + 1)) + min - 1 + : p * (max - min) + min +} + +const getBetPoints = (contract: PseudoNumericContract, bets: Bet[]) => { + return sortBy(bets, (b) => b.createdTime).map( + (b) => [new Date(b.createdTime), getY(b.probAfter, contract)] as const + ) +} + +const getStartPoint = (contract: PseudoNumericContract, start: Date) => { + return [start, getY(getInitialProbability(contract), contract)] as const +} + +const getEndPoint = (contract: PseudoNumericContract, end: Date) => { + return [end, getY(getProbability(contract), contract)] as const +} + +export const PseudoNumericContractChart = (props: { + contract: PseudoNumericContract + bets: Bet[] + height?: number +}) => { + const { contract, bets } = props + const [contractStart, contractEnd] = getDateRange(contract) + const betPoints = useMemo( + () => getBetPoints(contract, bets), + [contract, bets] + ) + const data = useMemo( + () => [ + getStartPoint(contract, contractStart), + ...betPoints, + getEndPoint(contract, contractEnd ?? MAX_DATE), + ], + [contract, betPoints, contractStart, contractEnd] + ) + const visibleRange = [contractStart, contractEnd ?? Date.now()] + + const isMobile = useIsMobile(800) + const containerRef = useRef<HTMLDivElement>(null) + const width = useElementWidth(containerRef) ?? 0 + const height = props.height ?? (isMobile ? 150 : 250) + const xScale = scaleTime(visibleRange, [0, width - MARGIN_X]) + const yScale = contract.isLogScale + ? scaleLog( + [Math.max(contract.min, 1), contract.max], + [height - MARGIN_Y, 0] + ).clamp(true) // make sure zeroes go to the bottom + : scaleLinear([contract.min, contract.max], [height - MARGIN_Y, 0]) + + return ( + <div ref={containerRef}> + {width && ( + <SingleValueHistoryChart + w={width} + h={height} + xScale={xScale} + yScale={yScale} + data={data} + color={NUMERIC_GRAPH_COLOR} + /> + )} + </div> + ) +} diff --git a/web/components/charts/generic-charts.tsx b/web/components/charts/generic-charts.tsx new file mode 100644 index 00000000..2bb5e089 --- /dev/null +++ b/web/components/charts/generic-charts.tsx @@ -0,0 +1,393 @@ +import { useCallback, useMemo, useState } from 'react' +import { + axisBottom, + axisLeft, + bisector, + curveLinear, + curveStepAfter, + pointer, + stack, + stackOrderReverse, + D3BrushEvent, + ScaleTime, + ScaleContinuousNumeric, + SeriesPoint, +} from 'd3' +import { range, sortBy } from 'lodash' +import dayjs from 'dayjs' + +import { + SVGChart, + AreaPath, + AreaWithTopStroke, + ChartTooltip, + TooltipPosition, +} from './helpers' +import { formatLargeNumber } from 'common/util/format' +import { useEvent } from 'web/hooks/use-event' +import { Row } from 'web/components/layout/row' + +export type MultiPoint = readonly [Date, number[]] // [time, [ordered outcome probs]] +export type HistoryPoint = readonly [Date, number] // [time, number or percentage] +export type DistributionPoint = readonly [number, number] // [outcome amount, prob] +export type PositionValue<P> = TooltipPosition & { p: P } + +const formatPct = (n: number, digits?: number) => { + return `${(n * 100).toFixed(digits ?? 0)}%` +} + +const formatDate = ( + date: Date, + opts: { includeYear: boolean; includeHour: boolean; includeMinute: boolean } +) => { + const { includeYear, includeHour, includeMinute } = opts + const d = dayjs(date) + const now = Date.now() + if ( + d.add(1, 'minute').isAfter(now) && + d.subtract(1, 'minute').isBefore(now) + ) { + return 'Now' + } else { + const dayName = d.isSame(now, 'day') + ? 'Today' + : d.add(1, 'day').isSame(now, 'day') + ? 'Yesterday' + : null + let format = dayName ? `[${dayName}]` : 'MMM D' + if (includeMinute) { + format += ', h:mma' + } else if (includeHour) { + format += ', ha' + } else if (includeYear) { + format += ', YYYY' + } + return d.format(format) + } +} + +const getFormatterForDateRange = (start: Date, end: Date) => { + const opts = { + includeYear: !dayjs(start).isSame(end, 'year'), + includeHour: dayjs(start).add(8, 'day').isAfter(end), + includeMinute: dayjs(end).diff(start, 'hours') < 2, + } + return (d: Date) => formatDate(d, opts) +} + +const getTickValues = (min: number, max: number, n: number) => { + const step = (max - min) / (n - 1) + return [min, ...range(1, n - 1).map((i) => min + step * i), max] +} + +type LegendItem = { color: string; label: string; value?: string } + +const Legend = (props: { className?: string; items: LegendItem[] }) => { + const { items, className } = props + return ( + <ol className={className}> + {items.map((item) => ( + <li key={item.label} className="flex flex-row justify-between"> + <Row className="mr-4 items-center"> + <span + className="mr-2 h-4 w-4" + style={{ backgroundColor: item.color }} + ></span> + {item.label} + </Row> + {item.value} + </li> + ))} + </ol> + ) +} + +export const SingleValueDistributionChart = (props: { + data: DistributionPoint[] + w: number + h: number + color: string + xScale: ScaleContinuousNumeric<number, number> + yScale: ScaleContinuousNumeric<number, number> +}) => { + const { color, data, yScale, w, h } = props + + // note that we have to type this funkily in order to succesfully store + // a function inside of useState + const [viewXScale, setViewXScale] = + useState<ScaleContinuousNumeric<number, number>>() + const [mouseState, setMouseState] = + useState<PositionValue<DistributionPoint>>() + const xScale = viewXScale ?? props.xScale + + const px = useCallback((p: DistributionPoint) => xScale(p[0]), [xScale]) + const py0 = yScale(yScale.domain()[0]) + const py1 = useCallback((p: DistributionPoint) => yScale(p[1]), [yScale]) + const xBisector = bisector((p: DistributionPoint) => p[0]) + + const { fmtX, fmtY, xAxis, yAxis } = useMemo(() => { + const fmtX = (n: number) => formatLargeNumber(n) + const fmtY = (n: number) => formatPct(n, 2) + const xAxis = axisBottom<number>(xScale).ticks(w / 100) + const yAxis = axisLeft<number>(yScale).tickFormat(fmtY) + return { fmtX, fmtY, xAxis, yAxis } + }, [w, xScale, yScale]) + + const onSelect = useEvent((ev: D3BrushEvent<DistributionPoint>) => { + if (ev.selection) { + const [mouseX0, mouseX1] = ev.selection as [number, number] + setViewXScale(() => + xScale.copy().domain([xScale.invert(mouseX0), xScale.invert(mouseX1)]) + ) + setMouseState(undefined) + } else { + setViewXScale(undefined) + setMouseState(undefined) + } + }) + + const onMouseOver = useEvent((ev: React.PointerEvent) => { + if (ev.pointerType === 'mouse') { + const [mouseX, mouseY] = pointer(ev) + const queryX = xScale.invert(mouseX) + const [_x, y] = data[xBisector.center(data, queryX)] + setMouseState({ top: mouseY - 10, left: mouseX + 60, p: [queryX, y] }) + } + }) + + const onMouseLeave = useEvent(() => { + setMouseState(undefined) + }) + + return ( + <div className="relative"> + {mouseState && ( + <ChartTooltip className="text-sm" {...mouseState}> + <strong>{fmtY(mouseState.p[1])}</strong> {fmtX(mouseState.p[0])} + </ChartTooltip> + )} + <SVGChart + w={w} + h={h} + xAxis={xAxis} + yAxis={yAxis} + onSelect={onSelect} + onMouseOver={onMouseOver} + onMouseLeave={onMouseLeave} + > + <AreaWithTopStroke + color={color} + data={data} + px={px} + py0={py0} + py1={py1} + curve={curveLinear} + /> + </SVGChart> + </div> + ) +} + +export const MultiValueHistoryChart = (props: { + data: MultiPoint[] + w: number + h: number + labels: readonly string[] + colors: readonly string[] + xScale: ScaleTime<number, number> + yScale: ScaleContinuousNumeric<number, number> + pct?: boolean +}) => { + const { colors, data, yScale, labels, w, h, pct } = props + + const [viewXScale, setViewXScale] = useState<ScaleTime<number, number>>() + const [mouseState, setMouseState] = useState<PositionValue<MultiPoint>>() + const xScale = viewXScale ?? props.xScale + + type SP = SeriesPoint<MultiPoint> + const px = useCallback((p: SP) => xScale(p.data[0]), [xScale]) + const py0 = useCallback((p: SP) => yScale(p[0]), [yScale]) + const py1 = useCallback((p: SP) => yScale(p[1]), [yScale]) + const xBisector = bisector((p: MultiPoint) => p[0]) + + const { fmtX, fmtY, xAxis, yAxis } = useMemo(() => { + const [start, end] = xScale.domain() + const fmtX = getFormatterForDateRange(start, end) + const fmtY = (n: number) => (pct ? formatPct(n, 0) : formatLargeNumber(n)) + + const [min, max] = yScale.domain() + const pctTickValues = getTickValues(min, max, h < 200 ? 3 : 5) + const xAxis = axisBottom<Date>(xScale).ticks(w / 100) + const yAxis = pct + ? axisLeft<number>(yScale).tickValues(pctTickValues).tickFormat(fmtY) + : axisLeft<number>(yScale) + + return { fmtX, fmtY, xAxis, yAxis } + }, [w, h, pct, xScale, yScale]) + + const series = useMemo(() => { + const d3Stack = stack<MultiPoint, number>() + .keys(range(0, labels.length)) + .value(([_date, probs], o) => probs[o]) + .order(stackOrderReverse) + return d3Stack(data) + }, [data, labels.length]) + + const onSelect = useEvent((ev: D3BrushEvent<MultiPoint>) => { + if (ev.selection) { + const [mouseX0, mouseX1] = ev.selection as [number, number] + setViewXScale(() => + xScale.copy().domain([xScale.invert(mouseX0), xScale.invert(mouseX1)]) + ) + setMouseState(undefined) + } else { + setViewXScale(undefined) + setMouseState(undefined) + } + }) + + const onMouseOver = useEvent((ev: React.PointerEvent) => { + if (ev.pointerType === 'mouse') { + const [mouseX, mouseY] = pointer(ev) + const queryX = xScale.invert(mouseX) + const [_x, ys] = data[xBisector.left(data, queryX) - 1] + setMouseState({ top: mouseY - 10, left: mouseX + 60, p: [queryX, ys] }) + } + }) + + const onMouseLeave = useEvent(() => { + setMouseState(undefined) + }) + + const mouseProbs = mouseState?.p[1] ?? [] + const legendItems = sortBy( + mouseProbs.map((p, i) => ({ + color: colors[i], + label: labels[i], + value: fmtY(p), + p, + })), + (item) => -item.p + ).slice(0, 10) + + return ( + <div className="relative"> + {mouseState && ( + <ChartTooltip {...mouseState}> + {fmtX(mouseState.p[0])} + <Legend className="text-sm" items={legendItems} /> + </ChartTooltip> + )} + <SVGChart + w={w} + h={h} + xAxis={xAxis} + yAxis={yAxis} + onSelect={onSelect} + onMouseOver={onMouseOver} + onMouseLeave={onMouseLeave} + > + {series.map((s, i) => ( + <AreaPath + key={i} + data={s} + px={px} + py0={py0} + py1={py1} + curve={curveStepAfter} + fill={colors[i]} + /> + ))} + </SVGChart> + </div> + ) +} + +export const SingleValueHistoryChart = (props: { + data: HistoryPoint[] + w: number + h: number + color: string + xScale: ScaleTime<number, number> + yScale: ScaleContinuousNumeric<number, number> + pct?: boolean +}) => { + const { color, data, pct, yScale, w, h } = props + + const [viewXScale, setViewXScale] = useState<ScaleTime<number, number>>() + const [mouseState, setMouseState] = useState<PositionValue<HistoryPoint>>() + const xScale = viewXScale ?? props.xScale + + const px = useCallback((p: HistoryPoint) => xScale(p[0]), [xScale]) + const py0 = yScale(yScale.domain()[0]) + const py1 = useCallback((p: HistoryPoint) => yScale(p[1]), [yScale]) + const xBisector = bisector((p: HistoryPoint) => p[0]) + + const { fmtX, fmtY, xAxis, yAxis } = useMemo(() => { + const [start, end] = xScale.domain() + const fmtX = getFormatterForDateRange(start, end) + const fmtY = (n: number) => (pct ? formatPct(n, 0) : formatLargeNumber(n)) + + const [min, max] = yScale.domain() + const pctTickValues = getTickValues(min, max, h < 200 ? 3 : 5) + const xAxis = axisBottom<Date>(xScale).ticks(w / 100) + const yAxis = pct + ? axisLeft<number>(yScale).tickValues(pctTickValues).tickFormat(fmtY) + : axisLeft<number>(yScale) + return { fmtX, fmtY, xAxis, yAxis } + }, [w, h, pct, xScale, yScale]) + + const onSelect = useEvent((ev: D3BrushEvent<HistoryPoint>) => { + if (ev.selection) { + const [mouseX0, mouseX1] = ev.selection as [number, number] + setViewXScale(() => + xScale.copy().domain([xScale.invert(mouseX0), xScale.invert(mouseX1)]) + ) + setMouseState(undefined) + } else { + setViewXScale(undefined) + setMouseState(undefined) + } + }) + + const onMouseOver = useEvent((ev: React.PointerEvent) => { + if (ev.pointerType === 'mouse') { + const [mouseX, mouseY] = pointer(ev) + const queryX = xScale.invert(mouseX) + const [_x, y] = data[xBisector.left(data, queryX) - 1] + setMouseState({ top: mouseY - 10, left: mouseX + 60, p: [queryX, y] }) + } + }) + + const onMouseLeave = useEvent(() => { + setMouseState(undefined) + }) + + return ( + <div className="relative"> + {mouseState && ( + <ChartTooltip className="text-sm" {...mouseState}> + <strong>{fmtY(mouseState.p[1])}</strong> {fmtX(mouseState.p[0])} + </ChartTooltip> + )} + <SVGChart + w={w} + h={h} + xAxis={xAxis} + yAxis={yAxis} + onSelect={onSelect} + onMouseOver={onMouseOver} + onMouseLeave={onMouseLeave} + > + <AreaWithTopStroke + color={color} + data={data} + px={px} + py0={py0} + py1={py1} + curve={curveStepAfter} + /> + </SVGChart> + </div> + ) +} diff --git a/web/components/charts/helpers.tsx b/web/components/charts/helpers.tsx new file mode 100644 index 00000000..20231bc9 --- /dev/null +++ b/web/components/charts/helpers.tsx @@ -0,0 +1,207 @@ +import { ReactNode, SVGProps, memo, useRef, useEffect, useMemo } from 'react' +import { + Axis, + CurveFactory, + D3BrushEvent, + area, + brushX, + curveStepAfter, + line, + select, +} from 'd3' +import { nanoid } from 'nanoid' +import clsx from 'clsx' + +import { Contract } from 'common/contract' + +export const MARGIN = { top: 20, right: 10, bottom: 20, left: 40 } +export const MARGIN_X = MARGIN.right + MARGIN.left +export const MARGIN_Y = MARGIN.top + MARGIN.bottom + +export const MAX_TIMESTAMP = 8640000000000000 +export const MAX_DATE = new Date(MAX_TIMESTAMP) + +export const XAxis = <X,>(props: { w: number; h: number; axis: Axis<X> }) => { + const { h, axis } = props + const axisRef = useRef<SVGGElement>(null) + useEffect(() => { + if (axisRef.current != null) { + select(axisRef.current) + .transition() + .duration(250) + .call(axis) + .select('.domain') + .attr('stroke-width', 0) + } + }, [h, axis]) + return <g ref={axisRef} transform={`translate(0, ${h})`} /> +} + +export const YAxis = <Y,>(props: { w: number; h: number; axis: Axis<Y> }) => { + const { w, h, axis } = props + const axisRef = useRef<SVGGElement>(null) + useEffect(() => { + if (axisRef.current != null) { + select(axisRef.current) + .transition() + .duration(250) + .call(axis) + .call((g) => + g.selectAll('.tick line').attr('x2', w).attr('stroke-opacity', 0.1) + ) + .select('.domain') + .attr('stroke-width', 0) + } + }, [w, h, axis]) + return <g ref={axisRef} /> +} + +const LinePathInternal = <P,>( + props: { + data: P[] + px: number | ((p: P) => number) + py: number | ((p: P) => number) + curve?: CurveFactory + } & SVGProps<SVGPathElement> +) => { + const { data, px, py, curve, ...rest } = props + const d3Line = line<P>(px, py).curve(curve ?? curveStepAfter) + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return <path {...rest} fill="none" d={d3Line(data)!} /> +} +export const LinePath = memo(LinePathInternal) as typeof LinePathInternal + +const AreaPathInternal = <P,>( + props: { + data: P[] + px: number | ((p: P) => number) + py0: number | ((p: P) => number) + py1: number | ((p: P) => number) + curve?: CurveFactory + } & SVGProps<SVGPathElement> +) => { + const { data, px, py0, py1, curve, ...rest } = props + const d3Area = area<P>(px, py0, py1).curve(curve ?? curveStepAfter) + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return <path {...rest} d={d3Area(data)!} /> +} +export const AreaPath = memo(AreaPathInternal) as typeof AreaPathInternal + +export const AreaWithTopStroke = <P,>(props: { + color: string + data: P[] + px: number | ((p: P) => number) + py0: number | ((p: P) => number) + py1: number | ((p: P) => number) + curve?: CurveFactory +}) => { + const { color, data, px, py0, py1, curve } = props + return ( + <g> + <AreaPath + data={data} + px={px} + py0={py0} + py1={py1} + curve={curve} + fill={color} + opacity={0.3} + /> + <LinePath data={data} px={px} py={py1} curve={curve} stroke={color} /> + </g> + ) +} + +export const SVGChart = <X, Y>(props: { + children: ReactNode + w: number + h: number + xAxis: Axis<X> + yAxis: Axis<Y> + onSelect?: (ev: D3BrushEvent<any>) => void + onMouseOver?: (ev: React.PointerEvent) => void + onMouseLeave?: (ev: React.PointerEvent) => void + pct?: boolean +}) => { + const { children, w, h, xAxis, yAxis, onMouseOver, onMouseLeave, onSelect } = + props + const overlayRef = useRef<SVGGElement>(null) + const innerW = w - MARGIN_X + const innerH = h - MARGIN_Y + const clipPathId = useMemo(() => nanoid(), []) + + const justSelected = useRef(false) + useEffect(() => { + if (onSelect != null && overlayRef.current) { + const brush = brushX().extent([ + [0, 0], + [innerW, innerH], + ]) + brush.on('end', (ev) => { + // when we clear the brush after a selection, that would normally cause + // another 'end' event, so we have to suppress it with this flag + if (!justSelected.current) { + justSelected.current = true + onSelect(ev) + if (overlayRef.current) { + select(overlayRef.current).call(brush.clear) + } + } else { + justSelected.current = false + } + }) + select(overlayRef.current).call(brush) + } + }, [innerW, innerH, onSelect]) + + return ( + <svg className="w-full" width={w} height={h} viewBox={`0 0 ${w} ${h}`}> + <clipPath id={clipPathId}> + <rect x={0} y={0} width={innerW} height={innerH} /> + </clipPath> + <g transform={`translate(${MARGIN.left}, ${MARGIN.top})`}> + <XAxis axis={xAxis} w={innerW} h={innerH} /> + <YAxis axis={yAxis} w={innerW} h={innerH} /> + <g clipPath={`url(#${clipPathId})`}>{children}</g> + <g + ref={overlayRef} + x="0" + y="0" + width={innerW} + height={innerH} + fill="none" + pointerEvents="all" + onPointerEnter={onMouseOver} + onPointerMove={onMouseOver} + onPointerLeave={onMouseLeave} + /> + </g> + </svg> + ) +} + +export type TooltipPosition = { top: number; left: number } + +export const ChartTooltip = ( + props: TooltipPosition & { className?: string; children: React.ReactNode } +) => { + const { top, left, className, children } = props + return ( + <div + className={clsx( + className, + 'pointer-events-none absolute z-10 whitespace-pre rounded border-2 border-black bg-white/90 p-2' + )} + style={{ top, left }} + > + {children} + </div> + ) +} + +export const getDateRange = (contract: Contract) => { + const { createdTime, closeTime, resolutionTime } = contract + const isClosed = !!closeTime && Date.now() > closeTime + const endDate = resolutionTime ?? (isClosed ? closeTime : null) + return [new Date(createdTime), endDate ? new Date(endDate) : null] as const +} diff --git a/web/components/contract/contract-overview.tsx b/web/components/contract/contract-overview.tsx index 139b30fe..add9ba48 100644 --- a/web/components/contract/contract-overview.tsx +++ b/web/components/contract/contract-overview.tsx @@ -2,7 +2,12 @@ import React from 'react' import { tradingAllowed } from 'web/lib/firebase/contracts' import { Col } from '../layout/col' -import { ContractProbGraph } from './contract-prob-graph' +import { + BinaryContractChart, + NumericContractChart, + PseudoNumericContractChart, + ChoiceContractChart, +} from 'web/components/charts/contract' import { useUser } from 'web/hooks/use-user' import { Row } from '../layout/row' import { Linkify } from '../linkify' @@ -14,7 +19,6 @@ import { } from './contract-card' import { Bet } from 'common/bet' import BetButton, { BinaryMobileBetting } from '../bet-button' -import { AnswersGraph } from '../answers/answers-graph' import { Contract, CPMMContract, @@ -25,7 +29,6 @@ import { BinaryContract, } from 'common/contract' import { ContractDetails } from './contract-details' -import { NumericGraph } from './numeric-graph' const OverviewQuestion = (props: { text: string }) => ( <Linkify className="text-lg text-indigo-700 sm:text-2xl" text={props.text} /> @@ -63,7 +66,7 @@ const NumericOverview = (props: { contract: NumericContract }) => { contract={contract} /> </Col> - <NumericGraph contract={contract} /> + <NumericContractChart contract={contract} /> </Col> ) } @@ -83,7 +86,7 @@ const BinaryOverview = (props: { contract: BinaryContract; bets: Bet[] }) => { /> </Row> </Col> - <ContractProbGraph contract={contract} bets={[...bets].reverse()} /> + <BinaryContractChart contract={contract} bets={bets} /> <Row className="items-center justify-between gap-4 xl:hidden"> {tradingAllowed(contract) && ( <BinaryMobileBetting contract={contract} /> @@ -109,7 +112,7 @@ const ChoiceOverview = (props: { )} </Col> <Col className={'mb-1 gap-y-2'}> - <AnswersGraph contract={contract} bets={[...bets].reverse()} /> + <ChoiceContractChart contract={contract} bets={bets} /> </Col> </Col> ) @@ -136,7 +139,7 @@ const PseudoNumericOverview = (props: { {tradingAllowed(contract) && <BetWidget contract={contract} />} </Row> </Col> - <ContractProbGraph contract={contract} bets={[...bets].reverse()} /> + <PseudoNumericContractChart contract={contract} bets={bets} /> </Col> ) } diff --git a/web/components/contract/contract-prob-graph.tsx b/web/components/contract/contract-prob-graph.tsx deleted file mode 100644 index 60ef85b5..00000000 --- a/web/components/contract/contract-prob-graph.tsx +++ /dev/null @@ -1,203 +0,0 @@ -import { DatumValue } from '@nivo/core' -import { ResponsiveLine, SliceTooltipProps } from '@nivo/line' -import { BasicTooltip } from '@nivo/tooltip' -import dayjs from 'dayjs' -import { memo } from 'react' -import { Bet } from 'common/bet' -import { getInitialProbability } from 'common/calculate' -import { BinaryContract, PseudoNumericContract } from 'common/contract' -import { useWindowSize } from 'web/hooks/use-window-size' -import { formatLargeNumber } from 'common/util/format' - -export const ContractProbGraph = memo(function ContractProbGraph(props: { - contract: BinaryContract | PseudoNumericContract - bets: Bet[] - height?: number -}) { - const { contract, height } = props - const { resolutionTime, closeTime, outcomeType } = contract - const now = Date.now() - const isBinary = outcomeType === 'BINARY' - const isLogScale = outcomeType === 'PSEUDO_NUMERIC' && contract.isLogScale - - const bets = props.bets.filter((bet) => !bet.isAnte && !bet.isRedemption) - - const startProb = getInitialProbability(contract) - - const times = [contract.createdTime, ...bets.map((bet) => bet.createdTime)] - - const f: (p: number) => number = isBinary - ? (p) => p - : isLogScale - ? (p) => p * Math.log10(contract.max - contract.min + 1) - : (p) => p * (contract.max - contract.min) + contract.min - - const probs = [startProb, ...bets.map((bet) => bet.probAfter)].map(f) - - const isClosed = !!closeTime && now > closeTime - const latestTime = dayjs( - resolutionTime && isClosed - ? Math.min(resolutionTime, closeTime) - : isClosed - ? closeTime - : resolutionTime ?? now - ) - - // Add a fake datapoint so the line continues to the right - times.push(latestTime.valueOf()) - probs.push(probs[probs.length - 1]) - - const { width } = useWindowSize() - - const quartiles = !width || width < 800 ? [0, 50, 100] : [0, 25, 50, 75, 100] - - const yTickValues = isBinary - ? quartiles - : quartiles.map((x) => x / 100).map(f) - - const numXTickValues = !width || width < 800 ? 2 : 5 - const startDate = dayjs(times[0]) - const endDate = startDate.add(1, 'hour').isAfter(latestTime) - ? latestTime.add(1, 'hours') - : latestTime - const includeMinute = endDate.diff(startDate, 'hours') < 2 - - // Minimum number of points for the graph to have. For smooth tooltip movement - // If we aren't actually loading any data yet, skip adding extra points to let page load faster - // This fn runs again once DOM is finished loading - const totalPoints = width && bets.length ? (width > 800 ? 300 : 50) : 1 - - const timeStep: number = latestTime.diff(startDate, 'ms') / totalPoints - - const points: { x: Date; y: number }[] = [] - const s = isBinary ? 100 : 1 - - for (let i = 0; i < times.length - 1; i++) { - const p = probs[i] - const d0 = times[i] - const d1 = times[i + 1] - const msDiff = d1 - d0 - const numPoints = Math.floor(msDiff / timeStep) - points.push({ x: new Date(times[i]), y: s * p }) - if (numPoints > 1) { - const thisTimeStep: number = msDiff / numPoints - for (let n = 1; n < numPoints; n++) { - points.push({ x: new Date(d0 + thisTimeStep * n), y: s * p }) - } - } - } - - const data = [ - { id: 'Yes', data: points, color: isBinary ? '#11b981' : '#5fa5f9' }, - ] - - const multiYear = !startDate.isSame(latestTime, 'year') - const lessThanAWeek = startDate.add(8, 'day').isAfter(latestTime) - - const formatter = isBinary - ? formatPercent - : isLogScale - ? (x: DatumValue) => - formatLargeNumber(10 ** +x.valueOf() + contract.min - 1) - : (x: DatumValue) => formatLargeNumber(+x.valueOf()) - - return ( - <div - className="w-full overflow-visible" - style={{ height: height ?? (!width || width >= 800 ? 250 : 150) }} - > - <ResponsiveLine - data={data} - yScale={ - isBinary - ? { min: 0, max: 100, type: 'linear' } - : isLogScale - ? { - min: 0, - max: Math.log10(contract.max - contract.min + 1), - type: 'linear', - } - : { min: contract.min, max: contract.max, type: 'linear' } - } - yFormat={formatter} - gridYValues={yTickValues} - axisLeft={{ - tickValues: yTickValues, - format: formatter, - }} - xScale={{ - type: 'time', - min: startDate.toDate(), - max: endDate.toDate(), - }} - xFormat={(d) => - formatTime(now, +d.valueOf(), multiYear, lessThanAWeek, lessThanAWeek) - } - axisBottom={{ - tickValues: numXTickValues, - format: (time) => - formatTime(now, +time, multiYear, lessThanAWeek, includeMinute), - }} - colors={{ datum: 'color' }} - curve="stepAfter" - enablePoints={false} - pointBorderWidth={1} - pointBorderColor="#fff" - enableSlices="x" - enableGridX={false} - enableArea - areaBaselineValue={isBinary || isLogScale ? 0 : contract.min} - margin={{ top: 20, right: 20, bottom: 25, left: 40 }} - animate={false} - sliceTooltip={SliceTooltip} - /> - </div> - ) -}) - -const SliceTooltip = ({ slice }: SliceTooltipProps) => { - return ( - <BasicTooltip - id={slice.points.map((point) => [ - <span key="date"> - <strong>{point.data[`yFormatted`]}</strong> {point.data['xFormatted']} - </span>, - ])} - /> - ) -} - -function formatPercent(y: DatumValue) { - return `${Math.round(+y.toString())}%` -} - -function formatTime( - now: number, - time: number, - includeYear: boolean, - includeHour: boolean, - includeMinute: boolean -) { - const d = dayjs(time) - if (d.add(1, 'minute').isAfter(now) && d.subtract(1, 'minute').isBefore(now)) - return 'Now' - - let format: string - if (d.isSame(now, 'day')) { - format = '[Today]' - } else if (d.add(1, 'day').isSame(now, 'day')) { - format = '[Yesterday]' - } else { - format = 'MMM D' - } - - if (includeMinute) { - format += ', h:mma' - } else if (includeHour) { - format += ', ha' - } else if (includeYear) { - format += ', YYYY' - } - - return d.format(format) -} diff --git a/web/components/contract/numeric-graph.tsx b/web/components/contract/numeric-graph.tsx deleted file mode 100644 index f6532b9b..00000000 --- a/web/components/contract/numeric-graph.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import { DatumValue } from '@nivo/core' -import { Point, ResponsiveLine } from '@nivo/line' -import { NUMERIC_GRAPH_COLOR } from 'common/numeric-constants' -import { memo } from 'react' -import { range } from 'lodash' -import { getDpmOutcomeProbabilities } from '../../../common/calculate-dpm' -import { NumericContract } from '../../../common/contract' -import { useWindowSize } from '../../hooks/use-window-size' -import { Col } from '../layout/col' -import { formatLargeNumber } from 'common/util/format' - -export const NumericGraph = memo(function NumericGraph(props: { - contract: NumericContract - height?: number -}) { - const { contract, height } = props - const { totalShares, bucketCount, min, max } = contract - - const bucketProbs = getDpmOutcomeProbabilities(totalShares) - - const xs = range(bucketCount).map( - (i) => min + ((max - min) * i) / bucketCount - ) - const probs = range(bucketCount).map((i) => bucketProbs[`${i}`] * 100) - const points = probs.map((prob, i) => ({ x: xs[i], y: prob })) - const maxProb = Math.max(...probs) - const data = [{ id: 'Probability', data: points, color: NUMERIC_GRAPH_COLOR }] - - const yTickValues = [ - 0, - 0.25 * maxProb, - 0.5 & maxProb, - 0.75 * maxProb, - maxProb, - ] - - const { width } = useWindowSize() - - const numXTickValues = !width || width < 800 ? 2 : 5 - - return ( - <div - className="w-full overflow-hidden" - style={{ height: height ?? (!width || width >= 800 ? 350 : 250) }} - > - <ResponsiveLine - data={data} - yScale={{ min: 0, max: maxProb, type: 'linear' }} - yFormat={formatPercent} - axisLeft={{ - tickValues: yTickValues, - format: formatPercent, - }} - xScale={{ - type: 'linear', - min: min, - max: max, - }} - xFormat={(d) => `${formatLargeNumber(+d, 3)}`} - axisBottom={{ - tickValues: numXTickValues, - format: (d) => `${formatLargeNumber(+d, 3)}`, - }} - colors={{ datum: 'color' }} - pointSize={0} - enableSlices="x" - sliceTooltip={({ slice }) => { - const point = slice.points[0] - return <Tooltip point={point} /> - }} - enableGridX={!!width && width >= 800} - enableArea - margin={{ top: 20, right: 28, bottom: 22, left: 50 }} - /> - </div> - ) -}) - -function formatPercent(y: DatumValue) { - const p = Math.round(+y * 100) / 100 - return `${p}%` -} - -function Tooltip(props: { point: Point }) { - const { point } = props - return ( - <Col className="border border-gray-300 bg-white py-2 px-3"> - <div - className="pb-1" - style={{ - color: point.serieColor, - }} - > - <strong>{point.serieId}</strong> {point.data.yFormatted} - </div> - <div>{formatLargeNumber(+point.data.x)}</div> - </Col> - ) -} diff --git a/web/hooks/use-element-width.tsx b/web/hooks/use-element-width.tsx new file mode 100644 index 00000000..1c373839 --- /dev/null +++ b/web/hooks/use-element-width.tsx @@ -0,0 +1,17 @@ +import { RefObject, useState, useEffect } from 'react' + +// todo: consider consolidation with use-measure-size +export const useElementWidth = <T extends Element>(ref: RefObject<T>) => { + const [width, setWidth] = useState<number>() + useEffect(() => { + const handleResize = () => { + setWidth(ref.current?.clientWidth) + } + handleResize() + window.addEventListener('resize', handleResize) + return () => { + window.removeEventListener('resize', handleResize) + } + }, [ref]) + return width +} diff --git a/web/package.json b/web/package.json index 3ccbc96c..570139f4 100644 --- a/web/package.json +++ b/web/package.json @@ -39,6 +39,7 @@ "browser-image-compression": "2.0.0", "clsx": "1.1.1", "cors": "2.8.5", + "d3": "7.6.1", "daisyui": "1.16.4", "dayjs": "1.10.7", "firebase": "9.9.3", @@ -56,9 +57,9 @@ "react-expanding-textarea": "2.3.5", "react-hot-toast": "2.2.0", "react-instantsearch-hooks-web": "6.24.1", + "react-masonry-css": "1.0.16", "react-query": "3.39.0", "react-twitter-embed": "4.0.4", - "react-masonry-css": "1.0.16", "string-similarity": "^4.0.4", "tippy.js": "6.3.7" }, @@ -66,6 +67,7 @@ "@tailwindcss/forms": "0.4.0", "@tailwindcss/line-clamp": "^0.3.1", "@tailwindcss/typography": "^0.5.1", + "@types/d3": "7.4.0", "@types/lodash": "4.14.178", "@types/node": "16.11.11", "@types/react": "17.0.43", diff --git a/web/pages/embed/[username]/[contractSlug].tsx b/web/pages/embed/[username]/[contractSlug].tsx index 75a9ad05..e925a1f6 100644 --- a/web/pages/embed/[username]/[contractSlug].tsx +++ b/web/pages/embed/[username]/[contractSlug].tsx @@ -2,7 +2,6 @@ import { Bet } from 'common/bet' import { Contract } from 'common/contract' import { DOMAIN } from 'common/envs/constants' import { useState } from 'react' -import { AnswersGraph } from 'web/components/answers/answers-graph' import { BetInline } from 'web/components/bet-inline' import { Button } from 'web/components/button' import { @@ -12,8 +11,7 @@ import { PseudoNumericResolutionOrExpectation, } from 'web/components/contract/contract-card' import { MarketSubheader } from 'web/components/contract/contract-details' -import { ContractProbGraph } from 'web/components/contract/contract-prob-graph' -import { NumericGraph } from 'web/components/contract/numeric-graph' +import { ContractChart } from 'web/components/charts/contract' import { Col } from 'web/components/layout/col' import { Row } from 'web/components/layout/row' import { Spacer } from 'web/components/layout/spacer' @@ -134,22 +132,7 @@ export function ContractEmbed(props: { contract: Contract; bets: Bet[] }) { )} <div className="mx-1 mb-2 min-h-0 flex-1" ref={setElem}> - {(isBinary || isPseudoNumeric) && ( - <ContractProbGraph - contract={contract} - bets={[...bets].reverse()} - height={graphHeight} - /> - )} - - {(outcomeType === 'FREE_RESPONSE' || - outcomeType === 'MULTIPLE_CHOICE') && ( - <AnswersGraph contract={contract} bets={bets} height={graphHeight} /> - )} - - {outcomeType === 'NUMERIC' && ( - <NumericGraph contract={contract} height={graphHeight} /> - )} + <ContractChart contract={contract} bets={bets} height={graphHeight} /> </div> </Col> ) diff --git a/yarn.lock b/yarn.lock index 9829f0b1..17a065f7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3250,6 +3250,216 @@ resolved "https://registry.yarnpkg.com/@types/cors/-/cors-2.8.12.tgz#6b2c510a7ad7039e98e7b8d3d6598f4359e5c080" integrity sha512-vt+kDhq/M2ayberEtJcIN/hxXy1Pk+59g2FV/ZQceeaTyCtCucjL2Q7FXlFjtWn4n15KCr1NE2lNNFhp0lEThw== +"@types/d3-array@*": + version "3.0.3" + resolved "https://registry.yarnpkg.com/@types/d3-array/-/d3-array-3.0.3.tgz#87d990bf504d14ad6b16766979d04e943c046dac" + integrity sha512-Reoy+pKnvsksN0lQUlcH6dOGjRZ/3WRwXR//m+/8lt1BXeI4xyaUZoqULNjyXXRuh0Mj4LNpkCvhUpQlY3X5xQ== + +"@types/d3-axis@*": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@types/d3-axis/-/d3-axis-3.0.1.tgz#6afc20744fa5cc0cbc3e2bd367b140a79ed3e7a8" + integrity sha512-zji/iIbdd49g9WN0aIsGcwcTBUkgLsCSwB+uH+LPVDAiKWENMtI3cJEWt+7/YYwelMoZmbBfzA3qCdrZ2XFNnw== + dependencies: + "@types/d3-selection" "*" + +"@types/d3-brush@*": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@types/d3-brush/-/d3-brush-3.0.1.tgz#ae5f17ce391935ca88b29000e60ee20452c6357c" + integrity sha512-B532DozsiTuQMHu2YChdZU0qsFJSio3Q6jmBYGYNp3gMDzBmuFFgPt9qKA4VYuLZMp4qc6eX7IUFUEsvHiXZAw== + dependencies: + "@types/d3-selection" "*" + +"@types/d3-chord@*": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@types/d3-chord/-/d3-chord-3.0.1.tgz#54c8856c19c8e4ab36a53f73ba737de4768ad248" + integrity sha512-eQfcxIHrg7V++W8Qxn6QkqBNBokyhdWSAS73AbkbMzvLQmVVBviknoz2SRS/ZJdIOmhcmmdCRE/NFOm28Z1AMw== + +"@types/d3-color@*": + version "3.1.0" + resolved "https://registry.yarnpkg.com/@types/d3-color/-/d3-color-3.1.0.tgz#6594da178ded6c7c3842f3cc0ac84b156f12f2d4" + integrity sha512-HKuicPHJuvPgCD+np6Se9MQvS6OCbJmOjGvylzMJRlDwUXjKTTXs6Pwgk79O09Vj/ho3u1ofXnhFOaEWWPrlwA== + +"@types/d3-contour@*": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@types/d3-contour/-/d3-contour-3.0.1.tgz#9ff4e2fd2a3910de9c5097270a7da8a6ef240017" + integrity sha512-C3zfBrhHZvrpAAK3YXqLWVAGo87A4SvJ83Q/zVJ8rFWJdKejUnDYaWZPkA8K84kb2vDA/g90LTQAz7etXcgoQQ== + dependencies: + "@types/d3-array" "*" + "@types/geojson" "*" + +"@types/d3-delaunay@*": + version "6.0.1" + resolved "https://registry.yarnpkg.com/@types/d3-delaunay/-/d3-delaunay-6.0.1.tgz#006b7bd838baec1511270cb900bf4fc377bbbf41" + integrity sha512-tLxQ2sfT0p6sxdG75c6f/ekqxjyYR0+LwPrsO1mbC9YDBzPJhs2HbJJRrn8Ez1DBoHRo2yx7YEATI+8V1nGMnQ== + +"@types/d3-dispatch@*": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@types/d3-dispatch/-/d3-dispatch-3.0.1.tgz#a1b18ae5fa055a6734cb3bd3cbc6260ef19676e3" + integrity sha512-NhxMn3bAkqhjoxabVJWKryhnZXXYYVQxaBnbANu0O94+O/nX9qSjrA1P1jbAQJxJf+VC72TxDX/YJcKue5bRqw== + +"@types/d3-drag@*": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@types/d3-drag/-/d3-drag-3.0.1.tgz#fb1e3d5cceeee4d913caa59dedf55c94cb66e80f" + integrity sha512-o1Va7bLwwk6h03+nSM8dpaGEYnoIG19P0lKqlic8Un36ymh9NSkNFX1yiXMKNMx8rJ0Kfnn2eovuFaL6Jvj0zA== + dependencies: + "@types/d3-selection" "*" + +"@types/d3-dsv@*": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@types/d3-dsv/-/d3-dsv-3.0.0.tgz#f3c61fb117bd493ec0e814856feb804a14cfc311" + integrity sha512-o0/7RlMl9p5n6FQDptuJVMxDf/7EDEv2SYEO/CwdG2tr1hTfUVi0Iavkk2ax+VpaQ/1jVhpnj5rq1nj8vwhn2A== + +"@types/d3-ease@*": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@types/d3-ease/-/d3-ease-3.0.0.tgz#c29926f8b596f9dadaeca062a32a45365681eae0" + integrity sha512-aMo4eaAOijJjA6uU+GIeW018dvy9+oH5Y2VPPzjjfxevvGQ/oRDs+tfYC9b50Q4BygRR8yE2QCLsrT0WtAVseA== + +"@types/d3-fetch@*": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@types/d3-fetch/-/d3-fetch-3.0.1.tgz#f9fa88b81aa2eea5814f11aec82ecfddbd0b8fe0" + integrity sha512-toZJNOwrOIqz7Oh6Q7l2zkaNfXkfR7mFSJvGvlD/Ciq/+SQ39d5gynHJZ/0fjt83ec3WL7+u3ssqIijQtBISsw== + dependencies: + "@types/d3-dsv" "*" + +"@types/d3-force@*": + version "3.0.3" + resolved "https://registry.yarnpkg.com/@types/d3-force/-/d3-force-3.0.3.tgz#76cb20d04ae798afede1ea6e41750763ff5a9c82" + integrity sha512-z8GteGVfkWJMKsx6hwC3SiTSLspL98VNpmvLpEFJQpZPq6xpA1I8HNBDNSpukfK0Vb0l64zGFhzunLgEAcBWSA== + +"@types/d3-format@*": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@types/d3-format/-/d3-format-3.0.1.tgz#194f1317a499edd7e58766f96735bdc0216bb89d" + integrity sha512-5KY70ifCCzorkLuIkDe0Z9YTf9RR2CjBX1iaJG+rgM/cPP+sO+q9YdQ9WdhQcgPj1EQiJ2/0+yUkkziTG6Lubg== + +"@types/d3-geo@*": + version "3.0.2" + resolved "https://registry.yarnpkg.com/@types/d3-geo/-/d3-geo-3.0.2.tgz#e7ec5f484c159b2c404c42d260e6d99d99f45d9a" + integrity sha512-DbqK7MLYA8LpyHQfv6Klz0426bQEf7bRTvhMy44sNGVyZoWn//B0c+Qbeg8Osi2Obdc9BLLXYAKpyWege2/7LQ== + dependencies: + "@types/geojson" "*" + +"@types/d3-hierarchy@*": + version "3.1.0" + resolved "https://registry.yarnpkg.com/@types/d3-hierarchy/-/d3-hierarchy-3.1.0.tgz#4561bb7ace038f247e108295ef77b6a82193ac25" + integrity sha512-g+sey7qrCa3UbsQlMZZBOHROkFqx7KZKvUpRzI/tAp/8erZWpYq7FgNKvYwebi2LaEiVs1klhUfd3WCThxmmWQ== + +"@types/d3-interpolate@*": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@types/d3-interpolate/-/d3-interpolate-3.0.1.tgz#e7d17fa4a5830ad56fe22ce3b4fac8541a9572dc" + integrity sha512-jx5leotSeac3jr0RePOH1KdR9rISG91QIE4Q2PYTu4OymLTZfA3SrnURSLzKH48HmXVUru50b8nje4E79oQSQw== + dependencies: + "@types/d3-color" "*" + +"@types/d3-path@*": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@types/d3-path/-/d3-path-3.0.0.tgz#939e3a784ae4f80b1fde8098b91af1776ff1312b" + integrity sha512-0g/A+mZXgFkQxN3HniRDbXMN79K3CdTpLsevj+PXiTcb2hVyvkZUBg37StmgCQkaD84cUJ4uaDAWq7UJOQy2Tg== + +"@types/d3-polygon@*": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@types/d3-polygon/-/d3-polygon-3.0.0.tgz#5200a3fa793d7736fa104285fa19b0dbc2424b93" + integrity sha512-D49z4DyzTKXM0sGKVqiTDTYr+DHg/uxsiWDAkNrwXYuiZVd9o9wXZIo+YsHkifOiyBkmSWlEngHCQme54/hnHw== + +"@types/d3-quadtree@*": + version "3.0.2" + resolved "https://registry.yarnpkg.com/@types/d3-quadtree/-/d3-quadtree-3.0.2.tgz#433112a178eb7df123aab2ce11c67f51cafe8ff5" + integrity sha512-QNcK8Jguvc8lU+4OfeNx+qnVy7c0VrDJ+CCVFS9srBo2GL9Y18CnIxBdTF3v38flrGy5s1YggcoAiu6s4fLQIw== + +"@types/d3-random@*": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@types/d3-random/-/d3-random-3.0.1.tgz#5c8d42b36cd4c80b92e5626a252f994ca6bfc953" + integrity sha512-IIE6YTekGczpLYo/HehAy3JGF1ty7+usI97LqraNa8IiDur+L44d0VOjAvFQWJVdZOJHukUJw+ZdZBlgeUsHOQ== + +"@types/d3-scale-chromatic@*": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.0.0.tgz#103124777e8cdec85b20b51fd3397c682ee1e954" + integrity sha512-dsoJGEIShosKVRBZB0Vo3C8nqSDqVGujJU6tPznsBJxNJNwMF8utmS83nvCBKQYPpjCzaaHcrf66iTRpZosLPw== + +"@types/d3-scale@*": + version "4.0.2" + resolved "https://registry.yarnpkg.com/@types/d3-scale/-/d3-scale-4.0.2.tgz#41be241126af4630524ead9cb1008ab2f0f26e69" + integrity sha512-Yk4htunhPAwN0XGlIwArRomOjdoBFXC3+kCxK2Ubg7I9shQlVSJy/pG/Ht5ASN+gdMIalpk8TJ5xV74jFsetLA== + dependencies: + "@types/d3-time" "*" + +"@types/d3-selection@*": + version "3.0.3" + resolved "https://registry.yarnpkg.com/@types/d3-selection/-/d3-selection-3.0.3.tgz#57be7da68e7d9c9b29efefd8ea5a9ef1171e42ba" + integrity sha512-Mw5cf6nlW1MlefpD9zrshZ+DAWL4IQ5LnWfRheW6xwsdaWOb6IRRu2H7XPAQcyXEx1D7XQWgdoKR83ui1/HlEA== + +"@types/d3-shape@*": + version "3.1.0" + resolved "https://registry.yarnpkg.com/@types/d3-shape/-/d3-shape-3.1.0.tgz#1d87a6ddcf28285ef1e5c278ca4bdbc0658f3505" + integrity sha512-jYIYxFFA9vrJ8Hd4Se83YI6XF+gzDL1aC5DCsldai4XYYiVNdhtpGbA/GM6iyQ8ayhSp3a148LY34hy7A4TxZA== + dependencies: + "@types/d3-path" "*" + +"@types/d3-time-format@*": + version "4.0.0" + resolved "https://registry.yarnpkg.com/@types/d3-time-format/-/d3-time-format-4.0.0.tgz#ee7b6e798f8deb2d9640675f8811d0253aaa1946" + integrity sha512-yjfBUe6DJBsDin2BMIulhSHmr5qNR5Pxs17+oW4DoVPyVIXZ+m6bs7j1UVKP08Emv6jRmYrYqxYzO63mQxy1rw== + +"@types/d3-time@*": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@types/d3-time/-/d3-time-3.0.0.tgz#e1ac0f3e9e195135361fa1a1d62f795d87e6e819" + integrity sha512-sZLCdHvBUcNby1cB6Fd3ZBrABbjz3v1Vm90nysCQ6Vt7vd6e/h9Lt7SiJUoEX0l4Dzc7P5llKyhqSi1ycSf1Hg== + +"@types/d3-timer@*": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@types/d3-timer/-/d3-timer-3.0.0.tgz#e2505f1c21ec08bda8915238e397fb71d2fc54ce" + integrity sha512-HNB/9GHqu7Fo8AQiugyJbv6ZxYz58wef0esl4Mv828w1ZKpAshw/uFWVDUcIB9KKFeFKoxS3cHY07FFgtTRZ1g== + +"@types/d3-transition@*": + version "3.0.2" + resolved "https://registry.yarnpkg.com/@types/d3-transition/-/d3-transition-3.0.2.tgz#393dc3e3d55009a43cc6f252e73fccab6d78a8a4" + integrity sha512-jo5o/Rf+/u6uerJ/963Dc39NI16FQzqwOc54bwvksGAdVfvDrqDpVeq95bEvPtBwLCVZutAEyAtmSyEMxN7vxQ== + dependencies: + "@types/d3-selection" "*" + +"@types/d3-zoom@*": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@types/d3-zoom/-/d3-zoom-3.0.1.tgz#4bfc7e29625c4f79df38e2c36de52ec3e9faf826" + integrity sha512-7s5L9TjfqIYQmQQEUcpMAcBOahem7TRoSO/+Gkz02GbMVuULiZzjF2BOdw291dbO2aNon4m2OdFsRGaCq2caLQ== + dependencies: + "@types/d3-interpolate" "*" + "@types/d3-selection" "*" + +"@types/d3@7.4.0": + version "7.4.0" + resolved "https://registry.yarnpkg.com/@types/d3/-/d3-7.4.0.tgz#fc5cac5b1756fc592a3cf1f3dc881bf08225f515" + integrity sha512-jIfNVK0ZlxcuRDKtRS/SypEyOQ6UHaFQBKv032X45VvxSJ6Yi5G9behy9h6tNTHTDGh5Vq+KbmBjUWLgY4meCA== + dependencies: + "@types/d3-array" "*" + "@types/d3-axis" "*" + "@types/d3-brush" "*" + "@types/d3-chord" "*" + "@types/d3-color" "*" + "@types/d3-contour" "*" + "@types/d3-delaunay" "*" + "@types/d3-dispatch" "*" + "@types/d3-drag" "*" + "@types/d3-dsv" "*" + "@types/d3-ease" "*" + "@types/d3-fetch" "*" + "@types/d3-force" "*" + "@types/d3-format" "*" + "@types/d3-geo" "*" + "@types/d3-hierarchy" "*" + "@types/d3-interpolate" "*" + "@types/d3-path" "*" + "@types/d3-polygon" "*" + "@types/d3-quadtree" "*" + "@types/d3-random" "*" + "@types/d3-scale" "*" + "@types/d3-scale-chromatic" "*" + "@types/d3-selection" "*" + "@types/d3-shape" "*" + "@types/d3-time" "*" + "@types/d3-time-format" "*" + "@types/d3-timer" "*" + "@types/d3-transition" "*" + "@types/d3-zoom" "*" + "@types/eslint-scope@^3.7.3": version "3.7.3" resolved "https://registry.yarnpkg.com/@types/eslint-scope/-/eslint-scope-3.7.3.tgz#125b88504b61e3c8bc6f870882003253005c3224" @@ -3299,6 +3509,11 @@ "@types/express-serve-static-core" "*" "@types/serve-static" "*" +"@types/geojson@*": + version "7946.0.10" + resolved "https://registry.yarnpkg.com/@types/geojson/-/geojson-7946.0.10.tgz#6dfbf5ea17142f7f9a043809f1cd4c448cb68249" + integrity sha512-Nmh0K3iWQJzniTuPRcJn5hxXkfB1T1pgB89SBig5PlJQU5yocazeu4jATJlaA0GYFKWMqDdvYemoSnF2pXgLVA== + "@types/google.maps@^3.45.3": version "3.49.0" resolved "https://registry.yarnpkg.com/@types/google.maps/-/google.maps-3.49.0.tgz#26fcf3d86ecbc6545db0e6691a434ec8132df48b" @@ -4834,6 +5049,11 @@ comma-separated-tokens@^1.0.0: resolved "https://registry.yarnpkg.com/comma-separated-tokens/-/comma-separated-tokens-1.0.8.tgz#632b80b6117867a158f1080ad498b2fbe7e3f5ea" integrity sha512-GHuDRO12Sypu2cV70d1dkA2EUmXHgntrzbpvOB+Qy+49ypNfGgFQIC2fhhXbnyrJRynDCAARsT7Ou0M6hirpfw== +commander@7, commander@^7.2.0: + version "7.2.0" + resolved "https://registry.yarnpkg.com/commander/-/commander-7.2.0.tgz#a36cb57d0b501ce108e4d20559a150a391d97ab7" + integrity sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw== + commander@^2.20.0: version "2.20.3" resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33" @@ -4844,11 +5064,6 @@ commander@^5.1.0: resolved "https://registry.yarnpkg.com/commander/-/commander-5.1.0.tgz#46abbd1652f8e059bddaef99bbdcb2ad9cf179ae" integrity sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg== -commander@^7.2.0: - version "7.2.0" - resolved "https://registry.yarnpkg.com/commander/-/commander-7.2.0.tgz#a36cb57d0b501ce108e4d20559a150a391d97ab7" - integrity sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw== - commander@^8.0.0, commander@^8.3.0: version "8.3.0" resolved "https://registry.yarnpkg.com/commander/-/commander-8.3.0.tgz#4837ea1b2da67b9c616a67afbb0fafee567bca66" @@ -5242,11 +5457,60 @@ d3-array@2, d3-array@^2.3.0: dependencies: internmap "^1.0.0" +"d3-array@2 - 3", "d3-array@2.10.0 - 3", "d3-array@2.5.0 - 3", d3-array@3, d3-array@^3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/d3-array/-/d3-array-3.2.0.tgz#15bf96cd9b7333e02eb8de8053d78962eafcff14" + integrity sha512-3yXFQo0oG3QCxbF06rMPFyGRMGJNS7NvsV1+2joOjbBE+9xvWQ8+GcMJAjRCzw06zQ3/arXeJgbPYcjUCuC+3g== + dependencies: + internmap "1 - 2" + +d3-axis@3: + version "3.0.0" + resolved "https://registry.yarnpkg.com/d3-axis/-/d3-axis-3.0.0.tgz#c42a4a13e8131d637b745fc2973824cfeaf93322" + integrity sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw== + +d3-brush@3: + version "3.0.0" + resolved "https://registry.yarnpkg.com/d3-brush/-/d3-brush-3.0.0.tgz#6f767c4ed8dcb79de7ede3e1c0f89e63ef64d31c" + integrity sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ== + dependencies: + d3-dispatch "1 - 3" + d3-drag "2 - 3" + d3-interpolate "1 - 3" + d3-selection "3" + d3-transition "3" + +d3-chord@3: + version "3.0.1" + resolved "https://registry.yarnpkg.com/d3-chord/-/d3-chord-3.0.1.tgz#d156d61f485fce8327e6abf339cb41d8cbba6966" + integrity sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g== + dependencies: + d3-path "1 - 3" + "d3-color@1 - 2", d3-color@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/d3-color/-/d3-color-2.0.0.tgz#8d625cab42ed9b8f601a1760a389f7ea9189d62e" integrity sha512-SPXi0TSKPD4g9tw0NMZFnR95XVgUZiBH+uUTqQuDu1OsE2zomHU7ho0FISciaPvosimixwHFl3WHLGabv6dDgQ== +"d3-color@1 - 3", d3-color@3: + version "3.1.0" + resolved "https://registry.yarnpkg.com/d3-color/-/d3-color-3.1.0.tgz#395b2833dfac71507f12ac2f7af23bf819de24e2" + integrity sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA== + +d3-contour@4: + version "4.0.0" + resolved "https://registry.yarnpkg.com/d3-contour/-/d3-contour-4.0.0.tgz#5a1337c6da0d528479acdb5db54bc81a0ff2ec6b" + integrity sha512-7aQo0QHUTu/Ko3cP9YK9yUTxtoDEiDGwnBHyLxG5M4vqlBkO/uixMRele3nfsfj6UXOcuReVpVXzAboGraYIJw== + dependencies: + d3-array "^3.2.0" + +d3-delaunay@6: + version "6.0.2" + resolved "https://registry.yarnpkg.com/d3-delaunay/-/d3-delaunay-6.0.2.tgz#7fd3717ad0eade2fc9939f4260acfb503f984e92" + integrity sha512-IMLNldruDQScrcfT+MWnazhHbDJhcRJyOEBAJfwQnHle1RPh6WDuLvxNArUju2VSMSUuKlY5BGHRJ2cYyoFLQQ== + dependencies: + delaunator "5" + d3-delaunay@^5.3.0: version "5.3.0" resolved "https://registry.yarnpkg.com/d3-delaunay/-/d3-delaunay-5.3.0.tgz#b47f05c38f854a4e7b3cea80e0bb12e57398772d" @@ -5254,16 +5518,76 @@ d3-delaunay@^5.3.0: dependencies: delaunator "4" +"d3-dispatch@1 - 3", d3-dispatch@3: + version "3.0.1" + resolved "https://registry.yarnpkg.com/d3-dispatch/-/d3-dispatch-3.0.1.tgz#5fc75284e9c2375c36c839411a0cf550cbfc4d5e" + integrity sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg== + +"d3-drag@2 - 3", d3-drag@3: + version "3.0.0" + resolved "https://registry.yarnpkg.com/d3-drag/-/d3-drag-3.0.0.tgz#994aae9cd23c719f53b5e10e3a0a6108c69607ba" + integrity sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg== + dependencies: + d3-dispatch "1 - 3" + d3-selection "3" + +"d3-dsv@1 - 3", d3-dsv@3: + version "3.0.1" + resolved "https://registry.yarnpkg.com/d3-dsv/-/d3-dsv-3.0.1.tgz#c63af978f4d6a0d084a52a673922be2160789b73" + integrity sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q== + dependencies: + commander "7" + iconv-lite "0.6" + rw "1" + +"d3-ease@1 - 3", d3-ease@3: + version "3.0.1" + resolved "https://registry.yarnpkg.com/d3-ease/-/d3-ease-3.0.1.tgz#9658ac38a2140d59d346160f1f6c30fda0bd12f4" + integrity sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w== + +d3-fetch@3: + version "3.0.1" + resolved "https://registry.yarnpkg.com/d3-fetch/-/d3-fetch-3.0.1.tgz#83141bff9856a0edb5e38de89cdcfe63d0a60a22" + integrity sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw== + dependencies: + d3-dsv "1 - 3" + +d3-force@3: + version "3.0.0" + resolved "https://registry.yarnpkg.com/d3-force/-/d3-force-3.0.0.tgz#3e2ba1a61e70888fe3d9194e30d6d14eece155c4" + integrity sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg== + dependencies: + d3-dispatch "1 - 3" + d3-quadtree "1 - 3" + d3-timer "1 - 3" + "d3-format@1 - 2": version "2.0.0" resolved "https://registry.yarnpkg.com/d3-format/-/d3-format-2.0.0.tgz#a10bcc0f986c372b729ba447382413aabf5b0767" integrity sha512-Ab3S6XuE/Q+flY96HXT0jOXcM4EAClYFnRGY5zsjRGNy6qCYrQsMffs7cV5Q9xejb35zxW5hf/guKw34kvIKsA== +"d3-format@1 - 3", d3-format@3: + version "3.1.0" + resolved "https://registry.yarnpkg.com/d3-format/-/d3-format-3.1.0.tgz#9260e23a28ea5cb109e93b21a06e24e2ebd55641" + integrity sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA== + d3-format@^1.4.4: version "1.4.5" resolved "https://registry.yarnpkg.com/d3-format/-/d3-format-1.4.5.tgz#374f2ba1320e3717eb74a9356c67daee17a7edb4" integrity sha512-J0piedu6Z8iB6TbIGfZgDzfXxUFN3qQRMofy2oPdXzQibYGqPB/9iMcxr/TGalU+2RsyDO+U4f33id8tbnSRMQ== +d3-geo@3: + version "3.0.1" + resolved "https://registry.yarnpkg.com/d3-geo/-/d3-geo-3.0.1.tgz#4f92362fd8685d93e3b1fae0fd97dc8980b1ed7e" + integrity sha512-Wt23xBych5tSy9IYAM1FR2rWIBFWa52B/oF/GYe5zbdHrg08FU8+BuI6X4PvTwPDdqdAdq04fuWJpELtsaEjeA== + dependencies: + d3-array "2.5.0 - 3" + +d3-hierarchy@3: + version "3.1.2" + resolved "https://registry.yarnpkg.com/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz#b01cd42c1eed3d46db77a5966cf726f8c09160c6" + integrity sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA== + "d3-interpolate@1 - 2", "d3-interpolate@1.2.0 - 2", d3-interpolate@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/d3-interpolate/-/d3-interpolate-2.0.1.tgz#98be499cfb8a3b94d4ff616900501a64abc91163" @@ -5271,11 +5595,46 @@ d3-format@^1.4.4: dependencies: d3-color "1 - 2" +"d3-interpolate@1 - 3", "d3-interpolate@1.2.0 - 3", d3-interpolate@3: + version "3.0.1" + resolved "https://registry.yarnpkg.com/d3-interpolate/-/d3-interpolate-3.0.1.tgz#3c47aa5b32c5b3dfb56ef3fd4342078a632b400d" + integrity sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g== + dependencies: + d3-color "1 - 3" + d3-path@1: version "1.0.9" resolved "https://registry.yarnpkg.com/d3-path/-/d3-path-1.0.9.tgz#48c050bb1fe8c262493a8caf5524e3e9591701cf" integrity sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg== +"d3-path@1 - 3", d3-path@3: + version "3.0.1" + resolved "https://registry.yarnpkg.com/d3-path/-/d3-path-3.0.1.tgz#f09dec0aaffd770b7995f1a399152bf93052321e" + integrity sha512-gq6gZom9AFZby0YLduxT1qmrp4xpBA1YZr19OI717WIdKE2OM5ETq5qrHLb301IgxhLwcuxvGZVLeeWc/k1I6w== + +d3-polygon@3: + version "3.0.1" + resolved "https://registry.yarnpkg.com/d3-polygon/-/d3-polygon-3.0.1.tgz#0b45d3dd1c48a29c8e057e6135693ec80bf16398" + integrity sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg== + +"d3-quadtree@1 - 3", d3-quadtree@3: + version "3.0.1" + resolved "https://registry.yarnpkg.com/d3-quadtree/-/d3-quadtree-3.0.1.tgz#6dca3e8be2b393c9a9d514dabbd80a92deef1a4f" + integrity sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw== + +d3-random@3: + version "3.0.1" + resolved "https://registry.yarnpkg.com/d3-random/-/d3-random-3.0.1.tgz#d4926378d333d9c0bfd1e6fa0194d30aebaa20f4" + integrity sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ== + +d3-scale-chromatic@3: + version "3.0.0" + resolved "https://registry.yarnpkg.com/d3-scale-chromatic/-/d3-scale-chromatic-3.0.0.tgz#15b4ceb8ca2bb0dcb6d1a641ee03d59c3b62376a" + integrity sha512-Lx9thtxAKrO2Pq6OO2Ua474opeziKr279P/TKZsMAhYyNDD3EnCffdbgeSYN5O7m2ByQsxtuP2CSDczNUIZ22g== + dependencies: + d3-color "1 - 3" + d3-interpolate "1 - 3" + d3-scale-chromatic@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/d3-scale-chromatic/-/d3-scale-chromatic-2.0.0.tgz#c13f3af86685ff91323dc2f0ebd2dabbd72d8bab" @@ -5284,6 +5643,17 @@ d3-scale-chromatic@^2.0.0: d3-color "1 - 2" d3-interpolate "1 - 2" +d3-scale@4: + version "4.0.2" + resolved "https://registry.yarnpkg.com/d3-scale/-/d3-scale-4.0.2.tgz#82b38e8e8ff7080764f8dcec77bd4be393689396" + integrity sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ== + dependencies: + d3-array "2.10.0 - 3" + d3-format "1 - 3" + d3-interpolate "1.2.0 - 3" + d3-time "2.1.1 - 3" + d3-time-format "2 - 4" + d3-scale@^3.2.3: version "3.3.0" resolved "https://registry.yarnpkg.com/d3-scale/-/d3-scale-3.3.0.tgz#28c600b29f47e5b9cd2df9749c206727966203f3" @@ -5295,6 +5665,18 @@ d3-scale@^3.2.3: d3-time "^2.1.1" d3-time-format "2 - 3" +"d3-selection@2 - 3", d3-selection@3: + version "3.0.0" + resolved "https://registry.yarnpkg.com/d3-selection/-/d3-selection-3.0.0.tgz#c25338207efa72cc5b9bd1458a1a41901f1e1b31" + integrity sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ== + +d3-shape@3: + version "3.1.0" + resolved "https://registry.yarnpkg.com/d3-shape/-/d3-shape-3.1.0.tgz#c8a495652d83ea6f524e482fca57aa3f8bc32556" + integrity sha512-tGDh1Muf8kWjEDT/LswZJ8WF85yDZLvVJpYU9Nq+8+yW1Z5enxrmXOhTArlkaElU+CTn0OTVNli+/i+HP45QEQ== + dependencies: + d3-path "1 - 3" + d3-shape@^1.3.5: version "1.3.7" resolved "https://registry.yarnpkg.com/d3-shape/-/d3-shape-1.3.7.tgz#df63801be07bc986bc54f63789b4fe502992b5d7" @@ -5309,6 +5691,13 @@ d3-shape@^1.3.5: dependencies: d3-time "1 - 2" +"d3-time-format@2 - 4", d3-time-format@4: + version "4.1.0" + resolved "https://registry.yarnpkg.com/d3-time-format/-/d3-time-format-4.1.0.tgz#7ab5257a5041d11ecb4fe70a5c7d16a195bb408a" + integrity sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg== + dependencies: + d3-time "1 - 3" + "d3-time@1 - 2", d3-time@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/d3-time/-/d3-time-2.1.1.tgz#e9d8a8a88691f4548e68ca085e5ff956724a6682" @@ -5316,11 +5705,81 @@ d3-shape@^1.3.5: dependencies: d3-array "2" +"d3-time@1 - 3", "d3-time@2.1.1 - 3", d3-time@3: + version "3.0.0" + resolved "https://registry.yarnpkg.com/d3-time/-/d3-time-3.0.0.tgz#65972cb98ae2d4954ef5c932e8704061335d4975" + integrity sha512-zmV3lRnlaLI08y9IMRXSDshQb5Nj77smnfpnd2LrBa/2K281Jijactokeak14QacHs/kKq0AQ121nidNYlarbQ== + dependencies: + d3-array "2 - 3" + d3-time@^1.0.11: version "1.1.0" resolved "https://registry.yarnpkg.com/d3-time/-/d3-time-1.1.0.tgz#b1e19d307dae9c900b7e5b25ffc5dcc249a8a0f1" integrity sha512-Xh0isrZ5rPYYdqhAVk8VLnMEidhz5aP7htAADH6MfzgmmicPkTo8LhkLxci61/lCB7n7UmE3bN0leRt+qvkLxA== +"d3-timer@1 - 3", d3-timer@3: + version "3.0.1" + resolved "https://registry.yarnpkg.com/d3-timer/-/d3-timer-3.0.1.tgz#6284d2a2708285b1abb7e201eda4380af35e63b0" + integrity sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA== + +"d3-transition@2 - 3", d3-transition@3: + version "3.0.1" + resolved "https://registry.yarnpkg.com/d3-transition/-/d3-transition-3.0.1.tgz#6869fdde1448868077fdd5989200cb61b2a1645f" + integrity sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w== + dependencies: + d3-color "1 - 3" + d3-dispatch "1 - 3" + d3-ease "1 - 3" + d3-interpolate "1 - 3" + d3-timer "1 - 3" + +d3-zoom@3: + version "3.0.0" + resolved "https://registry.yarnpkg.com/d3-zoom/-/d3-zoom-3.0.0.tgz#d13f4165c73217ffeaa54295cd6969b3e7aee8f3" + integrity sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw== + dependencies: + d3-dispatch "1 - 3" + d3-drag "2 - 3" + d3-interpolate "1 - 3" + d3-selection "2 - 3" + d3-transition "2 - 3" + +d3@7.6.1: + version "7.6.1" + resolved "https://registry.yarnpkg.com/d3/-/d3-7.6.1.tgz#b21af9563485ed472802f8c611cc43be6c37c40c" + integrity sha512-txMTdIHFbcpLx+8a0IFhZsbp+PfBBPt8yfbmukZTQFroKuFqIwqswF0qE5JXWefylaAVpSXFoKm3yP+jpNLFLw== + dependencies: + d3-array "3" + d3-axis "3" + d3-brush "3" + d3-chord "3" + d3-color "3" + d3-contour "4" + d3-delaunay "6" + d3-dispatch "3" + d3-drag "3" + d3-dsv "3" + d3-ease "3" + d3-fetch "3" + d3-force "3" + d3-format "3" + d3-geo "3" + d3-hierarchy "3" + d3-interpolate "3" + d3-path "3" + d3-polygon "3" + d3-quadtree "3" + d3-random "3" + d3-scale "4" + d3-scale-chromatic "3" + d3-selection "3" + d3-shape "3" + d3-time "3" + d3-time-format "4" + d3-timer "3" + d3-transition "3" + d3-zoom "3" + daisyui@1.16.4: version "1.16.4" resolved "https://registry.yarnpkg.com/daisyui/-/daisyui-1.16.4.tgz#52773401c0962e37ef40507d29f0e513c7f2856f" @@ -5464,6 +5923,13 @@ delaunator@4: resolved "https://registry.yarnpkg.com/delaunator/-/delaunator-4.0.1.tgz#3d779687f57919a7a418f8ab947d3bddb6846957" integrity sha512-WNPWi1IRKZfCt/qIDMfERkDp93+iZEmOxN2yy4Jg+Xhv8SLk2UTqqbe1sfiipn0and9QrE914/ihdx82Y/Giag== +delaunator@5: + version "5.0.0" + resolved "https://registry.yarnpkg.com/delaunator/-/delaunator-5.0.0.tgz#60f052b28bd91c9b4566850ebf7756efe821d81b" + integrity sha512-AyLvtyJdbv/U1GkiS6gUUzclRoAY4Gs75qkMygJJhU75LW4DNuSF2RMzpxs9jw9Oz1BobHjTdkG3zdP55VxAqw== + dependencies: + robust-predicates "^3.0.0" + delayed-stream@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" @@ -7381,6 +7847,13 @@ iconv-lite@0.4.24: dependencies: safer-buffer ">= 2.1.2 < 3" +iconv-lite@0.6: + version "0.6.3" + resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.6.3.tgz#a52f80bf38da1952eb5c681790719871a1a72501" + integrity sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw== + dependencies: + safer-buffer ">= 2.1.2 < 3.0.0" + icss-utils@^5.0.0, icss-utils@^5.1.0: version "5.1.0" resolved "https://registry.yarnpkg.com/icss-utils/-/icss-utils-5.1.0.tgz#c6be6858abd013d768e98366ae47e25d5887b1ae" @@ -7519,6 +7992,11 @@ internal-slot@^1.0.3: has "^1.0.3" side-channel "^1.0.4" +"internmap@1 - 2": + version "2.0.3" + resolved "https://registry.yarnpkg.com/internmap/-/internmap-2.0.3.tgz#6685f23755e43c524e251d29cbc97248e3061009" + integrity sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg== + internmap@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/internmap/-/internmap-1.0.1.tgz#0017cc8a3b99605f0302f2b198d272e015e5df95" @@ -10672,6 +11150,11 @@ rimraf@3.0.2, rimraf@^3.0.0, rimraf@^3.0.2: dependencies: glob "^7.1.3" +robust-predicates@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/robust-predicates/-/robust-predicates-3.0.1.tgz#ecde075044f7f30118682bd9fb3f123109577f9a" + integrity sha512-ndEIpszUHiG4HtDsQLeIuMvRsDnn8c8rYStabochtUeCvfuvNptb5TUbVD68LRAILPX7p9nqQGh4xJgn3EHS/g== + rope-sequence@^1.3.0: version "1.3.3" resolved "https://registry.yarnpkg.com/rope-sequence/-/rope-sequence-1.3.3.tgz#3f67fc106288b84b71532b4a5fd9d4881e4457f0" @@ -10699,6 +11182,11 @@ run-parallel@^1.1.9: dependencies: queue-microtask "^1.2.2" +rw@1: + version "1.3.3" + resolved "https://registry.yarnpkg.com/rw/-/rw-1.3.3.tgz#3f862dfa91ab766b14885ef4d01124bfda074fb4" + integrity sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ== + rxjs@^6.6.3: version "6.6.7" resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-6.6.7.tgz#90ac018acabf491bf65044235d5863c4dab804c9" @@ -10723,7 +11211,7 @@ safe-buffer@5.2.1, safe-buffer@>=5.1.0, safe-buffer@^5.0.1, safe-buffer@^5.1.0, resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== -"safer-buffer@>= 2.1.2 < 3": +"safer-buffer@>= 2.1.2 < 3", "safer-buffer@>= 2.1.2 < 3.0.0": version "2.1.2" resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== From c16adb9ec9fceeed1921957e4273b02f765b1888 Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Tue, 27 Sep 2022 21:18:22 -0700 Subject: [PATCH 015/135] Fix potential clock sync issues with graph updating (#947) --- web/components/charts/contract/binary.tsx | 17 ++++++++++++++--- web/components/charts/contract/choice.tsx | 18 ++++++++++++++---- .../charts/contract/pseudo-numeric.tsx | 18 ++++++++++++++---- web/components/charts/helpers.tsx | 15 +++++++++++++++ 4 files changed, 57 insertions(+), 11 deletions(-) diff --git a/web/components/charts/contract/binary.tsx b/web/components/charts/contract/binary.tsx index f7ed406b..8cd4bcb4 100644 --- a/web/components/charts/contract/binary.tsx +++ b/web/components/charts/contract/binary.tsx @@ -1,12 +1,18 @@ import { useMemo, useRef } from 'react' -import { sortBy } from 'lodash' +import { last, sortBy } from 'lodash' import { scaleTime, scaleLinear } from 'd3' import { Bet } from 'common/bet' import { getInitialProbability, getProbability } from 'common/calculate' import { BinaryContract } from 'common/contract' import { useIsMobile } from 'web/hooks/use-is-mobile' -import { MARGIN_X, MARGIN_Y, MAX_DATE, getDateRange } from '../helpers' +import { + MARGIN_X, + MARGIN_Y, + MAX_DATE, + getDateRange, + getRightmostVisibleDate, +} from '../helpers' import { SingleValueHistoryChart } from '../generic-charts' import { useElementWidth } from 'web/hooks/use-element-width' @@ -40,7 +46,12 @@ export const BinaryContractChart = (props: { ], [contract, betPoints, contractStart, contractEnd] ) - const visibleRange = [contractStart, contractEnd ?? Date.now()] + const rightmostDate = getRightmostVisibleDate( + contractEnd, + last(betPoints)?.[0], + new Date(Date.now()) + ) + const visibleRange = [contractStart, rightmostDate] const isMobile = useIsMobile(800) const containerRef = useRef<HTMLDivElement>(null) const width = useElementWidth(containerRef) ?? 0 diff --git a/web/components/charts/contract/choice.tsx b/web/components/charts/contract/choice.tsx index 4b4fa34d..ce87c382 100644 --- a/web/components/charts/contract/choice.tsx +++ b/web/components/charts/contract/choice.tsx @@ -1,5 +1,5 @@ import { useMemo, useRef } from 'react' -import { sum, sortBy, groupBy } from 'lodash' +import { last, sum, sortBy, groupBy } from 'lodash' import { scaleTime, scaleLinear } from 'd3' import { Bet } from 'common/bet' @@ -7,7 +7,13 @@ import { Answer } from 'common/answer' import { FreeResponseContract, MultipleChoiceContract } from 'common/contract' import { getOutcomeProbability } from 'common/calculate' import { useIsMobile } from 'web/hooks/use-is-mobile' -import { MARGIN_X, MARGIN_Y, MAX_DATE, getDateRange } from '../helpers' +import { + MARGIN_X, + MARGIN_Y, + MAX_DATE, + getDateRange, + getRightmostVisibleDate, +} from '../helpers' import { MultiPoint, MultiValueHistoryChart } from '../generic-charts' import { useElementWidth } from 'web/hooks/use-element-width' @@ -143,8 +149,12 @@ export const ChoiceContractChart = (props: { ], [answers, contract, betPoints, contractStart, contractEnd] ) - const visibleRange = [contractStart, contractEnd ?? Date.now()] - + const rightmostDate = getRightmostVisibleDate( + contractEnd, + last(betPoints)?.[0], + new Date(Date.now()) + ) + const visibleRange = [contractStart, rightmostDate] const isMobile = useIsMobile(800) const containerRef = useRef<HTMLDivElement>(null) const width = useElementWidth(containerRef) ?? 0 diff --git a/web/components/charts/contract/pseudo-numeric.tsx b/web/components/charts/contract/pseudo-numeric.tsx index 9b67a169..8b0319f6 100644 --- a/web/components/charts/contract/pseudo-numeric.tsx +++ b/web/components/charts/contract/pseudo-numeric.tsx @@ -1,5 +1,5 @@ import { useMemo, useRef } from 'react' -import { sortBy } from 'lodash' +import { last, sortBy } from 'lodash' import { scaleTime, scaleLog, scaleLinear } from 'd3' import { Bet } from 'common/bet' @@ -7,7 +7,13 @@ import { getInitialProbability, getProbability } from 'common/calculate' import { PseudoNumericContract } from 'common/contract' import { NUMERIC_GRAPH_COLOR } from 'common/numeric-constants' import { useIsMobile } from 'web/hooks/use-is-mobile' -import { MARGIN_X, MARGIN_Y, MAX_DATE, getDateRange } from '../helpers' +import { + MARGIN_X, + MARGIN_Y, + MAX_DATE, + getDateRange, + getRightmostVisibleDate, +} from '../helpers' import { SingleValueHistoryChart } from '../generic-charts' import { useElementWidth } from 'web/hooks/use-element-width' @@ -55,8 +61,12 @@ export const PseudoNumericContractChart = (props: { ], [contract, betPoints, contractStart, contractEnd] ) - const visibleRange = [contractStart, contractEnd ?? Date.now()] - + const rightmostDate = getRightmostVisibleDate( + contractEnd, + last(betPoints)?.[0], + new Date(Date.now()) + ) + const visibleRange = [contractStart, rightmostDate] const isMobile = useIsMobile(800) const containerRef = useRef<HTMLDivElement>(null) const width = useElementWidth(containerRef) ?? 0 diff --git a/web/components/charts/helpers.tsx b/web/components/charts/helpers.tsx index 20231bc9..658fcbc9 100644 --- a/web/components/charts/helpers.tsx +++ b/web/components/charts/helpers.tsx @@ -205,3 +205,18 @@ export const getDateRange = (contract: Contract) => { const endDate = resolutionTime ?? (isClosed ? closeTime : null) return [new Date(createdTime), endDate ? new Date(endDate) : null] as const } + +export const getRightmostVisibleDate = ( + contractEnd: Date | null | undefined, + lastActivity: Date | null | undefined, + now: Date +) => { + if (contractEnd != null) { + return contractEnd + } else if (lastActivity != null) { + // client-DB clock divergence may cause last activity to be later than now + return new Date(Math.max(lastActivity.getTime(), now.getTime())) + } else { + return now + } +} From a5b943965c32ecde87bfd3db99c0ef8a9f709e43 Mon Sep 17 00:00:00 2001 From: Pico2x <pico2x@gmail.com> Date: Wed, 28 Sep 2022 00:59:24 -0400 Subject: [PATCH 016/135] Create cowp.tsx --- web/pages/cowp.tsx | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 web/pages/cowp.tsx diff --git a/web/pages/cowp.tsx b/web/pages/cowp.tsx new file mode 100644 index 00000000..6e5059d9 --- /dev/null +++ b/web/pages/cowp.tsx @@ -0,0 +1,11 @@ +import { Page } from 'web/components/page' + +const App = () => { + return ( + <Page className=""> + <img src="https://i.imgur.com/Lt54IiU.png" /> + </Page> + ) +} + +export default App From 95f26044796a25b29325087969eb9ce1c9e19dd3 Mon Sep 17 00:00:00 2001 From: Pico2x <pico2x@gmail.com> Date: Wed, 28 Sep 2022 01:04:38 -0400 Subject: [PATCH 017/135] Cowp SEO friendly --- web/pages/cowp.tsx | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/web/pages/cowp.tsx b/web/pages/cowp.tsx index 6e5059d9..4d7d81e9 100644 --- a/web/pages/cowp.tsx +++ b/web/pages/cowp.tsx @@ -1,9 +1,18 @@ +import Link from 'next/link' import { Page } from 'web/components/page' +import { SEO } from 'web/components/SEO' const App = () => { return ( <Page className=""> - <img src="https://i.imgur.com/Lt54IiU.png" /> + <SEO + title="COWP" + description="A picture of a cowpy cowp copwer cowp saying 'salutations'" + url="/cowp" + /> + <Link href="https://en.wikipedia.org/wiki/Earl_Cowper"> + <img src="https://i.imgur.com/Lt54IiU.png" /> + </Link> </Page> ) } From f52127237ee1ee1715e370beceac168b3499da07 Mon Sep 17 00:00:00 2001 From: Pico2x <pico2x@gmail.com> Date: Wed, 28 Sep 2022 01:21:38 -0400 Subject: [PATCH 018/135] COWP for cows --- web/pages/cowp.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/pages/cowp.tsx b/web/pages/cowp.tsx index 4d7d81e9..21494c37 100644 --- a/web/pages/cowp.tsx +++ b/web/pages/cowp.tsx @@ -10,7 +10,7 @@ const App = () => { description="A picture of a cowpy cowp copwer cowp saying 'salutations'" url="/cowp" /> - <Link href="https://en.wikipedia.org/wiki/Earl_Cowper"> + <Link href="https://www.youtube.com/watch?v=FavUpD_IjVY"> <img src="https://i.imgur.com/Lt54IiU.png" /> </Link> </Page> From 5b54e7d468d1112fb4a4ef4ae9d6ebd2441cce84 Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Tue, 27 Sep 2022 22:25:37 -0700 Subject: [PATCH 019/135] Limit max width of FR legend tooltip labels (#948) --- web/components/charts/generic-charts.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/web/components/charts/generic-charts.tsx b/web/components/charts/generic-charts.tsx index 2bb5e089..05c26a42 100644 --- a/web/components/charts/generic-charts.tsx +++ b/web/components/charts/generic-charts.tsx @@ -88,12 +88,12 @@ const Legend = (props: { className?: string; items: LegendItem[] }) => { <ol className={className}> {items.map((item) => ( <li key={item.label} className="flex flex-row justify-between"> - <Row className="mr-4 items-center"> + <Row className="mr-2 items-center overflow-hidden"> <span - className="mr-2 h-4 w-4" + className="mr-2 h-4 w-4 shrink-0" style={{ backgroundColor: item.color }} ></span> - {item.label} + <span className="overflow-hidden text-ellipsis">{item.label}</span> </Row> {item.value} </li> @@ -275,7 +275,7 @@ export const MultiValueHistoryChart = (props: { {mouseState && ( <ChartTooltip {...mouseState}> {fmtX(mouseState.p[0])} - <Legend className="text-sm" items={legendItems} /> + <Legend className="max-w-xs text-sm" items={legendItems} /> </ChartTooltip> )} <SVGChart From 8f88af4e2a0e8ac73a7411e8f897d902b9f8c074 Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Wed, 28 Sep 2022 00:56:43 -0700 Subject: [PATCH 020/135] Fix an edge case with chart mouseover tooltips (#949) --- web/components/charts/generic-charts.tsx | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/web/components/charts/generic-charts.tsx b/web/components/charts/generic-charts.tsx index 05c26a42..cb12ea45 100644 --- a/web/components/charts/generic-charts.tsx +++ b/web/components/charts/generic-charts.tsx @@ -150,7 +150,13 @@ export const SingleValueDistributionChart = (props: { if (ev.pointerType === 'mouse') { const [mouseX, mouseY] = pointer(ev) const queryX = xScale.invert(mouseX) - const [_x, y] = data[xBisector.center(data, queryX)] + const item = data[xBisector.left(data, queryX) - 1] + if (item == null) { + // this can happen if you are on the very left or right edge of the chart, + // so your queryX is out of bounds + return + } + const [_x, y] = item setMouseState({ top: mouseY - 10, left: mouseX + 60, p: [queryX, y] }) } }) @@ -250,7 +256,13 @@ export const MultiValueHistoryChart = (props: { if (ev.pointerType === 'mouse') { const [mouseX, mouseY] = pointer(ev) const queryX = xScale.invert(mouseX) - const [_x, ys] = data[xBisector.left(data, queryX) - 1] + const item = data[xBisector.left(data, queryX) - 1] + if (item == null) { + // this can happen if you are on the very left or right edge of the chart, + // so your queryX is out of bounds + return + } + const [_x, ys] = item setMouseState({ top: mouseY - 10, left: mouseX + 60, p: [queryX, ys] }) } }) @@ -354,7 +366,13 @@ export const SingleValueHistoryChart = (props: { if (ev.pointerType === 'mouse') { const [mouseX, mouseY] = pointer(ev) const queryX = xScale.invert(mouseX) - const [_x, y] = data[xBisector.left(data, queryX) - 1] + const item = data[xBisector.left(data, queryX) - 1] + if (item == null) { + // this can happen if you are on the very left or right edge of the chart, + // so your queryX is out of bounds + return + } + const [_x, y] = item setMouseState({ top: mouseY - 10, left: mouseX + 60, p: [queryX, y] }) } }) From 925a9e850f9e835f7350e4d9ccad4b7609d6ac42 Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Wed, 28 Sep 2022 00:58:51 -0700 Subject: [PATCH 021/135] Hack up brush rendering to fix possible Chrome bug (#950) --- web/components/charts/helpers.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/web/components/charts/helpers.tsx b/web/components/charts/helpers.tsx index 658fcbc9..b4fbac58 100644 --- a/web/components/charts/helpers.tsx +++ b/web/components/charts/helpers.tsx @@ -150,7 +150,13 @@ export const SVGChart = <X, Y>(props: { justSelected.current = false } }) - select(overlayRef.current).call(brush) + // mqp: shape-rendering null overrides the default d3-brush shape-rendering + // of `crisp-edges`, which seems to cause graphical glitches on Chrome + // (i.e. the bug where the area fill flickers white) + select(overlayRef.current) + .call(brush) + .select('.selection') + .attr('shape-rendering', 'null') } }, [innerW, innerH, onSelect]) From 9238b20242477166ce01ecc2e668d00dba5a79d5 Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Wed, 28 Sep 2022 01:00:39 -0700 Subject: [PATCH 022/135] Modularize d3 imports (#951) --- web/components/charts/contract/binary.tsx | 2 +- web/components/charts/contract/choice.tsx | 2 +- web/components/charts/contract/numeric.tsx | 2 +- .../charts/contract/pseudo-numeric.tsx | 2 +- web/components/charts/generic-charts.tsx | 14 +- web/components/charts/helpers.tsx | 14 +- web/package.json | 7 +- yarn.lock | 198 ++---------------- 8 files changed, 43 insertions(+), 198 deletions(-) diff --git a/web/components/charts/contract/binary.tsx b/web/components/charts/contract/binary.tsx index 8cd4bcb4..372577c4 100644 --- a/web/components/charts/contract/binary.tsx +++ b/web/components/charts/contract/binary.tsx @@ -1,6 +1,6 @@ import { useMemo, useRef } from 'react' import { last, sortBy } from 'lodash' -import { scaleTime, scaleLinear } from 'd3' +import { scaleTime, scaleLinear } from 'd3-scale' import { Bet } from 'common/bet' import { getInitialProbability, getProbability } from 'common/calculate' diff --git a/web/components/charts/contract/choice.tsx b/web/components/charts/contract/choice.tsx index ce87c382..5786b7bb 100644 --- a/web/components/charts/contract/choice.tsx +++ b/web/components/charts/contract/choice.tsx @@ -1,6 +1,6 @@ import { useMemo, useRef } from 'react' import { last, sum, sortBy, groupBy } from 'lodash' -import { scaleTime, scaleLinear } from 'd3' +import { scaleTime, scaleLinear } from 'd3-scale' import { Bet } from 'common/bet' import { Answer } from 'common/answer' diff --git a/web/components/charts/contract/numeric.tsx b/web/components/charts/contract/numeric.tsx index 6adc52f0..6b574f15 100644 --- a/web/components/charts/contract/numeric.tsx +++ b/web/components/charts/contract/numeric.tsx @@ -1,6 +1,6 @@ import { useMemo, useRef } from 'react' import { max, range } from 'lodash' -import { scaleLinear } from 'd3' +import { scaleLinear } from 'd3-scale' import { getDpmOutcomeProbabilities } from 'common/calculate-dpm' import { NumericContract } from 'common/contract' diff --git a/web/components/charts/contract/pseudo-numeric.tsx b/web/components/charts/contract/pseudo-numeric.tsx index 8b0319f6..987a7fea 100644 --- a/web/components/charts/contract/pseudo-numeric.tsx +++ b/web/components/charts/contract/pseudo-numeric.tsx @@ -1,6 +1,6 @@ import { useMemo, useRef } from 'react' import { last, sortBy } from 'lodash' -import { scaleTime, scaleLog, scaleLinear } from 'd3' +import { scaleTime, scaleLog, scaleLinear } from 'd3-scale' import { Bet } from 'common/bet' import { getInitialProbability, getProbability } from 'common/calculate' diff --git a/web/components/charts/generic-charts.tsx b/web/components/charts/generic-charts.tsx index cb12ea45..0d262e17 100644 --- a/web/components/charts/generic-charts.tsx +++ b/web/components/charts/generic-charts.tsx @@ -1,18 +1,16 @@ import { useCallback, useMemo, useState } from 'react' +import { bisector } from 'd3-array' +import { axisBottom, axisLeft } from 'd3-axis' +import { D3BrushEvent } from 'd3-brush' +import { ScaleTime, ScaleContinuousNumeric } from 'd3-scale' +import { pointer } from 'd3-selection' import { - axisBottom, - axisLeft, - bisector, curveLinear, curveStepAfter, - pointer, stack, stackOrderReverse, - D3BrushEvent, - ScaleTime, - ScaleContinuousNumeric, SeriesPoint, -} from 'd3' +} from 'd3-shape' import { range, sortBy } from 'lodash' import dayjs from 'dayjs' diff --git a/web/components/charts/helpers.tsx b/web/components/charts/helpers.tsx index b4fbac58..644a421c 100644 --- a/web/components/charts/helpers.tsx +++ b/web/components/charts/helpers.tsx @@ -1,14 +1,8 @@ import { ReactNode, SVGProps, memo, useRef, useEffect, useMemo } from 'react' -import { - Axis, - CurveFactory, - D3BrushEvent, - area, - brushX, - curveStepAfter, - line, - select, -} from 'd3' +import { select } from 'd3-selection' +import { Axis } from 'd3-axis' +import { brushX, D3BrushEvent } from 'd3-brush' +import { area, line, curveStepAfter, CurveFactory } from 'd3-shape' import { nanoid } from 'nanoid' import clsx from 'clsx' diff --git a/web/package.json b/web/package.json index 570139f4..a3ec9aaa 100644 --- a/web/package.json +++ b/web/package.json @@ -39,7 +39,12 @@ "browser-image-compression": "2.0.0", "clsx": "1.1.1", "cors": "2.8.5", - "d3": "7.6.1", + "d3-array": "3.2.0", + "d3-axis": "3.0.0", + "d3-brush": "3.0.0", + "d3-scale": "4.0.2", + "d3-shape": "3.1.0", + "d3-selection": "3.0.0", "daisyui": "1.16.4", "dayjs": "1.10.7", "firebase": "9.9.3", diff --git a/yarn.lock b/yarn.lock index 17a065f7..d0ef33f1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5049,11 +5049,6 @@ comma-separated-tokens@^1.0.0: resolved "https://registry.yarnpkg.com/comma-separated-tokens/-/comma-separated-tokens-1.0.8.tgz#632b80b6117867a158f1080ad498b2fbe7e3f5ea" integrity sha512-GHuDRO12Sypu2cV70d1dkA2EUmXHgntrzbpvOB+Qy+49ypNfGgFQIC2fhhXbnyrJRynDCAARsT7Ou0M6hirpfw== -commander@7, commander@^7.2.0: - version "7.2.0" - resolved "https://registry.yarnpkg.com/commander/-/commander-7.2.0.tgz#a36cb57d0b501ce108e4d20559a150a391d97ab7" - integrity sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw== - commander@^2.20.0: version "2.20.3" resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33" @@ -5064,6 +5059,11 @@ commander@^5.1.0: resolved "https://registry.yarnpkg.com/commander/-/commander-5.1.0.tgz#46abbd1652f8e059bddaef99bbdcb2ad9cf179ae" integrity sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg== +commander@^7.2.0: + version "7.2.0" + resolved "https://registry.yarnpkg.com/commander/-/commander-7.2.0.tgz#a36cb57d0b501ce108e4d20559a150a391d97ab7" + integrity sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw== + commander@^8.0.0, commander@^8.3.0: version "8.3.0" resolved "https://registry.yarnpkg.com/commander/-/commander-8.3.0.tgz#4837ea1b2da67b9c616a67afbb0fafee567bca66" @@ -5457,19 +5457,19 @@ d3-array@2, d3-array@^2.3.0: dependencies: internmap "^1.0.0" -"d3-array@2 - 3", "d3-array@2.10.0 - 3", "d3-array@2.5.0 - 3", d3-array@3, d3-array@^3.2.0: +"d3-array@2 - 3", "d3-array@2.10.0 - 3", d3-array@3.2.0: version "3.2.0" resolved "https://registry.yarnpkg.com/d3-array/-/d3-array-3.2.0.tgz#15bf96cd9b7333e02eb8de8053d78962eafcff14" integrity sha512-3yXFQo0oG3QCxbF06rMPFyGRMGJNS7NvsV1+2joOjbBE+9xvWQ8+GcMJAjRCzw06zQ3/arXeJgbPYcjUCuC+3g== dependencies: internmap "1 - 2" -d3-axis@3: +d3-axis@3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/d3-axis/-/d3-axis-3.0.0.tgz#c42a4a13e8131d637b745fc2973824cfeaf93322" integrity sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw== -d3-brush@3: +d3-brush@3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/d3-brush/-/d3-brush-3.0.0.tgz#6f767c4ed8dcb79de7ede3e1c0f89e63ef64d31c" integrity sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ== @@ -5480,37 +5480,16 @@ d3-brush@3: d3-selection "3" d3-transition "3" -d3-chord@3: - version "3.0.1" - resolved "https://registry.yarnpkg.com/d3-chord/-/d3-chord-3.0.1.tgz#d156d61f485fce8327e6abf339cb41d8cbba6966" - integrity sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g== - dependencies: - d3-path "1 - 3" - "d3-color@1 - 2", d3-color@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/d3-color/-/d3-color-2.0.0.tgz#8d625cab42ed9b8f601a1760a389f7ea9189d62e" integrity sha512-SPXi0TSKPD4g9tw0NMZFnR95XVgUZiBH+uUTqQuDu1OsE2zomHU7ho0FISciaPvosimixwHFl3WHLGabv6dDgQ== -"d3-color@1 - 3", d3-color@3: +"d3-color@1 - 3": version "3.1.0" resolved "https://registry.yarnpkg.com/d3-color/-/d3-color-3.1.0.tgz#395b2833dfac71507f12ac2f7af23bf819de24e2" integrity sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA== -d3-contour@4: - version "4.0.0" - resolved "https://registry.yarnpkg.com/d3-contour/-/d3-contour-4.0.0.tgz#5a1337c6da0d528479acdb5db54bc81a0ff2ec6b" - integrity sha512-7aQo0QHUTu/Ko3cP9YK9yUTxtoDEiDGwnBHyLxG5M4vqlBkO/uixMRele3nfsfj6UXOcuReVpVXzAboGraYIJw== - dependencies: - d3-array "^3.2.0" - -d3-delaunay@6: - version "6.0.2" - resolved "https://registry.yarnpkg.com/d3-delaunay/-/d3-delaunay-6.0.2.tgz#7fd3717ad0eade2fc9939f4260acfb503f984e92" - integrity sha512-IMLNldruDQScrcfT+MWnazhHbDJhcRJyOEBAJfwQnHle1RPh6WDuLvxNArUju2VSMSUuKlY5BGHRJ2cYyoFLQQ== - dependencies: - delaunator "5" - d3-delaunay@^5.3.0: version "5.3.0" resolved "https://registry.yarnpkg.com/d3-delaunay/-/d3-delaunay-5.3.0.tgz#b47f05c38f854a4e7b3cea80e0bb12e57398772d" @@ -5518,12 +5497,12 @@ d3-delaunay@^5.3.0: dependencies: delaunator "4" -"d3-dispatch@1 - 3", d3-dispatch@3: +"d3-dispatch@1 - 3": version "3.0.1" resolved "https://registry.yarnpkg.com/d3-dispatch/-/d3-dispatch-3.0.1.tgz#5fc75284e9c2375c36c839411a0cf550cbfc4d5e" integrity sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg== -"d3-drag@2 - 3", d3-drag@3: +"d3-drag@2 - 3": version "3.0.0" resolved "https://registry.yarnpkg.com/d3-drag/-/d3-drag-3.0.0.tgz#994aae9cd23c719f53b5e10e3a0a6108c69607ba" integrity sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg== @@ -5531,42 +5510,17 @@ d3-delaunay@^5.3.0: d3-dispatch "1 - 3" d3-selection "3" -"d3-dsv@1 - 3", d3-dsv@3: - version "3.0.1" - resolved "https://registry.yarnpkg.com/d3-dsv/-/d3-dsv-3.0.1.tgz#c63af978f4d6a0d084a52a673922be2160789b73" - integrity sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q== - dependencies: - commander "7" - iconv-lite "0.6" - rw "1" - -"d3-ease@1 - 3", d3-ease@3: +"d3-ease@1 - 3": version "3.0.1" resolved "https://registry.yarnpkg.com/d3-ease/-/d3-ease-3.0.1.tgz#9658ac38a2140d59d346160f1f6c30fda0bd12f4" integrity sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w== -d3-fetch@3: - version "3.0.1" - resolved "https://registry.yarnpkg.com/d3-fetch/-/d3-fetch-3.0.1.tgz#83141bff9856a0edb5e38de89cdcfe63d0a60a22" - integrity sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw== - dependencies: - d3-dsv "1 - 3" - -d3-force@3: - version "3.0.0" - resolved "https://registry.yarnpkg.com/d3-force/-/d3-force-3.0.0.tgz#3e2ba1a61e70888fe3d9194e30d6d14eece155c4" - integrity sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg== - dependencies: - d3-dispatch "1 - 3" - d3-quadtree "1 - 3" - d3-timer "1 - 3" - "d3-format@1 - 2": version "2.0.0" resolved "https://registry.yarnpkg.com/d3-format/-/d3-format-2.0.0.tgz#a10bcc0f986c372b729ba447382413aabf5b0767" integrity sha512-Ab3S6XuE/Q+flY96HXT0jOXcM4EAClYFnRGY5zsjRGNy6qCYrQsMffs7cV5Q9xejb35zxW5hf/guKw34kvIKsA== -"d3-format@1 - 3", d3-format@3: +"d3-format@1 - 3": version "3.1.0" resolved "https://registry.yarnpkg.com/d3-format/-/d3-format-3.1.0.tgz#9260e23a28ea5cb109e93b21a06e24e2ebd55641" integrity sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA== @@ -5576,18 +5530,6 @@ d3-format@^1.4.4: resolved "https://registry.yarnpkg.com/d3-format/-/d3-format-1.4.5.tgz#374f2ba1320e3717eb74a9356c67daee17a7edb4" integrity sha512-J0piedu6Z8iB6TbIGfZgDzfXxUFN3qQRMofy2oPdXzQibYGqPB/9iMcxr/TGalU+2RsyDO+U4f33id8tbnSRMQ== -d3-geo@3: - version "3.0.1" - resolved "https://registry.yarnpkg.com/d3-geo/-/d3-geo-3.0.1.tgz#4f92362fd8685d93e3b1fae0fd97dc8980b1ed7e" - integrity sha512-Wt23xBych5tSy9IYAM1FR2rWIBFWa52B/oF/GYe5zbdHrg08FU8+BuI6X4PvTwPDdqdAdq04fuWJpELtsaEjeA== - dependencies: - d3-array "2.5.0 - 3" - -d3-hierarchy@3: - version "3.1.2" - resolved "https://registry.yarnpkg.com/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz#b01cd42c1eed3d46db77a5966cf726f8c09160c6" - integrity sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA== - "d3-interpolate@1 - 2", "d3-interpolate@1.2.0 - 2", d3-interpolate@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/d3-interpolate/-/d3-interpolate-2.0.1.tgz#98be499cfb8a3b94d4ff616900501a64abc91163" @@ -5595,7 +5537,7 @@ d3-hierarchy@3: dependencies: d3-color "1 - 2" -"d3-interpolate@1 - 3", "d3-interpolate@1.2.0 - 3", d3-interpolate@3: +"d3-interpolate@1 - 3", "d3-interpolate@1.2.0 - 3": version "3.0.1" resolved "https://registry.yarnpkg.com/d3-interpolate/-/d3-interpolate-3.0.1.tgz#3c47aa5b32c5b3dfb56ef3fd4342078a632b400d" integrity sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g== @@ -5607,34 +5549,11 @@ d3-path@1: resolved "https://registry.yarnpkg.com/d3-path/-/d3-path-1.0.9.tgz#48c050bb1fe8c262493a8caf5524e3e9591701cf" integrity sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg== -"d3-path@1 - 3", d3-path@3: +"d3-path@1 - 3": version "3.0.1" resolved "https://registry.yarnpkg.com/d3-path/-/d3-path-3.0.1.tgz#f09dec0aaffd770b7995f1a399152bf93052321e" integrity sha512-gq6gZom9AFZby0YLduxT1qmrp4xpBA1YZr19OI717WIdKE2OM5ETq5qrHLb301IgxhLwcuxvGZVLeeWc/k1I6w== -d3-polygon@3: - version "3.0.1" - resolved "https://registry.yarnpkg.com/d3-polygon/-/d3-polygon-3.0.1.tgz#0b45d3dd1c48a29c8e057e6135693ec80bf16398" - integrity sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg== - -"d3-quadtree@1 - 3", d3-quadtree@3: - version "3.0.1" - resolved "https://registry.yarnpkg.com/d3-quadtree/-/d3-quadtree-3.0.1.tgz#6dca3e8be2b393c9a9d514dabbd80a92deef1a4f" - integrity sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw== - -d3-random@3: - version "3.0.1" - resolved "https://registry.yarnpkg.com/d3-random/-/d3-random-3.0.1.tgz#d4926378d333d9c0bfd1e6fa0194d30aebaa20f4" - integrity sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ== - -d3-scale-chromatic@3: - version "3.0.0" - resolved "https://registry.yarnpkg.com/d3-scale-chromatic/-/d3-scale-chromatic-3.0.0.tgz#15b4ceb8ca2bb0dcb6d1a641ee03d59c3b62376a" - integrity sha512-Lx9thtxAKrO2Pq6OO2Ua474opeziKr279P/TKZsMAhYyNDD3EnCffdbgeSYN5O7m2ByQsxtuP2CSDczNUIZ22g== - dependencies: - d3-color "1 - 3" - d3-interpolate "1 - 3" - d3-scale-chromatic@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/d3-scale-chromatic/-/d3-scale-chromatic-2.0.0.tgz#c13f3af86685ff91323dc2f0ebd2dabbd72d8bab" @@ -5643,7 +5562,7 @@ d3-scale-chromatic@^2.0.0: d3-color "1 - 2" d3-interpolate "1 - 2" -d3-scale@4: +d3-scale@4.0.2: version "4.0.2" resolved "https://registry.yarnpkg.com/d3-scale/-/d3-scale-4.0.2.tgz#82b38e8e8ff7080764f8dcec77bd4be393689396" integrity sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ== @@ -5665,12 +5584,12 @@ d3-scale@^3.2.3: d3-time "^2.1.1" d3-time-format "2 - 3" -"d3-selection@2 - 3", d3-selection@3: +d3-selection@3, d3-selection@3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/d3-selection/-/d3-selection-3.0.0.tgz#c25338207efa72cc5b9bd1458a1a41901f1e1b31" integrity sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ== -d3-shape@3: +d3-shape@3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/d3-shape/-/d3-shape-3.1.0.tgz#c8a495652d83ea6f524e482fca57aa3f8bc32556" integrity sha512-tGDh1Muf8kWjEDT/LswZJ8WF85yDZLvVJpYU9Nq+8+yW1Z5enxrmXOhTArlkaElU+CTn0OTVNli+/i+HP45QEQ== @@ -5691,7 +5610,7 @@ d3-shape@^1.3.5: dependencies: d3-time "1 - 2" -"d3-time-format@2 - 4", d3-time-format@4: +"d3-time-format@2 - 4": version "4.1.0" resolved "https://registry.yarnpkg.com/d3-time-format/-/d3-time-format-4.1.0.tgz#7ab5257a5041d11ecb4fe70a5c7d16a195bb408a" integrity sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg== @@ -5705,7 +5624,7 @@ d3-shape@^1.3.5: dependencies: d3-array "2" -"d3-time@1 - 3", "d3-time@2.1.1 - 3", d3-time@3: +"d3-time@1 - 3", "d3-time@2.1.1 - 3": version "3.0.0" resolved "https://registry.yarnpkg.com/d3-time/-/d3-time-3.0.0.tgz#65972cb98ae2d4954ef5c932e8704061335d4975" integrity sha512-zmV3lRnlaLI08y9IMRXSDshQb5Nj77smnfpnd2LrBa/2K281Jijactokeak14QacHs/kKq0AQ121nidNYlarbQ== @@ -5717,12 +5636,12 @@ d3-time@^1.0.11: resolved "https://registry.yarnpkg.com/d3-time/-/d3-time-1.1.0.tgz#b1e19d307dae9c900b7e5b25ffc5dcc249a8a0f1" integrity sha512-Xh0isrZ5rPYYdqhAVk8VLnMEidhz5aP7htAADH6MfzgmmicPkTo8LhkLxci61/lCB7n7UmE3bN0leRt+qvkLxA== -"d3-timer@1 - 3", d3-timer@3: +"d3-timer@1 - 3": version "3.0.1" resolved "https://registry.yarnpkg.com/d3-timer/-/d3-timer-3.0.1.tgz#6284d2a2708285b1abb7e201eda4380af35e63b0" integrity sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA== -"d3-transition@2 - 3", d3-transition@3: +d3-transition@3: version "3.0.1" resolved "https://registry.yarnpkg.com/d3-transition/-/d3-transition-3.0.1.tgz#6869fdde1448868077fdd5989200cb61b2a1645f" integrity sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w== @@ -5733,53 +5652,6 @@ d3-time@^1.0.11: d3-interpolate "1 - 3" d3-timer "1 - 3" -d3-zoom@3: - version "3.0.0" - resolved "https://registry.yarnpkg.com/d3-zoom/-/d3-zoom-3.0.0.tgz#d13f4165c73217ffeaa54295cd6969b3e7aee8f3" - integrity sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw== - dependencies: - d3-dispatch "1 - 3" - d3-drag "2 - 3" - d3-interpolate "1 - 3" - d3-selection "2 - 3" - d3-transition "2 - 3" - -d3@7.6.1: - version "7.6.1" - resolved "https://registry.yarnpkg.com/d3/-/d3-7.6.1.tgz#b21af9563485ed472802f8c611cc43be6c37c40c" - integrity sha512-txMTdIHFbcpLx+8a0IFhZsbp+PfBBPt8yfbmukZTQFroKuFqIwqswF0qE5JXWefylaAVpSXFoKm3yP+jpNLFLw== - dependencies: - d3-array "3" - d3-axis "3" - d3-brush "3" - d3-chord "3" - d3-color "3" - d3-contour "4" - d3-delaunay "6" - d3-dispatch "3" - d3-drag "3" - d3-dsv "3" - d3-ease "3" - d3-fetch "3" - d3-force "3" - d3-format "3" - d3-geo "3" - d3-hierarchy "3" - d3-interpolate "3" - d3-path "3" - d3-polygon "3" - d3-quadtree "3" - d3-random "3" - d3-scale "4" - d3-scale-chromatic "3" - d3-selection "3" - d3-shape "3" - d3-time "3" - d3-time-format "4" - d3-timer "3" - d3-transition "3" - d3-zoom "3" - daisyui@1.16.4: version "1.16.4" resolved "https://registry.yarnpkg.com/daisyui/-/daisyui-1.16.4.tgz#52773401c0962e37ef40507d29f0e513c7f2856f" @@ -5923,13 +5795,6 @@ delaunator@4: resolved "https://registry.yarnpkg.com/delaunator/-/delaunator-4.0.1.tgz#3d779687f57919a7a418f8ab947d3bddb6846957" integrity sha512-WNPWi1IRKZfCt/qIDMfERkDp93+iZEmOxN2yy4Jg+Xhv8SLk2UTqqbe1sfiipn0and9QrE914/ihdx82Y/Giag== -delaunator@5: - version "5.0.0" - resolved "https://registry.yarnpkg.com/delaunator/-/delaunator-5.0.0.tgz#60f052b28bd91c9b4566850ebf7756efe821d81b" - integrity sha512-AyLvtyJdbv/U1GkiS6gUUzclRoAY4Gs75qkMygJJhU75LW4DNuSF2RMzpxs9jw9Oz1BobHjTdkG3zdP55VxAqw== - dependencies: - robust-predicates "^3.0.0" - delayed-stream@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" @@ -7847,13 +7712,6 @@ iconv-lite@0.4.24: dependencies: safer-buffer ">= 2.1.2 < 3" -iconv-lite@0.6: - version "0.6.3" - resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.6.3.tgz#a52f80bf38da1952eb5c681790719871a1a72501" - integrity sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw== - dependencies: - safer-buffer ">= 2.1.2 < 3.0.0" - icss-utils@^5.0.0, icss-utils@^5.1.0: version "5.1.0" resolved "https://registry.yarnpkg.com/icss-utils/-/icss-utils-5.1.0.tgz#c6be6858abd013d768e98366ae47e25d5887b1ae" @@ -11150,11 +11008,6 @@ rimraf@3.0.2, rimraf@^3.0.0, rimraf@^3.0.2: dependencies: glob "^7.1.3" -robust-predicates@^3.0.0: - version "3.0.1" - resolved "https://registry.yarnpkg.com/robust-predicates/-/robust-predicates-3.0.1.tgz#ecde075044f7f30118682bd9fb3f123109577f9a" - integrity sha512-ndEIpszUHiG4HtDsQLeIuMvRsDnn8c8rYStabochtUeCvfuvNptb5TUbVD68LRAILPX7p9nqQGh4xJgn3EHS/g== - rope-sequence@^1.3.0: version "1.3.3" resolved "https://registry.yarnpkg.com/rope-sequence/-/rope-sequence-1.3.3.tgz#3f67fc106288b84b71532b4a5fd9d4881e4457f0" @@ -11182,11 +11035,6 @@ run-parallel@^1.1.9: dependencies: queue-microtask "^1.2.2" -rw@1: - version "1.3.3" - resolved "https://registry.yarnpkg.com/rw/-/rw-1.3.3.tgz#3f862dfa91ab766b14885ef4d01124bfda074fb4" - integrity sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ== - rxjs@^6.6.3: version "6.6.7" resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-6.6.7.tgz#90ac018acabf491bf65044235d5863c4dab804c9" @@ -11211,7 +11059,7 @@ safe-buffer@5.2.1, safe-buffer@>=5.1.0, safe-buffer@^5.0.1, safe-buffer@^5.1.0, resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== -"safer-buffer@>= 2.1.2 < 3", "safer-buffer@>= 2.1.2 < 3.0.0": +"safer-buffer@>= 2.1.2 < 3": version "2.1.2" resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== From 89c3ea559c166669b618fbd4e5ba8bde7250e479 Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Wed, 28 Sep 2022 01:18:11 -0700 Subject: [PATCH 023/135] Clamp time range in history chart scales (#952) --- web/components/charts/contract/binary.tsx | 2 +- web/components/charts/contract/choice.tsx | 2 +- web/components/charts/contract/pseudo-numeric.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/web/components/charts/contract/binary.tsx b/web/components/charts/contract/binary.tsx index 372577c4..6d906998 100644 --- a/web/components/charts/contract/binary.tsx +++ b/web/components/charts/contract/binary.tsx @@ -56,7 +56,7 @@ export const BinaryContractChart = (props: { const containerRef = useRef<HTMLDivElement>(null) const width = useElementWidth(containerRef) ?? 0 const height = props.height ?? (isMobile ? 250 : 350) - const xScale = scaleTime(visibleRange, [0, width - MARGIN_X]) + const xScale = scaleTime(visibleRange, [0, width - MARGIN_X]).clamp(true) const yScale = scaleLinear([0, 1], [height - MARGIN_Y, 0]) return ( <div ref={containerRef}> diff --git a/web/components/charts/contract/choice.tsx b/web/components/charts/contract/choice.tsx index 5786b7bb..7cf3e5ed 100644 --- a/web/components/charts/contract/choice.tsx +++ b/web/components/charts/contract/choice.tsx @@ -159,7 +159,7 @@ export const ChoiceContractChart = (props: { const containerRef = useRef<HTMLDivElement>(null) const width = useElementWidth(containerRef) ?? 0 const height = props.height ?? (isMobile ? 150 : 250) - const xScale = scaleTime(visibleRange, [0, width - MARGIN_X]) + const xScale = scaleTime(visibleRange, [0, width - MARGIN_X]).clamp(true) const yScale = scaleLinear([0, 1], [height - MARGIN_Y, 0]) return ( <div ref={containerRef}> diff --git a/web/components/charts/contract/pseudo-numeric.tsx b/web/components/charts/contract/pseudo-numeric.tsx index 987a7fea..0e2aaad0 100644 --- a/web/components/charts/contract/pseudo-numeric.tsx +++ b/web/components/charts/contract/pseudo-numeric.tsx @@ -71,7 +71,7 @@ export const PseudoNumericContractChart = (props: { const containerRef = useRef<HTMLDivElement>(null) const width = useElementWidth(containerRef) ?? 0 const height = props.height ?? (isMobile ? 150 : 250) - const xScale = scaleTime(visibleRange, [0, width - MARGIN_X]) + const xScale = scaleTime(visibleRange, [0, width - MARGIN_X]).clamp(true) const yScale = contract.isLogScale ? scaleLog( [Math.max(contract.min, 1), contract.max], From 513cf7b290bf84b2c98bed69fe957a9cd2da9829 Mon Sep 17 00:00:00 2001 From: ingawei <ingawei@gmail.com> Date: Wed, 28 Sep 2022 06:45:32 -0700 Subject: [PATCH 024/135] added order book --- web/components/bet-button.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/web/components/bet-button.tsx b/web/components/bet-button.tsx index 808b450f..622192c7 100644 --- a/web/components/bet-button.tsx +++ b/web/components/bet-button.tsx @@ -17,6 +17,7 @@ import { BetSignUpPrompt } from './sign-up-prompt' import { User } from 'web/lib/firebase/users' import { SellRow } from './sell-row' import { useUnfilledBets } from 'web/hooks/use-bets' +import { LimitBets, OrderBookButton } from './limit-bets' /** Button that opens BetPanel in a new modal */ export default function BetButton(props: { @@ -115,6 +116,11 @@ export function SignedInBinaryMobileBetting(props: { 'border-greyscale-3 bg-greyscale-1 rounded-md border-2 px-4 py-2' } /> + <LimitBets + className="mt-4" + contract={contract as CPMMBinaryContract} + bets={unfilledBets} + /> </Col> </> ) From e0e6838711131419a352a1be66f2b18d38d9bb12 Mon Sep 17 00:00:00 2001 From: ingawei <ingawei@users.noreply.github.com> Date: Wed, 28 Sep 2022 13:46:41 +0000 Subject: [PATCH 025/135] Auto-remove unused imports --- web/components/bet-button.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/components/bet-button.tsx b/web/components/bet-button.tsx index 622192c7..e51fc527 100644 --- a/web/components/bet-button.tsx +++ b/web/components/bet-button.tsx @@ -17,7 +17,7 @@ import { BetSignUpPrompt } from './sign-up-prompt' import { User } from 'web/lib/firebase/users' import { SellRow } from './sell-row' import { useUnfilledBets } from 'web/hooks/use-bets' -import { LimitBets, OrderBookButton } from './limit-bets' +import { LimitBets } from './limit-bets' /** Button that opens BetPanel in a new modal */ export default function BetButton(props: { From 7c8e977d60f6f2ca49631c717823e3c58253ea3e Mon Sep 17 00:00:00 2001 From: ingawei <46611122+ingawei@users.noreply.github.com> Date: Wed, 28 Sep 2022 09:04:47 -0500 Subject: [PATCH 026/135] order book things (#953) Adding order book to limit orders in mobile modal. This is pretty ugly and just a quick fix because people are complaining. --- web/components/bet-button.tsx | 6 ------ web/components/bet-panel.tsx | 7 ++++++- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/web/components/bet-button.tsx b/web/components/bet-button.tsx index e51fc527..808b450f 100644 --- a/web/components/bet-button.tsx +++ b/web/components/bet-button.tsx @@ -17,7 +17,6 @@ import { BetSignUpPrompt } from './sign-up-prompt' import { User } from 'web/lib/firebase/users' import { SellRow } from './sell-row' import { useUnfilledBets } from 'web/hooks/use-bets' -import { LimitBets } from './limit-bets' /** Button that opens BetPanel in a new modal */ export default function BetButton(props: { @@ -116,11 +115,6 @@ export function SignedInBinaryMobileBetting(props: { 'border-greyscale-3 bg-greyscale-1 rounded-md border-2 px-4 py-2' } /> - <LimitBets - className="mt-4" - contract={contract as CPMMBinaryContract} - bets={unfilledBets} - /> </Col> </> ) diff --git a/web/components/bet-panel.tsx b/web/components/bet-panel.tsx index 90918283..5d908937 100644 --- a/web/components/bet-panel.tsx +++ b/web/components/bet-panel.tsx @@ -419,7 +419,7 @@ export function BuyPanel(props: { open={seeLimit} setOpen={setSeeLimit} position="center" - className="rounded-lg bg-white px-4 pb-8" + className="rounded-lg bg-white px-4 pb-4" > <Title text="Limit Order" /> <LimitOrderPanel @@ -428,6 +428,11 @@ export function BuyPanel(props: { user={user} unfilledBets={unfilledBets} /> + <LimitBets + contract={contract} + bets={unfilledBets as LimitBet[]} + className="mt-4" + /> </Modal> </Col> </Col> From dba938032f96d46bb0aa63b1ffcf89212270d02c Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Wed, 28 Sep 2022 09:52:16 -0400 Subject: [PATCH 027/135] Listen for updates on daily mover contract --- web/components/contract/prob-change-table.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/web/components/contract/prob-change-table.tsx b/web/components/contract/prob-change-table.tsx index 07b7c659..f54ad915 100644 --- a/web/components/contract/prob-change-table.tsx +++ b/web/components/contract/prob-change-table.tsx @@ -7,6 +7,7 @@ import { SiteLink } from '../site-link' import { Col } from '../layout/col' import { Row } from '../layout/row' import { LoadingIndicator } from '../loading-indicator' +import { useContractWithPreload } from 'web/hooks/use-contract' export function ProbChangeTable(props: { changes: CPMMContract[] | undefined @@ -59,7 +60,9 @@ export function ProbChangeRow(props: { contract: CPMMContract className?: string }) { - const { contract, className } = props + const { className } = props + const contract = + (useContractWithPreload(props.contract) as CPMMContract) ?? props.contract return ( <Row className={clsx( From eb762d9b9ee557ffddffc005b8931dff9e66f476 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Wed, 28 Sep 2022 12:28:39 -0400 Subject: [PATCH 028/135] Make loading more sequential for updateMetrics to prevent firebase error. --- functions/src/update-metrics.ts | 51 ++++++++++++++++++++++----------- 1 file changed, 35 insertions(+), 16 deletions(-) diff --git a/functions/src/update-metrics.ts b/functions/src/update-metrics.ts index d2b5f9b2..12f41453 100644 --- a/functions/src/update-metrics.ts +++ b/functions/src/update-metrics.ts @@ -17,7 +17,8 @@ import { computeVolume, } from '../../common/calculate-metrics' import { getProbability } from '../../common/calculate' -import { Group } from 'common/group' +import { Group } from '../../common/group' +import { batchedWaitAll } from '../../common/util/promise' const firestore = admin.firestore() @@ -27,28 +28,46 @@ export const updateMetrics = functions .onRun(updateMetricsCore) export async function updateMetricsCore() { - const [users, contracts, bets, allPortfolioHistories, groups] = - await Promise.all([ - getValues<User>(firestore.collection('users')), - getValues<Contract>(firestore.collection('contracts')), - getValues<Bet>(firestore.collectionGroup('bets')), - getValues<PortfolioMetrics>( - firestore - .collectionGroup('portfolioHistory') - .where('timestamp', '>', Date.now() - 31 * DAY_MS) // so it includes just over a month ago - ), - getValues<Group>(firestore.collection('groups')), - ]) + console.log('Loading users') + const users = await getValues<User>(firestore.collection('users')) + console.log('Loading contracts') + const contracts = await getValues<Contract>(firestore.collection('contracts')) + + console.log('Loading portfolio history') + const allPortfolioHistories = await getValues<PortfolioMetrics>( + firestore + .collectionGroup('portfolioHistory') + .where('timestamp', '>', Date.now() - 31 * DAY_MS) // so it includes just over a month ago + ) + + console.log('Loading groups') + const groups = await getValues<Group>(firestore.collection('groups')) + + console.log('Loading bets') + const contractBets = await batchedWaitAll( + contracts + .filter((c) => c.id) + .map( + (c) => () => + getValues<Bet>( + firestore.collection('contracts').doc(c.id).collection('bets') + ) + ), + 100 + ) + const bets = contractBets.flat() + + console.log('Loading group contracts') const contractsByGroup = await Promise.all( - groups.map((group) => { - return getValues( + groups.map((group) => + getValues( firestore .collection('groups') .doc(group.id) .collection('groupContracts') ) - }) + ) ) log( `Loaded ${users.length} users, ${contracts.length} contracts, and ${bets.length} bets.` From d55cedb36c46fa3ad9c9fd33f076b418d3f40c50 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Wed, 28 Sep 2022 13:11:26 -0400 Subject: [PATCH 029/135] Load comments via static props --- web/components/contract/contract-tabs.tsx | 9 +++++--- web/lib/firebase/comments.ts | 2 +- web/pages/[username]/[contractSlug].tsx | 27 ++++++++++++++++++----- 3 files changed, 29 insertions(+), 9 deletions(-) diff --git a/web/components/contract/contract-tabs.tsx b/web/components/contract/contract-tabs.tsx index bd3204ed..33a3c05a 100644 --- a/web/components/contract/contract-tabs.tsx +++ b/web/components/contract/contract-tabs.tsx @@ -23,13 +23,15 @@ import { HOUSE_LIQUIDITY_PROVIDER_ID, } from 'common/antes' import { buildArray } from 'common/util/array' +import { ContractComment } from 'common/comment' export function ContractTabs(props: { contract: Contract bets: Bet[] userBets: Bet[] + comments: ContractComment[] }) { - const { contract, bets, userBets } = props + const { contract, bets, userBets, comments } = props const yourTrades = ( <div> @@ -42,7 +44,7 @@ export function ContractTabs(props: { const tabs = buildArray( { title: 'Comments', - content: <CommentsTabContent contract={contract} />, + content: <CommentsTabContent contract={contract} comments={comments} />, }, { title: capitalize(PAST_BETS), @@ -61,10 +63,11 @@ export function ContractTabs(props: { const CommentsTabContent = memo(function CommentsTabContent(props: { contract: Contract + comments: ContractComment[] }) { const { contract } = props const tips = useTipTxns({ contractId: contract.id }) - const comments = useComments(contract.id) + const comments = useComments(contract.id) ?? props.comments if (comments == null) { return <LoadingIndicator /> } diff --git a/web/lib/firebase/comments.ts b/web/lib/firebase/comments.ts index db4e8ede..733a1e06 100644 --- a/web/lib/firebase/comments.ts +++ b/web/lib/firebase/comments.ts @@ -131,7 +131,7 @@ function getCommentsOnPostCollection(postId: string) { } export async function listAllComments(contractId: string) { - return await getValues<Comment>( + return await getValues<ContractComment>( query(getCommentsCollection(contractId), orderBy('createdTime', 'desc')) ) } diff --git a/web/pages/[username]/[contractSlug].tsx b/web/pages/[username]/[contractSlug].tsx index 1dde2f95..93b53447 100644 --- a/web/pages/[username]/[contractSlug].tsx +++ b/web/pages/[username]/[contractSlug].tsx @@ -46,6 +46,8 @@ import { BetSignUpPrompt } from 'web/components/sign-up-prompt' import { PlayMoneyDisclaimer } from 'web/components/play-money-disclaimer' import BetButton from 'web/components/bet-button' import { BetsSummary } from 'web/components/bet-summary' +import { listAllComments } from 'web/lib/firebase/comments' +import { ContractComment } from 'common/comment' export const getStaticProps = fromPropz(getStaticPropz) export async function getStaticPropz(props: { @@ -55,10 +57,15 @@ export async function getStaticPropz(props: { const contract = (await getContractFromSlug(contractSlug)) || null const contractId = contract?.id const bets = contractId ? await listAllBets(contractId) : [] + const comments = contractId ? await listAllComments(contractId) : [] return { - // Limit the data sent to the client. Client will still load all bets directly. - props: { contract, bets: bets.slice(0, 5000) }, + props: { + contract, + // Limit the data sent to the client. Client will still load all bets/comments directly. + bets: bets.slice(0, 5000), + comments: comments.slice(0, 1000), + }, revalidate: 5, // regenerate after five seconds } } @@ -70,9 +77,14 @@ export async function getStaticPaths() { export default function ContractPage(props: { contract: Contract | null bets: Bet[] + comments: ContractComment[] backToHome?: () => void }) { - props = usePropz(props, getStaticPropz) ?? { contract: null, bets: [] } + props = usePropz(props, getStaticPropz) ?? { + contract: null, + bets: [], + comments: [], + } const inIframe = useIsIframe() if (inIframe) { @@ -147,7 +159,7 @@ export function ContractPageContent( contract: Contract } ) { - const { backToHome } = props + const { backToHome, comments } = props const contract = useContractWithPreload(props.contract) ?? props.contract const user = useUser() usePrefetch(user?.id) @@ -258,7 +270,12 @@ export function ContractPageContent( userBets={userBets} /> - <ContractTabs contract={contract} bets={bets} userBets={userBets} /> + <ContractTabs + contract={contract} + bets={bets} + userBets={userBets} + comments={comments} + /> {!user ? ( <Col className="mt-4 max-w-sm items-center xl:hidden"> From 83de206e9e08bbc61414b30aa52f471cea4b75fe Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Wed, 28 Sep 2022 14:20:28 -0700 Subject: [PATCH 030/135] Simply don't print zero (#954) --- web/components/charts/contract/binary.tsx | 2 +- web/components/charts/contract/choice.tsx | 2 +- web/components/charts/contract/numeric.tsx | 2 +- web/components/charts/contract/pseudo-numeric.tsx | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/web/components/charts/contract/binary.tsx b/web/components/charts/contract/binary.tsx index 6d906998..8f0f8c9a 100644 --- a/web/components/charts/contract/binary.tsx +++ b/web/components/charts/contract/binary.tsx @@ -60,7 +60,7 @@ export const BinaryContractChart = (props: { const yScale = scaleLinear([0, 1], [height - MARGIN_Y, 0]) return ( <div ref={containerRef}> - {width && ( + {width > 0 && ( <SingleValueHistoryChart w={width} h={height} diff --git a/web/components/charts/contract/choice.tsx b/web/components/charts/contract/choice.tsx index 7cf3e5ed..56ab018e 100644 --- a/web/components/charts/contract/choice.tsx +++ b/web/components/charts/contract/choice.tsx @@ -163,7 +163,7 @@ export const ChoiceContractChart = (props: { const yScale = scaleLinear([0, 1], [height - MARGIN_Y, 0]) return ( <div ref={containerRef}> - {width && ( + {width > 0 && ( <MultiValueHistoryChart w={width} h={height} diff --git a/web/components/charts/contract/numeric.tsx b/web/components/charts/contract/numeric.tsx index 6b574f15..d19147f6 100644 --- a/web/components/charts/contract/numeric.tsx +++ b/web/components/charts/contract/numeric.tsx @@ -37,7 +37,7 @@ export const NumericContractChart = (props: { const yScale = scaleLinear([0, maxY], [height - MARGIN_Y, 0]) return ( <div ref={containerRef}> - {width && ( + {width > 0 && ( <SingleValueDistributionChart w={width} h={height} diff --git a/web/components/charts/contract/pseudo-numeric.tsx b/web/components/charts/contract/pseudo-numeric.tsx index 0e2aaad0..db78cfec 100644 --- a/web/components/charts/contract/pseudo-numeric.tsx +++ b/web/components/charts/contract/pseudo-numeric.tsx @@ -81,7 +81,7 @@ export const PseudoNumericContractChart = (props: { return ( <div ref={containerRef}> - {width && ( + {width > 0 && ( <SingleValueHistoryChart w={width} h={height} From 1f2c7271b795863abdeee72e971657c8d6655b9a Mon Sep 17 00:00:00 2001 From: Sinclair Chen <abc.sinclair@gmail.com> Date: Wed, 28 Sep 2022 14:30:00 -0700 Subject: [PATCH 031/135] put edit profile z-index below side menu --- web/components/user-page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/components/user-page.tsx b/web/components/user-page.tsx index fde75607..623b4d35 100644 --- a/web/components/user-page.tsx +++ b/web/components/user-page.tsx @@ -83,7 +83,7 @@ export function UserPage(props: { user: User }) { className="bg-white shadow-sm shadow-indigo-300" /> {isCurrentUser && ( - <div className="absolute z-50 ml-16 mt-16 rounded-full bg-indigo-600 p-2 text-white shadow-sm shadow-indigo-300"> + <div className="absolute ml-16 mt-16 rounded-full bg-indigo-600 p-2 text-white shadow-sm shadow-indigo-300"> <SiteLink href="/profile"> <PencilIcon className="h-5" />{' '} </SiteLink> From 7f7e7acd61bb3b5a035449d0d8c125c25be524e9 Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Wed, 28 Sep 2022 18:03:30 -0700 Subject: [PATCH 032/135] Make binary and pseudonumeric charts re-render less on contract diff (#955) --- web/components/charts/contract/binary.tsx | 25 ++++----- .../charts/contract/pseudo-numeric.tsx | 54 +++++++++---------- 2 files changed, 34 insertions(+), 45 deletions(-) diff --git a/web/components/charts/contract/binary.tsx b/web/components/charts/contract/binary.tsx index 8f0f8c9a..aa79c354 100644 --- a/web/components/charts/contract/binary.tsx +++ b/web/components/charts/contract/binary.tsx @@ -3,7 +3,7 @@ import { last, sortBy } from 'lodash' import { scaleTime, scaleLinear } from 'd3-scale' import { Bet } from 'common/bet' -import { getInitialProbability, getProbability } from 'common/calculate' +import { getProbability, getInitialProbability } from 'common/calculate' import { BinaryContract } from 'common/contract' import { useIsMobile } from 'web/hooks/use-is-mobile' import { @@ -22,36 +22,31 @@ const getBetPoints = (bets: Bet[]) => { ) } -const getStartPoint = (contract: BinaryContract, start: Date) => { - return [start, getInitialProbability(contract)] as const -} - -const getEndPoint = (contract: BinaryContract, end: Date) => { - return [end, getProbability(contract)] as const -} - export const BinaryContractChart = (props: { contract: BinaryContract bets: Bet[] height?: number }) => { const { contract, bets } = props - const [contractStart, contractEnd] = getDateRange(contract) + const [startDate, endDate] = getDateRange(contract) + const startP = getInitialProbability(contract) + const endP = getProbability(contract) const betPoints = useMemo(() => getBetPoints(bets), [bets]) const data = useMemo( () => [ - getStartPoint(contract, contractStart), + [startDate, startP] as const, ...betPoints, - getEndPoint(contract, contractEnd ?? MAX_DATE), + [endDate ?? MAX_DATE, endP] as const, ], - [contract, betPoints, contractStart, contractEnd] + [startDate, startP, endDate, endP, betPoints] ) + const rightmostDate = getRightmostVisibleDate( - contractEnd, + endDate, last(betPoints)?.[0], new Date(Date.now()) ) - const visibleRange = [contractStart, rightmostDate] + const visibleRange = [startDate, rightmostDate] const isMobile = useIsMobile(800) const containerRef = useRef<HTMLDivElement>(null) const width = useElementWidth(containerRef) ?? 0 diff --git a/web/components/charts/contract/pseudo-numeric.tsx b/web/components/charts/contract/pseudo-numeric.tsx index db78cfec..2b23eb4d 100644 --- a/web/components/charts/contract/pseudo-numeric.tsx +++ b/web/components/charts/contract/pseudo-numeric.tsx @@ -21,63 +21,57 @@ import { useElementWidth } from 'web/hooks/use-element-width' // contracts. the values are stored "linearly" and can include zero. // as a result, we have to do some weird-looking stuff in this code -const getY = (p: number, contract: PseudoNumericContract) => { - const { min, max, isLogScale } = contract - return isLogScale - ? 10 ** (p * Math.log10(max - min + 1)) + min - 1 - : p * (max - min) + min +const getScaleP = (min: number, max: number, isLogScale: boolean) => { + return (p: number) => + isLogScale + ? 10 ** (p * Math.log10(max - min + 1)) + min - 1 + : p * (max - min) + min } -const getBetPoints = (contract: PseudoNumericContract, bets: Bet[]) => { +const getBetPoints = (bets: Bet[], scaleP: (p: number) => number) => { return sortBy(bets, (b) => b.createdTime).map( - (b) => [new Date(b.createdTime), getY(b.probAfter, contract)] as const + (b) => [new Date(b.createdTime), scaleP(b.probAfter)] as const ) } -const getStartPoint = (contract: PseudoNumericContract, start: Date) => { - return [start, getY(getInitialProbability(contract), contract)] as const -} - -const getEndPoint = (contract: PseudoNumericContract, end: Date) => { - return [end, getY(getProbability(contract), contract)] as const -} - export const PseudoNumericContractChart = (props: { contract: PseudoNumericContract bets: Bet[] height?: number }) => { const { contract, bets } = props - const [contractStart, contractEnd] = getDateRange(contract) - const betPoints = useMemo( - () => getBetPoints(contract, bets), - [contract, bets] + const { min, max, isLogScale } = contract + const [startDate, endDate] = getDateRange(contract) + const scaleP = useMemo( + () => getScaleP(min, max, isLogScale), + [min, max, isLogScale] ) + const startP = scaleP(getInitialProbability(contract)) + const endP = scaleP(getProbability(contract)) + const betPoints = useMemo(() => getBetPoints(bets, scaleP), [bets, scaleP]) const data = useMemo( () => [ - getStartPoint(contract, contractStart), + [startDate, startP] as const, ...betPoints, - getEndPoint(contract, contractEnd ?? MAX_DATE), + [endDate ?? MAX_DATE, endP] as const, ], - [contract, betPoints, contractStart, contractEnd] + [betPoints, startDate, startP, endDate, endP] ) const rightmostDate = getRightmostVisibleDate( - contractEnd, + endDate, last(betPoints)?.[0], new Date(Date.now()) ) - const visibleRange = [contractStart, rightmostDate] + const visibleRange = [startDate, rightmostDate] const isMobile = useIsMobile(800) const containerRef = useRef<HTMLDivElement>(null) const width = useElementWidth(containerRef) ?? 0 const height = props.height ?? (isMobile ? 150 : 250) const xScale = scaleTime(visibleRange, [0, width - MARGIN_X]).clamp(true) - const yScale = contract.isLogScale - ? scaleLog( - [Math.max(contract.min, 1), contract.max], - [height - MARGIN_Y, 0] - ).clamp(true) // make sure zeroes go to the bottom - : scaleLinear([contract.min, contract.max], [height - MARGIN_Y, 0]) + // clamp log scale to make sure zeroes go to the bottom + const yScale = isLogScale + ? scaleLog([Math.max(min, 1), max], [height - MARGIN_Y, 0]).clamp(true) + : scaleLinear([min, max], [height - MARGIN_Y, 0]) return ( <div ref={containerRef}> From be010da9f567efcc55a9830190b8a538f0f4ac4f Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Wed, 28 Sep 2022 21:14:34 -0700 Subject: [PATCH 033/135] Refactor chart tooltip stuff, add bet avatar to tooltips (#958) * Use objects instead of tuples for chart data * Carry bet data down into charts * Refactor to invert control of chart tooltip display * Jazz up the chart tooltips with avatars * Tidying --- web/components/avatar.tsx | 5 +- web/components/charts/contract/binary.tsx | 35 ++- web/components/charts/contract/choice.tsx | 83 ++++--- web/components/charts/contract/numeric.tsx | 33 ++- .../charts/contract/pseudo-numeric.tsx | 36 ++- web/components/charts/generic-charts.tsx | 235 ++++++------------ web/components/charts/helpers.tsx | 70 +++++- 7 files changed, 288 insertions(+), 209 deletions(-) diff --git a/web/components/avatar.tsx b/web/components/avatar.tsx index abb67d46..27861909 100644 --- a/web/components/avatar.tsx +++ b/web/components/avatar.tsx @@ -8,13 +8,14 @@ export function Avatar(props: { username?: string avatarUrl?: string noLink?: boolean - size?: number | 'xs' | 'sm' + size?: number | 'xxs' | 'xs' | 'sm' className?: string }) { const { username, noLink, size, className } = props const [avatarUrl, setAvatarUrl] = useState(props.avatarUrl) useEffect(() => setAvatarUrl(props.avatarUrl), [props.avatarUrl]) - const s = size == 'xs' ? 6 : size === 'sm' ? 8 : size || 10 + const s = + size == 'xxs' ? 4 : size == 'xs' ? 6 : size === 'sm' ? 8 : size || 10 const sizeInPx = s * 4 const onClick = diff --git a/web/components/charts/contract/binary.tsx b/web/components/charts/contract/binary.tsx index aa79c354..74ac472b 100644 --- a/web/components/charts/contract/binary.tsx +++ b/web/components/charts/contract/binary.tsx @@ -12,13 +12,34 @@ import { MAX_DATE, getDateRange, getRightmostVisibleDate, + formatDateInRange, + formatPct, } from '../helpers' -import { SingleValueHistoryChart } from '../generic-charts' +import { + SingleValueHistoryTooltipProps, + SingleValueHistoryChart, +} from '../generic-charts' import { useElementWidth } from 'web/hooks/use-element-width' +import { Row } from 'web/components/layout/row' +import { Avatar } from 'web/components/avatar' const getBetPoints = (bets: Bet[]) => { - return sortBy(bets, (b) => b.createdTime).map( - (b) => [new Date(b.createdTime), b.probAfter] as const + return sortBy(bets, (b) => b.createdTime).map((b) => ({ + x: new Date(b.createdTime), + y: b.probAfter, + datum: b, + })) +} + +const BinaryChartTooltip = (props: SingleValueHistoryTooltipProps<Bet>) => { + const { x, y, xScale, datum } = props + const [start, end] = xScale.domain() + return ( + <Row className="items-center gap-2 text-sm"> + {datum && <Avatar size="xs" avatarUrl={datum.userAvatarUrl} />} + <strong>{formatPct(y)}</strong> + <span>{formatDateInRange(x, start, end)}</span> + </Row> ) } @@ -34,16 +55,16 @@ export const BinaryContractChart = (props: { const betPoints = useMemo(() => getBetPoints(bets), [bets]) const data = useMemo( () => [ - [startDate, startP] as const, + { x: startDate, y: startP }, ...betPoints, - [endDate ?? MAX_DATE, endP] as const, + { x: endDate ?? MAX_DATE, y: endP }, ], [startDate, startP, endDate, endP, betPoints] ) const rightmostDate = getRightmostVisibleDate( endDate, - last(betPoints)?.[0], + last(betPoints)?.x, new Date(Date.now()) ) const visibleRange = [startDate, rightmostDate] @@ -53,6 +74,7 @@ export const BinaryContractChart = (props: { const height = props.height ?? (isMobile ? 250 : 350) const xScale = scaleTime(visibleRange, [0, width - MARGIN_X]).clamp(true) const yScale = scaleLinear([0, 1], [height - MARGIN_Y, 0]) + return ( <div ref={containerRef}> {width > 0 && ( @@ -63,6 +85,7 @@ export const BinaryContractChart = (props: { yScale={yScale} data={data} color="#11b981" + Tooltip={BinaryChartTooltip} pct /> )} diff --git a/web/components/charts/contract/choice.tsx b/web/components/charts/contract/choice.tsx index 56ab018e..08d20442 100644 --- a/web/components/charts/contract/choice.tsx +++ b/web/components/charts/contract/choice.tsx @@ -8,14 +8,23 @@ import { FreeResponseContract, MultipleChoiceContract } from 'common/contract' import { getOutcomeProbability } from 'common/calculate' import { useIsMobile } from 'web/hooks/use-is-mobile' import { + Legend, MARGIN_X, MARGIN_Y, MAX_DATE, getDateRange, getRightmostVisibleDate, + formatPct, + formatDateInRange, } from '../helpers' -import { MultiPoint, MultiValueHistoryChart } from '../generic-charts' +import { + MultiPoint, + MultiValueHistoryChart, + MultiValueHistoryTooltipProps, +} from '../generic-charts' import { useElementWidth } from 'web/hooks/use-element-width' +import { Row } from 'web/components/layout/row' +import { Avatar } from 'web/components/avatar' // thanks to https://observablehq.com/@jonhelfman/optimal-orders-for-choosing-categorical-colors const CATEGORY_COLORS = [ @@ -92,28 +101,13 @@ const getTrackedAnswers = ( ).slice(0, topN) } -const getStartPoint = (answers: Answer[], start: Date) => { - return [start, answers.map((_) => 0)] as const -} - -const getEndPoint = ( - answers: Answer[], - contract: FreeResponseContract | MultipleChoiceContract, - end: Date -) => { - return [ - end, - answers.map((a) => getOutcomeProbability(contract, a.id)), - ] as const -} - const getBetPoints = (answers: Answer[], bets: Bet[]) => { const sortedBets = sortBy(bets, (b) => b.createdTime) const betsByOutcome = groupBy(sortedBets, (bet) => bet.outcome) const sharesByOutcome = Object.fromEntries( Object.keys(betsByOutcome).map((outcome) => [outcome, 0]) ) - const points: MultiPoint[] = [] + const points: MultiPoint<Bet>[] = [] for (const bet of sortedBets) { const { outcome, shares } = bet sharesByOutcome[outcome] += shares @@ -121,10 +115,11 @@ const getBetPoints = (answers: Answer[], bets: Bet[]) => { const sharesSquared = sum( Object.values(sharesByOutcome).map((shares) => shares ** 2) ) - points.push([ - new Date(bet.createdTime), - answers.map((answer) => sharesByOutcome[answer.id] ** 2 / sharesSquared), - ]) + points.push({ + x: new Date(bet.createdTime), + y: answers.map((a) => sharesByOutcome[a.id] ** 2 / sharesSquared), + datum: bet, + }) } return points } @@ -135,7 +130,7 @@ export const ChoiceContractChart = (props: { height?: number }) => { const { contract, bets } = props - const [contractStart, contractEnd] = getDateRange(contract) + const [start, end] = getDateRange(contract) const answers = useMemo( () => getTrackedAnswers(contract, CATEGORY_COLORS.length), [contract] @@ -143,24 +138,54 @@ export const ChoiceContractChart = (props: { const betPoints = useMemo(() => getBetPoints(answers, bets), [answers, bets]) const data = useMemo( () => [ - getStartPoint(answers, contractStart), + { x: start, y: answers.map((_) => 0) }, ...betPoints, - getEndPoint(answers, contract, contractEnd ?? MAX_DATE), + { + x: end ?? MAX_DATE, + y: answers.map((a) => getOutcomeProbability(contract, a.id)), + }, ], - [answers, contract, betPoints, contractStart, contractEnd] + [answers, contract, betPoints, start, end] ) const rightmostDate = getRightmostVisibleDate( - contractEnd, - last(betPoints)?.[0], + end, + last(betPoints)?.x, new Date(Date.now()) ) - const visibleRange = [contractStart, rightmostDate] + const visibleRange = [start, rightmostDate] const isMobile = useIsMobile(800) const containerRef = useRef<HTMLDivElement>(null) const width = useElementWidth(containerRef) ?? 0 const height = props.height ?? (isMobile ? 150 : 250) const xScale = scaleTime(visibleRange, [0, width - MARGIN_X]).clamp(true) const yScale = scaleLinear([0, 1], [height - MARGIN_Y, 0]) + + const ChoiceTooltip = useMemo( + () => (props: MultiValueHistoryTooltipProps<Bet>) => { + const { x, y, xScale, datum } = props + const [start, end] = xScale.domain() + const legendItems = sortBy( + y.map((p, i) => ({ + color: CATEGORY_COLORS[i], + label: answers[i].text, + value: formatPct(p), + p, + })), + (item) => -item.p + ).slice(0, 10) + return ( + <div> + <Row className="items-center gap-2"> + {datum && <Avatar size="xxs" avatarUrl={datum.userAvatarUrl} />} + <span>{formatDateInRange(x, start, end)}</span> + </Row> + <Legend className="max-w-xs text-sm" items={legendItems} /> + </div> + ) + }, + [answers] + ) + return ( <div ref={containerRef}> {width > 0 && ( @@ -171,7 +196,7 @@ export const ChoiceContractChart = (props: { yScale={yScale} data={data} colors={CATEGORY_COLORS} - labels={answers.map((answer) => answer.text)} + Tooltip={ChoiceTooltip} pct /> )} diff --git a/web/components/charts/contract/numeric.tsx b/web/components/charts/contract/numeric.tsx index d19147f6..b45a6cca 100644 --- a/web/components/charts/contract/numeric.tsx +++ b/web/components/charts/contract/numeric.tsx @@ -1,21 +1,35 @@ import { useMemo, useRef } from 'react' -import { max, range } from 'lodash' +import { range } from 'lodash' import { scaleLinear } from 'd3-scale' +import { formatLargeNumber } from 'common/util/format' import { getDpmOutcomeProbabilities } from 'common/calculate-dpm' import { NumericContract } from 'common/contract' import { NUMERIC_GRAPH_COLOR } from 'common/numeric-constants' import { useIsMobile } from 'web/hooks/use-is-mobile' -import { MARGIN_X, MARGIN_Y } from '../helpers' -import { SingleValueDistributionChart } from '../generic-charts' +import { MARGIN_X, MARGIN_Y, formatPct } from '../helpers' +import { + SingleValueDistributionChart, + SingleValueDistributionTooltipProps, +} from '../generic-charts' import { useElementWidth } from 'web/hooks/use-element-width' const getNumericChartData = (contract: NumericContract) => { const { totalShares, bucketCount, min, max } = contract const step = (max - min) / bucketCount const bucketProbs = getDpmOutcomeProbabilities(totalShares) - return range(bucketCount).map( - (i) => [min + step * (i + 0.5), bucketProbs[`${i}`]] as const + return range(bucketCount).map((i) => ({ + x: min + step * (i + 0.5), + y: bucketProbs[`${i}`], + })) +} + +const NumericChartTooltip = (props: SingleValueDistributionTooltipProps) => { + const { x, y } = props + return ( + <span className="text-sm"> + <strong>{formatPct(y, 2)}</strong> {formatLargeNumber(x)} + </span> ) } @@ -24,16 +38,14 @@ export const NumericContractChart = (props: { height?: number }) => { const { contract } = props + const { min, max } = contract const data = useMemo(() => getNumericChartData(contract), [contract]) const isMobile = useIsMobile(800) const containerRef = useRef<HTMLDivElement>(null) const width = useElementWidth(containerRef) ?? 0 const height = props.height ?? (isMobile ? 150 : 250) - const maxY = max(data.map((d) => d[1])) as number - const xScale = scaleLinear( - [contract.min, contract.max], - [0, width - MARGIN_X] - ) + const maxY = Math.max(...data.map((d) => d.y)) + const xScale = scaleLinear([min, max], [0, width - MARGIN_X]) const yScale = scaleLinear([0, maxY], [height - MARGIN_Y, 0]) return ( <div ref={containerRef}> @@ -45,6 +57,7 @@ export const NumericContractChart = (props: { yScale={yScale} data={data} color={NUMERIC_GRAPH_COLOR} + Tooltip={NumericChartTooltip} /> )} </div> diff --git a/web/components/charts/contract/pseudo-numeric.tsx b/web/components/charts/contract/pseudo-numeric.tsx index 2b23eb4d..56359bc7 100644 --- a/web/components/charts/contract/pseudo-numeric.tsx +++ b/web/components/charts/contract/pseudo-numeric.tsx @@ -4,6 +4,7 @@ import { scaleTime, scaleLog, scaleLinear } from 'd3-scale' import { Bet } from 'common/bet' import { getInitialProbability, getProbability } from 'common/calculate' +import { formatLargeNumber } from 'common/util/format' import { PseudoNumericContract } from 'common/contract' import { NUMERIC_GRAPH_COLOR } from 'common/numeric-constants' import { useIsMobile } from 'web/hooks/use-is-mobile' @@ -13,9 +14,15 @@ import { MAX_DATE, getDateRange, getRightmostVisibleDate, + formatDateInRange, } from '../helpers' -import { SingleValueHistoryChart } from '../generic-charts' +import { + SingleValueHistoryChart, + SingleValueHistoryTooltipProps, +} from '../generic-charts' import { useElementWidth } from 'web/hooks/use-element-width' +import { Row } from 'web/components/layout/row' +import { Avatar } from 'web/components/avatar' // mqp: note that we have an idiosyncratic version of 'log scale' // contracts. the values are stored "linearly" and can include zero. @@ -29,8 +36,24 @@ const getScaleP = (min: number, max: number, isLogScale: boolean) => { } const getBetPoints = (bets: Bet[], scaleP: (p: number) => number) => { - return sortBy(bets, (b) => b.createdTime).map( - (b) => [new Date(b.createdTime), scaleP(b.probAfter)] as const + return sortBy(bets, (b) => b.createdTime).map((b) => ({ + x: new Date(b.createdTime), + y: scaleP(b.probAfter), + datum: b, + })) +} + +const PseudoNumericChartTooltip = ( + props: SingleValueHistoryTooltipProps<Bet> +) => { + const { x, y, xScale, datum } = props + const [start, end] = xScale.domain() + return ( + <Row className="items-center gap-2 text-sm"> + {datum && <Avatar size="xs" avatarUrl={datum.userAvatarUrl} />} + <strong>{formatLargeNumber(y)}</strong> + <span>{formatDateInRange(x, start, end)}</span> + </Row> ) } @@ -51,15 +74,15 @@ export const PseudoNumericContractChart = (props: { const betPoints = useMemo(() => getBetPoints(bets, scaleP), [bets, scaleP]) const data = useMemo( () => [ - [startDate, startP] as const, + { x: startDate, y: startP }, ...betPoints, - [endDate ?? MAX_DATE, endP] as const, + { x: endDate ?? MAX_DATE, y: endP }, ], [betPoints, startDate, startP, endDate, endP] ) const rightmostDate = getRightmostVisibleDate( endDate, - last(betPoints)?.[0], + last(betPoints)?.x, new Date(Date.now()) ) const visibleRange = [startDate, rightmostDate] @@ -82,6 +105,7 @@ export const PseudoNumericContractChart = (props: { xScale={xScale} yScale={yScale} data={data} + Tooltip={PseudoNumericChartTooltip} color={NUMERIC_GRAPH_COLOR} /> )} diff --git a/web/components/charts/generic-charts.tsx b/web/components/charts/generic-charts.tsx index 0d262e17..d9872b0e 100644 --- a/web/components/charts/generic-charts.tsx +++ b/web/components/charts/generic-charts.tsx @@ -11,127 +11,59 @@ import { stackOrderReverse, SeriesPoint, } from 'd3-shape' -import { range, sortBy } from 'lodash' -import dayjs from 'dayjs' +import { range } from 'lodash' import { SVGChart, AreaPath, AreaWithTopStroke, - ChartTooltip, + TooltipContent, + TooltipContainer, TooltipPosition, + formatPct, } from './helpers' -import { formatLargeNumber } from 'common/util/format' import { useEvent } from 'web/hooks/use-event' -import { Row } from 'web/components/layout/row' -export type MultiPoint = readonly [Date, number[]] // [time, [ordered outcome probs]] -export type HistoryPoint = readonly [Date, number] // [time, number or percentage] -export type DistributionPoint = readonly [number, number] // [outcome amount, prob] -export type PositionValue<P> = TooltipPosition & { p: P } +export type MultiPoint<T = never> = { x: Date; y: number[]; datum?: T } +export type HistoryPoint<T = never> = { x: Date; y: number; datum?: T } +export type DistributionPoint<T = never> = { x: number; y: number; datum?: T } -const formatPct = (n: number, digits?: number) => { - return `${(n * 100).toFixed(digits ?? 0)}%` -} - -const formatDate = ( - date: Date, - opts: { includeYear: boolean; includeHour: boolean; includeMinute: boolean } -) => { - const { includeYear, includeHour, includeMinute } = opts - const d = dayjs(date) - const now = Date.now() - if ( - d.add(1, 'minute').isAfter(now) && - d.subtract(1, 'minute').isBefore(now) - ) { - return 'Now' - } else { - const dayName = d.isSame(now, 'day') - ? 'Today' - : d.add(1, 'day').isSame(now, 'day') - ? 'Yesterday' - : null - let format = dayName ? `[${dayName}]` : 'MMM D' - if (includeMinute) { - format += ', h:mma' - } else if (includeHour) { - format += ', ha' - } else if (includeYear) { - format += ', YYYY' - } - return d.format(format) - } -} - -const getFormatterForDateRange = (start: Date, end: Date) => { - const opts = { - includeYear: !dayjs(start).isSame(end, 'year'), - includeHour: dayjs(start).add(8, 'day').isAfter(end), - includeMinute: dayjs(end).diff(start, 'hours') < 2, - } - return (d: Date) => formatDate(d, opts) -} +type PositionValue<P> = TooltipPosition & { p: P } const getTickValues = (min: number, max: number, n: number) => { const step = (max - min) / (n - 1) return [min, ...range(1, n - 1).map((i) => min + step * i), max] } -type LegendItem = { color: string; label: string; value?: string } - -const Legend = (props: { className?: string; items: LegendItem[] }) => { - const { items, className } = props - return ( - <ol className={className}> - {items.map((item) => ( - <li key={item.label} className="flex flex-row justify-between"> - <Row className="mr-2 items-center overflow-hidden"> - <span - className="mr-2 h-4 w-4 shrink-0" - style={{ backgroundColor: item.color }} - ></span> - <span className="overflow-hidden text-ellipsis">{item.label}</span> - </Row> - {item.value} - </li> - ))} - </ol> - ) -} - -export const SingleValueDistributionChart = (props: { - data: DistributionPoint[] +export const SingleValueDistributionChart = <T,>(props: { + data: DistributionPoint<T>[] w: number h: number color: string xScale: ScaleContinuousNumeric<number, number> yScale: ScaleContinuousNumeric<number, number> + Tooltip?: TooltipContent<SingleValueDistributionTooltipProps<T>> }) => { - const { color, data, yScale, w, h } = props + const { color, data, yScale, w, h, Tooltip } = props - // note that we have to type this funkily in order to succesfully store - // a function inside of useState const [viewXScale, setViewXScale] = useState<ScaleContinuousNumeric<number, number>>() const [mouseState, setMouseState] = - useState<PositionValue<DistributionPoint>>() + useState<PositionValue<DistributionPoint<T>>>() const xScale = viewXScale ?? props.xScale - const px = useCallback((p: DistributionPoint) => xScale(p[0]), [xScale]) + const px = useCallback((p: DistributionPoint<T>) => xScale(p.x), [xScale]) const py0 = yScale(yScale.domain()[0]) - const py1 = useCallback((p: DistributionPoint) => yScale(p[1]), [yScale]) - const xBisector = bisector((p: DistributionPoint) => p[0]) + const py1 = useCallback((p: DistributionPoint<T>) => yScale(p.y), [yScale]) + const xBisector = bisector((p: DistributionPoint<T>) => p.x) - const { fmtX, fmtY, xAxis, yAxis } = useMemo(() => { - const fmtX = (n: number) => formatLargeNumber(n) - const fmtY = (n: number) => formatPct(n, 2) + const { xAxis, yAxis } = useMemo(() => { const xAxis = axisBottom<number>(xScale).ticks(w / 100) - const yAxis = axisLeft<number>(yScale).tickFormat(fmtY) - return { fmtX, fmtY, xAxis, yAxis } + const yAxis = axisLeft<number>(yScale).tickFormat((n) => formatPct(n, 2)) + return { xAxis, yAxis } }, [w, xScale, yScale]) - const onSelect = useEvent((ev: D3BrushEvent<DistributionPoint>) => { + const onSelect = useEvent((ev: D3BrushEvent<DistributionPoint<T>>) => { if (ev.selection) { const [mouseX0, mouseX1] = ev.selection as [number, number] setViewXScale(() => @@ -154,8 +86,8 @@ export const SingleValueDistributionChart = (props: { // so your queryX is out of bounds return } - const [_x, y] = item - setMouseState({ top: mouseY - 10, left: mouseX + 60, p: [queryX, y] }) + const p = { x: queryX, y: item.y, datum: item.datum } + setMouseState({ top: mouseY - 10, left: mouseX + 60, p }) } }) @@ -165,10 +97,10 @@ export const SingleValueDistributionChart = (props: { return ( <div className="relative"> - {mouseState && ( - <ChartTooltip className="text-sm" {...mouseState}> - <strong>{fmtY(mouseState.p[1])}</strong> {fmtX(mouseState.p[0])} - </ChartTooltip> + {mouseState && Tooltip && ( + <TooltipContainer className="text-sm" {...mouseState}> + <Tooltip xScale={xScale} {...mouseState.p} /> + </TooltipContainer> )} <SVGChart w={w} @@ -192,52 +124,54 @@ export const SingleValueDistributionChart = (props: { ) } -export const MultiValueHistoryChart = (props: { - data: MultiPoint[] +export type SingleValueDistributionTooltipProps<T = unknown> = + DistributionPoint<T> & { + xScale: React.ComponentProps< + typeof SingleValueDistributionChart<T> + >['xScale'] + } + +export const MultiValueHistoryChart = <T,>(props: { + data: MultiPoint<T>[] w: number h: number - labels: readonly string[] colors: readonly string[] xScale: ScaleTime<number, number> yScale: ScaleContinuousNumeric<number, number> + Tooltip?: TooltipContent<MultiValueHistoryTooltipProps<T>> pct?: boolean }) => { - const { colors, data, yScale, labels, w, h, pct } = props + const { colors, data, yScale, w, h, Tooltip, pct } = props const [viewXScale, setViewXScale] = useState<ScaleTime<number, number>>() - const [mouseState, setMouseState] = useState<PositionValue<MultiPoint>>() + const [mouseState, setMouseState] = useState<PositionValue<MultiPoint<T>>>() const xScale = viewXScale ?? props.xScale - type SP = SeriesPoint<MultiPoint> - const px = useCallback((p: SP) => xScale(p.data[0]), [xScale]) + type SP = SeriesPoint<MultiPoint<T>> + const px = useCallback((p: SP) => xScale(p.data.x), [xScale]) const py0 = useCallback((p: SP) => yScale(p[0]), [yScale]) const py1 = useCallback((p: SP) => yScale(p[1]), [yScale]) - const xBisector = bisector((p: MultiPoint) => p[0]) - - const { fmtX, fmtY, xAxis, yAxis } = useMemo(() => { - const [start, end] = xScale.domain() - const fmtX = getFormatterForDateRange(start, end) - const fmtY = (n: number) => (pct ? formatPct(n, 0) : formatLargeNumber(n)) + const xBisector = bisector((p: MultiPoint<T>) => p.x) + const { xAxis, yAxis } = useMemo(() => { const [min, max] = yScale.domain() const pctTickValues = getTickValues(min, max, h < 200 ? 3 : 5) const xAxis = axisBottom<Date>(xScale).ticks(w / 100) const yAxis = pct - ? axisLeft<number>(yScale).tickValues(pctTickValues).tickFormat(fmtY) + ? axisLeft<number>(yScale).tickValues(pctTickValues).tickFormat(formatPct) : axisLeft<number>(yScale) - - return { fmtX, fmtY, xAxis, yAxis } + return { xAxis, yAxis } }, [w, h, pct, xScale, yScale]) const series = useMemo(() => { - const d3Stack = stack<MultiPoint, number>() - .keys(range(0, labels.length)) - .value(([_date, probs], o) => probs[o]) + const d3Stack = stack<MultiPoint<T>, number>() + .keys(range(0, Math.max(...data.map(({ y }) => y.length)))) + .value(({ y }, o) => y[o]) .order(stackOrderReverse) return d3Stack(data) - }, [data, labels.length]) + }, [data]) - const onSelect = useEvent((ev: D3BrushEvent<MultiPoint>) => { + const onSelect = useEvent((ev: D3BrushEvent<MultiPoint<T>>) => { if (ev.selection) { const [mouseX0, mouseX1] = ev.selection as [number, number] setViewXScale(() => @@ -260,8 +194,8 @@ export const MultiValueHistoryChart = (props: { // so your queryX is out of bounds return } - const [_x, ys] = item - setMouseState({ top: mouseY - 10, left: mouseX + 60, p: [queryX, ys] }) + const p = { x: queryX, y: item.y, datum: item.datum } + setMouseState({ top: mouseY - 10, left: mouseX + 60, p }) } }) @@ -269,24 +203,12 @@ export const MultiValueHistoryChart = (props: { setMouseState(undefined) }) - const mouseProbs = mouseState?.p[1] ?? [] - const legendItems = sortBy( - mouseProbs.map((p, i) => ({ - color: colors[i], - label: labels[i], - value: fmtY(p), - p, - })), - (item) => -item.p - ).slice(0, 10) - return ( <div className="relative"> - {mouseState && ( - <ChartTooltip {...mouseState}> - {fmtX(mouseState.p[0])} - <Legend className="max-w-xs text-sm" items={legendItems} /> - </ChartTooltip> + {mouseState && Tooltip && ( + <TooltipContainer top={mouseState.top} left={mouseState.left}> + <Tooltip xScale={xScale} {...mouseState.p} /> + </TooltipContainer> )} <SVGChart w={w} @@ -313,41 +235,42 @@ export const MultiValueHistoryChart = (props: { ) } -export const SingleValueHistoryChart = (props: { - data: HistoryPoint[] +export type MultiValueHistoryTooltipProps<T = unknown> = MultiPoint<T> & { + xScale: React.ComponentProps<typeof MultiValueHistoryChart<T>>['xScale'] +} + +export const SingleValueHistoryChart = <T,>(props: { + data: HistoryPoint<T>[] w: number h: number color: string xScale: ScaleTime<number, number> yScale: ScaleContinuousNumeric<number, number> + Tooltip?: TooltipContent<SingleValueHistoryTooltipProps<T>> pct?: boolean }) => { - const { color, data, pct, yScale, w, h } = props + const { color, data, pct, yScale, w, h, Tooltip } = props const [viewXScale, setViewXScale] = useState<ScaleTime<number, number>>() - const [mouseState, setMouseState] = useState<PositionValue<HistoryPoint>>() + const [mouseState, setMouseState] = useState<PositionValue<HistoryPoint<T>>>() const xScale = viewXScale ?? props.xScale - const px = useCallback((p: HistoryPoint) => xScale(p[0]), [xScale]) + const px = useCallback((p: HistoryPoint<T>) => xScale(p.x), [xScale]) const py0 = yScale(yScale.domain()[0]) - const py1 = useCallback((p: HistoryPoint) => yScale(p[1]), [yScale]) - const xBisector = bisector((p: HistoryPoint) => p[0]) - - const { fmtX, fmtY, xAxis, yAxis } = useMemo(() => { - const [start, end] = xScale.domain() - const fmtX = getFormatterForDateRange(start, end) - const fmtY = (n: number) => (pct ? formatPct(n, 0) : formatLargeNumber(n)) + const py1 = useCallback((p: HistoryPoint<T>) => yScale(p.y), [yScale]) + const xBisector = bisector((p: HistoryPoint<T>) => p.x) + const { xAxis, yAxis } = useMemo(() => { const [min, max] = yScale.domain() const pctTickValues = getTickValues(min, max, h < 200 ? 3 : 5) const xAxis = axisBottom<Date>(xScale).ticks(w / 100) const yAxis = pct - ? axisLeft<number>(yScale).tickValues(pctTickValues).tickFormat(fmtY) + ? axisLeft<number>(yScale).tickValues(pctTickValues).tickFormat(formatPct) : axisLeft<number>(yScale) - return { fmtX, fmtY, xAxis, yAxis } + return { xAxis, yAxis } }, [w, h, pct, xScale, yScale]) - const onSelect = useEvent((ev: D3BrushEvent<HistoryPoint>) => { + const onSelect = useEvent((ev: D3BrushEvent<HistoryPoint<T>>) => { if (ev.selection) { const [mouseX0, mouseX1] = ev.selection as [number, number] setViewXScale(() => @@ -370,8 +293,8 @@ export const SingleValueHistoryChart = (props: { // so your queryX is out of bounds return } - const [_x, y] = item - setMouseState({ top: mouseY - 10, left: mouseX + 60, p: [queryX, y] }) + const p = { x: queryX, y: item.y, datum: item.datum } + setMouseState({ top: mouseY - 10, left: mouseX + 60, p }) } }) @@ -381,10 +304,10 @@ export const SingleValueHistoryChart = (props: { return ( <div className="relative"> - {mouseState && ( - <ChartTooltip className="text-sm" {...mouseState}> - <strong>{fmtY(mouseState.p[1])}</strong> {fmtX(mouseState.p[0])} - </ChartTooltip> + {mouseState && Tooltip && ( + <TooltipContainer top={mouseState.top} left={mouseState.left}> + <Tooltip xScale={xScale} {...mouseState.p} /> + </TooltipContainer> )} <SVGChart w={w} @@ -407,3 +330,7 @@ export const SingleValueHistoryChart = (props: { </div> ) } + +export type SingleValueHistoryTooltipProps<T = unknown> = HistoryPoint<T> & { + xScale: React.ComponentProps<typeof SingleValueHistoryChart<T>>['xScale'] +} diff --git a/web/components/charts/helpers.tsx b/web/components/charts/helpers.tsx index 644a421c..2ed59ce2 100644 --- a/web/components/charts/helpers.tsx +++ b/web/components/charts/helpers.tsx @@ -4,9 +4,11 @@ import { Axis } from 'd3-axis' import { brushX, D3BrushEvent } from 'd3-brush' import { area, line, curveStepAfter, CurveFactory } from 'd3-shape' import { nanoid } from 'nanoid' +import dayjs from 'dayjs' import clsx from 'clsx' import { Contract } from 'common/contract' +import { Row } from 'web/components/layout/row' export const MARGIN = { top: 20, right: 10, bottom: 20, left: 40 } export const MARGIN_X = MARGIN.right + MARGIN.left @@ -180,9 +182,9 @@ export const SVGChart = <X, Y>(props: { ) } +export type TooltipContent<P> = React.ComponentType<P> export type TooltipPosition = { top: number; left: number } - -export const ChartTooltip = ( +export const TooltipContainer = ( props: TooltipPosition & { className?: string; children: React.ReactNode } ) => { const { top, left, className, children } = props @@ -199,6 +201,27 @@ export const ChartTooltip = ( ) } +export type LegendItem = { color: string; label: string; value?: string } +export const Legend = (props: { className?: string; items: LegendItem[] }) => { + const { items, className } = props + return ( + <ol className={className}> + {items.map((item) => ( + <li key={item.label} className="flex flex-row justify-between"> + <Row className="mr-2 items-center overflow-hidden"> + <span + className="mr-2 h-4 w-4 shrink-0" + style={{ backgroundColor: item.color }} + ></span> + <span className="overflow-hidden text-ellipsis">{item.label}</span> + </Row> + {item.value} + </li> + ))} + </ol> + ) +} + export const getDateRange = (contract: Contract) => { const { createdTime, closeTime, resolutionTime } = contract const isClosed = !!closeTime && Date.now() > closeTime @@ -220,3 +243,46 @@ export const getRightmostVisibleDate = ( return now } } + +export const formatPct = (n: number, digits?: number) => { + return `${(n * 100).toFixed(digits ?? 0)}%` +} + +export const formatDate = ( + date: Date, + opts: { includeYear: boolean; includeHour: boolean; includeMinute: boolean } +) => { + const { includeYear, includeHour, includeMinute } = opts + const d = dayjs(date) + const now = Date.now() + if ( + d.add(1, 'minute').isAfter(now) && + d.subtract(1, 'minute').isBefore(now) + ) { + return 'Now' + } else { + const dayName = d.isSame(now, 'day') + ? 'Today' + : d.add(1, 'day').isSame(now, 'day') + ? 'Yesterday' + : null + let format = dayName ? `[${dayName}]` : 'MMM D' + if (includeMinute) { + format += ', h:mma' + } else if (includeHour) { + format += ', ha' + } else if (includeYear) { + format += ', YYYY' + } + return d.format(format) + } +} + +export const formatDateInRange = (d: Date, start: Date, end: Date) => { + const opts = { + includeYear: !dayjs(start).isSame(end, 'year'), + includeHour: dayjs(start).add(8, 'day').isAfter(end), + includeMinute: dayjs(end).diff(start, 'hours') < 2, + } + return formatDate(d, opts) +} From 8862425120f25e422cf8ded9f7251f787efe5918 Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Wed, 28 Sep 2022 21:43:04 -0700 Subject: [PATCH 034/135] Clean up chart tooltip handling (#959) --- web/components/charts/contract/binary.tsx | 3 +- web/components/charts/contract/choice.tsx | 3 +- web/components/charts/contract/numeric.tsx | 3 +- .../charts/contract/pseudo-numeric.tsx | 3 +- web/components/charts/generic-charts.tsx | 256 +++++++----------- web/components/charts/helpers.tsx | 91 +++++-- 6 files changed, 173 insertions(+), 186 deletions(-) diff --git a/web/components/charts/contract/binary.tsx b/web/components/charts/contract/binary.tsx index 74ac472b..1f163266 100644 --- a/web/components/charts/contract/binary.tsx +++ b/web/components/charts/contract/binary.tsx @@ -32,7 +32,8 @@ const getBetPoints = (bets: Bet[]) => { } const BinaryChartTooltip = (props: SingleValueHistoryTooltipProps<Bet>) => { - const { x, y, xScale, datum } = props + const { p, xScale } = props + const { x, y, datum } = p const [start, end] = xScale.domain() return ( <Row className="items-center gap-2 text-sm"> diff --git a/web/components/charts/contract/choice.tsx b/web/components/charts/contract/choice.tsx index 08d20442..11b1f8c3 100644 --- a/web/components/charts/contract/choice.tsx +++ b/web/components/charts/contract/choice.tsx @@ -162,7 +162,8 @@ export const ChoiceContractChart = (props: { const ChoiceTooltip = useMemo( () => (props: MultiValueHistoryTooltipProps<Bet>) => { - const { x, y, xScale, datum } = props + const { p, xScale } = props + const { x, y, datum } = p const [start, end] = xScale.domain() const legendItems = sortBy( y.map((p, i) => ({ diff --git a/web/components/charts/contract/numeric.tsx b/web/components/charts/contract/numeric.tsx index b45a6cca..de1d1a0c 100644 --- a/web/components/charts/contract/numeric.tsx +++ b/web/components/charts/contract/numeric.tsx @@ -25,7 +25,8 @@ const getNumericChartData = (contract: NumericContract) => { } const NumericChartTooltip = (props: SingleValueDistributionTooltipProps) => { - const { x, y } = props + const { p } = props + const { x, y } = p return ( <span className="text-sm"> <strong>{formatPct(y, 2)}</strong> {formatLargeNumber(x)} diff --git a/web/components/charts/contract/pseudo-numeric.tsx b/web/components/charts/contract/pseudo-numeric.tsx index 56359bc7..fcfe9d38 100644 --- a/web/components/charts/contract/pseudo-numeric.tsx +++ b/web/components/charts/contract/pseudo-numeric.tsx @@ -46,7 +46,8 @@ const getBetPoints = (bets: Bet[], scaleP: (p: number) => number) => { const PseudoNumericChartTooltip = ( props: SingleValueHistoryTooltipProps<Bet> ) => { - const { x, y, xScale, datum } = props + const { p, xScale } = props + const { x, y, datum } = p const [start, end] = xScale.domain() return ( <Row className="items-center gap-2 text-sm"> diff --git a/web/components/charts/generic-charts.tsx b/web/components/charts/generic-charts.tsx index d9872b0e..8bbfc659 100644 --- a/web/components/charts/generic-charts.tsx +++ b/web/components/charts/generic-charts.tsx @@ -3,7 +3,6 @@ import { bisector } from 'd3-array' import { axisBottom, axisLeft } from 'd3-axis' import { D3BrushEvent } from 'd3-brush' import { ScaleTime, ScaleContinuousNumeric } from 'd3-scale' -import { pointer } from 'd3-selection' import { curveLinear, curveStepAfter, @@ -18,17 +17,25 @@ import { AreaPath, AreaWithTopStroke, TooltipContent, - TooltipContainer, - TooltipPosition, formatPct, } from './helpers' import { useEvent } from 'web/hooks/use-event' -export type MultiPoint<T = never> = { x: Date; y: number[]; datum?: T } -export type HistoryPoint<T = never> = { x: Date; y: number; datum?: T } -export type DistributionPoint<T = never> = { x: number; y: number; datum?: T } - -type PositionValue<P> = TooltipPosition & { p: P } +export type MultiPoint<T = never> = { + x: Date + y: number[] + datum?: T +} +export type HistoryPoint<T = never> = { + x: Date + y: number + datum?: T +} +export type DistributionPoint<T = never> = { + x: number + y: number + datum?: T +} const getTickValues = (min: number, max: number, n: number) => { const step = (max - min) / (n - 1) @@ -48,8 +55,6 @@ export const SingleValueDistributionChart = <T,>(props: { const [viewXScale, setViewXScale] = useState<ScaleContinuousNumeric<number, number>>() - const [mouseState, setMouseState] = - useState<PositionValue<DistributionPoint<T>>>() const xScale = viewXScale ?? props.xScale const px = useCallback((p: DistributionPoint<T>) => xScale(p.x), [xScale]) @@ -69,67 +74,48 @@ export const SingleValueDistributionChart = <T,>(props: { setViewXScale(() => xScale.copy().domain([xScale.invert(mouseX0), xScale.invert(mouseX1)]) ) - setMouseState(undefined) } else { setViewXScale(undefined) - setMouseState(undefined) } }) - const onMouseOver = useEvent((ev: React.PointerEvent) => { - if (ev.pointerType === 'mouse') { - const [mouseX, mouseY] = pointer(ev) - const queryX = xScale.invert(mouseX) - const item = data[xBisector.left(data, queryX) - 1] - if (item == null) { - // this can happen if you are on the very left or right edge of the chart, - // so your queryX is out of bounds - return - } - const p = { x: queryX, y: item.y, datum: item.datum } - setMouseState({ top: mouseY - 10, left: mouseX + 60, p }) + const onMouseOver = useEvent((mouseX: number) => { + const queryX = xScale.invert(mouseX) + const item = data[xBisector.left(data, queryX) - 1] + if (item == null) { + // this can happen if you are on the very left or right edge of the chart, + // so your queryX is out of bounds + return } - }) - - const onMouseLeave = useEvent(() => { - setMouseState(undefined) + return { x: queryX, y: item.y, datum: item.datum } }) return ( - <div className="relative"> - {mouseState && Tooltip && ( - <TooltipContainer className="text-sm" {...mouseState}> - <Tooltip xScale={xScale} {...mouseState.p} /> - </TooltipContainer> - )} - <SVGChart - w={w} - h={h} - xAxis={xAxis} - yAxis={yAxis} - onSelect={onSelect} - onMouseOver={onMouseOver} - onMouseLeave={onMouseLeave} - > - <AreaWithTopStroke - color={color} - data={data} - px={px} - py0={py0} - py1={py1} - curve={curveLinear} - /> - </SVGChart> - </div> + <SVGChart + w={w} + h={h} + xAxis={xAxis} + yAxis={yAxis} + onSelect={onSelect} + onMouseOver={onMouseOver} + Tooltip={Tooltip} + > + <AreaWithTopStroke + color={color} + data={data} + px={px} + py0={py0} + py1={py1} + curve={curveLinear} + /> + </SVGChart> ) } -export type SingleValueDistributionTooltipProps<T = unknown> = - DistributionPoint<T> & { - xScale: React.ComponentProps< - typeof SingleValueDistributionChart<T> - >['xScale'] - } +export type SingleValueDistributionTooltipProps<T = unknown> = { + p: DistributionPoint<T> + xScale: React.ComponentProps<typeof SingleValueDistributionChart<T>>['xScale'] +} export const MultiValueHistoryChart = <T,>(props: { data: MultiPoint<T>[] @@ -144,7 +130,6 @@ export const MultiValueHistoryChart = <T,>(props: { const { colors, data, yScale, w, h, Tooltip, pct } = props const [viewXScale, setViewXScale] = useState<ScaleTime<number, number>>() - const [mouseState, setMouseState] = useState<PositionValue<MultiPoint<T>>>() const xScale = viewXScale ?? props.xScale type SP = SeriesPoint<MultiPoint<T>> @@ -177,65 +162,49 @@ export const MultiValueHistoryChart = <T,>(props: { setViewXScale(() => xScale.copy().domain([xScale.invert(mouseX0), xScale.invert(mouseX1)]) ) - setMouseState(undefined) } else { setViewXScale(undefined) - setMouseState(undefined) } }) - const onMouseOver = useEvent((ev: React.PointerEvent) => { - if (ev.pointerType === 'mouse') { - const [mouseX, mouseY] = pointer(ev) - const queryX = xScale.invert(mouseX) - const item = data[xBisector.left(data, queryX) - 1] - if (item == null) { - // this can happen if you are on the very left or right edge of the chart, - // so your queryX is out of bounds - return - } - const p = { x: queryX, y: item.y, datum: item.datum } - setMouseState({ top: mouseY - 10, left: mouseX + 60, p }) + const onMouseOver = useEvent((mouseX: number) => { + const queryX = xScale.invert(mouseX) + const item = data[xBisector.left(data, queryX) - 1] + if (item == null) { + // this can happen if you are on the very left or right edge of the chart, + // so your queryX is out of bounds + return } - }) - - const onMouseLeave = useEvent(() => { - setMouseState(undefined) + return { x: queryX, y: item.y, datum: item.datum } }) return ( - <div className="relative"> - {mouseState && Tooltip && ( - <TooltipContainer top={mouseState.top} left={mouseState.left}> - <Tooltip xScale={xScale} {...mouseState.p} /> - </TooltipContainer> - )} - <SVGChart - w={w} - h={h} - xAxis={xAxis} - yAxis={yAxis} - onSelect={onSelect} - onMouseOver={onMouseOver} - onMouseLeave={onMouseLeave} - > - {series.map((s, i) => ( - <AreaPath - key={i} - data={s} - px={px} - py0={py0} - py1={py1} - curve={curveStepAfter} - fill={colors[i]} - /> - ))} - </SVGChart> - </div> + <SVGChart + w={w} + h={h} + xAxis={xAxis} + yAxis={yAxis} + onSelect={onSelect} + onMouseOver={onMouseOver} + Tooltip={Tooltip} + > + {series.map((s, i) => ( + <AreaPath + key={i} + data={s} + px={px} + py0={py0} + py1={py1} + curve={curveStepAfter} + fill={colors[i]} + /> + ))} + </SVGChart> ) } -export type MultiValueHistoryTooltipProps<T = unknown> = MultiPoint<T> & { +export type MultiValueHistoryTooltipProps<T = unknown> = { + p: MultiPoint<T> xScale: React.ComponentProps<typeof MultiValueHistoryChart<T>>['xScale'] } @@ -252,7 +221,6 @@ export const SingleValueHistoryChart = <T,>(props: { const { color, data, pct, yScale, w, h, Tooltip } = props const [viewXScale, setViewXScale] = useState<ScaleTime<number, number>>() - const [mouseState, setMouseState] = useState<PositionValue<HistoryPoint<T>>>() const xScale = viewXScale ?? props.xScale const px = useCallback((p: HistoryPoint<T>) => xScale(p.x), [xScale]) @@ -276,61 +244,45 @@ export const SingleValueHistoryChart = <T,>(props: { setViewXScale(() => xScale.copy().domain([xScale.invert(mouseX0), xScale.invert(mouseX1)]) ) - setMouseState(undefined) } else { setViewXScale(undefined) - setMouseState(undefined) } }) - const onMouseOver = useEvent((ev: React.PointerEvent) => { - if (ev.pointerType === 'mouse') { - const [mouseX, mouseY] = pointer(ev) - const queryX = xScale.invert(mouseX) - const item = data[xBisector.left(data, queryX) - 1] - if (item == null) { - // this can happen if you are on the very left or right edge of the chart, - // so your queryX is out of bounds - return - } - const p = { x: queryX, y: item.y, datum: item.datum } - setMouseState({ top: mouseY - 10, left: mouseX + 60, p }) + const onMouseOver = useEvent((mouseX: number) => { + const queryX = xScale.invert(mouseX) + const item = data[xBisector.left(data, queryX) - 1] + if (item == null) { + // this can happen if you are on the very left or right edge of the chart, + // so your queryX is out of bounds + return } - }) - - const onMouseLeave = useEvent(() => { - setMouseState(undefined) + return { x: queryX, y: item.y, datum: item.datum } }) return ( - <div className="relative"> - {mouseState && Tooltip && ( - <TooltipContainer top={mouseState.top} left={mouseState.left}> - <Tooltip xScale={xScale} {...mouseState.p} /> - </TooltipContainer> - )} - <SVGChart - w={w} - h={h} - xAxis={xAxis} - yAxis={yAxis} - onSelect={onSelect} - onMouseOver={onMouseOver} - onMouseLeave={onMouseLeave} - > - <AreaWithTopStroke - color={color} - data={data} - px={px} - py0={py0} - py1={py1} - curve={curveStepAfter} - /> - </SVGChart> - </div> + <SVGChart + w={w} + h={h} + xAxis={xAxis} + yAxis={yAxis} + onSelect={onSelect} + onMouseOver={onMouseOver} + Tooltip={Tooltip} + > + <AreaWithTopStroke + color={color} + data={data} + px={px} + py0={py0} + py1={py1} + curve={curveStepAfter} + /> + </SVGChart> ) } -export type SingleValueHistoryTooltipProps<T = unknown> = HistoryPoint<T> & { +export type SingleValueHistoryTooltipProps<T = unknown> = { + p: HistoryPoint<T> xScale: React.ComponentProps<typeof SingleValueHistoryChart<T>>['xScale'] } diff --git a/web/components/charts/helpers.tsx b/web/components/charts/helpers.tsx index 2ed59ce2..55bb6e90 100644 --- a/web/components/charts/helpers.tsx +++ b/web/components/charts/helpers.tsx @@ -1,5 +1,13 @@ -import { ReactNode, SVGProps, memo, useRef, useEffect, useMemo } from 'react' -import { select } from 'd3-selection' +import { + ReactNode, + SVGProps, + memo, + useRef, + useEffect, + useMemo, + useState, +} from 'react' +import { pointer, select } from 'd3-selection' import { Axis } from 'd3-axis' import { brushX, D3BrushEvent } from 'd3-brush' import { area, line, curveStepAfter, CurveFactory } from 'd3-shape' @@ -108,19 +116,18 @@ export const AreaWithTopStroke = <P,>(props: { ) } -export const SVGChart = <X, Y>(props: { +export const SVGChart = <X, Y, P, XS>(props: { children: ReactNode w: number h: number xAxis: Axis<X> yAxis: Axis<Y> onSelect?: (ev: D3BrushEvent<any>) => void - onMouseOver?: (ev: React.PointerEvent) => void - onMouseLeave?: (ev: React.PointerEvent) => void - pct?: boolean + onMouseOver?: (mouseX: number, mouseY: number) => P | undefined + Tooltip?: TooltipContent<{ xScale: XS } & { p: P }> }) => { - const { children, w, h, xAxis, yAxis, onMouseOver, onMouseLeave, onSelect } = - props + const { children, w, h, xAxis, yAxis, onMouseOver, onSelect, Tooltip } = props + const [mouseState, setMouseState] = useState<TooltipPosition & { p: P }>() const overlayRef = useRef<SVGGElement>(null) const innerW = w - MARGIN_X const innerH = h - MARGIN_Y @@ -139,6 +146,7 @@ export const SVGChart = <X, Y>(props: { if (!justSelected.current) { justSelected.current = true onSelect(ev) + setMouseState(undefined) if (overlayRef.current) { select(overlayRef.current).call(brush.clear) } @@ -156,29 +164,52 @@ export const SVGChart = <X, Y>(props: { } }, [innerW, innerH, onSelect]) + const onPointerMove = (ev: React.PointerEvent) => { + if (ev.pointerType === 'mouse' && onMouseOver) { + const [mouseX, mouseY] = pointer(ev) + const p = onMouseOver(mouseX, mouseY) + if (p != null) { + setMouseState({ top: mouseY - 10, left: mouseX + 60, p }) + } else { + setMouseState(undefined) + } + } + } + + const onPointerLeave = () => { + setMouseState(undefined) + } + return ( - <svg className="w-full" width={w} height={h} viewBox={`0 0 ${w} ${h}`}> - <clipPath id={clipPathId}> - <rect x={0} y={0} width={innerW} height={innerH} /> - </clipPath> - <g transform={`translate(${MARGIN.left}, ${MARGIN.top})`}> - <XAxis axis={xAxis} w={innerW} h={innerH} /> - <YAxis axis={yAxis} w={innerW} h={innerH} /> - <g clipPath={`url(#${clipPathId})`}>{children}</g> - <g - ref={overlayRef} - x="0" - y="0" - width={innerW} - height={innerH} - fill="none" - pointerEvents="all" - onPointerEnter={onMouseOver} - onPointerMove={onMouseOver} - onPointerLeave={onMouseLeave} - /> - </g> - </svg> + <div className="relative"> + {mouseState && Tooltip && ( + <TooltipContainer top={mouseState.top} left={mouseState.left}> + <Tooltip xScale={xAxis.scale() as XS} p={mouseState.p} /> + </TooltipContainer> + )} + <svg className="w-full" width={w} height={h} viewBox={`0 0 ${w} ${h}`}> + <clipPath id={clipPathId}> + <rect x={0} y={0} width={innerW} height={innerH} /> + </clipPath> + <g transform={`translate(${MARGIN.left}, ${MARGIN.top})`}> + <XAxis axis={xAxis} w={innerW} h={innerH} /> + <YAxis axis={yAxis} w={innerW} h={innerH} /> + <g clipPath={`url(#${clipPathId})`}>{children}</g> + <g + ref={overlayRef} + x="0" + y="0" + width={innerW} + height={innerH} + fill="none" + pointerEvents="all" + onPointerEnter={onPointerMove} + onPointerMove={onPointerMove} + onPointerLeave={onPointerLeave} + /> + </g> + </svg> + </div> ) } From 15cd8b1f9458572b9a1edd3bf8aa23adb1bdb9de Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Wed, 28 Sep 2022 23:27:42 -0700 Subject: [PATCH 035/135] Fix a couple small chart bugs (#960) * Fix time clamping causing little visual glitch * Fix tick formatting glitch --- web/components/charts/contract/binary.tsx | 6 ++--- web/components/charts/contract/choice.tsx | 6 ++--- .../charts/contract/pseudo-numeric.tsx | 6 ++--- web/components/charts/generic-charts.tsx | 26 +++++++------------ web/components/charts/helpers.tsx | 3 --- 5 files changed, 18 insertions(+), 29 deletions(-) diff --git a/web/components/charts/contract/binary.tsx b/web/components/charts/contract/binary.tsx index 1f163266..8a378799 100644 --- a/web/components/charts/contract/binary.tsx +++ b/web/components/charts/contract/binary.tsx @@ -5,11 +5,11 @@ import { scaleTime, scaleLinear } from 'd3-scale' import { Bet } from 'common/bet' import { getProbability, getInitialProbability } from 'common/calculate' import { BinaryContract } from 'common/contract' +import { DAY_MS } from 'common/util/time' import { useIsMobile } from 'web/hooks/use-is-mobile' import { MARGIN_X, MARGIN_Y, - MAX_DATE, getDateRange, getRightmostVisibleDate, formatDateInRange, @@ -58,7 +58,7 @@ export const BinaryContractChart = (props: { () => [ { x: startDate, y: startP }, ...betPoints, - { x: endDate ?? MAX_DATE, y: endP }, + { x: endDate ?? new Date(Date.now() + DAY_MS), y: endP }, ], [startDate, startP, endDate, endP, betPoints] ) @@ -73,7 +73,7 @@ export const BinaryContractChart = (props: { const containerRef = useRef<HTMLDivElement>(null) const width = useElementWidth(containerRef) ?? 0 const height = props.height ?? (isMobile ? 250 : 350) - const xScale = scaleTime(visibleRange, [0, width - MARGIN_X]).clamp(true) + const xScale = scaleTime(visibleRange, [0, width - MARGIN_X]) const yScale = scaleLinear([0, 1], [height - MARGIN_Y, 0]) return ( diff --git a/web/components/charts/contract/choice.tsx b/web/components/charts/contract/choice.tsx index 11b1f8c3..0811a2ed 100644 --- a/web/components/charts/contract/choice.tsx +++ b/web/components/charts/contract/choice.tsx @@ -7,11 +7,11 @@ import { Answer } from 'common/answer' import { FreeResponseContract, MultipleChoiceContract } from 'common/contract' import { getOutcomeProbability } from 'common/calculate' import { useIsMobile } from 'web/hooks/use-is-mobile' +import { DAY_MS } from 'common/util/time' import { Legend, MARGIN_X, MARGIN_Y, - MAX_DATE, getDateRange, getRightmostVisibleDate, formatPct, @@ -141,7 +141,7 @@ export const ChoiceContractChart = (props: { { x: start, y: answers.map((_) => 0) }, ...betPoints, { - x: end ?? MAX_DATE, + x: end ?? new Date(Date.now() + DAY_MS), y: answers.map((a) => getOutcomeProbability(contract, a.id)), }, ], @@ -157,7 +157,7 @@ export const ChoiceContractChart = (props: { const containerRef = useRef<HTMLDivElement>(null) const width = useElementWidth(containerRef) ?? 0 const height = props.height ?? (isMobile ? 150 : 250) - const xScale = scaleTime(visibleRange, [0, width - MARGIN_X]).clamp(true) + const xScale = scaleTime(visibleRange, [0, width - MARGIN_X]) const yScale = scaleLinear([0, 1], [height - MARGIN_Y, 0]) const ChoiceTooltip = useMemo( diff --git a/web/components/charts/contract/pseudo-numeric.tsx b/web/components/charts/contract/pseudo-numeric.tsx index fcfe9d38..f1b438dc 100644 --- a/web/components/charts/contract/pseudo-numeric.tsx +++ b/web/components/charts/contract/pseudo-numeric.tsx @@ -3,6 +3,7 @@ import { last, sortBy } from 'lodash' import { scaleTime, scaleLog, scaleLinear } from 'd3-scale' import { Bet } from 'common/bet' +import { DAY_MS } from 'common/util/time' import { getInitialProbability, getProbability } from 'common/calculate' import { formatLargeNumber } from 'common/util/format' import { PseudoNumericContract } from 'common/contract' @@ -11,7 +12,6 @@ import { useIsMobile } from 'web/hooks/use-is-mobile' import { MARGIN_X, MARGIN_Y, - MAX_DATE, getDateRange, getRightmostVisibleDate, formatDateInRange, @@ -77,7 +77,7 @@ export const PseudoNumericContractChart = (props: { () => [ { x: startDate, y: startP }, ...betPoints, - { x: endDate ?? MAX_DATE, y: endP }, + { x: endDate ?? new Date(Date.now() + DAY_MS), y: endP }, ], [betPoints, startDate, startP, endDate, endP] ) @@ -91,7 +91,7 @@ export const PseudoNumericContractChart = (props: { const containerRef = useRef<HTMLDivElement>(null) const width = useElementWidth(containerRef) ?? 0 const height = props.height ?? (isMobile ? 150 : 250) - const xScale = scaleTime(visibleRange, [0, width - MARGIN_X]).clamp(true) + const xScale = scaleTime(visibleRange, [0, width - MARGIN_X]) // clamp log scale to make sure zeroes go to the bottom const yScale = isLogScale ? scaleLog([Math.max(min, 1), max], [height - MARGIN_Y, 0]).clamp(true) diff --git a/web/components/charts/generic-charts.tsx b/web/components/charts/generic-charts.tsx index 8bbfc659..161721f1 100644 --- a/web/components/charts/generic-charts.tsx +++ b/web/components/charts/generic-charts.tsx @@ -21,21 +21,9 @@ import { } from './helpers' import { useEvent } from 'web/hooks/use-event' -export type MultiPoint<T = never> = { - x: Date - y: number[] - datum?: T -} -export type HistoryPoint<T = never> = { - x: Date - y: number - datum?: T -} -export type DistributionPoint<T = never> = { - x: number - y: number - datum?: T -} +export type MultiPoint<T = never> = { x: Date; y: number[]; datum?: T } +export type HistoryPoint<T = never> = { x: Date; y: number; datum?: T } +export type DistributionPoint<T = never> = { x: number; y: number; datum?: T } const getTickValues = (min: number, max: number, n: number) => { const step = (max - min) / (n - 1) @@ -143,7 +131,9 @@ export const MultiValueHistoryChart = <T,>(props: { const pctTickValues = getTickValues(min, max, h < 200 ? 3 : 5) const xAxis = axisBottom<Date>(xScale).ticks(w / 100) const yAxis = pct - ? axisLeft<number>(yScale).tickValues(pctTickValues).tickFormat(formatPct) + ? axisLeft<number>(yScale) + .tickValues(pctTickValues) + .tickFormat((n) => formatPct(n)) : axisLeft<number>(yScale) return { xAxis, yAxis } }, [w, h, pct, xScale, yScale]) @@ -233,7 +223,9 @@ export const SingleValueHistoryChart = <T,>(props: { const pctTickValues = getTickValues(min, max, h < 200 ? 3 : 5) const xAxis = axisBottom<Date>(xScale).ticks(w / 100) const yAxis = pct - ? axisLeft<number>(yScale).tickValues(pctTickValues).tickFormat(formatPct) + ? axisLeft<number>(yScale) + .tickValues(pctTickValues) + .tickFormat((n) => formatPct(n)) : axisLeft<number>(yScale) return { xAxis, yAxis } }, [w, h, pct, xScale, yScale]) diff --git a/web/components/charts/helpers.tsx b/web/components/charts/helpers.tsx index 55bb6e90..236c6e1d 100644 --- a/web/components/charts/helpers.tsx +++ b/web/components/charts/helpers.tsx @@ -22,9 +22,6 @@ export const MARGIN = { top: 20, right: 10, bottom: 20, left: 40 } export const MARGIN_X = MARGIN.right + MARGIN.left export const MARGIN_Y = MARGIN.top + MARGIN.bottom -export const MAX_TIMESTAMP = 8640000000000000 -export const MAX_DATE = new Date(MAX_TIMESTAMP) - export const XAxis = <X,>(props: { w: number; h: number; axis: Axis<X> }) => { const { h, axis } = props const axisRef = useRef<SVGGElement>(null) From 4cc985634a1f9841888e682f13bc3d55c4052ef0 Mon Sep 17 00:00:00 2001 From: Sinclair Chen <abc.sinclair@gmail.com> Date: Thu, 29 Sep 2022 07:04:11 -0700 Subject: [PATCH 036/135] Put slider z-index under bottom menu --- web/components/amount-input.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/components/amount-input.tsx b/web/components/amount-input.tsx index fbb49677..1c9d1c3b 100644 --- a/web/components/amount-input.tsx +++ b/web/components/amount-input.tsx @@ -162,7 +162,7 @@ export function BuyAmountInput(props: { max="205" value={getRaw(amount ?? 0)} onChange={(e) => onAmountChange(parseRaw(parseInt(e.target.value)))} - className="range range-lg only-thumb z-40 my-auto align-middle xl:hidden" + className="range range-lg only-thumb my-auto align-middle xl:hidden" step="5" /> )} From 46fab105d93c00df78ac8ab0f2ce10f256f18a3c Mon Sep 17 00:00:00 2001 From: Sinclair Chen <abc.sinclair@gmail.com> Date: Thu, 29 Sep 2022 07:26:19 -0700 Subject: [PATCH 037/135] Fix tipper icon progression --- web/components/tipper.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/components/tipper.tsx b/web/components/tipper.tsx index 46a988f6..ccb8361f 100644 --- a/web/components/tipper.tsx +++ b/web/components/tipper.tsx @@ -128,7 +128,7 @@ function DownTip(props: { onClick?: () => void }) { function UpTip(props: { onClick?: () => void; value: number }) { const { onClick, value } = props - const IconKind = value >= 10 ? ChevronDoubleRightIcon : ChevronRightIcon + const IconKind = value > TIP_SIZE ? ChevronDoubleRightIcon : ChevronRightIcon return ( <Tooltip className="h-6 w-6" From cd7ddae133e8c6a77a311f2c32f58292eabe2543 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Thu, 29 Sep 2022 12:30:58 -0400 Subject: [PATCH 038/135] Add profit of bets made within last week --- functions/src/weekly-portfolio-emails.ts | 46 +++++++++++++++--------- 1 file changed, 30 insertions(+), 16 deletions(-) diff --git a/functions/src/weekly-portfolio-emails.ts b/functions/src/weekly-portfolio-emails.ts index dcbb68dd..2f1ee789 100644 --- a/functions/src/weekly-portfolio-emails.ts +++ b/functions/src/weekly-portfolio-emails.ts @@ -3,7 +3,6 @@ import * as admin from 'firebase-admin' import { Contract, CPMMContract } from '../../common/contract' import { - getAllPrivateUsers, getPrivateUser, getUser, getValue, @@ -20,6 +19,7 @@ import { sendWeeklyPortfolioUpdateEmail } from './emails' import { contractUrl } from './utils' import { Txn } from '../../common/txn' import { formatMoney } from '../../common/util/format' +import { getContractBetMetrics } from '../../common/calculate' // TODO: reset weeklyPortfolioUpdateEmailSent to false for all users at the start of each week export const weeklyPortfolioUpdateEmails = functions @@ -36,12 +36,12 @@ const firestore = admin.firestore() export async function sendPortfolioUpdateEmailsToAllUsers() { const privateUsers = isProd() ? // ian & stephen's ids - // ? filterDefined([ - // await getPrivateUser('AJwLWoo3xue32XIiAVrL5SyR1WB2'), - // await getPrivateUser('tlmGNz9kjXc2EteizMORes4qvWl2'), - // ]) - await getAllPrivateUsers() - : filterDefined([await getPrivateUser('6hHpzvRG0pMq8PNJs7RZj2qlZGn2')]) + filterDefined([ + await getPrivateUser('AJwLWoo3xue32XIiAVrL5SyR1WB2'), + // await getPrivateUser('tlmGNz9kjXc2EteizMORes4qvWl2'), + ]) + : // await getAllPrivateUsers() + filterDefined([await getPrivateUser('6hHpzvRG0pMq8PNJs7RZj2qlZGn2')]) // get all users that haven't unsubscribed from weekly emails const privateUsersToSendEmailsTo = privateUsers .filter((user) => { @@ -165,28 +165,42 @@ export async function sendPortfolioUpdateEmailsToAllUsers() { const bets = userBets.filter( (bet) => bet.contractId === contract.id ) + const previousBets = bets.filter( + (b) => b.createdTime < Date.now() - 7 * DAY_MS + ) + + const betsInLastWeek = bets.filter( + (b) => b.createdTime >= Date.now() - 7 * DAY_MS + ) const marketProbabilityAWeekAgo = cpmmContract.prob - cpmmContract.probChanges.week const currentMarketProbability = cpmmContract.resolutionProbability ? cpmmContract.resolutionProbability : cpmmContract.prob - const betsValueAWeekAgo = computeInvestmentValueCustomProb( - bets.filter((b) => b.createdTime < Date.now() - 7 * DAY_MS), + const betsMadeAWeekAgoValue = computeInvestmentValueCustomProb( + previousBets, contract, marketProbabilityAWeekAgo ) - const currentBetsValue = computeInvestmentValueCustomProb( - bets, + const currentBetsMadeAWeekAgoValue = + computeInvestmentValueCustomProb( + previousBets, + contract, + currentMarketProbability + ) + const betsMadeInLastWeekProfit = getContractBetMetrics( contract, - currentMarketProbability - ) + betsInLastWeek + ).profit const marketChange = currentMarketProbability - marketProbabilityAWeekAgo return { - currentValue: currentBetsValue, - pastValue: betsValueAWeekAgo, - difference: currentBetsValue - betsValueAWeekAgo, + currentValue: currentBetsMadeAWeekAgoValue, + pastValue: betsMadeAWeekAgoValue, + difference: + betsMadeInLastWeekProfit + + (currentBetsMadeAWeekAgoValue - betsMadeAWeekAgoValue), contractSlug: contract.slug, marketProbAWeekAgo: marketProbabilityAWeekAgo, questionTitle: contract.question, From 35aa6c0429775f4ea5449cb233133053f9166b21 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Thu, 29 Sep 2022 12:32:47 -0400 Subject: [PATCH 039/135] Test sample of users' portfolios --- functions/src/emails.ts | 4 ++-- functions/src/weekly-portfolio-emails.ts | 13 +++++++------ 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/functions/src/emails.ts b/functions/src/emails.ts index dd91789a..1c7914be 100644 --- a/functions/src/emails.ts +++ b/functions/src/emails.ts @@ -648,8 +648,8 @@ export const sendWeeklyPortfolioUpdateEmail = async ( }) await sendTemplateEmail( - privateUser.email, - // 'iansphilips@gmail.com', + // privateUser.email, + 'iansphilips@gmail.com', `Here's your weekly portfolio update!`, investments.length === 0 ? 'portfolio-update-no-movers' diff --git a/functions/src/weekly-portfolio-emails.ts b/functions/src/weekly-portfolio-emails.ts index 2f1ee789..740a0ced 100644 --- a/functions/src/weekly-portfolio-emails.ts +++ b/functions/src/weekly-portfolio-emails.ts @@ -3,6 +3,7 @@ import * as admin from 'firebase-admin' import { Contract, CPMMContract } from '../../common/contract' import { + getAllPrivateUsers, getPrivateUser, getUser, getValue, @@ -36,12 +37,12 @@ const firestore = admin.firestore() export async function sendPortfolioUpdateEmailsToAllUsers() { const privateUsers = isProd() ? // ian & stephen's ids - filterDefined([ - await getPrivateUser('AJwLWoo3xue32XIiAVrL5SyR1WB2'), - // await getPrivateUser('tlmGNz9kjXc2EteizMORes4qvWl2'), - ]) - : // await getAllPrivateUsers() - filterDefined([await getPrivateUser('6hHpzvRG0pMq8PNJs7RZj2qlZGn2')]) + // filterDefined([ + // await getPrivateUser('AJwLWoo3xue32XIiAVrL5SyR1WB2'), + // await getPrivateUser('tlmGNz9kjXc2EteizMORes4qvWl2'), + // ]) + await getAllPrivateUsers() + : filterDefined([await getPrivateUser('6hHpzvRG0pMq8PNJs7RZj2qlZGn2')]) // get all users that haven't unsubscribed from weekly emails const privateUsersToSendEmailsTo = privateUsers .filter((user) => { From 2cc08ba9e7947640e18365a459c21bc089707cd2 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Thu, 29 Sep 2022 11:42:16 -0500 Subject: [PATCH 040/135] Daily movers cleanup --- web/components/contract/prob-change-table.tsx | 12 +++++------- web/pages/daily-movers.tsx | 18 +++++++++++------- web/pages/home/index.tsx | 4 ++-- 3 files changed, 18 insertions(+), 16 deletions(-) diff --git a/web/components/contract/prob-change-table.tsx b/web/components/contract/prob-change-table.tsx index f54ad915..6b671830 100644 --- a/web/components/contract/prob-change-table.tsx +++ b/web/components/contract/prob-change-table.tsx @@ -1,5 +1,5 @@ +import { sortBy } from 'lodash' 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' @@ -17,16 +17,14 @@ export function ProbChangeTable(props: { if (!changes) return <LoadingIndicator /> - const [positiveChanges, negativeChanges] = partition( - changes, - (c) => c.probChanges.day > 0 - ) + const descendingChanges = sortBy(changes, (c) => c.probChanges.day).reverse() + const ascendingChanges = sortBy(changes, (c) => c.probChanges.day) const threshold = 0.01 - const positiveAboveThreshold = positiveChanges.filter( + const positiveAboveThreshold = descendingChanges.filter( (c) => c.probChanges.day > threshold ) - const negativeAboveThreshold = negativeChanges.filter( + const negativeAboveThreshold = ascendingChanges.filter( (c) => c.probChanges.day < threshold ) const maxRows = Math.min( diff --git a/web/pages/daily-movers.tsx b/web/pages/daily-movers.tsx index 0a17e9e2..53e37420 100644 --- a/web/pages/daily-movers.tsx +++ b/web/pages/daily-movers.tsx @@ -8,20 +8,24 @@ import { useUser } from 'web/hooks/use-user' export default function DailyMovers() { const user = useUser() - const bettorId = user?.id ?? undefined - - const changes = useProbChanges({ bettorId })?.filter( - (c) => Math.abs(c.probChanges.day) >= 0.01 - ) - useTracking('view daily movers') return ( <Page> <Col className="pm:mx-10 gap-4 sm:px-4 sm:pb-4"> <Title className="mx-4 !mb-0 sm:mx-0" text="Daily movers" /> - <ProbChangeTable changes={changes} full /> + {user && <ProbChangesWrapper userId={user.id} />} </Col> </Page> ) } + +function ProbChangesWrapper(props: { userId: string }) { + const { userId } = props + + const changes = useProbChanges({ bettorId: userId })?.filter( + (c) => Math.abs(c.probChanges.day) >= 0.01 + ) + + return <ProbChangeTable changes={changes} full /> +} diff --git a/web/pages/home/index.tsx b/web/pages/home/index.tsx index ba2851bf..e920b20f 100644 --- a/web/pages/home/index.tsx +++ b/web/pages/home/index.tsx @@ -286,9 +286,9 @@ function GroupSection(props: { ) } -function DailyMoversSection(props: { userId: string | null | undefined }) { +function DailyMoversSection(props: { userId: string }) { const { userId } = props - const changes = useProbChanges({ bettorId: userId ?? undefined })?.filter( + const changes = useProbChanges({ bettorId: userId })?.filter( (c) => Math.abs(c.probChanges.day) >= 0.01 ) From ec1a9fab777769413142d8f63fa6222bf6721de4 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Thu, 29 Sep 2022 13:06:12 -0400 Subject: [PATCH 041/135] Show change in M$ --- functions/src/emails.ts | 4 ++-- functions/src/weekly-portfolio-emails.ts | 14 +++++++------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/functions/src/emails.ts b/functions/src/emails.ts index 1c7914be..6888cfb1 100644 --- a/functions/src/emails.ts +++ b/functions/src/emails.ts @@ -20,7 +20,7 @@ import { getNotificationDestinationsForUser } from '../../common/user-notificati import { PerContractInvestmentsData, OverallPerformanceData, -} from 'functions/src/weekly-portfolio-emails' +} from './weekly-portfolio-emails' export const sendMarketResolutionEmail = async ( reason: notification_reason_types, @@ -643,7 +643,7 @@ export const sendWeeklyPortfolioUpdateEmail = async ( templateData[`question${i + 1}Title`] = investment.questionTitle templateData[`question${i + 1}Url`] = investment.questionUrl templateData[`question${i + 1}Prob`] = investment.questionProb - templateData[`question${i + 1}Change`] = investment.questionChange + templateData[`question${i + 1}Change`] = formatMoney(investment.difference) templateData[`question${i + 1}ChangeStyle`] = investment.questionChangeStyle }) diff --git a/functions/src/weekly-portfolio-emails.ts b/functions/src/weekly-portfolio-emails.ts index 740a0ced..198fa7ca 100644 --- a/functions/src/weekly-portfolio-emails.ts +++ b/functions/src/weekly-portfolio-emails.ts @@ -22,7 +22,6 @@ import { Txn } from '../../common/txn' import { formatMoney } from '../../common/util/format' import { getContractBetMetrics } from '../../common/calculate' -// TODO: reset weeklyPortfolioUpdateEmailSent to false for all users at the start of each week export const weeklyPortfolioUpdateEmails = functions .runWith({ secrets: ['MAILGUN_KEY'], memory: '4GB' }) // every minute on Friday for an hour at 12pm PT (UTC -07:00) @@ -179,6 +178,8 @@ export async function sendPortfolioUpdateEmailsToAllUsers() { const currentMarketProbability = cpmmContract.resolutionProbability ? cpmmContract.resolutionProbability : cpmmContract.prob + + // TODO: returns 0 for resolved markets - doesn't include them const betsMadeAWeekAgoValue = computeInvestmentValueCustomProb( previousBets, contract, @@ -196,12 +197,13 @@ export async function sendPortfolioUpdateEmailsToAllUsers() { ).profit const marketChange = currentMarketProbability - marketProbabilityAWeekAgo + const profit = + betsMadeInLastWeekProfit + + (currentBetsMadeAWeekAgoValue - betsMadeAWeekAgoValue) return { currentValue: currentBetsMadeAWeekAgoValue, pastValue: betsMadeAWeekAgoValue, - difference: - betsMadeInLastWeekProfit + - (currentBetsMadeAWeekAgoValue - betsMadeAWeekAgoValue), + difference: profit, contractSlug: contract.slug, marketProbAWeekAgo: marketProbabilityAWeekAgo, questionTitle: contract.question, @@ -214,9 +216,7 @@ export async function sendPortfolioUpdateEmailsToAllUsers() { Math.round(marketChange * 100) + '%', questionChangeStyle: `color: ${ - currentMarketProbability > marketProbabilityAWeekAgo - ? 'rgba(0,160,0,1)' - : '#a80000' + profit > 0 ? 'rgba(0,160,0,1)' : '#a80000' };`, } as PerContractInvestmentsData }) From 2d1fd078342e1af9c91c50aeaa5b49d6f276504a Mon Sep 17 00:00:00 2001 From: Olivia Appleton <gabe@gabeappleton.me> Date: Thu, 29 Sep 2022 13:27:07 -0400 Subject: [PATCH 042/135] Add documentation for newer market types (#934) --- docs/docs/api.md | 29 +++++++++++++++++++++-------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/docs/docs/api.md b/docs/docs/api.md index 5fc95a4a..deaf4cd2 100644 --- a/docs/docs/api.md +++ b/docs/docs/api.md @@ -65,21 +65,21 @@ Requires no authorization. Gets a group by its slug. -Requires no authorization. +Requires no authorization. Note: group is singular in the URL. ### `GET /v0/group/by-id/[id]` Gets a group by its unique ID. -Requires no authorization. +Requires no authorization. Note: group is singular in the URL. ### `GET /v0/group/by-id/[id]/markets` Gets a group's markets by its unique ID. -Requires no authorization. +Requires no authorization. Note: group is singular in the URL. ### `GET /v0/markets` @@ -158,13 +158,16 @@ Requires no authorization. // i.e. https://manifold.markets/Austin/test-market is the same as https://manifold.markets/foo/test-market url: string - outcomeType: string // BINARY, FREE_RESPONSE, or NUMERIC + outcomeType: string // BINARY, FREE_RESPONSE, MULTIPLE_CHOICE, NUMERIC, or PSEUDO_NUMERIC mechanism: string // dpm-2 or cpmm-1 probability: number pool: { outcome: number } // For CPMM markets, the number of shares in the liquidity pool. For DPM markets, the amount of mana invested in each answer. p?: number // CPMM markets only, probability constant in y^p * n^(1-p) = k totalLiquidity?: number // CPMM markets only, the amount of mana deposited into the liquidity pool + min?: number // PSEUDO_NUMERIC markets only, the minimum resolvable value + max?: number // PSEUDO_NUMERIC markets only, the maximum resolvable value + isLogScale?: bool // PSEUDO_NUMERIC markets only, if true `number = (max - min + 1)^probability + minstart - 1`, otherwise `number = min + (max - min) * probability` volume: number volume7Days: number @@ -408,7 +411,7 @@ Requires no authorization. type FullMarket = LiteMarket & { bets: Bet[] comments: Comment[] - answers?: Answer[] + answers?: Answer[] // Free response markets only description: JSONContent // Rich text content. See https://tiptap.dev/guide/output#option-1-json textDescription: string // string description without formatting, images, or embeds } @@ -554,7 +557,7 @@ Creates a new market on behalf of the authorized user. Parameters: -- `outcomeType`: Required. One of `BINARY`, `FREE_RESPONSE`, or `NUMERIC`. +- `outcomeType`: Required. One of `BINARY`, `FREE_RESPONSE`, `MULTIPLE_CHOICE`, or `PSEUDO_NUMERIC`. - `question`: Required. The headline question for the market. - `description`: Required. A long description describing the rules for the market. - Note: string descriptions do **not** turn into links, mentions, formatted text. Instead, rich text descriptions must be in [TipTap json](https://tiptap.dev/guide/output#option-1-json). @@ -569,6 +572,12 @@ For numeric markets, you must also provide: - `min`: The minimum value that the market may resolve to. - `max`: The maximum value that the market may resolve to. +- `isLogScale`: If true, your numeric market will increase exponentially from min to max. +- `initialValue`: An initial value for the market, between min and max, exclusive. + +For multiple choice markets, you must also provide: + +- `answers`: An array of strings, each of which will be a valid answer for the market. Example request: @@ -605,15 +614,18 @@ For binary markets: - `outcome`: Required. One of `YES`, `NO`, `MKT`, or `CANCEL`. - `probabilityInt`: Optional. The probability to use for `MKT` resolution. -For free response markets: +For free response or multiple choice markets: - `outcome`: Required. One of `MKT`, `CANCEL`, or a `number` indicating the answer index. -- `resolutions`: An array of `{ answer, pct }` objects to use as the weights for resolving in favor of multiple free response options. Can only be set with `MKT` outcome. +- `resolutions`: An array of `{ answer, pct }` objects to use as the weights for resolving in favor of multiple free response options. Can only be set with `MKT` outcome. Note that the total weights must add to 100. For numeric markets: - `outcome`: Required. One of `CANCEL`, or a `number` indicating the selected numeric bucket ID. - `value`: The value that the market may resolves to. +- `probabilityInt`: Required if `value` is present. Should be equal to + - If log scale: `log10(value - min + 1) / log10(max - min + 1)` + - Otherwise: `(value - min) / (max - min)` Example request: @@ -757,6 +769,7 @@ Requires no authorization. ## Changelog +- 2022-09-24: Expand market POST docs to include new market types (`PSEUDO_NUMERIC`, `MULTIPLE_CHOICE`) - 2022-07-15: Add user by username and user by ID APIs - 2022-06-08: Add paging to markets endpoint - 2022-06-05: Add new authorized write endpoints From 1e6b72059e22da4e3d11f0eb8e1c563c61986eae Mon Sep 17 00:00:00 2001 From: Olivia Appleton <gabe@gabeappleton.me> Date: Thu, 29 Sep 2022 14:17:52 -0400 Subject: [PATCH 043/135] Expose multiple choice answer probabilities (#939) * Expose multiple choice answer probabilities * Run prettier * Update api.md Co-authored-by: Austin Chen <akrolsmir@gmail.com> --- docs/docs/api.md | 2 +- web/pages/api/v0/_types.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/docs/api.md b/docs/docs/api.md index deaf4cd2..d25a18be 100644 --- a/docs/docs/api.md +++ b/docs/docs/api.md @@ -411,7 +411,7 @@ Requires no authorization. type FullMarket = LiteMarket & { bets: Bet[] comments: Comment[] - answers?: Answer[] // Free response markets only + answers?: Answer[] // dpm-2 markets only description: JSONContent // Rich text content. See https://tiptap.dev/guide/output#option-1-json textDescription: string // string description without formatting, images, or embeds } diff --git a/web/pages/api/v0/_types.ts b/web/pages/api/v0/_types.ts index ea2f053b..ccaa217d 100644 --- a/web/pages/api/v0/_types.ts +++ b/web/pages/api/v0/_types.ts @@ -149,7 +149,8 @@ export function toFullMarket( ): FullMarket { const liteMarket = toLiteMarket(contract) const answers = - contract.outcomeType === 'FREE_RESPONSE' + contract.outcomeType === 'FREE_RESPONSE' || + contract.outcomeType === 'MULTIPLE_CHOICE' ? contract.answers.map((answer) => augmentAnswerWithProbability(contract, answer) ) From 1755fb15d462f2d25930c1b3ae234bb60b5e38eb Mon Sep 17 00:00:00 2001 From: Pico2x <pico2x@gmail.com> Date: Thu, 29 Sep 2022 19:38:36 +0100 Subject: [PATCH 044/135] SEO for posts --- web/pages/post/[...slugs]/index.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/web/pages/post/[...slugs]/index.tsx b/web/pages/post/[...slugs]/index.tsx index b71b7cca..537afc1e 100644 --- a/web/pages/post/[...slugs]/index.tsx +++ b/web/pages/post/[...slugs]/index.tsx @@ -24,6 +24,7 @@ import { PostCommentInput, PostCommentThread } from 'web/posts/post-comments' import { useCommentsOnPost } from 'web/hooks/use-comments' import { useUser } from 'web/hooks/use-user' import { usePost } from 'web/hooks/use-post' +import { SEO } from 'web/components/SEO' export async function getStaticProps(props: { params: { slugs: string[] } }) { const { slugs } = props.params @@ -68,6 +69,11 @@ export default function PostPage(props: { return ( <Page> + <SEO + title={post.title} + description={'A post by ' + creator.username} + url={'/post/' + post.slug} + /> <div className="mx-auto w-full max-w-3xl "> <Title className="!mt-0 py-4 px-2" text={post.title} /> <Row> From 9fc1e855ff4085ab7faac71e6a19d1748e494cf9 Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Thu, 29 Sep 2022 13:53:43 -0500 Subject: [PATCH 045/135] portfolio graph: put profit first --- .../portfolio/portfolio-value-section.tsx | 39 ++++++++++--------- 1 file changed, 20 insertions(+), 19 deletions(-) diff --git a/web/components/portfolio/portfolio-value-section.tsx b/web/components/portfolio/portfolio-value-section.tsx index ec364c8d..688dbf10 100644 --- a/web/components/portfolio/portfolio-value-section.tsx +++ b/web/components/portfolio/portfolio-value-section.tsx @@ -15,7 +15,7 @@ export const PortfolioValueSection = memo( const [portfolioPeriod, setPortfolioPeriod] = useState<Period>('weekly') const portfolioHistory = usePortfolioHistory(userId, portfolioPeriod) - const [graphMode, setGraphMode] = useState<'profit' | 'value'>('value') + const [graphMode, setGraphMode] = useState<'profit' | 'value'>('profit') const [graphDisplayNumber, setGraphDisplayNumber] = useState< number | string | null >(null) @@ -40,24 +40,6 @@ export const PortfolioValueSection = memo( <> <Row className="mb-2 justify-between"> <Row className="gap-4 sm:gap-8"> - <Col - className={clsx( - 'cursor-pointer', - graphMode != 'value' ? 'opacity-40 hover:opacity-80' : '' - )} - onClick={() => setGraphMode('value')} - > - <div className="text-greyscale-6 text-xs sm:text-sm"> - Portfolio value - </div> - <div className={clsx('text-lg text-indigo-600 sm:text-xl')}> - {graphMode === 'value' - ? graphDisplayNumber - ? graphDisplayNumber - : formatMoney(totalValue) - : formatMoney(totalValue)} - </div> - </Col> <Col className={clsx( 'cursor-pointer', @@ -91,6 +73,25 @@ export const PortfolioValueSection = memo( : formatMoney(totalProfit)} </div> </Col> + + <Col + className={clsx( + 'cursor-pointer', + graphMode != 'value' ? 'opacity-40 hover:opacity-80' : '' + )} + onClick={() => setGraphMode('value')} + > + <div className="text-greyscale-6 text-xs sm:text-sm"> + Portfolio value + </div> + <div className={clsx('text-lg text-indigo-600 sm:text-xl')}> + {graphMode === 'value' + ? graphDisplayNumber + ? graphDisplayNumber + : formatMoney(totalValue) + : formatMoney(totalValue)} + </div> + </Col> </Row> </Row> <PortfolioValueGraph From 8929b2e6ba719e31062bf59e12c10cf6118a38c4 Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Thu, 29 Sep 2022 12:51:38 -0700 Subject: [PATCH 046/135] Improve typing for chart tooltip stuff (#962) --- web/components/charts/contract/binary.tsx | 8 +-- web/components/charts/contract/choice.tsx | 9 +-- web/components/charts/contract/numeric.tsx | 14 ++-- .../charts/contract/pseudo-numeric.tsx | 10 +-- web/components/charts/generic-charts.tsx | 72 ++++++++----------- web/components/charts/helpers.tsx | 17 +++-- 6 files changed, 54 insertions(+), 76 deletions(-) diff --git a/web/components/charts/contract/binary.tsx b/web/components/charts/contract/binary.tsx index 8a378799..55cf4e88 100644 --- a/web/components/charts/contract/binary.tsx +++ b/web/components/charts/contract/binary.tsx @@ -8,6 +8,7 @@ import { BinaryContract } from 'common/contract' import { DAY_MS } from 'common/util/time' import { useIsMobile } from 'web/hooks/use-is-mobile' import { + TooltipProps, MARGIN_X, MARGIN_Y, getDateRange, @@ -15,10 +16,7 @@ import { formatDateInRange, formatPct, } from '../helpers' -import { - SingleValueHistoryTooltipProps, - SingleValueHistoryChart, -} from '../generic-charts' +import { HistoryPoint, SingleValueHistoryChart } from '../generic-charts' import { useElementWidth } from 'web/hooks/use-element-width' import { Row } from 'web/components/layout/row' import { Avatar } from 'web/components/avatar' @@ -31,7 +29,7 @@ const getBetPoints = (bets: Bet[]) => { })) } -const BinaryChartTooltip = (props: SingleValueHistoryTooltipProps<Bet>) => { +const BinaryChartTooltip = (props: TooltipProps<HistoryPoint<Bet>>) => { const { p, xScale } = props const { x, y, datum } = p const [start, end] = xScale.domain() diff --git a/web/components/charts/contract/choice.tsx b/web/components/charts/contract/choice.tsx index 0811a2ed..d5d0d09e 100644 --- a/web/components/charts/contract/choice.tsx +++ b/web/components/charts/contract/choice.tsx @@ -10,6 +10,7 @@ import { useIsMobile } from 'web/hooks/use-is-mobile' import { DAY_MS } from 'common/util/time' import { Legend, + TooltipProps, MARGIN_X, MARGIN_Y, getDateRange, @@ -17,11 +18,7 @@ import { formatPct, formatDateInRange, } from '../helpers' -import { - MultiPoint, - MultiValueHistoryChart, - MultiValueHistoryTooltipProps, -} from '../generic-charts' +import { MultiPoint, MultiValueHistoryChart } from '../generic-charts' import { useElementWidth } from 'web/hooks/use-element-width' import { Row } from 'web/components/layout/row' import { Avatar } from 'web/components/avatar' @@ -161,7 +158,7 @@ export const ChoiceContractChart = (props: { const yScale = scaleLinear([0, 1], [height - MARGIN_Y, 0]) const ChoiceTooltip = useMemo( - () => (props: MultiValueHistoryTooltipProps<Bet>) => { + () => (props: TooltipProps<MultiPoint<Bet>>) => { const { p, xScale } = props const { x, y, datum } = p const [start, end] = xScale.domain() diff --git a/web/components/charts/contract/numeric.tsx b/web/components/charts/contract/numeric.tsx index de1d1a0c..3c14149a 100644 --- a/web/components/charts/contract/numeric.tsx +++ b/web/components/charts/contract/numeric.tsx @@ -7,11 +7,8 @@ import { getDpmOutcomeProbabilities } from 'common/calculate-dpm' import { NumericContract } from 'common/contract' import { NUMERIC_GRAPH_COLOR } from 'common/numeric-constants' import { useIsMobile } from 'web/hooks/use-is-mobile' -import { MARGIN_X, MARGIN_Y, formatPct } from '../helpers' -import { - SingleValueDistributionChart, - SingleValueDistributionTooltipProps, -} from '../generic-charts' +import { TooltipProps, MARGIN_X, MARGIN_Y, formatPct } from '../helpers' +import { DistributionPoint, DistributionChart } from '../generic-charts' import { useElementWidth } from 'web/hooks/use-element-width' const getNumericChartData = (contract: NumericContract) => { @@ -24,9 +21,8 @@ const getNumericChartData = (contract: NumericContract) => { })) } -const NumericChartTooltip = (props: SingleValueDistributionTooltipProps) => { - const { p } = props - const { x, y } = p +const NumericChartTooltip = (props: TooltipProps<DistributionPoint>) => { + const { x, y } = props.p return ( <span className="text-sm"> <strong>{formatPct(y, 2)}</strong> {formatLargeNumber(x)} @@ -51,7 +47,7 @@ export const NumericContractChart = (props: { return ( <div ref={containerRef}> {width > 0 && ( - <SingleValueDistributionChart + <DistributionChart w={width} h={height} xScale={xScale} diff --git a/web/components/charts/contract/pseudo-numeric.tsx b/web/components/charts/contract/pseudo-numeric.tsx index f1b438dc..fb88b15a 100644 --- a/web/components/charts/contract/pseudo-numeric.tsx +++ b/web/components/charts/contract/pseudo-numeric.tsx @@ -10,16 +10,14 @@ import { PseudoNumericContract } from 'common/contract' import { NUMERIC_GRAPH_COLOR } from 'common/numeric-constants' import { useIsMobile } from 'web/hooks/use-is-mobile' import { + TooltipProps, MARGIN_X, MARGIN_Y, getDateRange, getRightmostVisibleDate, formatDateInRange, } from '../helpers' -import { - SingleValueHistoryChart, - SingleValueHistoryTooltipProps, -} from '../generic-charts' +import { HistoryPoint, SingleValueHistoryChart } from '../generic-charts' import { useElementWidth } from 'web/hooks/use-element-width' import { Row } from 'web/components/layout/row' import { Avatar } from 'web/components/avatar' @@ -43,9 +41,7 @@ const getBetPoints = (bets: Bet[], scaleP: (p: number) => number) => { })) } -const PseudoNumericChartTooltip = ( - props: SingleValueHistoryTooltipProps<Bet> -) => { +const PseudoNumericChartTooltip = (props: TooltipProps<HistoryPoint<Bet>>) => { const { p, xScale } = props const { x, y, datum } = p const [start, end] = xScale.domain() diff --git a/web/components/charts/generic-charts.tsx b/web/components/charts/generic-charts.tsx index 161721f1..344ae061 100644 --- a/web/components/charts/generic-charts.tsx +++ b/web/components/charts/generic-charts.tsx @@ -16,28 +16,29 @@ import { SVGChart, AreaPath, AreaWithTopStroke, - TooltipContent, + Point, + TooltipComponent, formatPct, } from './helpers' import { useEvent } from 'web/hooks/use-event' -export type MultiPoint<T = never> = { x: Date; y: number[]; datum?: T } -export type HistoryPoint<T = never> = { x: Date; y: number; datum?: T } -export type DistributionPoint<T = never> = { x: number; y: number; datum?: T } +export type MultiPoint<T = unknown> = Point<Date, number[], T> +export type HistoryPoint<T = unknown> = Point<Date, number, T> +export type DistributionPoint<T = unknown> = Point<number, number, T> const getTickValues = (min: number, max: number, n: number) => { const step = (max - min) / (n - 1) return [min, ...range(1, n - 1).map((i) => min + step * i), max] } -export const SingleValueDistributionChart = <T,>(props: { - data: DistributionPoint<T>[] +export const DistributionChart = <P extends DistributionPoint>(props: { + data: P[] w: number h: number color: string xScale: ScaleContinuousNumeric<number, number> yScale: ScaleContinuousNumeric<number, number> - Tooltip?: TooltipContent<SingleValueDistributionTooltipProps<T>> + Tooltip?: TooltipComponent<P> }) => { const { color, data, yScale, w, h, Tooltip } = props @@ -45,10 +46,10 @@ export const SingleValueDistributionChart = <T,>(props: { useState<ScaleContinuousNumeric<number, number>>() const xScale = viewXScale ?? props.xScale - const px = useCallback((p: DistributionPoint<T>) => xScale(p.x), [xScale]) + const px = useCallback((p: P) => xScale(p.x), [xScale]) const py0 = yScale(yScale.domain()[0]) - const py1 = useCallback((p: DistributionPoint<T>) => yScale(p.y), [yScale]) - const xBisector = bisector((p: DistributionPoint<T>) => p.x) + const py1 = useCallback((p: P) => yScale(p.y), [yScale]) + const xBisector = bisector((p: P) => p.x) const { xAxis, yAxis } = useMemo(() => { const xAxis = axisBottom<number>(xScale).ticks(w / 100) @@ -56,7 +57,7 @@ export const SingleValueDistributionChart = <T,>(props: { return { xAxis, yAxis } }, [w, xScale, yScale]) - const onSelect = useEvent((ev: D3BrushEvent<DistributionPoint<T>>) => { + const onSelect = useEvent((ev: D3BrushEvent<P>) => { if (ev.selection) { const [mouseX0, mouseX1] = ev.selection as [number, number] setViewXScale(() => @@ -75,7 +76,7 @@ export const SingleValueDistributionChart = <T,>(props: { // so your queryX is out of bounds return } - return { x: queryX, y: item.y, datum: item.datum } + return { ...item, x: queryX } }) return ( @@ -100,19 +101,14 @@ export const SingleValueDistributionChart = <T,>(props: { ) } -export type SingleValueDistributionTooltipProps<T = unknown> = { - p: DistributionPoint<T> - xScale: React.ComponentProps<typeof SingleValueDistributionChart<T>>['xScale'] -} - -export const MultiValueHistoryChart = <T,>(props: { - data: MultiPoint<T>[] +export const MultiValueHistoryChart = <P extends MultiPoint>(props: { + data: P[] w: number h: number colors: readonly string[] xScale: ScaleTime<number, number> yScale: ScaleContinuousNumeric<number, number> - Tooltip?: TooltipContent<MultiValueHistoryTooltipProps<T>> + Tooltip?: TooltipComponent<P> pct?: boolean }) => { const { colors, data, yScale, w, h, Tooltip, pct } = props @@ -120,11 +116,11 @@ export const MultiValueHistoryChart = <T,>(props: { const [viewXScale, setViewXScale] = useState<ScaleTime<number, number>>() const xScale = viewXScale ?? props.xScale - type SP = SeriesPoint<MultiPoint<T>> + type SP = SeriesPoint<P> const px = useCallback((p: SP) => xScale(p.data.x), [xScale]) const py0 = useCallback((p: SP) => yScale(p[0]), [yScale]) const py1 = useCallback((p: SP) => yScale(p[1]), [yScale]) - const xBisector = bisector((p: MultiPoint<T>) => p.x) + const xBisector = bisector((p: P) => p.x) const { xAxis, yAxis } = useMemo(() => { const [min, max] = yScale.domain() @@ -139,14 +135,14 @@ export const MultiValueHistoryChart = <T,>(props: { }, [w, h, pct, xScale, yScale]) const series = useMemo(() => { - const d3Stack = stack<MultiPoint<T>, number>() + const d3Stack = stack<P, number>() .keys(range(0, Math.max(...data.map(({ y }) => y.length)))) .value(({ y }, o) => y[o]) .order(stackOrderReverse) return d3Stack(data) }, [data]) - const onSelect = useEvent((ev: D3BrushEvent<MultiPoint<T>>) => { + const onSelect = useEvent((ev: D3BrushEvent<P>) => { if (ev.selection) { const [mouseX0, mouseX1] = ev.selection as [number, number] setViewXScale(() => @@ -165,7 +161,7 @@ export const MultiValueHistoryChart = <T,>(props: { // so your queryX is out of bounds return } - return { x: queryX, y: item.y, datum: item.datum } + return { ...item, x: queryX } }) return ( @@ -193,19 +189,14 @@ export const MultiValueHistoryChart = <T,>(props: { ) } -export type MultiValueHistoryTooltipProps<T = unknown> = { - p: MultiPoint<T> - xScale: React.ComponentProps<typeof MultiValueHistoryChart<T>>['xScale'] -} - -export const SingleValueHistoryChart = <T,>(props: { - data: HistoryPoint<T>[] +export const SingleValueHistoryChart = <P extends HistoryPoint>(props: { + data: P[] w: number h: number color: string xScale: ScaleTime<number, number> yScale: ScaleContinuousNumeric<number, number> - Tooltip?: TooltipContent<SingleValueHistoryTooltipProps<T>> + Tooltip?: TooltipComponent<P> pct?: boolean }) => { const { color, data, pct, yScale, w, h, Tooltip } = props @@ -213,10 +204,10 @@ export const SingleValueHistoryChart = <T,>(props: { const [viewXScale, setViewXScale] = useState<ScaleTime<number, number>>() const xScale = viewXScale ?? props.xScale - const px = useCallback((p: HistoryPoint<T>) => xScale(p.x), [xScale]) + const px = useCallback((p: P) => xScale(p.x), [xScale]) const py0 = yScale(yScale.domain()[0]) - const py1 = useCallback((p: HistoryPoint<T>) => yScale(p.y), [yScale]) - const xBisector = bisector((p: HistoryPoint<T>) => p.x) + const py1 = useCallback((p: P) => yScale(p.y), [yScale]) + const xBisector = bisector((p: P) => p.x) const { xAxis, yAxis } = useMemo(() => { const [min, max] = yScale.domain() @@ -230,7 +221,7 @@ export const SingleValueHistoryChart = <T,>(props: { return { xAxis, yAxis } }, [w, h, pct, xScale, yScale]) - const onSelect = useEvent((ev: D3BrushEvent<HistoryPoint<T>>) => { + const onSelect = useEvent((ev: D3BrushEvent<P>) => { if (ev.selection) { const [mouseX0, mouseX1] = ev.selection as [number, number] setViewXScale(() => @@ -249,7 +240,7 @@ export const SingleValueHistoryChart = <T,>(props: { // so your queryX is out of bounds return } - return { x: queryX, y: item.y, datum: item.datum } + return { ...item, x: queryX } }) return ( @@ -273,8 +264,3 @@ export const SingleValueHistoryChart = <T,>(props: { </SVGChart> ) } - -export type SingleValueHistoryTooltipProps<T = unknown> = { - p: HistoryPoint<T> - xScale: React.ComponentProps<typeof SingleValueHistoryChart<T>>['xScale'] -} diff --git a/web/components/charts/helpers.tsx b/web/components/charts/helpers.tsx index 236c6e1d..35c8a335 100644 --- a/web/components/charts/helpers.tsx +++ b/web/components/charts/helpers.tsx @@ -8,7 +8,7 @@ import { useState, } from 'react' import { pointer, select } from 'd3-selection' -import { Axis } from 'd3-axis' +import { Axis, AxisScale } from 'd3-axis' import { brushX, D3BrushEvent } from 'd3-brush' import { area, line, curveStepAfter, CurveFactory } from 'd3-shape' import { nanoid } from 'nanoid' @@ -18,6 +18,10 @@ import clsx from 'clsx' import { Contract } from 'common/contract' import { Row } from 'web/components/layout/row' +export type Point<X, Y, T = unknown> = { x: X; y: Y; datum?: T } +export type XScale<P> = P extends Point<infer X, infer _> ? AxisScale<X> : never +export type YScale<P> = P extends Point<infer _, infer Y> ? AxisScale<Y> : never + export const MARGIN = { top: 20, right: 10, bottom: 20, left: 40 } export const MARGIN_X = MARGIN.right + MARGIN.left export const MARGIN_Y = MARGIN.top + MARGIN.bottom @@ -113,15 +117,15 @@ export const AreaWithTopStroke = <P,>(props: { ) } -export const SVGChart = <X, Y, P, XS>(props: { +export const SVGChart = <X, Y, P extends Point<X, Y>>(props: { children: ReactNode w: number h: number xAxis: Axis<X> - yAxis: Axis<Y> + yAxis: Axis<number> onSelect?: (ev: D3BrushEvent<any>) => void onMouseOver?: (mouseX: number, mouseY: number) => P | undefined - Tooltip?: TooltipContent<{ xScale: XS } & { p: P }> + Tooltip?: TooltipComponent<P> }) => { const { children, w, h, xAxis, yAxis, onMouseOver, onSelect, Tooltip } = props const [mouseState, setMouseState] = useState<TooltipPosition & { p: P }>() @@ -181,7 +185,7 @@ export const SVGChart = <X, Y, P, XS>(props: { <div className="relative"> {mouseState && Tooltip && ( <TooltipContainer top={mouseState.top} left={mouseState.left}> - <Tooltip xScale={xAxis.scale() as XS} p={mouseState.p} /> + <Tooltip xScale={xAxis.scale()} p={mouseState.p} /> </TooltipContainer> )} <svg className="w-full" width={w} height={h} viewBox={`0 0 ${w} ${h}`}> @@ -210,7 +214,8 @@ export const SVGChart = <X, Y, P, XS>(props: { ) } -export type TooltipContent<P> = React.ComponentType<P> +export type TooltipProps<P> = { p: P; xScale: XScale<P> } +export type TooltipComponent<P> = React.ComponentType<TooltipProps<P>> export type TooltipPosition = { top: number; left: number } export const TooltipContainer = ( props: TooltipPosition & { className?: string; children: React.ReactNode } From b7df1a7043407047ff1d355a2e1cd052a42a8c70 Mon Sep 17 00:00:00 2001 From: Sinclair Chen <abc.sinclair@gmail.com> Date: Thu, 29 Sep 2022 14:28:04 -0700 Subject: [PATCH 047/135] Add ||spoilers|| (#942) * Add ||spoilers|| * Add spoiler button to format menu --- common/util/parse.ts | 2 + common/util/tiptap-spoiler.ts | 116 ++++++++++++++++++++++++++++++++++ web/components/editor.tsx | 16 +++++ 3 files changed, 134 insertions(+) create mode 100644 common/util/tiptap-spoiler.ts diff --git a/common/util/parse.ts b/common/util/parse.ts index 0bbd5cd9..72ceaf15 100644 --- a/common/util/parse.ts +++ b/common/util/parse.ts @@ -25,6 +25,7 @@ import Iframe from './tiptap-iframe' import TiptapTweet from './tiptap-tweet-type' import { find } from 'linkifyjs' import { uniq } from 'lodash' +import { TiptapSpoiler } from './tiptap-spoiler' /** get first url in text. like "notion.so " -> "http://notion.so"; "notion" -> null */ export function getUrl(text: string) { @@ -103,6 +104,7 @@ export const exhibitExts = [ Mention, Iframe, TiptapTweet, + TiptapSpoiler, ] export function richTextToString(text?: JSONContent) { diff --git a/common/util/tiptap-spoiler.ts b/common/util/tiptap-spoiler.ts new file mode 100644 index 00000000..5502da58 --- /dev/null +++ b/common/util/tiptap-spoiler.ts @@ -0,0 +1,116 @@ +// adapted from @n8body/tiptap-spoiler + +import { + Mark, + markInputRule, + markPasteRule, + mergeAttributes, +} from '@tiptap/core' +import type { ElementType } from 'react' + +declare module '@tiptap/core' { + interface Commands<ReturnType> { + spoilerEditor: { + setSpoiler: () => ReturnType + toggleSpoiler: () => ReturnType + unsetSpoiler: () => ReturnType + } + } +} + +export type SpoilerOptions = { + HTMLAttributes: Record<string, any> + spoilerOpenClass: string + spoilerCloseClass?: string + inputRegex: RegExp + pasteRegex: RegExp + as: ElementType +} + +const spoilerInputRegex = /(?:^|\s)((?:\|\|)((?:[^||]+))(?:\|\|))$/ +const spoilerPasteRegex = /(?:^|\s)((?:\|\|)((?:[^||]+))(?:\|\|))/g + +export const TiptapSpoiler = Mark.create<SpoilerOptions>({ + name: 'spoiler', + + inline: true, + group: 'inline', + inclusive: false, + exitable: true, + content: 'inline*', + + priority: 200, // higher priority than other formatting so they go inside + + addOptions() { + return { + HTMLAttributes: { 'aria-label': 'spoiler' }, + spoilerOpenClass: '', + spoilerCloseClass: undefined, + inputRegex: spoilerInputRegex, + pasteRegex: spoilerPasteRegex, + as: 'span', + editing: false, + } + }, + + addCommands() { + return { + setSpoiler: + () => + ({ commands }) => + commands.setMark(this.name), + toggleSpoiler: + () => + ({ commands }) => + commands.toggleMark(this.name), + unsetSpoiler: + () => + ({ commands }) => + commands.unsetMark(this.name), + } + }, + + addInputRules() { + return [ + markInputRule({ + find: this.options.inputRegex, + type: this.type, + }), + ] + }, + + addPasteRules() { + return [ + markPasteRule({ + find: this.options.pasteRegex, + type: this.type, + }), + ] + }, + + parseHTML() { + return [ + { + tag: 'span', + getAttrs: (node) => + (node as HTMLElement).ariaLabel?.toLowerCase() === 'spoiler' && null, + }, + ] + }, + + renderHTML({ HTMLAttributes }) { + const elem = document.createElement(this.options.as as string) + + Object.entries( + mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { + class: this.options.spoilerCloseClass ?? this.options.spoilerOpenClass, + }) + ).forEach(([attr, val]) => elem.setAttribute(attr, val)) + + elem.addEventListener('click', () => { + elem.setAttribute('class', this.options.spoilerOpenClass) + }) + + return elem + }, +}) diff --git a/web/components/editor.tsx b/web/components/editor.tsx index 95f18b3f..5050a261 100644 --- a/web/components/editor.tsx +++ b/web/components/editor.tsx @@ -29,6 +29,7 @@ import { EmbedModal } from './editor/embed-modal' import { CheckIcon, CodeIcon, + EyeOffIcon, PhotographIcon, PresentationChartLineIcon, TrashIcon, @@ -40,6 +41,7 @@ import BoldIcon from 'web/lib/icons/bold-icon' import ItalicIcon from 'web/lib/icons/italic-icon' import LinkIcon from 'web/lib/icons/link-icon' import { getUrl } from 'common/util/parse' +import { TiptapSpoiler } from 'common/util/tiptap-spoiler' const DisplayImage = Image.configure({ HTMLAttributes: { @@ -107,6 +109,9 @@ export function useTextEditor(props: { }), Iframe, TiptapTweet, + TiptapSpoiler.configure({ + spoilerOpenClass: 'rounded-sm bg-greyscale-2', + }), ], content: defaultValue, }) @@ -166,6 +171,7 @@ function FloatingMenu(props: { editor: Editor | null }) { const isBold = editor.isActive('bold') const isItalic = editor.isActive('italic') const isLink = editor.isActive('link') + const isSpoiler = editor.isActive('spoiler') const setLink = () => { const href = url && getUrl(url) @@ -194,6 +200,11 @@ function FloatingMenu(props: { editor: Editor | null }) { <button onClick={() => (isLink ? unsetLink() : setUrl(''))}> <LinkIcon className={clsx('h-5', isLink && 'text-indigo-200')} /> </button> + <button onClick={() => editor.chain().focus().toggleSpoiler().run()}> + <EyeOffIcon + className={clsx('h-5', isSpoiler && 'text-indigo-200')} + /> + </button> </> ) : ( <> @@ -329,6 +340,11 @@ export function RichContent(props: { }), Iframe, TiptapTweet, + TiptapSpoiler.configure({ + spoilerOpenClass: 'rounded-sm bg-greyscale-2 cursor-text', + spoilerCloseClass: + 'rounded-sm bg-greyscale-6 text-greyscale-6 cursor-pointer select-none', + }), ], content, editable: false, From 262183e0e66e09bf19261524ddb94ef26ec7e07a Mon Sep 17 00:00:00 2001 From: ingawei <46611122+ingawei@users.noreply.github.com> Date: Thu, 29 Sep 2022 18:53:36 -0500 Subject: [PATCH 048/135] Inga/quick toggle fix (#964) getting rid of unused component --- .../portfolio/portfolio-value-section.tsx | 32 ------------------- 1 file changed, 32 deletions(-) diff --git a/web/components/portfolio/portfolio-value-section.tsx b/web/components/portfolio/portfolio-value-section.tsx index 688dbf10..d7fff6ef 100644 --- a/web/components/portfolio/portfolio-value-section.tsx +++ b/web/components/portfolio/portfolio-value-section.tsx @@ -4,7 +4,6 @@ import { last } from 'lodash' import { memo, useRef, useState } from 'react' import { usePortfolioHistory } from 'web/hooks/use-portfolio-history' import { Period } from 'web/lib/firebase/users' -import { PillButton } from '../buttons/pill-button' import { Col } from '../layout/col' import { Row } from '../layout/row' import { PortfolioValueGraph } from './portfolio-value-graph' @@ -147,34 +146,3 @@ export function PortfolioPeriodSelection(props: { </Row> ) } - -export function GraphToggle(props: { - setGraphMode: (mode: 'profit' | 'value') => void - graphMode: string -}) { - const { setGraphMode, graphMode } = props - return ( - <Row className="relative mt-1 ml-1 items-center gap-1.5 sm:ml-0 sm:gap-2"> - <PillButton - selected={graphMode === 'value'} - onSelect={() => { - setGraphMode('value') - }} - xs={true} - className="z-50" - > - Value - </PillButton> - <PillButton - selected={graphMode === 'profit'} - onSelect={() => { - setGraphMode('profit') - }} - xs={true} - className="z-50" - > - Profit - </PillButton> - </Row> - ) -} From 2625ab1549808287357a506afc89ca70a9f1ba54 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Thu, 29 Sep 2022 18:13:33 -0600 Subject: [PATCH 049/135] Portfolio email ux --- .../weekly-portfolio-update.html | 2 +- functions/src/emails.ts | 8 ++--- functions/src/weekly-portfolio-emails.ts | 29 +++++++------------ 3 files changed, 16 insertions(+), 23 deletions(-) diff --git a/functions/src/email-templates/weekly-portfolio-update.html b/functions/src/email-templates/weekly-portfolio-update.html index fd99837f..921a58e5 100644 --- a/functions/src/email-templates/weekly-portfolio-update.html +++ b/functions/src/email-templates/weekly-portfolio-update.html @@ -320,7 +320,7 @@ style="line-height: 24px; margin: 10px 0; margin-top: 20px; margin-bottom: 20px;" data-testid="4XoHRGw1Y"> <span style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;"> - And here's some of the biggest changes in your portfolio: + And here's some recent changes in your investments: </span> </p> </div> diff --git a/functions/src/emails.ts b/functions/src/emails.ts index 6888cfb1..993fac81 100644 --- a/functions/src/emails.ts +++ b/functions/src/emails.ts @@ -643,13 +643,13 @@ export const sendWeeklyPortfolioUpdateEmail = async ( templateData[`question${i + 1}Title`] = investment.questionTitle templateData[`question${i + 1}Url`] = investment.questionUrl templateData[`question${i + 1}Prob`] = investment.questionProb - templateData[`question${i + 1}Change`] = formatMoney(investment.difference) - templateData[`question${i + 1}ChangeStyle`] = investment.questionChangeStyle + templateData[`question${i + 1}Change`] = formatMoney(investment.profit) + templateData[`question${i + 1}ChangeStyle`] = investment.profitStyle }) await sendTemplateEmail( - // privateUser.email, - 'iansphilips@gmail.com', + privateUser.email, + // 'iansphilips@gmail.com', `Here's your weekly portfolio update!`, investments.length === 0 ? 'portfolio-update-no-movers' diff --git a/functions/src/weekly-portfolio-emails.ts b/functions/src/weekly-portfolio-emails.ts index 198fa7ca..0167be35 100644 --- a/functions/src/weekly-portfolio-emails.ts +++ b/functions/src/weekly-portfolio-emails.ts @@ -195,15 +195,13 @@ export async function sendPortfolioUpdateEmailsToAllUsers() { contract, betsInLastWeek ).profit - const marketChange = - currentMarketProbability - marketProbabilityAWeekAgo const profit = betsMadeInLastWeekProfit + (currentBetsMadeAWeekAgoValue - betsMadeAWeekAgoValue) return { currentValue: currentBetsMadeAWeekAgoValue, pastValue: betsMadeAWeekAgoValue, - difference: profit, + profit, contractSlug: contract.slug, marketProbAWeekAgo: marketProbabilityAWeekAgo, questionTitle: contract.question, @@ -211,17 +209,13 @@ export async function sendPortfolioUpdateEmailsToAllUsers() { questionProb: cpmmContract.resolution ? cpmmContract.resolution : Math.round(cpmmContract.prob * 100) + '%', - questionChange: - (marketChange > 0 ? '+' : '') + - Math.round(marketChange * 100) + - '%', - questionChangeStyle: `color: ${ + profitStyle: `color: ${ profit > 0 ? 'rgba(0,160,0,1)' : '#a80000' };`, } as PerContractInvestmentsData }) ), - (differences) => Math.abs(differences.difference) + (differences) => Math.abs(differences.profit) ).reverse() log( @@ -233,12 +227,10 @@ export async function sendPortfolioUpdateEmailsToAllUsers() { const [winningInvestments, losingInvestments] = partition( investmentValueDifferences.filter( - (diff) => - diff.pastValue > 0.01 && - Math.abs(diff.difference / diff.pastValue) > 0.01 // difference is greater than 1% + (diff) => diff.pastValue > 0.01 && Math.abs(diff.profit) > 1 ), (investmentsData: PerContractInvestmentsData) => { - return investmentsData.difference > 0 + return investmentsData.profit > 0 } ) // pick 3 winning investments and 3 losing investments @@ -251,7 +243,9 @@ export async function sendPortfolioUpdateEmailsToAllUsers() { worstInvestments.length === 0 && usersToContractsCreated[privateUser.id].length === 0 ) { - log('No bets in last week, no market movers, no markets created') + log( + 'No bets in last week, no market movers, no markets created. Not sending an email.' + ) await firestore.collection('private-users').doc(privateUser.id).update({ weeklyPortfolioUpdateEmailSent: true, }) @@ -268,7 +262,7 @@ export async function sendPortfolioUpdateEmailsToAllUsers() { }) log('Sent weekly portfolio update email to', privateUser.email) count++ - log('sent out emails to user count:', count) + log('sent out emails to users:', count) }) ) } @@ -277,11 +271,10 @@ export type PerContractInvestmentsData = { questionTitle: string questionUrl: string questionProb: string - questionChange: string - questionChangeStyle: string + profitStyle: string currentValue: number pastValue: number - difference: number + profit: number } export type OverallPerformanceData = { From 5b5a919ed7a483c3c5789bfeb5914f5392924a5e Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Thu, 29 Sep 2022 20:18:33 -0700 Subject: [PATCH 050/135] Expose `onMouseOver` chart event to hook into from outside (#967) --- web/components/charts/contract/binary.tsx | 13 ++++---- web/components/charts/contract/choice.tsx | 4 ++- web/components/charts/contract/numeric.tsx | 4 ++- .../charts/contract/pseudo-numeric.tsx | 4 ++- web/components/charts/generic-charts.tsx | 32 ++++++++----------- 5 files changed, 29 insertions(+), 28 deletions(-) diff --git a/web/components/charts/contract/binary.tsx b/web/components/charts/contract/binary.tsx index 55cf4e88..a5740a3e 100644 --- a/web/components/charts/contract/binary.tsx +++ b/web/components/charts/contract/binary.tsx @@ -46,20 +46,20 @@ export const BinaryContractChart = (props: { contract: BinaryContract bets: Bet[] height?: number + onMouseOver?: (p: HistoryPoint<Bet> | undefined) => void }) => { - const { contract, bets } = props + const { contract, bets, onMouseOver } = props const [startDate, endDate] = getDateRange(contract) const startP = getInitialProbability(contract) const endP = getProbability(contract) const betPoints = useMemo(() => getBetPoints(bets), [bets]) - const data = useMemo( - () => [ + const data = useMemo(() => { + return [ { x: startDate, y: startP }, ...betPoints, { x: endDate ?? new Date(Date.now() + DAY_MS), y: endP }, - ], - [startDate, startP, endDate, endP, betPoints] - ) + ] + }, [startDate, startP, endDate, endP, betPoints]) const rightmostDate = getRightmostVisibleDate( endDate, @@ -84,6 +84,7 @@ export const BinaryContractChart = (props: { yScale={yScale} data={data} color="#11b981" + onMouseOver={onMouseOver} Tooltip={BinaryChartTooltip} pct /> diff --git a/web/components/charts/contract/choice.tsx b/web/components/charts/contract/choice.tsx index d5d0d09e..05d3255e 100644 --- a/web/components/charts/contract/choice.tsx +++ b/web/components/charts/contract/choice.tsx @@ -125,8 +125,9 @@ export const ChoiceContractChart = (props: { contract: FreeResponseContract | MultipleChoiceContract bets: Bet[] height?: number + onMouseOver?: (p: MultiPoint<Bet> | undefined) => void }) => { - const { contract, bets } = props + const { contract, bets, onMouseOver } = props const [start, end] = getDateRange(contract) const answers = useMemo( () => getTrackedAnswers(contract, CATEGORY_COLORS.length), @@ -194,6 +195,7 @@ export const ChoiceContractChart = (props: { yScale={yScale} data={data} colors={CATEGORY_COLORS} + onMouseOver={onMouseOver} Tooltip={ChoiceTooltip} pct /> diff --git a/web/components/charts/contract/numeric.tsx b/web/components/charts/contract/numeric.tsx index 3c14149a..dd031ab8 100644 --- a/web/components/charts/contract/numeric.tsx +++ b/web/components/charts/contract/numeric.tsx @@ -33,8 +33,9 @@ const NumericChartTooltip = (props: TooltipProps<DistributionPoint>) => { export const NumericContractChart = (props: { contract: NumericContract height?: number + onMouseOver?: (p: DistributionPoint | undefined) => void }) => { - const { contract } = props + const { contract, onMouseOver } = props const { min, max } = contract const data = useMemo(() => getNumericChartData(contract), [contract]) const isMobile = useIsMobile(800) @@ -54,6 +55,7 @@ export const NumericContractChart = (props: { yScale={yScale} data={data} color={NUMERIC_GRAPH_COLOR} + onMouseOver={onMouseOver} Tooltip={NumericChartTooltip} /> )} diff --git a/web/components/charts/contract/pseudo-numeric.tsx b/web/components/charts/contract/pseudo-numeric.tsx index fb88b15a..385e56dd 100644 --- a/web/components/charts/contract/pseudo-numeric.tsx +++ b/web/components/charts/contract/pseudo-numeric.tsx @@ -58,8 +58,9 @@ export const PseudoNumericContractChart = (props: { contract: PseudoNumericContract bets: Bet[] height?: number + onMouseOver?: (p: HistoryPoint<Bet> | undefined) => void }) => { - const { contract, bets } = props + const { contract, bets, onMouseOver } = props const { min, max, isLogScale } = contract const [startDate, endDate] = getDateRange(contract) const scaleP = useMemo( @@ -102,6 +103,7 @@ export const PseudoNumericContractChart = (props: { xScale={xScale} yScale={yScale} data={data} + onMouseOver={onMouseOver} Tooltip={PseudoNumericChartTooltip} color={NUMERIC_GRAPH_COLOR} /> diff --git a/web/components/charts/generic-charts.tsx b/web/components/charts/generic-charts.tsx index 344ae061..5ae30ad4 100644 --- a/web/components/charts/generic-charts.tsx +++ b/web/components/charts/generic-charts.tsx @@ -38,6 +38,7 @@ export const DistributionChart = <P extends DistributionPoint>(props: { color: string xScale: ScaleContinuousNumeric<number, number> yScale: ScaleContinuousNumeric<number, number> + onMouseOver?: (p: P | undefined) => void Tooltip?: TooltipComponent<P> }) => { const { color, data, yScale, w, h, Tooltip } = props @@ -71,12 +72,9 @@ export const DistributionChart = <P extends DistributionPoint>(props: { const onMouseOver = useEvent((mouseX: number) => { const queryX = xScale.invert(mouseX) const item = data[xBisector.left(data, queryX) - 1] - if (item == null) { - // this can happen if you are on the very left or right edge of the chart, - // so your queryX is out of bounds - return - } - return { ...item, x: queryX } + const result = item ? { ...item, x: queryX } : undefined + props.onMouseOver?.(result) + return result }) return ( @@ -108,6 +106,7 @@ export const MultiValueHistoryChart = <P extends MultiPoint>(props: { colors: readonly string[] xScale: ScaleTime<number, number> yScale: ScaleContinuousNumeric<number, number> + onMouseOver?: (p: P | undefined) => void Tooltip?: TooltipComponent<P> pct?: boolean }) => { @@ -156,12 +155,9 @@ export const MultiValueHistoryChart = <P extends MultiPoint>(props: { const onMouseOver = useEvent((mouseX: number) => { const queryX = xScale.invert(mouseX) const item = data[xBisector.left(data, queryX) - 1] - if (item == null) { - // this can happen if you are on the very left or right edge of the chart, - // so your queryX is out of bounds - return - } - return { ...item, x: queryX } + const result = item ? { ...item, x: queryX } : undefined + props.onMouseOver?.(result) + return result }) return ( @@ -196,10 +192,11 @@ export const SingleValueHistoryChart = <P extends HistoryPoint>(props: { color: string xScale: ScaleTime<number, number> yScale: ScaleContinuousNumeric<number, number> + onMouseOver?: (p: P | undefined) => void Tooltip?: TooltipComponent<P> pct?: boolean }) => { - const { color, data, pct, yScale, w, h, Tooltip } = props + const { color, data, yScale, w, h, Tooltip, pct } = props const [viewXScale, setViewXScale] = useState<ScaleTime<number, number>>() const xScale = viewXScale ?? props.xScale @@ -235,12 +232,9 @@ export const SingleValueHistoryChart = <P extends HistoryPoint>(props: { const onMouseOver = useEvent((mouseX: number) => { const queryX = xScale.invert(mouseX) const item = data[xBisector.left(data, queryX) - 1] - if (item == null) { - // this can happen if you are on the very left or right edge of the chart, - // so your queryX is out of bounds - return - } - return { ...item, x: queryX } + const result = item ? { ...item, x: queryX } : undefined + props.onMouseOver?.(result) + return result }) return ( From 715bae57e09a72286be89f94b6a35ebd3643dea2 Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Thu, 29 Sep 2022 21:35:20 -0700 Subject: [PATCH 051/135] Fix date memoization in charts (#972) * Memoize on numbers, not dates * Use numbers instead of dates to calculate visible range --- web/components/charts/contract/binary.tsx | 16 ++++++++-------- web/components/charts/contract/choice.tsx | 8 ++++---- .../charts/contract/pseudo-numeric.tsx | 16 ++++++++-------- web/components/charts/helpers.tsx | 10 +++++----- 4 files changed, 25 insertions(+), 25 deletions(-) diff --git a/web/components/charts/contract/binary.tsx b/web/components/charts/contract/binary.tsx index a5740a3e..a6ba9bee 100644 --- a/web/components/charts/contract/binary.tsx +++ b/web/components/charts/contract/binary.tsx @@ -49,24 +49,24 @@ export const BinaryContractChart = (props: { onMouseOver?: (p: HistoryPoint<Bet> | undefined) => void }) => { const { contract, bets, onMouseOver } = props - const [startDate, endDate] = getDateRange(contract) + const [start, end] = getDateRange(contract) const startP = getInitialProbability(contract) const endP = getProbability(contract) const betPoints = useMemo(() => getBetPoints(bets), [bets]) const data = useMemo(() => { return [ - { x: startDate, y: startP }, + { x: new Date(start), y: startP }, ...betPoints, - { x: endDate ?? new Date(Date.now() + DAY_MS), y: endP }, + { x: new Date(end ?? Date.now() + DAY_MS), y: endP }, ] - }, [startDate, startP, endDate, endP, betPoints]) + }, [start, startP, end, endP, betPoints]) const rightmostDate = getRightmostVisibleDate( - endDate, - last(betPoints)?.x, - new Date(Date.now()) + end, + last(betPoints)?.x?.getTime(), + Date.now() ) - const visibleRange = [startDate, rightmostDate] + const visibleRange = [start, rightmostDate] const isMobile = useIsMobile(800) const containerRef = useRef<HTMLDivElement>(null) const width = useElementWidth(containerRef) ?? 0 diff --git a/web/components/charts/contract/choice.tsx b/web/components/charts/contract/choice.tsx index 05d3255e..127e7d9c 100644 --- a/web/components/charts/contract/choice.tsx +++ b/web/components/charts/contract/choice.tsx @@ -136,10 +136,10 @@ export const ChoiceContractChart = (props: { const betPoints = useMemo(() => getBetPoints(answers, bets), [answers, bets]) const data = useMemo( () => [ - { x: start, y: answers.map((_) => 0) }, + { x: new Date(start), y: answers.map((_) => 0) }, ...betPoints, { - x: end ?? new Date(Date.now() + DAY_MS), + x: new Date(end ?? Date.now() + DAY_MS), y: answers.map((a) => getOutcomeProbability(contract, a.id)), }, ], @@ -147,8 +147,8 @@ export const ChoiceContractChart = (props: { ) const rightmostDate = getRightmostVisibleDate( end, - last(betPoints)?.x, - new Date(Date.now()) + last(betPoints)?.x?.getTime(), + Date.now() ) const visibleRange = [start, rightmostDate] const isMobile = useIsMobile(800) diff --git a/web/components/charts/contract/pseudo-numeric.tsx b/web/components/charts/contract/pseudo-numeric.tsx index 385e56dd..1232a96c 100644 --- a/web/components/charts/contract/pseudo-numeric.tsx +++ b/web/components/charts/contract/pseudo-numeric.tsx @@ -62,7 +62,7 @@ export const PseudoNumericContractChart = (props: { }) => { const { contract, bets, onMouseOver } = props const { min, max, isLogScale } = contract - const [startDate, endDate] = getDateRange(contract) + const [start, end] = getDateRange(contract) const scaleP = useMemo( () => getScaleP(min, max, isLogScale), [min, max, isLogScale] @@ -72,18 +72,18 @@ export const PseudoNumericContractChart = (props: { const betPoints = useMemo(() => getBetPoints(bets, scaleP), [bets, scaleP]) const data = useMemo( () => [ - { x: startDate, y: startP }, + { x: new Date(start), y: startP }, ...betPoints, - { x: endDate ?? new Date(Date.now() + DAY_MS), y: endP }, + { x: new Date(end ?? Date.now() + DAY_MS), y: endP }, ], - [betPoints, startDate, startP, endDate, endP] + [betPoints, start, startP, end, endP] ) const rightmostDate = getRightmostVisibleDate( - endDate, - last(betPoints)?.x, - new Date(Date.now()) + end, + last(betPoints)?.x?.getTime(), + Date.now() ) - const visibleRange = [startDate, rightmostDate] + const visibleRange = [start, rightmostDate] const isMobile = useIsMobile(800) const containerRef = useRef<HTMLDivElement>(null) const width = useElementWidth(containerRef) ?? 0 diff --git a/web/components/charts/helpers.tsx b/web/components/charts/helpers.tsx index 35c8a335..ea436213 100644 --- a/web/components/charts/helpers.tsx +++ b/web/components/charts/helpers.tsx @@ -259,19 +259,19 @@ export const getDateRange = (contract: Contract) => { const { createdTime, closeTime, resolutionTime } = contract const isClosed = !!closeTime && Date.now() > closeTime const endDate = resolutionTime ?? (isClosed ? closeTime : null) - return [new Date(createdTime), endDate ? new Date(endDate) : null] as const + return [createdTime, endDate ?? null] as const } export const getRightmostVisibleDate = ( - contractEnd: Date | null | undefined, - lastActivity: Date | null | undefined, - now: Date + contractEnd: number | null | undefined, + lastActivity: number | null | undefined, + now: number ) => { if (contractEnd != null) { return contractEnd } else if (lastActivity != null) { // client-DB clock divergence may cause last activity to be later than now - return new Date(Math.max(lastActivity.getTime(), now.getTime())) + return Math.max(lastActivity, now) } else { return now } From 13b3613460d56f9c1b086f202e485ca338c16137 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Thu, 29 Sep 2022 23:57:45 -0500 Subject: [PATCH 052/135] Show number of limit orders --- web/components/limit-bets.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/components/limit-bets.tsx b/web/components/limit-bets.tsx index 606bc7e0..bd9ed246 100644 --- a/web/components/limit-bets.tsx +++ b/web/components/limit-bets.tsx @@ -182,7 +182,7 @@ export function OrderBookButton(props: { size="xs" color="blue" > - Order book + {limitBets.length} Limit orders </Button> <Modal open={open} setOpen={setOpen} size="lg"> From b83e5db56370b6992ce0d556a39354a501eda633 Mon Sep 17 00:00:00 2001 From: ingawei <46611122+ingawei@users.noreply.github.com> Date: Fri, 30 Sep 2022 00:41:22 -0500 Subject: [PATCH 053/135] getting rid of daisy buttons (#969) * getting rid of daisy buttons so bet button does not turn black on mobile --- web/components/answers/answer-bet-panel.tsx | 6 +-- .../answers/answer-resolve-panel.tsx | 32 +++++++------ web/components/bet-panel.tsx | 14 ++---- web/components/bets-list.tsx | 3 +- web/components/button.tsx | 28 +++++++----- web/components/confirmation-button.tsx | 34 +++++++++----- web/components/groups/create-group-button.tsx | 7 +-- web/components/numeric-resolution-panel.tsx | 4 +- web/components/resolution-panel.tsx | 32 +++++-------- .../warning-confirmation-button.tsx | 45 ++++++++----------- web/components/yes-no-selector.tsx | 4 +- web/pages/profile.tsx | 5 ++- 12 files changed, 107 insertions(+), 107 deletions(-) diff --git a/web/components/answers/answer-bet-panel.tsx b/web/components/answers/answer-bet-panel.tsx index 85f61034..9867abab 100644 --- a/web/components/answers/answer-bet-panel.tsx +++ b/web/components/answers/answer-bet-panel.tsx @@ -184,16 +184,14 @@ export function AnswerBetPanel(props: { <Spacer h={6} /> {user ? ( <WarningConfirmationButton + size="xl" marketType="freeResponse" amount={betAmount} warning={warning} onSubmit={submitBet} isSubmitting={isSubmitting} disabled={!!betDisabled} - openModalButtonClass={clsx( - 'btn self-stretch', - betDisabled ? 'btn-disabled' : 'btn-primary' - )} + color={'indigo'} /> ) : ( <BetSignUpPrompt /> diff --git a/web/components/answers/answer-resolve-panel.tsx b/web/components/answers/answer-resolve-panel.tsx index ddb7942c..57871cb8 100644 --- a/web/components/answers/answer-resolve-panel.tsx +++ b/web/components/answers/answer-resolve-panel.tsx @@ -85,17 +85,6 @@ export function AnswerResolvePanel(props: { setIsSubmitting(false) } - const resolutionButtonClass = - resolveOption === 'CANCEL' - ? 'bg-yellow-400 hover:bg-yellow-500' - : resolveOption === 'CHOOSE' && answers.length - ? 'btn-primary' - : resolveOption === 'CHOOSE_MULTIPLE' && - answers.length > 1 && - answers.every((answer) => chosenAnswers[answer] > 0) - ? 'bg-blue-400 hover:bg-blue-500' - : 'btn-disabled' - return ( <Col className="gap-4 rounded"> <Row className="justify-between"> @@ -129,11 +118,28 @@ export function AnswerResolvePanel(props: { Clear </button> )} + <ResolveConfirmationButton + color={ + resolveOption === 'CANCEL' + ? 'yellow' + : resolveOption === 'CHOOSE' && answers.length + ? 'green' + : resolveOption === 'CHOOSE_MULTIPLE' && + answers.length > 1 && + answers.every((answer) => chosenAnswers[answer] > 0) + ? 'blue' + : 'indigo' + } + disabled={ + !resolveOption || + (resolveOption === 'CHOOSE' && !answers.length) || + (resolveOption === 'CHOOSE_MULTIPLE' && + (!(answers.length > 1) || + !answers.every((answer) => chosenAnswers[answer] > 0))) + } onResolve={onResolve} isSubmitting={isSubmitting} - openModalButtonClass={resolutionButtonClass} - submitButtonClass={resolutionButtonClass} /> </Row> </Col> diff --git a/web/components/bet-panel.tsx b/web/components/bet-panel.tsx index 5d908937..e93c0e62 100644 --- a/web/components/bet-panel.tsx +++ b/web/components/bet-panel.tsx @@ -395,22 +395,16 @@ export function BuyPanel(props: { <WarningConfirmationButton marketType="binary" amount={betAmount} - outcome={outcome} warning={warning} onSubmit={submitBet} isSubmitting={isSubmitting} - openModalButtonClass={clsx( - 'btn mb-2 flex-1', - betDisabled || outcome === undefined - ? 'btn-disabled bg-greyscale-2' - : outcome === 'NO' - ? 'border-none bg-red-400 hover:bg-red-500' - : 'border-none bg-teal-500 hover:bg-teal-600' - )} + disabled={!!betDisabled || outcome === undefined} + size="xl" + color={outcome === 'NO' ? 'red' : 'green'} /> )} <button - className="text-greyscale-6 mx-auto select-none text-sm underline xl:hidden" + className="text-greyscale-6 mx-auto mt-3 select-none text-sm underline xl:hidden" onClick={() => setSeeLimit(true)} > Advanced diff --git a/web/components/bets-list.tsx b/web/components/bets-list.tsx index 5a95f22f..0ba90c54 100644 --- a/web/components/bets-list.tsx +++ b/web/components/bets-list.tsx @@ -2,7 +2,6 @@ import Link from 'next/link' import { keyBy, groupBy, mapValues, sortBy, partition, sumBy } from 'lodash' import dayjs from 'dayjs' import { useMemo, useState } from 'react' -import clsx from 'clsx' import { ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/solid' import { Bet } from 'web/lib/firebase/bets' @@ -599,8 +598,8 @@ function SellButton(props: { return ( <ConfirmationButton openModalBtn={{ - className: clsx('btn-sm', isSubmitting && 'btn-disabled loading'), label: 'Sell', + disabled: isSubmitting, }} submitBtn={{ className: 'btn-primary', label: 'Sell' }} onSubmit={async () => { diff --git a/web/components/button.tsx b/web/components/button.tsx index ea9a3e88..51e25ea1 100644 --- a/web/components/button.tsx +++ b/web/components/button.tsx @@ -46,21 +46,27 @@ export function Button(props: { <button type={type} className={clsx( - 'font-md items-center justify-center rounded-md border border-transparent shadow-sm hover:transition-colors disabled:cursor-not-allowed disabled:opacity-50', + className, + 'font-md items-center justify-center rounded-md border border-transparent shadow-sm transition-colors disabled:cursor-not-allowed', sizeClasses, - color === 'green' && 'btn-primary text-white', - color === 'red' && 'bg-red-400 text-white hover:bg-red-500', - color === 'yellow' && 'bg-yellow-400 text-white hover:bg-yellow-500', - color === 'blue' && 'bg-blue-400 text-white hover:bg-blue-500', - color === 'indigo' && 'bg-indigo-500 text-white hover:bg-indigo-600', - color === 'gray' && 'bg-gray-50 text-gray-600 hover:bg-gray-200', + color === 'green' && + 'disabled:bg-greyscale-2 bg-teal-500 text-white hover:bg-teal-600', + color === 'red' && + 'disabled:bg-greyscale-2 bg-red-400 text-white hover:bg-red-500', + color === 'yellow' && + 'disabled:bg-greyscale-2 bg-yellow-400 text-white hover:bg-yellow-500', + color === 'blue' && + 'disabled:bg-greyscale-2 bg-blue-400 text-white hover:bg-blue-500', + color === 'indigo' && + 'disabled:bg-greyscale-2 bg-indigo-500 text-white hover:bg-indigo-600', + color === 'gray' && + 'bg-greyscale-1 text-greyscale-6 hover:bg-greyscale-2 disabled:opacity-50', color === 'gradient' && - 'border-none bg-gradient-to-r from-indigo-500 to-blue-500 text-white hover:from-indigo-700 hover:to-blue-700', + 'disabled:bg-greyscale-2 border-none bg-gradient-to-r from-indigo-500 to-blue-500 text-white hover:from-indigo-700 hover:to-blue-700', color === 'gray-white' && - 'text-greyscale-6 hover:bg-greyscale-2 border-none shadow-none', + 'text-greyscale-6 hover:bg-greyscale-2 border-none shadow-none disabled:opacity-50', color === 'highlight-blue' && - 'text-highlight-blue border-none shadow-none', - className + 'text-highlight-blue disabled:bg-greyscale-2 border-none shadow-none' )} disabled={disabled} onClick={onClick} diff --git a/web/components/confirmation-button.tsx b/web/components/confirmation-button.tsx index 8dbe90c2..2ad0cb3d 100644 --- a/web/components/confirmation-button.tsx +++ b/web/components/confirmation-button.tsx @@ -1,5 +1,6 @@ import clsx from 'clsx' import { ReactNode, useState } from 'react' +import { Button, ColorType, SizeType } from './button' import { Col } from './layout/col' import { Modal } from './layout/modal' import { Row } from './layout/row' @@ -9,6 +10,9 @@ export function ConfirmationButton(props: { label: string icon?: JSX.Element className?: string + color?: ColorType + size?: SizeType + disabled?: boolean } cancelBtn?: { label?: string @@ -68,13 +72,16 @@ export function ConfirmationButton(props: { </Row> </Col> </Modal> - <div - className={clsx('btn', openModalBtn.className)} + <Button + className={clsx(openModalBtn.className)} onClick={() => updateOpen(true)} + disabled={openModalBtn.disabled} + color={openModalBtn.color} + size={openModalBtn.size} > {openModalBtn.icon} {openModalBtn.label} - </div> + </Button> </> ) } @@ -84,18 +91,25 @@ export function ResolveConfirmationButton(props: { isSubmitting: boolean openModalButtonClass?: string submitButtonClass?: string + color?: ColorType + disabled?: boolean }) { - const { onResolve, isSubmitting, openModalButtonClass, submitButtonClass } = - props + const { + onResolve, + isSubmitting, + openModalButtonClass, + submitButtonClass, + color, + disabled, + } = props return ( <ConfirmationButton openModalBtn={{ - className: clsx( - 'border-none self-start', - openModalButtonClass, - isSubmitting && 'btn-disabled loading' - ), + className: clsx('border-none self-start', openModalButtonClass), label: 'Resolve', + color: color, + disabled: isSubmitting || disabled, + size: 'xl', }} cancelBtn={{ label: 'Back', diff --git a/web/components/groups/create-group-button.tsx b/web/components/groups/create-group-button.tsx index e0324c4e..e8376b4b 100644 --- a/web/components/groups/create-group-button.tsx +++ b/web/components/groups/create-group-button.tsx @@ -82,11 +82,8 @@ export function CreateGroupButton(props: { openModalBtn={{ label: label ? label : 'Create Group', icon: icon, - className: clsx( - isSubmitting ? 'loading btn-disabled' : 'btn-primary', - 'btn-sm, normal-case', - className - ), + className: className, + disabled: isSubmitting, }} submitBtn={{ label: 'Create', diff --git a/web/components/numeric-resolution-panel.tsx b/web/components/numeric-resolution-panel.tsx index 0220f7a7..fa36dff3 100644 --- a/web/components/numeric-resolution-panel.tsx +++ b/web/components/numeric-resolution-panel.tsx @@ -128,8 +128,10 @@ export function NumericResolutionPanel(props: { <ResolveConfirmationButton onResolve={resolve} isSubmitting={isSubmitting} - openModalButtonClass={clsx('w-full mt-2', submitButtonClass)} + openModalButtonClass={clsx('w-full mt-2')} submitButtonClass={submitButtonClass} + color={outcomeMode === 'CANCEL' ? 'yellow' : 'indigo'} + disabled={outcomeMode === undefined} /> </Col> ) diff --git a/web/components/resolution-panel.tsx b/web/components/resolution-panel.tsx index 7ef6e4f3..b3237eb4 100644 --- a/web/components/resolution-panel.tsx +++ b/web/components/resolution-panel.tsx @@ -57,17 +57,6 @@ export function ResolutionPanel(props: { setIsSubmitting(false) } - const submitButtonClass = - outcome === 'YES' - ? 'btn-primary' - : outcome === 'NO' - ? 'bg-red-400 hover:bg-red-500' - : outcome === 'CANCEL' - ? 'bg-yellow-400 hover:bg-yellow-500' - : outcome === 'MKT' - ? 'bg-blue-400 hover:bg-blue-500' - : 'btn-disabled' - return ( <Col className={clsx('relative rounded-md bg-white px-8 py-6', className)}> {isAdmin && !isCreator && ( @@ -76,18 +65,14 @@ export function ResolutionPanel(props: { </span> )} <div className="mb-6 whitespace-nowrap text-2xl">Resolve market</div> - <div className="mb-3 text-sm text-gray-500">Outcome</div> - <YesNoCancelSelector className="mx-auto my-2" selected={outcome} onSelect={setOutcome} btnClassName={isSubmitting ? 'btn-disabled' : ''} /> - <Spacer h={4} /> - <div> {outcome === 'YES' ? ( <> @@ -123,16 +108,23 @@ export function ResolutionPanel(props: { <>Resolving this market will immediately pay out {BETTORS}.</> )} </div> - <Spacer h={4} /> - {!!error && <div className="text-red-500">{error}</div>} - <ResolveConfirmationButton + color={ + outcome === 'YES' + ? 'green' + : outcome === 'NO' + ? 'red' + : outcome === 'CANCEL' + ? 'yellow' + : outcome === 'MKT' + ? 'blue' + : 'indigo' + } + disabled={!outcome} onResolve={resolve} isSubmitting={isSubmitting} - openModalButtonClass={clsx('w-full mt-2', submitButtonClass)} - submitButtonClass={submitButtonClass} /> </Col> ) diff --git a/web/components/warning-confirmation-button.tsx b/web/components/warning-confirmation-button.tsx index 7c546c3b..abdf443e 100644 --- a/web/components/warning-confirmation-button.tsx +++ b/web/components/warning-confirmation-button.tsx @@ -5,17 +5,18 @@ import { Row } from './layout/row' import { ConfirmationButton } from './confirmation-button' import { ExclamationIcon } from '@heroicons/react/solid' import { formatMoney } from 'common/util/format' +import { Button, ColorType, SizeType } from './button' export function WarningConfirmationButton(props: { amount: number | undefined - outcome?: 'YES' | 'NO' | undefined marketType: 'freeResponse' | 'binary' warning?: string onSubmit: () => void - disabled?: boolean + disabled: boolean isSubmitting: boolean openModalButtonClass?: string - submitButtonClassName?: string + color: ColorType + size: SizeType }) { const { amount, @@ -24,53 +25,43 @@ export function WarningConfirmationButton(props: { disabled, isSubmitting, openModalButtonClass, - submitButtonClassName, - outcome, - marketType, + size, + color, } = props + if (!warning) { return ( - <button - className={clsx( - openModalButtonClass, - isSubmitting ? 'loading btn-disabled' : '', - disabled && 'btn-disabled', - marketType === 'binary' - ? !outcome - ? 'btn-disabled bg-greyscale-2' - : '' - : '' - )} + <Button + size={size} + disabled={isSubmitting || disabled} + className={clsx(openModalButtonClass)} onClick={onSubmit} + color={color} > {isSubmitting ? 'Submitting...' : amount ? `Wager ${formatMoney(amount)}` : 'Wager'} - </button> + </Button> ) } return ( <ConfirmationButton openModalBtn={{ - className: clsx( - openModalButtonClass, - isSubmitting && 'btn-disabled loading' - ), label: amount ? `Wager ${formatMoney(amount)}` : 'Wager', + size: size, + color: 'yellow', + disabled: isSubmitting, }} cancelBtn={{ label: 'Cancel', - className: 'btn-warning', + className: 'btn btn-warning', }} submitBtn={{ label: 'Submit', - className: clsx( - 'border-none btn-sm btn-ghost self-center', - submitButtonClassName - ), + className: clsx('btn border-none btn-sm btn-ghost self-center'), }} onSubmit={onSubmit} > diff --git a/web/components/yes-no-selector.tsx b/web/components/yes-no-selector.tsx index f73cdef2..10a58a42 100644 --- a/web/components/yes-no-selector.tsx +++ b/web/components/yes-no-selector.tsx @@ -213,7 +213,7 @@ export function NumberCancelSelector(props: { return ( <Col className={clsx('gap-2', className)}> <Button - color={selected === 'NUMBER' ? 'green' : 'gray'} + color={selected === 'NUMBER' ? 'indigo' : 'gray'} onClick={() => onSelect('NUMBER')} className={clsx('whitespace-nowrap', btnClassName)} > @@ -244,7 +244,7 @@ function Button(props: { type="button" className={clsx( 'inline-flex flex-1 items-center justify-center rounded-md border border-transparent px-8 py-3 font-medium shadow-sm', - color === 'green' && 'btn-primary text-white', + color === 'green' && 'bg-teal-500 bg-teal-600 text-white', color === 'red' && 'bg-red-400 text-white hover:bg-red-500', color === 'yellow' && 'bg-yellow-400 text-white hover:bg-yellow-500', color === 'blue' && 'bg-blue-400 text-white hover:bg-blue-500', diff --git a/web/pages/profile.tsx b/web/pages/profile.tsx index caa9f47a..5773f30f 100644 --- a/web/pages/profile.tsx +++ b/web/pages/profile.tsx @@ -218,9 +218,10 @@ export default function ProfilePage(props: { /> <ConfirmationButton openModalBtn={{ - className: 'btn btn-primary btn-square p-2', + className: 'p-2', label: '', - icon: <RefreshIcon />, + icon: <RefreshIcon className="h-5 w-5" />, + color: 'indigo', }} submitBtn={{ label: 'Update key', From 523689b52520daec75ec44ccdc0bac1fd3e52c02 Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Thu, 29 Sep 2022 22:45:31 -0700 Subject: [PATCH 054/135] Keep tooltip within bounds of chart (well, for non-FR charts) (#970) --- web/components/charts/helpers.tsx | 53 ++++++++++++++++++++++++------- 1 file changed, 42 insertions(+), 11 deletions(-) diff --git a/web/components/charts/helpers.tsx b/web/components/charts/helpers.tsx index ea436213..b7948298 100644 --- a/web/components/charts/helpers.tsx +++ b/web/components/charts/helpers.tsx @@ -25,6 +25,8 @@ export type YScale<P> = P extends Point<infer _, infer Y> ? AxisScale<Y> : never export const MARGIN = { top: 20, right: 10, bottom: 20, left: 40 } export const MARGIN_X = MARGIN.right + MARGIN.left export const MARGIN_Y = MARGIN.top + MARGIN.bottom +const MARGIN_STYLE = `${MARGIN.top}px ${MARGIN.right}px ${MARGIN.bottom}px ${MARGIN.left}px` +const MARGIN_XFORM = `translate(${MARGIN.left}, ${MARGIN.top})` export const XAxis = <X,>(props: { w: number; h: number; axis: Axis<X> }) => { const { h, axis } = props @@ -128,7 +130,7 @@ export const SVGChart = <X, Y, P extends Point<X, Y>>(props: { Tooltip?: TooltipComponent<P> }) => { const { children, w, h, xAxis, yAxis, onMouseOver, onSelect, Tooltip } = props - const [mouseState, setMouseState] = useState<TooltipPosition & { p: P }>() + const [mouseState, setMouseState] = useState<{ pos: TooltipPosition; p: P }>() const overlayRef = useRef<SVGGElement>(null) const innerW = w - MARGIN_X const innerH = h - MARGIN_Y @@ -170,7 +172,8 @@ export const SVGChart = <X, Y, P extends Point<X, Y>>(props: { const [mouseX, mouseY] = pointer(ev) const p = onMouseOver(mouseX, mouseY) if (p != null) { - setMouseState({ top: mouseY - 10, left: mouseX + 60, p }) + const pos = getTooltipPosition(mouseX, mouseY, innerW, innerH) + setMouseState({ pos, p }) } else { setMouseState(undefined) } @@ -184,15 +187,15 @@ export const SVGChart = <X, Y, P extends Point<X, Y>>(props: { return ( <div className="relative"> {mouseState && Tooltip && ( - <TooltipContainer top={mouseState.top} left={mouseState.left}> + <TooltipContainer pos={mouseState.pos}> <Tooltip xScale={xAxis.scale()} p={mouseState.p} /> </TooltipContainer> )} - <svg className="w-full" width={w} height={h} viewBox={`0 0 ${w} ${h}`}> + <svg width={w} height={h} viewBox={`0 0 ${w} ${h}`}> <clipPath id={clipPathId}> <rect x={0} y={0} width={innerW} height={innerH} /> </clipPath> - <g transform={`translate(${MARGIN.left}, ${MARGIN.top})`}> + <g transform={MARGIN_XFORM}> <XAxis axis={xAxis} w={innerW} h={innerH} /> <YAxis axis={yAxis} w={innerW} h={innerH} /> <g clipPath={`url(#${clipPathId})`}>{children}</g> @@ -214,20 +217,48 @@ export const SVGChart = <X, Y, P extends Point<X, Y>>(props: { ) } +export type TooltipPosition = { + top?: number + right?: number + bottom?: number + left?: number +} + +export const getTooltipPosition = ( + mouseX: number, + mouseY: number, + w: number, + h: number +) => { + const result: TooltipPosition = {} + if (mouseX <= (3 * w) / 4) { + result.left = mouseX + 10 // in the left three quarters + } else { + result.right = w - mouseX + 10 // in the right quarter + } + if (mouseY <= h / 4) { + result.top = mouseY + 10 // in the top quarter + } else { + result.bottom = h - mouseY + 10 // in the bottom three quarters + } + return result +} + export type TooltipProps<P> = { p: P; xScale: XScale<P> } export type TooltipComponent<P> = React.ComponentType<TooltipProps<P>> -export type TooltipPosition = { top: number; left: number } -export const TooltipContainer = ( - props: TooltipPosition & { className?: string; children: React.ReactNode } -) => { - const { top, left, className, children } = props +export const TooltipContainer = (props: { + pos: TooltipPosition + className?: string + children: React.ReactNode +}) => { + const { pos, className, children } = props return ( <div className={clsx( className, 'pointer-events-none absolute z-10 whitespace-pre rounded border-2 border-black bg-white/90 p-2' )} - style={{ top, left }} + style={{ margin: MARGIN_STYLE, ...pos }} > {children} </div> From 7e91133229e176527e06bed3fa3d3ceacd3e2c5d Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Thu, 29 Sep 2022 22:45:51 -0700 Subject: [PATCH 055/135] Change styles on contract tooltips to be more like portfolio graph (#966) --- web/components/charts/contract/binary.tsx | 6 ++-- web/components/charts/contract/choice.tsx | 34 ++++++++++++++++--- web/components/charts/contract/numeric.tsx | 7 ++-- .../charts/contract/pseudo-numeric.tsx | 6 ++-- web/components/charts/helpers.tsx | 24 +------------ 5 files changed, 40 insertions(+), 37 deletions(-) diff --git a/web/components/charts/contract/binary.tsx b/web/components/charts/contract/binary.tsx index a6ba9bee..264e1e3b 100644 --- a/web/components/charts/contract/binary.tsx +++ b/web/components/charts/contract/binary.tsx @@ -34,10 +34,10 @@ const BinaryChartTooltip = (props: TooltipProps<HistoryPoint<Bet>>) => { const { x, y, datum } = p const [start, end] = xScale.domain() return ( - <Row className="items-center gap-2 text-sm"> + <Row className="items-center gap-2"> {datum && <Avatar size="xs" avatarUrl={datum.userAvatarUrl} />} - <strong>{formatPct(y)}</strong> - <span>{formatDateInRange(x, start, end)}</span> + <span className="font-semibold">{formatDateInRange(x, start, end)}</span> + <span className="text-greyscale-6">{formatPct(y)}</span> </Row> ) } diff --git a/web/components/charts/contract/choice.tsx b/web/components/charts/contract/choice.tsx index 127e7d9c..1908f98f 100644 --- a/web/components/charts/contract/choice.tsx +++ b/web/components/charts/contract/choice.tsx @@ -9,7 +9,6 @@ import { getOutcomeProbability } from 'common/calculate' import { useIsMobile } from 'web/hooks/use-is-mobile' import { DAY_MS } from 'common/util/time' import { - Legend, TooltipProps, MARGIN_X, MARGIN_Y, @@ -121,6 +120,29 @@ const getBetPoints = (answers: Answer[], bets: Bet[]) => { return points } +type LegendItem = { color: string; label: string; value?: string } +const Legend = (props: { className?: string; items: LegendItem[] }) => { + const { items, className } = props + return ( + <ol className={className}> + {items.map((item) => ( + <li key={item.label} className="flex flex-row justify-between gap-4"> + <Row className="items-center gap-2 overflow-hidden"> + <span + className="h-4 w-4 shrink-0" + style={{ backgroundColor: item.color }} + ></span> + <span className="text-semibold overflow-hidden text-ellipsis"> + {item.label} + </span> + </Row> + <span className="text-greyscale-6">{item.value}</span> + </li> + ))} + </ol> + ) +} + export const ChoiceContractChart = (props: { contract: FreeResponseContract | MultipleChoiceContract bets: Bet[] @@ -173,13 +195,15 @@ export const ChoiceContractChart = (props: { (item) => -item.p ).slice(0, 10) return ( - <div> + <> <Row className="items-center gap-2"> {datum && <Avatar size="xxs" avatarUrl={datum.userAvatarUrl} />} - <span>{formatDateInRange(x, start, end)}</span> + <span className="text-semibold text-base"> + {formatDateInRange(x, start, end)} + </span> </Row> - <Legend className="max-w-xs text-sm" items={legendItems} /> - </div> + <Legend className="max-w-xs" items={legendItems} /> + </> ) }, [answers] diff --git a/web/components/charts/contract/numeric.tsx b/web/components/charts/contract/numeric.tsx index dd031ab8..ac300361 100644 --- a/web/components/charts/contract/numeric.tsx +++ b/web/components/charts/contract/numeric.tsx @@ -24,9 +24,10 @@ const getNumericChartData = (contract: NumericContract) => { const NumericChartTooltip = (props: TooltipProps<DistributionPoint>) => { const { x, y } = props.p return ( - <span className="text-sm"> - <strong>{formatPct(y, 2)}</strong> {formatLargeNumber(x)} - </span> + <> + <span className="text-semibold">{formatLargeNumber(x)}</span> + <span className="text-greyscale-6">{formatPct(y, 2)}</span> + </> ) } diff --git a/web/components/charts/contract/pseudo-numeric.tsx b/web/components/charts/contract/pseudo-numeric.tsx index 1232a96c..adf2e493 100644 --- a/web/components/charts/contract/pseudo-numeric.tsx +++ b/web/components/charts/contract/pseudo-numeric.tsx @@ -46,10 +46,10 @@ const PseudoNumericChartTooltip = (props: TooltipProps<HistoryPoint<Bet>>) => { const { x, y, datum } = p const [start, end] = xScale.domain() return ( - <Row className="items-center gap-2 text-sm"> + <Row className="items-center gap-2"> {datum && <Avatar size="xs" avatarUrl={datum.userAvatarUrl} />} - <strong>{formatLargeNumber(y)}</strong> - <span>{formatDateInRange(x, start, end)}</span> + <span className="font-semibold">{formatDateInRange(x, start, end)}</span> + <span className="text-greyscale-6">{formatLargeNumber(y)}</span> </Row> ) } diff --git a/web/components/charts/helpers.tsx b/web/components/charts/helpers.tsx index b7948298..acd88a4f 100644 --- a/web/components/charts/helpers.tsx +++ b/web/components/charts/helpers.tsx @@ -16,7 +16,6 @@ import dayjs from 'dayjs' import clsx from 'clsx' import { Contract } from 'common/contract' -import { Row } from 'web/components/layout/row' export type Point<X, Y, T = unknown> = { x: X; y: Y; datum?: T } export type XScale<P> = P extends Point<infer X, infer _> ? AxisScale<X> : never @@ -256,7 +255,7 @@ export const TooltipContainer = (props: { <div className={clsx( className, - 'pointer-events-none absolute z-10 whitespace-pre rounded border-2 border-black bg-white/90 p-2' + 'pointer-events-none absolute z-10 whitespace-pre rounded bg-white/80 p-2 px-4 py-2 text-xs sm:text-sm' )} style={{ margin: MARGIN_STYLE, ...pos }} > @@ -265,27 +264,6 @@ export const TooltipContainer = (props: { ) } -export type LegendItem = { color: string; label: string; value?: string } -export const Legend = (props: { className?: string; items: LegendItem[] }) => { - const { items, className } = props - return ( - <ol className={className}> - {items.map((item) => ( - <li key={item.label} className="flex flex-row justify-between"> - <Row className="mr-2 items-center overflow-hidden"> - <span - className="mr-2 h-4 w-4 shrink-0" - style={{ backgroundColor: item.color }} - ></span> - <span className="overflow-hidden text-ellipsis">{item.label}</span> - </Row> - {item.value} - </li> - ))} - </ol> - ) -} - export const getDateRange = (contract: Contract) => { const { createdTime, closeTime, resolutionTime } = contract const isClosed = !!closeTime && Date.now() > closeTime From f892c92e262d027f6198d75c21bc0eafad7b0085 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Fri, 30 Sep 2022 01:11:04 -0500 Subject: [PATCH 056/135] Save portfolio sort and filter to local storage! --- web/components/bets-list.tsx | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/web/components/bets-list.tsx b/web/components/bets-list.tsx index 0ba90c54..c2773741 100644 --- a/web/components/bets-list.tsx +++ b/web/components/bets-list.tsx @@ -45,6 +45,11 @@ import { UserLink } from 'web/components/user-link' import { useUserBetContracts } from 'web/hooks/use-contracts' import { BetsSummary } from './bet-summary' import { ProfitBadge } from './profit-badge' +import { + storageStore, + usePersistentState, +} from 'web/hooks/use-persistent-state' +import { safeLocalStorage } from 'web/lib/util/local' type BetSort = 'newest' | 'profit' | 'closeTime' | 'value' type BetFilter = 'open' | 'limit_bet' | 'sold' | 'closed' | 'resolved' | 'all' @@ -75,8 +80,14 @@ export function BetsList(props: { user: User }) { return contractList ? keyBy(contractList, 'id') : undefined }, [contractList]) - const [sort, setSort] = useState<BetSort>('newest') - const [filter, setFilter] = useState<BetFilter>('all') + const [sort, setSort] = usePersistentState<BetSort>('newest', { + key: 'bets-list-sort', + store: storageStore(safeLocalStorage()), + }) + const [filter, setFilter] = usePersistentState<BetFilter>('all', { + key: 'bets-list-filter', + store: storageStore(safeLocalStorage()), + }) const [page, setPage] = useState(0) const start = page * CONTRACTS_PER_PAGE const end = start + CONTRACTS_PER_PAGE From 95c47aba1afc1a2e4eeb22d83525eda71b53c870 Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Fri, 30 Sep 2022 01:30:45 -0500 Subject: [PATCH 057/135] midterms: add CO, additional markets --- web/pages/midterms.tsx | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/web/pages/midterms.tsx b/web/pages/midterms.tsx index 1ae72f7a..d508743c 100644 --- a/web/pages/midterms.tsx +++ b/web/pages/midterms.tsx @@ -67,6 +67,12 @@ const senateMidterms: StateElectionMarket[] = [ slug: 'will-mike-lee-win-the-2022-utah-sen', isWinRepublican: true, }, + { + state: 'CO', + creatorUsername: 'SG', + slug: 'will-michael-bennet-win-the-2022-co', + isWinRepublican: false, + }, ] const App = () => { @@ -84,6 +90,25 @@ const App = () => { height={400} className="mt-8" ></iframe> + + <div className="mt-8 text-2xl">Related markets</div> + <iframe + src="https://manifold.markets/BoltonBailey/will-democrats-maintain-control-of" + title="Will the Democrats control the House after the Midterms?" + frameBorder="0" + width={800} + height={400} + className="mt-8" + ></iframe> + + <iframe + src="https://manifold.markets/SG/will-a-democrat-win-the-2024-us-pre" + title="Will a Democrat win the 2024 US presidential election?" + frameBorder="0" + width={800} + height={400} + className="mt-8" + ></iframe> </Col> </Page> ) From 608ee7b865d85d11d9e4f3e86428658a3dfc191c Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Fri, 30 Sep 2022 00:03:31 -0700 Subject: [PATCH 058/135] Chart visual style adjustment (#971) * Adjust area fill opacity on line charts * Light gray border on tooltips --- web/components/charts/helpers.tsx | 4 ++-- web/components/portfolio/portfolio-value-graph.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/web/components/charts/helpers.tsx b/web/components/charts/helpers.tsx index acd88a4f..ba9865b2 100644 --- a/web/components/charts/helpers.tsx +++ b/web/components/charts/helpers.tsx @@ -111,7 +111,7 @@ export const AreaWithTopStroke = <P,>(props: { py1={py1} curve={curve} fill={color} - opacity={0.3} + opacity={0.2} /> <LinePath data={data} px={px} py={py1} curve={curve} stroke={color} /> </g> @@ -255,7 +255,7 @@ export const TooltipContainer = (props: { <div className={clsx( className, - 'pointer-events-none absolute z-10 whitespace-pre rounded bg-white/80 p-2 px-4 py-2 text-xs sm:text-sm' + 'pointer-events-none absolute z-10 whitespace-pre rounded border border-gray-200 bg-white/80 p-2 px-4 py-2 text-xs sm:text-sm' )} style={{ margin: MARGIN_STYLE, ...pos }} > diff --git a/web/components/portfolio/portfolio-value-graph.tsx b/web/components/portfolio/portfolio-value-graph.tsx index 6ed5d195..e329457d 100644 --- a/web/components/portfolio/portfolio-value-graph.tsx +++ b/web/components/portfolio/portfolio-value-graph.tsx @@ -105,7 +105,7 @@ export const PortfolioValueGraph = memo(function PortfolioValueGraph(props: { sliceTooltip={({ slice }) => { handleGraphDisplayChange(slice.points[0].data.yFormatted) return ( - <div className="rounded bg-white px-4 py-2 opacity-80"> + <div className="rounded border border-gray-200 bg-white px-4 py-2 opacity-80"> <div key={slice.points[0].id} className="text-xs font-semibold sm:text-sm" From 1bc1debbe8db50f2a14f5614fed652510729be86 Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Fri, 30 Sep 2022 00:05:36 -0700 Subject: [PATCH 059/135] Fix default sizes on charts to make more sense --- web/components/charts/contract/binary.tsx | 2 +- web/components/charts/contract/choice.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/web/components/charts/contract/binary.tsx b/web/components/charts/contract/binary.tsx index 264e1e3b..a3f04a29 100644 --- a/web/components/charts/contract/binary.tsx +++ b/web/components/charts/contract/binary.tsx @@ -70,7 +70,7 @@ export const BinaryContractChart = (props: { const isMobile = useIsMobile(800) const containerRef = useRef<HTMLDivElement>(null) const width = useElementWidth(containerRef) ?? 0 - const height = props.height ?? (isMobile ? 250 : 350) + const height = props.height ?? (isMobile ? 150 : 250) const xScale = scaleTime(visibleRange, [0, width - MARGIN_X]) const yScale = scaleLinear([0, 1], [height - MARGIN_Y, 0]) diff --git a/web/components/charts/contract/choice.tsx b/web/components/charts/contract/choice.tsx index 1908f98f..7c9ec07a 100644 --- a/web/components/charts/contract/choice.tsx +++ b/web/components/charts/contract/choice.tsx @@ -176,7 +176,7 @@ export const ChoiceContractChart = (props: { const isMobile = useIsMobile(800) const containerRef = useRef<HTMLDivElement>(null) const width = useElementWidth(containerRef) ?? 0 - const height = props.height ?? (isMobile ? 150 : 250) + const height = props.height ?? (isMobile ? 250 : 350) const xScale = scaleTime(visibleRange, [0, width - MARGIN_X]) const yScale = scaleLinear([0, 1], [height - MARGIN_Y, 0]) From c16e5189f71207895164465f58263a70ead9e78f Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Fri, 30 Sep 2022 07:53:47 -0600 Subject: [PATCH 060/135] Don't send portfolio email to user less than 5 days old --- functions/src/weekly-portfolio-emails.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/functions/src/weekly-portfolio-emails.ts b/functions/src/weekly-portfolio-emails.ts index 0167be35..ab46c5f1 100644 --- a/functions/src/weekly-portfolio-emails.ts +++ b/functions/src/weekly-portfolio-emails.ts @@ -117,7 +117,8 @@ export async function sendPortfolioUpdateEmailsToAllUsers() { await Promise.all( privateUsersToSendEmailsTo.map(async (privateUser) => { const user = await getUser(privateUser.id) - if (!user) return + // Don't send to a user unless they're over 5 days old + if (!user || user.createdTime > Date.now() - 5 * DAY_MS) return const userBets = usersBets[privateUser.id] as Bet[] const contractsUserBetOn = contractsUsersBetOn.filter((contract) => userBets.some((bet) => bet.contractId === contract.id) From 138f34fc666a8e18a5d2222980e0139ab20684c2 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Fri, 30 Sep 2022 08:40:46 -0600 Subject: [PATCH 061/135] Add close now button to contract edit time --- web/components/contract/contract-details.tsx | 28 ++++++++++++++------ 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/web/components/contract/contract-details.tsx b/web/components/contract/contract-details.tsx index 3525b9f9..fc4bcfcf 100644 --- a/web/components/contract/contract-details.tsx +++ b/web/components/contract/contract-details.tsx @@ -356,18 +356,22 @@ function EditableCloseDate(props: { closeTime && dayJsCloseTime.format('HH:mm') ) - const newCloseTime = closeDate - ? dayjs(`${closeDate}T${closeHoursMinutes}`).valueOf() - : undefined - const isSameYear = dayJsCloseTime.isSame(dayJsNow, 'year') const isSameDay = dayJsCloseTime.isSame(dayJsNow, 'day') - const onSave = () => { + let newCloseTime = closeDate + ? dayjs(`${closeDate}T${closeHoursMinutes}`).valueOf() + : undefined + function onSave(customTime?: number) { + if (customTime) { + newCloseTime = customTime + setCloseDate(dayjs(newCloseTime).format('YYYY-MM-DD')) + setCloseHoursMinutes(dayjs(newCloseTime).format('HH:mm')) + } if (!newCloseTime) return if (newCloseTime === closeTime) setIsEditingCloseTime(false) - else if (newCloseTime > Date.now()) { + else { const content = contract.description const formattedCloseDate = dayjs(newCloseTime).format('YYYY-MM-DD h:mm a') @@ -416,13 +420,21 @@ function EditableCloseDate(props: { /> </Row> <Button - className="mt-2" + className="mt-4" size={'xs'} color={'indigo'} - onClick={onSave} + onClick={() => onSave()} > Done </Button> + <Button + className="mt-4" + size={'xs'} + color={'gray-white'} + onClick={() => onSave(Date.now())} + > + Close Now + </Button> </Col> </Modal> <DateTimeTooltip From 55f854115c1cc99b0945f4d8f8229dd0d9fc5072 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Fri, 30 Sep 2022 08:48:33 -0600 Subject: [PATCH 062/135] Remove green circle from resolution prob input --- web/components/probability-selector.tsx | 14 ++------- web/components/resolution-panel.tsx | 41 ++++++++++++++----------- 2 files changed, 26 insertions(+), 29 deletions(-) diff --git a/web/components/probability-selector.tsx b/web/components/probability-selector.tsx index 2fc03787..b13dcfd9 100644 --- a/web/components/probability-selector.tsx +++ b/web/components/probability-selector.tsx @@ -8,12 +8,12 @@ export function ProbabilitySelector(props: { const { probabilityInt, setProbabilityInt, isSubmitting } = props return ( - <Row className="items-center gap-2"> - <label className="input-group input-group-lg w-fit text-lg"> + <Row className="items-center gap-2"> + <label className="input-group input-group-lg text-lg"> <input type="number" value={probabilityInt} - className="input input-bordered input-md text-lg" + className="input input-bordered input-md w-28 text-lg" disabled={isSubmitting} min={1} max={99} @@ -23,14 +23,6 @@ export function ProbabilitySelector(props: { /> <span>%</span> </label> - <input - type="range" - className="range range-primary" - min={1} - max={99} - value={probabilityInt} - onChange={(e) => setProbabilityInt(parseInt(e.target.value))} - /> </Row> ) } diff --git a/web/components/resolution-panel.tsx b/web/components/resolution-panel.tsx index b3237eb4..f39284a5 100644 --- a/web/components/resolution-panel.tsx +++ b/web/components/resolution-panel.tsx @@ -11,6 +11,8 @@ import { ProbabilitySelector } from './probability-selector' import { getProbability } from 'common/calculate' import { BinaryContract, resolution } from 'common/contract' import { BETTOR, BETTORS, PAST_BETS } from 'common/user' +import { Row } from 'web/components/layout/row' +import { capitalize } from 'lodash' export function ResolutionPanel(props: { isAdmin: boolean @@ -94,9 +96,10 @@ export function ResolutionPanel(props: { withdrawn from your account </> ) : outcome === 'MKT' ? ( - <Col className="gap-6"> + <Col className="items-center gap-6"> <div> - {PAST_BETS} will be paid out at the probability you specify: + {capitalize(PAST_BETS)} will be paid out at the probability you + specify: </div> <ProbabilitySelector probabilityInt={Math.round(prob)} @@ -110,22 +113,24 @@ export function ResolutionPanel(props: { </div> <Spacer h={4} /> {!!error && <div className="text-red-500">{error}</div>} - <ResolveConfirmationButton - color={ - outcome === 'YES' - ? 'green' - : outcome === 'NO' - ? 'red' - : outcome === 'CANCEL' - ? 'yellow' - : outcome === 'MKT' - ? 'blue' - : 'indigo' - } - disabled={!outcome} - onResolve={resolve} - isSubmitting={isSubmitting} - /> + <Row className={'justify-center'}> + <ResolveConfirmationButton + color={ + outcome === 'YES' + ? 'green' + : outcome === 'NO' + ? 'red' + : outcome === 'CANCEL' + ? 'yellow' + : outcome === 'MKT' + ? 'blue' + : 'indigo' + } + disabled={!outcome} + onResolve={resolve} + isSubmitting={isSubmitting} + /> + </Row> </Col> ) } From a90b7656703a7e167c6e023044fb2ab387bca612 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Fri, 30 Sep 2022 09:27:42 -0600 Subject: [PATCH 063/135] Bounty comments (#944) * Adding, awarding, and sorting by bounties * Add notification for bounty award as tip * Fix merge * Wording * Allow adding in batches of m250 * import * imports * Style tabs * Refund unused bounties * Show curreantly available, reset open to 0 * Refactor * Rerun check prs * reset yarn.lock * Revert "reset yarn.lock" This reverts commit 4606984276821f403efd5ef7e1eca45b19ee4081. * undo yarn.lock changes * Track comment bounties --- common/comment.ts | 1 + common/contract.ts | 1 + common/economy.ts | 1 + common/envs/prod.ts | 1 + common/txn.ts | 33 ++++ functions/src/create-notification.ts | 44 +++++ functions/src/index.ts | 6 + functions/src/on-update-contract.ts | 134 +++++++++++---- functions/src/serve.ts | 3 + functions/src/update-comment-bounty.ts | 162 ++++++++++++++++++ web/components/award-bounty-button.tsx | 46 +++++ .../contract/add-comment-bounty.tsx | 74 ++++++++ .../contract/bountied-contract-badge.tsx | 9 + web/components/contract/contract-details.tsx | 3 + .../contract/contract-info-dialog.tsx | 6 +- web/components/contract/contract-tabs.tsx | 58 ++++++- .../liquidity-bounty-panel.tsx} | 63 ++++--- web/components/feed/feed-comments.tsx | 29 +++- web/components/layout/tabs.tsx | 22 ++- web/components/tipper.tsx | 4 +- web/components/user-page.tsx | 6 +- web/lib/firebase/api.ts | 8 + web/lib/firebase/comments.ts | 27 ++- web/package.json | 2 +- 24 files changed, 648 insertions(+), 95 deletions(-) create mode 100644 functions/src/update-comment-bounty.ts create mode 100644 web/components/award-bounty-button.tsx create mode 100644 web/components/contract/add-comment-bounty.tsx create mode 100644 web/components/contract/bountied-contract-badge.tsx rename web/components/{liquidity-panel.tsx => contract/liquidity-bounty-panel.tsx} (77%) diff --git a/common/comment.ts b/common/comment.ts index cdb62fd3..71c04af4 100644 --- a/common/comment.ts +++ b/common/comment.ts @@ -18,6 +18,7 @@ export type Comment<T extends AnyCommentType = AnyCommentType> = { userName: string userUsername: string userAvatarUrl?: string + bountiesAwarded?: number } & T export type OnContract = { diff --git a/common/contract.ts b/common/contract.ts index 248c9745..2e9d94c4 100644 --- a/common/contract.ts +++ b/common/contract.ts @@ -62,6 +62,7 @@ export type Contract<T extends AnyContractType = AnyContractType> = { featuredOnHomeRank?: number likedByUserIds?: string[] likedByUserCount?: number + openCommentBounties?: number } & T export type BinaryContract = Contract & Binary diff --git a/common/economy.ts b/common/economy.ts index 7ec52b30..d25a0c71 100644 --- a/common/economy.ts +++ b/common/economy.ts @@ -15,3 +15,4 @@ export const BETTING_STREAK_BONUS_AMOUNT = export const BETTING_STREAK_BONUS_MAX = econ?.BETTING_STREAK_BONUS_MAX ?? 50 export const BETTING_STREAK_RESET_HOUR = econ?.BETTING_STREAK_RESET_HOUR ?? 7 export const FREE_MARKETS_PER_USER_MAX = econ?.FREE_MARKETS_PER_USER_MAX ?? 5 +export const COMMENT_BOUNTY_AMOUNT = econ?.COMMENT_BOUNTY_AMOUNT ?? 250 diff --git a/common/envs/prod.ts b/common/envs/prod.ts index d0469d84..38dd4feb 100644 --- a/common/envs/prod.ts +++ b/common/envs/prod.ts @@ -41,6 +41,7 @@ export type Economy = { BETTING_STREAK_BONUS_MAX?: number BETTING_STREAK_RESET_HOUR?: number FREE_MARKETS_PER_USER_MAX?: number + COMMENT_BOUNTY_AMOUNT?: number } type FirebaseConfig = { diff --git a/common/txn.ts b/common/txn.ts index 2b7a32e8..c404059d 100644 --- a/common/txn.ts +++ b/common/txn.ts @@ -8,6 +8,7 @@ type AnyTxnType = | UniqueBettorBonus | BettingStreakBonus | CancelUniqueBettorBonus + | CommentBountyRefund type SourceType = 'USER' | 'CONTRACT' | 'CHARITY' | 'BANK' export type Txn<T extends AnyTxnType = AnyTxnType> = { @@ -31,6 +32,8 @@ export type Txn<T extends AnyTxnType = AnyTxnType> = { | 'UNIQUE_BETTOR_BONUS' | 'BETTING_STREAK_BONUS' | 'CANCEL_UNIQUE_BETTOR_BONUS' + | 'COMMENT_BOUNTY' + | 'REFUND_COMMENT_BOUNTY' // Any extra data data?: { [key: string]: any } @@ -98,6 +101,34 @@ type CancelUniqueBettorBonus = { } } +type CommentBountyDeposit = { + fromType: 'USER' + toType: 'BANK' + category: 'COMMENT_BOUNTY' + data: { + contractId: string + } +} + +type CommentBountyWithdrawal = { + fromType: 'BANK' + toType: 'USER' + category: 'COMMENT_BOUNTY' + data: { + contractId: string + commentId: string + } +} + +type CommentBountyRefund = { + fromType: 'BANK' + toType: 'USER' + category: 'REFUND_COMMENT_BOUNTY' + data: { + contractId: string + } +} + export type DonationTxn = Txn & Donation export type TipTxn = Txn & Tip export type ManalinkTxn = Txn & Manalink @@ -105,3 +136,5 @@ export type ReferralTxn = Txn & Referral export type BettingStreakBonusTxn = Txn & BettingStreakBonus export type UniqueBettorBonusTxn = Txn & UniqueBettorBonus export type CancelUniqueBettorBonusTxn = Txn & CancelUniqueBettorBonus +export type CommentBountyDepositTxn = Txn & CommentBountyDeposit +export type CommentBountyWithdrawalTxn = Txn & CommentBountyWithdrawal diff --git a/functions/src/create-notification.ts b/functions/src/create-notification.ts index 038e0142..9bd73d05 100644 --- a/functions/src/create-notification.ts +++ b/functions/src/create-notification.ts @@ -1046,3 +1046,47 @@ export const createContractResolvedNotifications = async ( ) ) } + +export const createBountyNotification = async ( + fromUser: User, + toUserId: string, + amount: number, + idempotencyKey: string, + contract: Contract, + commentId?: string +) => { + const privateUser = await getPrivateUser(toUserId) + if (!privateUser) return + const { sendToBrowser } = getNotificationDestinationsForUser( + privateUser, + 'tip_received' + ) + if (!sendToBrowser) return + + const slug = commentId + const notificationRef = firestore + .collection(`/users/${toUserId}/notifications`) + .doc(idempotencyKey) + const notification: Notification = { + id: idempotencyKey, + userId: toUserId, + reason: 'tip_received', + createdTime: Date.now(), + isSeen: false, + sourceId: commentId ? commentId : contract.id, + sourceType: 'tip', + sourceUpdateType: 'created', + sourceUserName: fromUser.name, + sourceUserUsername: fromUser.username, + sourceUserAvatarUrl: fromUser.avatarUrl, + sourceText: amount.toString(), + sourceContractCreatorUsername: contract.creatorUsername, + sourceContractTitle: contract.question, + sourceContractSlug: contract.slug, + sourceSlug: slug, + sourceTitle: contract.question, + } + return await notificationRef.set(removeUndefinedProps(notification)) + + // maybe TODO: send email notification to comment creator +} diff --git a/functions/src/index.ts b/functions/src/index.ts index 9a8ec232..f5c45004 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -52,6 +52,7 @@ export * from './unsubscribe' export * from './stripe' export * from './mana-bonus-email' export * from './close-market' +export * from './update-comment-bounty' import { health } from './health' import { transact } from './transact' @@ -65,6 +66,7 @@ import { sellshares } from './sell-shares' import { claimmanalink } from './claim-manalink' import { createmarket } from './create-market' import { addliquidity } from './add-liquidity' +import { addcommentbounty, awardcommentbounty } from './update-comment-bounty' import { withdrawliquidity } from './withdraw-liquidity' import { creategroup } from './create-group' import { resolvemarket } from './resolve-market' @@ -91,6 +93,8 @@ const sellSharesFunction = toCloudFunction(sellshares) const claimManalinkFunction = toCloudFunction(claimmanalink) const createMarketFunction = toCloudFunction(createmarket) const addLiquidityFunction = toCloudFunction(addliquidity) +const addCommentBounty = toCloudFunction(addcommentbounty) +const awardCommentBounty = toCloudFunction(awardcommentbounty) const withdrawLiquidityFunction = toCloudFunction(withdrawliquidity) const createGroupFunction = toCloudFunction(creategroup) const resolveMarketFunction = toCloudFunction(resolvemarket) @@ -127,4 +131,6 @@ export { acceptChallenge as acceptchallenge, createPostFunction as createpost, saveTwitchCredentials as savetwitchcredentials, + addCommentBounty as addcommentbounty, + awardCommentBounty as awardcommentbounty, } diff --git a/functions/src/on-update-contract.ts b/functions/src/on-update-contract.ts index 5e2a94c0..d667f0d2 100644 --- a/functions/src/on-update-contract.ts +++ b/functions/src/on-update-contract.ts @@ -1,44 +1,118 @@ import * as functions from 'firebase-functions' -import { getUser } from './utils' +import { getUser, getValues, log } from './utils' import { createCommentOrAnswerOrUpdatedContractNotification } from './create-notification' import { Contract } from '../../common/contract' +import { Txn } from '../../common/txn' +import { partition, sortBy } from 'lodash' +import { runTxn, TxnData } from './transact' +import * as admin from 'firebase-admin' export const onUpdateContract = functions.firestore .document('contracts/{contractId}') .onUpdate(async (change, context) => { const contract = change.after.data() as Contract + const previousContract = change.before.data() as Contract const { eventId } = context - - const contractUpdater = await getUser(contract.creatorId) - if (!contractUpdater) throw new Error('Could not find contract updater') - - const previousValue = change.before.data() as Contract - - // Resolution is handled in resolve-market.ts - if (!previousValue.isResolved && contract.isResolved) return + const { openCommentBounties, closeTime, question } = contract if ( - previousValue.closeTime !== contract.closeTime || - previousValue.question !== contract.question + !previousContract.isResolved && + contract.isResolved && + (openCommentBounties ?? 0) > 0 ) { - let sourceText = '' - if ( - previousValue.closeTime !== contract.closeTime && - contract.closeTime - ) { - sourceText = contract.closeTime.toString() - } else if (previousValue.question !== contract.question) { - sourceText = contract.question - } - - await createCommentOrAnswerOrUpdatedContractNotification( - contract.id, - 'contract', - 'updated', - contractUpdater, - eventId, - sourceText, - contract - ) + await handleUnusedCommentBountyRefunds(contract) + // No need to notify users of resolution, that's handled in resolve-market + return + } + if ( + previousContract.closeTime !== closeTime || + previousContract.question !== question + ) { + await handleUpdatedCloseTime(previousContract, contract, eventId) } }) + +async function handleUpdatedCloseTime( + previousContract: Contract, + contract: Contract, + eventId: string +) { + const contractUpdater = await getUser(contract.creatorId) + if (!contractUpdater) throw new Error('Could not find contract updater') + let sourceText = '' + if (previousContract.closeTime !== contract.closeTime && contract.closeTime) { + sourceText = contract.closeTime.toString() + } else if (previousContract.question !== contract.question) { + sourceText = contract.question + } + + await createCommentOrAnswerOrUpdatedContractNotification( + contract.id, + 'contract', + 'updated', + contractUpdater, + eventId, + sourceText, + contract + ) +} + +async function handleUnusedCommentBountyRefunds(contract: Contract) { + const outstandingCommentBounties = await getValues<Txn>( + firestore.collection('txns').where('category', '==', 'COMMENT_BOUNTY') + ) + + const commentBountiesOnThisContract = sortBy( + outstandingCommentBounties.filter( + (bounty) => bounty.data?.contractId === contract.id + ), + (bounty) => bounty.createdTime + ) + + const [toBank, fromBank] = partition( + commentBountiesOnThisContract, + (bounty) => bounty.toType === 'BANK' + ) + if (toBank.length <= fromBank.length) return + + await firestore + .collection('contracts') + .doc(contract.id) + .update({ openCommentBounties: 0 }) + + const refunds = toBank.slice(fromBank.length) + await Promise.all( + refunds.map(async (extraBountyTxn) => { + const result = await firestore.runTransaction(async (trans) => { + const bonusTxn: TxnData = { + fromId: extraBountyTxn.toId, + fromType: 'BANK', + toId: extraBountyTxn.fromId, + toType: 'USER', + amount: extraBountyTxn.amount, + token: 'M$', + category: 'REFUND_COMMENT_BOUNTY', + data: { + contractId: contract.id, + }, + } + return await runTxn(trans, bonusTxn) + }) + + if (result.status != 'success' || !result.txn) { + log( + `Couldn't refund bonus for user: ${extraBountyTxn.fromId} - status:`, + result.status + ) + log('message:', result.message) + } else { + log( + `Refund bonus txn for user: ${extraBountyTxn.fromId} completed:`, + result.txn?.id + ) + } + }) + ) +} + +const firestore = admin.firestore() diff --git a/functions/src/serve.ts b/functions/src/serve.ts index 99ac6281..d861dcbc 100644 --- a/functions/src/serve.ts +++ b/functions/src/serve.ts @@ -29,6 +29,7 @@ import { getcurrentuser } from './get-current-user' import { createpost } from './create-post' import { savetwitchcredentials } from './save-twitch-credentials' import { testscheduledfunction } from './test-scheduled-function' +import { addcommentbounty, awardcommentbounty } from './update-comment-bounty' type Middleware = (req: Request, res: Response, next: NextFunction) => void const app = express() @@ -61,6 +62,8 @@ addJsonEndpointRoute('/sellshares', sellshares) addJsonEndpointRoute('/claimmanalink', claimmanalink) addJsonEndpointRoute('/createmarket', createmarket) addJsonEndpointRoute('/addliquidity', addliquidity) +addJsonEndpointRoute('/addCommentBounty', addcommentbounty) +addJsonEndpointRoute('/awardCommentBounty', awardcommentbounty) addJsonEndpointRoute('/withdrawliquidity', withdrawliquidity) addJsonEndpointRoute('/creategroup', creategroup) addJsonEndpointRoute('/resolvemarket', resolvemarket) diff --git a/functions/src/update-comment-bounty.ts b/functions/src/update-comment-bounty.ts new file mode 100644 index 00000000..af1d6c0a --- /dev/null +++ b/functions/src/update-comment-bounty.ts @@ -0,0 +1,162 @@ +import * as admin from 'firebase-admin' +import { z } from 'zod' + +import { Contract } from '../../common/contract' +import { User } from '../../common/user' +import { removeUndefinedProps } from '../../common/util/object' +import { APIError, newEndpoint, validate } from './api' +import { + DEV_HOUSE_LIQUIDITY_PROVIDER_ID, + HOUSE_LIQUIDITY_PROVIDER_ID, +} from '../../common/antes' +import { isProd } from './utils' +import { + CommentBountyDepositTxn, + CommentBountyWithdrawalTxn, +} from '../../common/txn' +import { runTxn } from './transact' +import { Comment } from '../../common/comment' +import { createBountyNotification } from './create-notification' + +const bodySchema = z.object({ + contractId: z.string(), + amount: z.number().gt(0), +}) +const awardBodySchema = z.object({ + contractId: z.string(), + commentId: z.string(), + amount: z.number().gt(0), +}) + +export const addcommentbounty = newEndpoint({}, async (req, auth) => { + const { amount, contractId } = validate(bodySchema, req.body) + + if (!isFinite(amount)) throw new APIError(400, 'Invalid amount') + + // run as transaction to prevent race conditions + return await firestore.runTransaction(async (transaction) => { + const userDoc = firestore.doc(`users/${auth.uid}`) + const userSnap = await transaction.get(userDoc) + if (!userSnap.exists) throw new APIError(400, 'User not found') + const user = userSnap.data() as User + + const contractDoc = firestore.doc(`contracts/${contractId}`) + const contractSnap = await transaction.get(contractDoc) + if (!contractSnap.exists) throw new APIError(400, 'Invalid contract') + const contract = contractSnap.data() as Contract + + if (user.balance < amount) + throw new APIError(400, 'Insufficient user balance') + + const newCommentBountyTxn = { + fromId: user.id, + fromType: 'USER', + toId: isProd() + ? HOUSE_LIQUIDITY_PROVIDER_ID + : DEV_HOUSE_LIQUIDITY_PROVIDER_ID, + toType: 'BANK', + amount, + token: 'M$', + category: 'COMMENT_BOUNTY', + data: { + contractId, + }, + description: `Deposit M$${amount} from ${user.id} for comment bounty for contract ${contractId}`, + } as CommentBountyDepositTxn + + const result = await runTxn(transaction, newCommentBountyTxn) + + transaction.update( + contractDoc, + removeUndefinedProps({ + openCommentBounties: (contract.openCommentBounties ?? 0) + amount, + }) + ) + + return result + }) +}) +export const awardcommentbounty = newEndpoint({}, async (req, auth) => { + const { amount, commentId, contractId } = validate(awardBodySchema, req.body) + + if (!isFinite(amount)) throw new APIError(400, 'Invalid amount') + + // run as transaction to prevent race conditions + const res = await firestore.runTransaction(async (transaction) => { + const userDoc = firestore.doc(`users/${auth.uid}`) + const userSnap = await transaction.get(userDoc) + if (!userSnap.exists) throw new APIError(400, 'User not found') + const user = userSnap.data() as User + + const contractDoc = firestore.doc(`contracts/${contractId}`) + const contractSnap = await transaction.get(contractDoc) + if (!contractSnap.exists) throw new APIError(400, 'Invalid contract') + const contract = contractSnap.data() as Contract + + if (user.id !== contract.creatorId) + throw new APIError( + 400, + 'Only contract creator can award comment bounties' + ) + + const commentDoc = firestore.doc( + `contracts/${contractId}/comments/${commentId}` + ) + const commentSnap = await transaction.get(commentDoc) + if (!commentSnap.exists) throw new APIError(400, 'Invalid comment') + + const comment = commentSnap.data() as Comment + const amountAvailable = contract.openCommentBounties ?? 0 + if (amountAvailable < amount) + throw new APIError(400, 'Insufficient open bounty balance') + + const newCommentBountyTxn = { + fromId: isProd() + ? HOUSE_LIQUIDITY_PROVIDER_ID + : DEV_HOUSE_LIQUIDITY_PROVIDER_ID, + fromType: 'BANK', + toId: comment.userId, + toType: 'USER', + amount, + token: 'M$', + category: 'COMMENT_BOUNTY', + data: { + contractId, + commentId, + }, + description: `Withdrawal M$${amount} from BANK for comment ${comment.id} bounty for contract ${contractId}`, + } as CommentBountyWithdrawalTxn + + const result = await runTxn(transaction, newCommentBountyTxn) + + await transaction.update( + contractDoc, + removeUndefinedProps({ + openCommentBounties: amountAvailable - amount, + }) + ) + await transaction.update( + commentDoc, + removeUndefinedProps({ + bountiesAwarded: (comment.bountiesAwarded ?? 0) + amount, + }) + ) + + return { ...result, comment, contract, user } + }) + if (res.txn?.id) { + const { comment, contract, user } = res + await createBountyNotification( + user, + comment.userId, + amount, + res.txn.id, + contract, + comment.id + ) + } + + return res +}) + +const firestore = admin.firestore() diff --git a/web/components/award-bounty-button.tsx b/web/components/award-bounty-button.tsx new file mode 100644 index 00000000..7a69cf15 --- /dev/null +++ b/web/components/award-bounty-button.tsx @@ -0,0 +1,46 @@ +import clsx from 'clsx' +import { ContractComment } from 'common/comment' +import { useUser } from 'web/hooks/use-user' +import { awardCommentBounty } from 'web/lib/firebase/api' +import { track } from 'web/lib/service/analytics' +import { Row } from './layout/row' +import { Contract } from 'common/contract' +import { TextButton } from 'web/components/text-button' +import { COMMENT_BOUNTY_AMOUNT } from 'common/economy' +import { formatMoney } from 'common/util/format' + +export function AwardBountyButton(prop: { + comment: ContractComment + contract: Contract +}) { + const { comment, contract } = prop + + const me = useUser() + + const submit = () => { + const data = { + amount: COMMENT_BOUNTY_AMOUNT, + commentId: comment.id, + contractId: contract.id, + } + + awardCommentBounty(data) + .then((_) => { + console.log('success') + track('award comment bounty', data) + }) + .catch((reason) => console.log('Server error:', reason)) + + track('award comment bounty', data) + } + + const canUp = me && me.id !== comment.userId && contract.creatorId === me.id + if (!canUp) return <div /> + return ( + <Row className={clsx('-ml-2 items-center gap-0.5', !canUp ? '-ml-6' : '')}> + <TextButton className={'font-bold'} onClick={submit}> + Award {formatMoney(COMMENT_BOUNTY_AMOUNT)} + </TextButton> + </Row> + ) +} diff --git a/web/components/contract/add-comment-bounty.tsx b/web/components/contract/add-comment-bounty.tsx new file mode 100644 index 00000000..8b716e71 --- /dev/null +++ b/web/components/contract/add-comment-bounty.tsx @@ -0,0 +1,74 @@ +import { Contract } from 'common/contract' +import { useUser } from 'web/hooks/use-user' +import { useState } from 'react' +import { addCommentBounty } from 'web/lib/firebase/api' +import { track } from 'web/lib/service/analytics' +import { Row } from 'web/components/layout/row' +import clsx from 'clsx' +import { formatMoney } from 'common/util/format' +import { COMMENT_BOUNTY_AMOUNT } from 'common/economy' +import { Button } from 'web/components/button' + +export function AddCommentBountyPanel(props: { contract: Contract }) { + const { contract } = props + const { id: contractId, slug } = contract + + const user = useUser() + const amount = COMMENT_BOUNTY_AMOUNT + const totalAdded = contract.openCommentBounties ?? 0 + const [error, setError] = useState<string | undefined>(undefined) + const [isSuccess, setIsSuccess] = useState(false) + const [isLoading, setIsLoading] = useState(false) + + const submit = () => { + if ((user?.balance ?? 0) < amount) { + setError('Insufficient balance') + return + } + + setIsLoading(true) + setIsSuccess(false) + + addCommentBounty({ amount, contractId }) + .then((_) => { + track('offer comment bounty', { + amount, + contractId, + }) + setIsSuccess(true) + setError(undefined) + setIsLoading(false) + }) + .catch((_) => setError('Server error')) + + track('add comment bounty', { amount, contractId, slug }) + } + + return ( + <> + <div className="mb-4 text-gray-500"> + Add a {formatMoney(amount)} bounty for good comments that the creator + can award.{' '} + {totalAdded > 0 && `(${formatMoney(totalAdded)} currently added)`} + </div> + + <Row className={'items-center gap-2'}> + <Button + className={clsx('ml-2', isLoading && 'btn-disabled')} + onClick={submit} + disabled={isLoading} + color={'blue'} + > + Add {formatMoney(amount)} bounty + </Button> + <span className={'text-error'}>{error}</span> + </Row> + + {isSuccess && amount && ( + <div>Success! Added {formatMoney(amount)} in bounties.</div> + )} + + {isLoading && <div>Processing...</div>} + </> + ) +} diff --git a/web/components/contract/bountied-contract-badge.tsx b/web/components/contract/bountied-contract-badge.tsx new file mode 100644 index 00000000..8e3e8c5b --- /dev/null +++ b/web/components/contract/bountied-contract-badge.tsx @@ -0,0 +1,9 @@ +import { CurrencyDollarIcon } from '@heroicons/react/outline' + +export function BountiedContractBadge() { + 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"> + <CurrencyDollarIcon className={'h4 w-4'} /> Bounty + </span> + ) +} diff --git a/web/components/contract/contract-details.tsx b/web/components/contract/contract-details.tsx index fc4bcfcf..22167a9c 100644 --- a/web/components/contract/contract-details.tsx +++ b/web/components/contract/contract-details.tsx @@ -32,6 +32,7 @@ import { PlusCircleIcon } from '@heroicons/react/solid' import { GroupLink } from 'common/group' import { Subtitle } from '../subtitle' import { useIsMobile } from 'web/hooks/use-is-mobile' +import { BountiedContractBadge } from 'web/components/contract/bountied-contract-badge' export type ShowTime = 'resolve-date' | 'close-date' @@ -63,6 +64,8 @@ export function MiscDetails(props: { </Row> ) : (contract?.featuredOnHomeRank ?? 0) > 0 ? ( <FeaturedContractBadge /> + ) : (contract.openCommentBounties ?? 0) > 0 ? ( + <BountiedContractBadge /> ) : volume > 0 || !isNew ? ( <Row className={'shrink-0'}>{formatMoney(volume)} bet</Row> ) : ( diff --git a/web/components/contract/contract-info-dialog.tsx b/web/components/contract/contract-info-dialog.tsx index 5187030d..df6695ed 100644 --- a/web/components/contract/contract-info-dialog.tsx +++ b/web/components/contract/contract-info-dialog.tsx @@ -7,7 +7,7 @@ import { capitalize } from 'lodash' import { Contract } from 'common/contract' import { formatMoney } from 'common/util/format' import { contractPool, updateContract } from 'web/lib/firebase/contracts' -import { LiquidityPanel } from '../liquidity-panel' +import { LiquidityBountyPanel } from 'web/components/contract/liquidity-bounty-panel' import { Col } from '../layout/col' import { Modal } from '../layout/modal' import { Title } from '../title' @@ -196,9 +196,7 @@ export function ContractInfoDialog(props: { <Row className="flex-wrap"> <DuplicateContractButton contract={contract} /> </Row> - {contract.mechanism === 'cpmm-1' && !contract.resolution && ( - <LiquidityPanel contract={contract} /> - )} + {!contract.resolution && <LiquidityBountyPanel contract={contract} />} </Col> </Modal> </> diff --git a/web/components/contract/contract-tabs.tsx b/web/components/contract/contract-tabs.tsx index 33a3c05a..e53881d3 100644 --- a/web/components/contract/contract-tabs.tsx +++ b/web/components/contract/contract-tabs.tsx @@ -5,7 +5,7 @@ import { FeedBet } from '../feed/feed-bets' import { FeedLiquidity } from '../feed/feed-liquidity' import { FeedAnswerCommentGroup } from '../feed/feed-answer-comment-group' import { FeedCommentThread, ContractCommentInput } from '../feed/feed-comments' -import { groupBy, sortBy } from 'lodash' +import { groupBy, sortBy, sum } from 'lodash' import { Bet } from 'common/bet' import { Contract } from 'common/contract' import { PAST_BETS } from 'common/user' @@ -25,6 +25,13 @@ import { import { buildArray } from 'common/util/array' import { ContractComment } from 'common/comment' +import { formatMoney } from 'common/util/format' +import { Button } from 'web/components/button' +import { MINUTE_MS } from 'common/util/time' +import { useUser } from 'web/hooks/use-user' +import { COMMENT_BOUNTY_AMOUNT } from 'common/economy' +import { Tooltip } from 'web/components/tooltip' + export function ContractTabs(props: { contract: Contract bets: Bet[] @@ -32,6 +39,7 @@ export function ContractTabs(props: { comments: ContractComment[] }) { const { contract, bets, userBets, comments } = props + const { openCommentBounties } = contract const yourTrades = ( <div> @@ -43,8 +51,16 @@ export function ContractTabs(props: { const tabs = buildArray( { - title: 'Comments', + title: `Comments`, + tooltip: openCommentBounties + ? `The creator of this market may award ${formatMoney( + COMMENT_BOUNTY_AMOUNT + )} for good comments. ${formatMoney( + openCommentBounties + )} currently available.` + : undefined, content: <CommentsTabContent contract={contract} comments={comments} />, + inlineTabIcon: <span>({formatMoney(COMMENT_BOUNTY_AMOUNT)})</span>, }, { title: capitalize(PAST_BETS), @@ -68,6 +84,8 @@ const CommentsTabContent = memo(function CommentsTabContent(props: { const { contract } = props const tips = useTipTxns({ contractId: contract.id }) const comments = useComments(contract.id) ?? props.comments + const [sort, setSort] = useState<'Newest' | 'Best'>('Best') + const me = useUser() if (comments == null) { return <LoadingIndicator /> } @@ -119,12 +137,44 @@ const CommentsTabContent = memo(function CommentsTabContent(props: { </> ) } else { - const commentsByParent = groupBy(comments, (c) => c.replyToCommentId ?? '_') + const tipsOrBountiesAwarded = + Object.keys(tips).length > 0 || comments.some((c) => c.bountiesAwarded) + + const commentsByParent = groupBy( + sortBy(comments, (c) => + sort === 'Newest' + ? -c.createdTime + : // Is this too magic? If there are tips/bounties, 'Best' shows your own comments made within the last 10 minutes first, then sorts by score + tipsOrBountiesAwarded && + c.createdTime > Date.now() - 10 * MINUTE_MS && + c.userId === me?.id + ? -Infinity + : -((c.bountiesAwarded ?? 0) + sum(Object.values(tips[c.id] ?? []))) + ), + (c) => c.replyToCommentId ?? '_' + ) + const topLevelComments = commentsByParent['_'] ?? [] return ( <> + <Button + size={'xs'} + color={'gray-white'} + className="mb-4" + onClick={() => setSort(sort === 'Newest' ? 'Best' : 'Newest')} + > + <Tooltip + text={ + sort === 'Best' + ? 'Comments with tips or bounties will be shown first. Your comments made within the last 10 minutes will temporarily appear (to you) first.' + : '' + } + > + Sorted by: {sort} + </Tooltip> + </Button> <ContractCommentInput className="mb-5" contract={contract} /> - {sortBy(topLevelComments, (c) => -c.createdTime).map((parent) => ( + {topLevelComments.map((parent) => ( <FeedCommentThread key={parent.id} contract={contract} diff --git a/web/components/liquidity-panel.tsx b/web/components/contract/liquidity-bounty-panel.tsx similarity index 77% rename from web/components/liquidity-panel.tsx rename to web/components/contract/liquidity-bounty-panel.tsx index 7e216be5..4cc7fd70 100644 --- a/web/components/liquidity-panel.tsx +++ b/web/components/contract/liquidity-bounty-panel.tsx @@ -1,27 +1,30 @@ import clsx from 'clsx' import { useEffect, useState } from 'react' -import { CPMMContract } from 'common/contract' +import { Contract, CPMMContract } from 'common/contract' import { formatMoney } from 'common/util/format' import { useUser } from 'web/hooks/use-user' import { addLiquidity, withdrawLiquidity } from 'web/lib/firebase/api' -import { AmountInput } from './amount-input' -import { Row } from './layout/row' +import { AmountInput } from 'web/components/amount-input' +import { Row } from 'web/components/layout/row' import { useUserLiquidity } from 'web/hooks/use-liquidity' -import { Tabs } from './layout/tabs' -import { NoLabel, YesLabel } from './outcome-label' -import { Col } from './layout/col' +import { Tabs } from 'web/components/layout/tabs' +import { NoLabel, YesLabel } from 'web/components/outcome-label' +import { Col } from 'web/components/layout/col' import { track } from 'web/lib/service/analytics' -import { InfoTooltip } from './info-tooltip' +import { InfoTooltip } from 'web/components/info-tooltip' import { BETTORS, PRESENT_BET } from 'common/user' import { buildArray } from 'common/util/array' import { useAdmin } from 'web/hooks/use-admin' +import { AddCommentBountyPanel } from 'web/components/contract/add-comment-bounty' -export function LiquidityPanel(props: { contract: CPMMContract }) { +export function LiquidityBountyPanel(props: { contract: Contract }) { const { contract } = props + const isCPMM = contract.mechanism === 'cpmm-1' const user = useUser() - const lpShares = useUserLiquidity(contract, user?.id ?? '') + // eslint-disable-next-line react-hooks/rules-of-hooks + const lpShares = isCPMM && useUserLiquidity(contract, user?.id ?? '') const [showWithdrawal, setShowWithdrawal] = useState(false) @@ -33,28 +36,34 @@ export function LiquidityPanel(props: { contract: CPMMContract }) { const isCreator = user?.id === contract.creatorId const isAdmin = useAdmin() - if (!showWithdrawal && !isAdmin && !isCreator) return <></> - return ( <Tabs tabs={buildArray( - (isCreator || isAdmin) && { - title: (isAdmin ? '[Admin] ' : '') + 'Subsidize', - content: <AddLiquidityPanel contract={contract} />, - }, - showWithdrawal && { - title: 'Withdraw', - content: ( - <WithdrawLiquidityPanel - contract={contract} - lpShares={lpShares as { YES: number; NO: number }} - /> - ), - }, { - title: 'Pool', - content: <ViewLiquidityPanel contract={contract} />, - } + title: 'Bounty Comments', + content: <AddCommentBountyPanel contract={contract} />, + }, + (isCreator || isAdmin) && + isCPMM && { + title: (isAdmin ? '[Admin] ' : '') + 'Subsidize', + content: <AddLiquidityPanel contract={contract} />, + }, + showWithdrawal && + isCPMM && { + title: 'Withdraw', + content: ( + <WithdrawLiquidityPanel + contract={contract} + lpShares={lpShares as { YES: number; NO: number }} + /> + ), + }, + + (isCreator || isAdmin) && + isCPMM && { + title: 'Pool', + content: <ViewLiquidityPanel contract={contract} />, + } )} /> ) diff --git a/web/components/feed/feed-comments.tsx b/web/components/feed/feed-comments.tsx index 1b62690b..20d124f8 100644 --- a/web/components/feed/feed-comments.tsx +++ b/web/components/feed/feed-comments.tsx @@ -19,6 +19,7 @@ import { Content } from '../editor' import { Editor } from '@tiptap/react' import { UserLink } from 'web/components/user-link' import { CommentInput } from '../comment-input' +import { AwardBountyButton } from 'web/components/award-bounty-button' export type ReplyTo = { id: string; username: string } @@ -85,6 +86,7 @@ export function FeedComment(props: { commenterPositionShares, commenterPositionOutcome, createdTime, + bountiesAwarded, } = comment const betOutcome = comment.betOutcome let bought: string | undefined @@ -93,6 +95,7 @@ export function FeedComment(props: { bought = comment.betAmount >= 0 ? 'bought' : 'sold' money = formatMoney(Math.abs(comment.betAmount)) } + const totalAwarded = bountiesAwarded ?? 0 const router = useRouter() const highlighted = router.asPath.endsWith(`#${comment.id}`) @@ -162,6 +165,11 @@ export function FeedComment(props: { createdTime={createdTime} elementId={comment.id} /> + {totalAwarded > 0 && ( + <span className=" text-primary ml-2 text-sm"> + +{formatMoney(totalAwarded)} + </span> + )} </div> <Content className="mt-2 text-[15px] text-gray-700" @@ -170,6 +178,9 @@ export function FeedComment(props: { /> <Row className="mt-2 items-center gap-6 text-xs text-gray-500"> {tips && <Tipper comment={comment} tips={tips} />} + {(contract.openCommentBounties ?? 0) > 0 && ( + <AwardBountyButton comment={comment} contract={contract} /> + )} {onReplyClick && ( <button className="font-bold hover:underline" @@ -208,28 +219,32 @@ export function ContractCommentInput(props: { onSubmitComment?: () => void }) { const user = useUser() + const { contract, parentAnswerOutcome, parentCommentId, replyTo, className } = + props + const { openCommentBounties } = contract async function onSubmitComment(editor: Editor) { if (!user) { track('sign in to comment') return await firebaseLogin() } await createCommentOnContract( - props.contract.id, + contract.id, editor.getJSON(), user, - props.parentAnswerOutcome, - props.parentCommentId + !!openCommentBounties, + parentAnswerOutcome, + parentCommentId ) props.onSubmitComment?.() } return ( <CommentInput - replyTo={props.replyTo} - parentAnswerOutcome={props.parentAnswerOutcome} - parentCommentId={props.parentCommentId} + replyTo={replyTo} + parentAnswerOutcome={parentAnswerOutcome} + parentCommentId={parentCommentId} onSubmitComment={onSubmitComment} - className={props.className} + className={className} /> ) } diff --git a/web/components/layout/tabs.tsx b/web/components/layout/tabs.tsx index b82131ec..deff2203 100644 --- a/web/components/layout/tabs.tsx +++ b/web/components/layout/tabs.tsx @@ -3,13 +3,15 @@ import { useRouter, NextRouter } from 'next/router' import { ReactNode, useState } from 'react' import { track } from '@amplitude/analytics-browser' import { Col } from './col' +import { Tooltip } from 'web/components/tooltip' +import { Row } from 'web/components/layout/row' type Tab = { title: string - tabIcon?: ReactNode content: ReactNode - // If set, show a badge with this content - badge?: string + stackedTabIcon?: ReactNode + inlineTabIcon?: ReactNode + tooltip?: string } type TabProps = { @@ -56,12 +58,16 @@ export function ControlledTabs(props: TabProps & { activeIndex: number }) { )} aria-current={activeIndex === i ? 'page' : undefined} > - {tab.badge ? ( - <span className="px-0.5 font-bold">{tab.badge}</span> - ) : null} <Col> - {tab.tabIcon && <div className="mx-auto">{tab.tabIcon}</div>} - {tab.title} + <Tooltip text={tab.tooltip}> + {tab.stackedTabIcon && ( + <Row className="justify-center">{tab.stackedTabIcon}</Row> + )} + <Row className={'gap-1 '}> + {tab.title} + {tab.inlineTabIcon} + </Row> + </Tooltip> </Col> </a> ))} diff --git a/web/components/tipper.tsx b/web/components/tipper.tsx index ccb8361f..1dcb0f05 100644 --- a/web/components/tipper.tsx +++ b/web/components/tipper.tsx @@ -116,7 +116,7 @@ function DownTip(props: { onClick?: () => void }) { noTap > <button - className="hover:text-red-600 disabled:text-gray-300" + className="hover:text-red-600 disabled:text-gray-100" disabled={!onClick} onClick={onClick} > @@ -137,7 +137,7 @@ function UpTip(props: { onClick?: () => void; value: number }) { noTap > <button - className="hover:text-primary disabled:text-gray-300" + className="hover:text-primary disabled:text-gray-100" disabled={!onClick} onClick={onClick} > diff --git a/web/components/user-page.tsx b/web/components/user-page.tsx index 623b4d35..f9f77cf6 100644 --- a/web/components/user-page.tsx +++ b/web/components/user-page.tsx @@ -192,7 +192,7 @@ export function UserPage(props: { user: User }) { tabs={[ { title: 'Markets', - tabIcon: <ScaleIcon className="h-5" />, + stackedTabIcon: <ScaleIcon className="h-5" />, content: ( <> <Spacer h={4} /> @@ -202,7 +202,7 @@ export function UserPage(props: { user: User }) { }, { title: 'Portfolio', - tabIcon: <FolderIcon className="h-5" />, + stackedTabIcon: <FolderIcon className="h-5" />, content: ( <> <Spacer h={4} /> @@ -214,7 +214,7 @@ export function UserPage(props: { user: User }) { }, { title: 'Comments', - tabIcon: <ChatIcon className="h-5" />, + stackedTabIcon: <ChatIcon className="h-5" />, content: ( <> <Spacer h={4} /> diff --git a/web/lib/firebase/api.ts b/web/lib/firebase/api.ts index 8aa7a067..3e803bc6 100644 --- a/web/lib/firebase/api.ts +++ b/web/lib/firebase/api.ts @@ -46,6 +46,14 @@ export function addLiquidity(params: any) { return call(getFunctionUrl('addliquidity'), 'POST', params) } +export function addCommentBounty(params: any) { + return call(getFunctionUrl('addcommentbounty'), 'POST', params) +} + +export function awardCommentBounty(params: any) { + return call(getFunctionUrl('awardcommentbounty'), 'POST', params) +} + export function withdrawLiquidity(params: any) { return call(getFunctionUrl('withdrawliquidity'), 'POST', params) } diff --git a/web/lib/firebase/comments.ts b/web/lib/firebase/comments.ts index 733a1e06..e1b4ccef 100644 --- a/web/lib/firebase/comments.ts +++ b/web/lib/firebase/comments.ts @@ -35,6 +35,7 @@ export async function createCommentOnContract( contractId: string, content: JSONContent, user: User, + onContractWithBounty: boolean, answerOutcome?: string, replyToCommentId?: string ) { @@ -50,7 +51,8 @@ export async function createCommentOnContract( content, user, ref, - replyToCommentId + replyToCommentId, + onContractWithBounty ) } export async function createCommentOnGroup( @@ -95,7 +97,8 @@ async function createComment( content: JSONContent, user: User, ref: DocumentReference<DocumentData>, - replyToCommentId?: string + replyToCommentId?: string, + onContractWithBounty?: boolean ) { const comment = removeUndefinedProps({ id: ref.id, @@ -108,13 +111,19 @@ async function createComment( replyToCommentId: replyToCommentId, ...extraFields, }) - - track(`${extraFields.commentType} message`, { - user, - commentId: ref.id, - surfaceId, - replyToCommentId: replyToCommentId, - }) + track( + `${extraFields.commentType} message`, + removeUndefinedProps({ + user, + commentId: ref.id, + surfaceId, + replyToCommentId: replyToCommentId, + onContractWithBounty: + extraFields.commentType === 'contract' + ? onContractWithBounty + : undefined, + }) + ) return await setDoc(ref, comment) } diff --git a/web/package.json b/web/package.json index a3ec9aaa..a5fa8ced 100644 --- a/web/package.json +++ b/web/package.json @@ -22,7 +22,7 @@ "@amplitude/analytics-browser": "0.4.1", "@floating-ui/react-dom-interactions": "0.9.2", "@headlessui/react": "1.6.1", - "@heroicons/react": "1.0.5", + "@heroicons/react": "1.0.6", "@nivo/core": "0.80.0", "@nivo/line": "0.80.0", "@nivo/tooltip": "0.80.0", From 31de3636fdcb09437ad47dae33bfc7402f8783b5 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Fri, 30 Sep 2022 09:34:58 -0600 Subject: [PATCH 064/135] Fix comment tab title --- web/components/contract/contract-tabs.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/web/components/contract/contract-tabs.tsx b/web/components/contract/contract-tabs.tsx index e53881d3..9b732224 100644 --- a/web/components/contract/contract-tabs.tsx +++ b/web/components/contract/contract-tabs.tsx @@ -60,7 +60,9 @@ export function ContractTabs(props: { )} currently available.` : undefined, content: <CommentsTabContent contract={contract} comments={comments} />, - inlineTabIcon: <span>({formatMoney(COMMENT_BOUNTY_AMOUNT)})</span>, + inlineTabIcon: openCommentBounties ? ( + <span>({formatMoney(COMMENT_BOUNTY_AMOUNT)})</span> + ) : undefined, }, { title: capitalize(PAST_BETS), From 3677de58c3131d317c6ea0ce8097215ccf8f6dee Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Fri, 30 Sep 2022 10:00:55 -0600 Subject: [PATCH 065/135] Add tooltip and badge on contract for bounties --- .../contract/bountied-contract-badge.tsx | 25 +++++++++++++++++++ web/components/contract/contract-details.tsx | 19 +++++++++----- web/components/contract/contract-tabs.tsx | 7 ++---- 3 files changed, 40 insertions(+), 11 deletions(-) diff --git a/web/components/contract/bountied-contract-badge.tsx b/web/components/contract/bountied-contract-badge.tsx index 8e3e8c5b..3e1ed68c 100644 --- a/web/components/contract/bountied-contract-badge.tsx +++ b/web/components/contract/bountied-contract-badge.tsx @@ -1,4 +1,8 @@ import { CurrencyDollarIcon } from '@heroicons/react/outline' +import { Contract } from 'common/contract' +import { Tooltip } from 'web/components/tooltip' +import { formatMoney } from 'common/lib/util/format' +import { COMMENT_BOUNTY_AMOUNT } from 'common/economy' export function BountiedContractBadge() { return ( @@ -7,3 +11,24 @@ export function BountiedContractBadge() { </span> ) } + +export function BountiedContractSmallBadge(props: { contract: Contract }) { + const { contract } = props + const { openCommentBounties } = contract + if (!openCommentBounties) return <div /> + + return ( + <Tooltip text={CommentBountiesTooltipText(openCommentBounties)}> + <span className="bg-greyscale-4 inline-flex cursor-default items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium text-white"> + <CurrencyDollarIcon className={'h3 w-3'} /> Bountied Comments + </span> + </Tooltip> + ) +} + +export const CommentBountiesTooltipText = (openCommentBounties: number) => + `The creator of this market may award ${formatMoney( + COMMENT_BOUNTY_AMOUNT + )} for good comments. ${formatMoney( + openCommentBounties + )} currently available.` diff --git a/web/components/contract/contract-details.tsx b/web/components/contract/contract-details.tsx index 22167a9c..7c84fadf 100644 --- a/web/components/contract/contract-details.tsx +++ b/web/components/contract/contract-details.tsx @@ -32,7 +32,10 @@ import { PlusCircleIcon } from '@heroicons/react/solid' import { GroupLink } from 'common/group' import { Subtitle } from '../subtitle' import { useIsMobile } from 'web/hooks/use-is-mobile' -import { BountiedContractBadge } from 'web/components/contract/bountied-contract-badge' +import { + BountiedContractBadge, + BountiedContractSmallBadge, +} from 'web/components/contract/bountied-contract-badge' export type ShowTime = 'resolve-date' | 'close-date' @@ -129,9 +132,10 @@ export function ContractDetails(props: { </Row> {/* GROUPS */} {isMobile && ( - <div className="mt-2"> + <Row className="mt-2 gap-1"> + <BountiedContractSmallBadge contract={contract} /> <MarketGroups contract={contract} disabled={disabled} /> - </div> + </Row> )} </Col> ) @@ -181,7 +185,10 @@ export function MarketSubheader(props: { isCreator={isCreator} /> {!isMobile && ( - <MarketGroups contract={contract} disabled={disabled} /> + <Row className={'gap-1'}> + <BountiedContractSmallBadge contract={contract} /> + <MarketGroups contract={contract} disabled={disabled} /> + </Row> )} </Row> </Col> @@ -328,14 +335,14 @@ export function GroupDisplay(props: { groupToDisplay?: GroupLink | null }) { if (groupToDisplay) { return ( <Link prefetch={false} href={groupPath(groupToDisplay.slug)}> - <a className="bg-greyscale-4 hover:bg-greyscale-3 max-w-[140px] truncate rounded-full px-2 text-xs text-white sm:max-w-[250px]"> + <a className="bg-greyscale-4 hover:bg-greyscale-3 max-w-[140px] truncate rounded-full py-0.5 px-2 text-xs text-white sm:max-w-[250px]"> {groupToDisplay.name} </a> </Link> ) } else return ( - <div className="bg-greyscale-4 truncate rounded-full px-2 text-xs text-white"> + <div className="bg-greyscale-4 truncate rounded-full py-0.5 px-2 text-xs text-white"> No Group </div> ) diff --git a/web/components/contract/contract-tabs.tsx b/web/components/contract/contract-tabs.tsx index 9b732224..d29806b5 100644 --- a/web/components/contract/contract-tabs.tsx +++ b/web/components/contract/contract-tabs.tsx @@ -31,6 +31,7 @@ import { MINUTE_MS } from 'common/util/time' import { useUser } from 'web/hooks/use-user' import { COMMENT_BOUNTY_AMOUNT } from 'common/economy' import { Tooltip } from 'web/components/tooltip' +import { CommentBountiesTooltipText } from 'web/components/contract/bountied-contract-badge' export function ContractTabs(props: { contract: Contract @@ -53,11 +54,7 @@ export function ContractTabs(props: { { title: `Comments`, tooltip: openCommentBounties - ? `The creator of this market may award ${formatMoney( - COMMENT_BOUNTY_AMOUNT - )} for good comments. ${formatMoney( - openCommentBounties - )} currently available.` + ? CommentBountiesTooltipText(openCommentBounties) : undefined, content: <CommentsTabContent contract={contract} comments={comments} />, inlineTabIcon: openCommentBounties ? ( From ab883ea7777a9d8428a7a467d6fe6c5eae3ee77d Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Fri, 30 Sep 2022 12:00:14 -0500 Subject: [PATCH 066/135] Order home group sections by daily score. --- web/pages/home/edit.tsx | 16 ++----- web/pages/home/index.tsx | 93 ++++++++++++++++++++++------------------ 2 files changed, 56 insertions(+), 53 deletions(-) diff --git a/web/pages/home/edit.tsx b/web/pages/home/edit.tsx index 8c5f8ab5..0b496757 100644 --- a/web/pages/home/edit.tsx +++ b/web/pages/home/edit.tsx @@ -7,12 +7,11 @@ import { Row } from 'web/components/layout/row' import { Page } from 'web/components/page' import { SiteLink } from 'web/components/site-link' import { Title } from 'web/components/title' -import { useMemberGroupsSubscription } from 'web/hooks/use-group' import { useTracking } from 'web/hooks/use-tracking' import { useUser } from 'web/hooks/use-user' import { updateUser } from 'web/lib/firebase/users' import { track } from 'web/lib/service/analytics' -import { getHomeItems, TrendingGroupsSection } from '.' +import { getHomeItems } from '.' export default function Home() { const user = useUser() @@ -27,8 +26,7 @@ export default function Home() { setHomeSections(newHomeSections) } - const groups = useMemberGroupsSubscription(user) - const { sections } = getHomeItems(groups ?? [], homeSections) + const { sections } = getHomeItems(homeSections) return ( <Page> @@ -38,14 +36,8 @@ export default function Home() { <DoneButton /> </Row> - <Col className="gap-8 md:flex-row"> - <Col className="flex-1"> - <ArrangeHome - sections={sections} - setSectionIds={updateHomeSections} - /> - </Col> - <TrendingGroupsSection className="flex-1" user={user} full /> + <Col className="flex-1"> + <ArrangeHome sections={sections} setSectionIds={updateHomeSections} /> </Col> </Col> </Page> diff --git a/web/pages/home/index.tsx b/web/pages/home/index.tsx index e920b20f..de5e95f2 100644 --- a/web/pages/home/index.tsx +++ b/web/pages/home/index.tsx @@ -8,7 +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 { Dictionary, sortBy, sum } from 'lodash' import { Page } from 'web/components/page' import { Col } from 'web/components/layout/col' @@ -62,16 +62,16 @@ export default function Home() { } }) - const groups = useMemberGroupsSubscription(user) - - const { sections } = getHomeItems(groups ?? [], user?.homeSections ?? []) + const { sections } = getHomeItems(user?.homeSections ?? []) useEffect(() => { - if (user && !user.homeSections && sections.length > 0 && groups) { + if (user && !user.homeSections && sections.length > 0) { // Save initial home sections. updateUser(user.id, { homeSections: sections.map((s) => s.id) }) } - }, [user, sections, groups]) + }, [user, sections]) + + const groups = useMemberGroupsSubscription(user) const groupContracts = useContractsByDailyScoreGroups( groups?.map((g) => g.slug) @@ -94,14 +94,15 @@ export default function Home() { <LoadingIndicator /> ) : ( <> - {sections.map((section) => - renderSection(section, user, groups, groupContracts) - )} + {sections.map((section) => renderSection(section, user))} <TrendingGroupsSection user={user} /> + + {renderGroupSections(user, groups, groupContracts)} </> )} </Col> + <button type="button" className="fixed bottom-[70px] right-3 z-20 inline-flex items-center rounded-full border border-transparent bg-indigo-600 p-4 text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 lg:hidden" @@ -123,17 +124,12 @@ const HOME_SECTIONS = [ { label: 'New', id: 'newest' }, ] -export const getHomeItems = (groups: Group[], sections: string[]) => { +export const getHomeItems = (sections: string[]) => { // Accommodate old home sections. if (!isArray(sections)) sections = [] const items: { id: string; label: string; group?: Group }[] = [ ...HOME_SECTIONS, - ...groups.map((g) => ({ - label: g.name, - id: g.id, - group: g, - })), ] const itemsById = keyBy(items, 'id') @@ -152,12 +148,7 @@ export const getHomeItems = (groups: Group[], sections: string[]) => { } } -function renderSection( - section: { id: string; label: string }, - user: User, - groups: Group[] | undefined, - groupContracts: Dictionary<CPMMBinaryContract[]> | undefined -) { +function renderSection(section: { id: string; label: string }, user: User) { const { id, label } = section if (id === 'daily-movers') { return <DailyMoversSection key={id} userId={user.id} /> @@ -178,25 +169,47 @@ function renderSection( <SearchSection key={id} label={label} sort={sort.value} 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 +} + +function renderGroupSections( + user: User, + groups: Group[] | undefined, + groupContracts: Dictionary<CPMMBinaryContract[]> | undefined +) { + if (!groups || !groupContracts) { + return <LoadingIndicator /> } - return null + const filteredGroups = groups.filter((g) => groupContracts[g.slug]) + const orderedGroups = sortBy(filteredGroups, (g) => + // Sort by sum of top two daily scores. + sum( + sortBy(groupContracts[g.slug].map((c) => c.dailyScore)) + .reverse() + .slice(0, 2) + ) + ).reverse() + + return ( + <> + {orderedGroups.map((group) => { + const contracts = groupContracts[group.slug].filter( + (c) => Math.abs(c.probChanges.day) >= 0.01 + ) + if (contracts.length === 0) return null + + return ( + <GroupSection + key={group.id} + group={group} + user={user} + contracts={contracts} + /> + ) + })} + </> + ) } function SectionHeader(props: { @@ -371,9 +384,7 @@ export function TrendingGroupsSection(props: { return ( <Col className={className}> - <SectionHeader label="Trending groups" href="/explore-groups"> - {!full && <CustomizeButton className="mb-1" />} - </SectionHeader> + <SectionHeader label="Trending groups" href="/explore-groups" /> <Row className="flex-wrap gap-2"> {chosenGroups.map((g) => ( <PillButton From 9d81e3b6d1b31220d560cd9b7edc35716fd3d3e6 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Fri, 30 Sep 2022 13:22:10 -0500 Subject: [PATCH 067/135] Fix import --- web/components/contract/bountied-contract-badge.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/components/contract/bountied-contract-badge.tsx b/web/components/contract/bountied-contract-badge.tsx index 3e1ed68c..b3e230cb 100644 --- a/web/components/contract/bountied-contract-badge.tsx +++ b/web/components/contract/bountied-contract-badge.tsx @@ -1,7 +1,7 @@ import { CurrencyDollarIcon } from '@heroicons/react/outline' import { Contract } from 'common/contract' import { Tooltip } from 'web/components/tooltip' -import { formatMoney } from 'common/lib/util/format' +import { formatMoney } from 'common/util/format' import { COMMENT_BOUNTY_AMOUNT } from 'common/economy' export function BountiedContractBadge() { From b2f81c11496a54cd86b6b87144952ac4bb98272a Mon Sep 17 00:00:00 2001 From: Phil <phil.bladen@gmail.com> Date: Fri, 30 Sep 2022 20:01:51 +0100 Subject: [PATCH 068/135] Twitch minor fix (#973) * Made Twitch copy link buttons links so right-click -> copy URL works. * Added Twitch OBS screenshot to public folder. --- web/pages/twitch.tsx | 120 +++++++++++++---------- web/public/twitch-bot-obs-screenshot.jpg | Bin 0 -> 130637 bytes 2 files changed, 70 insertions(+), 50 deletions(-) create mode 100644 web/public/twitch-bot-obs-screenshot.jpg diff --git a/web/pages/twitch.tsx b/web/pages/twitch.tsx index 6508a69e..2331fa1c 100644 --- a/web/pages/twitch.tsx +++ b/web/pages/twitch.tsx @@ -257,6 +257,30 @@ function BotSetupStep(props: { ) } +function CopyLinkButton(props: { link: string; text: string }) { + const { link, text } = props + const toastTheme = { + className: '!bg-primary !text-white', + icon: <LinkIcon className="mr-2 h-6 w-6" aria-hidden="true" />, + } + const copyLinkCallback = async () => { + copyToClipboard(link) + toast.success(text + ' copied', toastTheme) + } + return ( + <a href={link} onClick={(e) => e.preventDefault()}> + <Button + size={'md'} + color={'green'} + className="w-full !border-none" + onClick={copyLinkCallback} + > + {text} + </Button> + </a> + ) +} + function BotConnectButton(props: { privateUser: PrivateUser | null | undefined }) { @@ -338,39 +362,67 @@ function SetUpBot(props: { }) { const { user, privateUser } = props const twitchLinked = + privateUser && privateUser?.twitchInfo?.twitchName && !privateUser?.twitchInfo?.needsRelinking ? true : undefined - const toastTheme = { - className: '!bg-primary !text-white', - icon: <LinkIcon className="mr-2 h-6 w-6" aria-hidden="true" />, - } - const copyOverlayLink = async () => { - if (!privateUser) return - copyToClipboard(getOverlayURLForUser(privateUser)) - toast.success('Overlay link copied!', toastTheme) - } - const copyDockLink = async () => { - if (!privateUser) return - copyToClipboard(getDockURLForUser(privateUser)) - toast.success('Dock link copied!', toastTheme) - } return ( <> <Title text={'Set up the bot for your own stream'} - className={'!mb-4 md:block'} + className={'!mb-0 md:block'} /> <Col className="gap-4"> <img - src="https://raw.githubusercontent.com/PhilBladen/ManifoldTwitchIntegration/master/docs/OBS.png" // TODO: Copy this into the Manifold codebase public folder - className="!-my-2" + src="/twitch-bot-obs-screenshot.jpg" + className="rounded-md border-t border-l border-r shadow-md" ></img> To add the bot to your stream make sure you have logged in then follow the steps below. - {!twitchLinked && ( + {twitchLinked && privateUser ? ( + <div className="flex flex-col gap-6 sm:flex-row"> + <BotSetupStep + stepNum={1} + overrideButton={ + twitchLinked && <BotConnectButton privateUser={privateUser} /> + } + > + Use the button above to add the bot to your channel. Then mod it + by typing in your Twitch chat: <b>/mod ManifoldBot</b> + <br /> + If the bot is not modded it will not be able to respond to + commands properly. + </BotSetupStep> + <BotSetupStep + stepNum={2} + overrideButton={ + <CopyLinkButton + link={getOverlayURLForUser(privateUser)} + text={'Overlay link'} + /> + } + > + Create a new browser source in your streaming software such as + OBS. Paste in the above link and type in the desired size. We + recommend 450x375. + </BotSetupStep> + <BotSetupStep + stepNum={3} + overrideButton={ + <CopyLinkButton + link={getDockURLForUser(privateUser)} + text={'Control dock link'} + /> + } + > + The bot can be controlled entirely through chat. But we made an + easy to use control panel. Share the link with your mods or embed + it into your OBS as a custom dock. + </BotSetupStep> + </div> + ) : ( <ButtonGetStarted user={user} privateUser={privateUser} @@ -378,38 +430,6 @@ function SetUpBot(props: { spinnerClass={'!my-0'} /> )} - <div className="flex flex-col gap-6 sm:flex-row"> - <BotSetupStep - stepNum={1} - overrideButton={ - twitchLinked && <BotConnectButton privateUser={privateUser} /> - } - > - Use the button above to add the bot to your channel. Then mod it by - typing in your Twitch chat: <b>/mod ManifoldBot</b> - <br /> - If the bot is not modded it will not be able to respond to commands - properly. - </BotSetupStep> - <BotSetupStep - stepNum={2} - buttonName={twitchLinked && 'Overlay link'} - buttonOnClick={copyOverlayLink} - > - Create a new browser source in your streaming software such as OBS. - Paste in the above link and type in the desired size. We recommend - 450x375. - </BotSetupStep> - <BotSetupStep - stepNum={3} - buttonName={twitchLinked && 'Control dock link'} - buttonOnClick={copyDockLink} - > - The bot can be controlled entirely through chat. But we made an easy - to use control panel. Share the link with your mods or embed it into - your OBS as a custom dock. - </BotSetupStep> - </div> <div> Need help? Contact SirSalty#5770 in Discord or email david@manifold.markets diff --git a/web/public/twitch-bot-obs-screenshot.jpg b/web/public/twitch-bot-obs-screenshot.jpg new file mode 100644 index 0000000000000000000000000000000000000000..2f14e0f14b7d05a24956f5e53b209ba217ebca3b GIT binary patch literal 130637 zcmeFZ1ymf(_AlC4u;3Eh2^QSlf(^k2_h3N>8Js{MNC*yt1$TFX2T5@EK(HV|0t5)| za3}e`bIy0}I`{w1eQ&+>{&&4oOn2|zwXJ@=tEZ~Ehx;%0D*!G?UP&H6KtKSV!~cN$ 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≪v^?Hhj8a>4`k6D3 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 V@dtH&c?tRbXZEkRncV00zX0#NsY(C< literal 0 HcmV?d00001 From a25acbe1db15b55f40a93ed5c3811cfdaa5d5066 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Fri, 30 Sep 2022 13:36:34 -0600 Subject: [PATCH 069/135] Parse ian's email prefs on dev --- functions/src/weekly-portfolio-emails.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/functions/src/weekly-portfolio-emails.ts b/functions/src/weekly-portfolio-emails.ts index ab46c5f1..bcf6da17 100644 --- a/functions/src/weekly-portfolio-emails.ts +++ b/functions/src/weekly-portfolio-emails.ts @@ -48,7 +48,7 @@ export async function sendPortfolioUpdateEmailsToAllUsers() { return isProd() ? user.notificationPreferences.profit_loss_updates.includes('email') && !user.weeklyPortfolioUpdateEmailSent - : true + : user.notificationPreferences.profit_loss_updates.includes('email') }) // Send emails in batches .slice(0, 200) From 17d1b8575c691f6314a49e8ccdf108e3c3163029 Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Fri, 30 Sep 2022 14:44:44 -0500 Subject: [PATCH 070/135] comment bounty styling --- .../contract/bountied-contract-badge.tsx | 8 +-- web/components/contract/contract-tabs.tsx | 53 +++++++++---------- 2 files changed, 30 insertions(+), 31 deletions(-) diff --git a/web/components/contract/bountied-contract-badge.tsx b/web/components/contract/bountied-contract-badge.tsx index b3e230cb..4b19df4c 100644 --- a/web/components/contract/bountied-contract-badge.tsx +++ b/web/components/contract/bountied-contract-badge.tsx @@ -12,15 +12,15 @@ export function BountiedContractBadge() { ) } -export function BountiedContractSmallBadge(props: { contract: Contract }) { - const { contract } = props +export function BountiedContractSmallBadge(props: { contract: Contract, showAmount?: boolean }) { + const { contract, showAmount } = props const { openCommentBounties } = contract if (!openCommentBounties) return <div /> return ( <Tooltip text={CommentBountiesTooltipText(openCommentBounties)}> - <span className="bg-greyscale-4 inline-flex cursor-default items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium text-white"> - <CurrencyDollarIcon className={'h3 w-3'} /> Bountied Comments + <span className="bg-indigo-300 inline-flex cursor-default items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium text-white"> + <CurrencyDollarIcon className={'h3 w-3'} />{showAmount && formatMoney(openCommentBounties)} Bounty </span> </Tooltip> ) diff --git a/web/components/contract/contract-tabs.tsx b/web/components/contract/contract-tabs.tsx index d29806b5..6bea13ed 100644 --- a/web/components/contract/contract-tabs.tsx +++ b/web/components/contract/contract-tabs.tsx @@ -25,13 +25,14 @@ import { import { buildArray } from 'common/util/array' import { ContractComment } from 'common/comment' -import { formatMoney } from 'common/util/format' import { Button } from 'web/components/button' import { MINUTE_MS } from 'common/util/time' import { useUser } from 'web/hooks/use-user' -import { COMMENT_BOUNTY_AMOUNT } from 'common/economy' import { Tooltip } from 'web/components/tooltip' -import { CommentBountiesTooltipText } from 'web/components/contract/bountied-contract-badge' +import { + BountiedContractSmallBadge, +} from 'web/components/contract/bountied-contract-badge' +import { Row } from '../layout/row' export function ContractTabs(props: { contract: Contract @@ -40,7 +41,6 @@ export function ContractTabs(props: { comments: ContractComment[] }) { const { contract, bets, userBets, comments } = props - const { openCommentBounties } = contract const yourTrades = ( <div> @@ -52,14 +52,8 @@ export function ContractTabs(props: { const tabs = buildArray( { - title: `Comments`, - tooltip: openCommentBounties - ? CommentBountiesTooltipText(openCommentBounties) - : undefined, + title: 'Comments', content: <CommentsTabContent contract={contract} comments={comments} />, - inlineTabIcon: openCommentBounties ? ( - <span>({formatMoney(COMMENT_BOUNTY_AMOUNT)})</span> - ) : undefined, }, { title: capitalize(PAST_BETS), @@ -156,23 +150,28 @@ const CommentsTabContent = memo(function CommentsTabContent(props: { const topLevelComments = commentsByParent['_'] ?? [] return ( <> - <Button - size={'xs'} - color={'gray-white'} - className="mb-4" - onClick={() => setSort(sort === 'Newest' ? 'Best' : 'Newest')} - > - <Tooltip - text={ - sort === 'Best' - ? 'Comments with tips or bounties will be shown first. Your comments made within the last 10 minutes will temporarily appear (to you) first.' - : '' - } - > - Sorted by: {sort} - </Tooltip> - </Button> <ContractCommentInput className="mb-5" contract={contract} /> + + <Row className="mb-4 items-center"> + <Button + size={'xs'} + color={'gray-white'} + onClick={() => setSort(sort === 'Newest' ? 'Best' : 'Newest')} + > + <Tooltip + text={ + sort === 'Best' + ? 'Comments with tips or bounties will be shown first. Your comments made within the last 10 minutes will temporarily appear (to you) first.' + : '' + } + > + Sorted by: {sort} + </Tooltip> + </Button> + + <BountiedContractSmallBadge contract={contract} showAmount /> + </Row> + {topLevelComments.map((parent) => ( <FeedCommentThread key={parent.id} From ac97e62f2e0d06bd2b58165c1b7cd728ba02e96d Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Fri, 30 Sep 2022 13:45:57 -0600 Subject: [PATCH 071/135] Add portfolio updates to notification settings --- common/notification.ts | 8 ++++---- web/components/notification-settings.tsx | 3 ++- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/common/notification.ts b/common/notification.ts index b42df541..d91dc300 100644 --- a/common/notification.ts +++ b/common/notification.ts @@ -116,8 +116,8 @@ export const NOTIFICATION_DESCRIPTIONS: notification_descriptions = { detailed: "Only answers by market creator on markets you're watching", }, betting_streaks: { - simple: 'For predictions made over consecutive days', - detailed: 'Bonuses for predictions made over consecutive days', + simple: `For prediction streaks`, + detailed: `Bonuses for predictions made over consecutive days (Prediction streaks)})`, }, comments_by_followed_users_on_watched_markets: { simple: 'Only comments by users you follow', @@ -159,8 +159,8 @@ export const NOTIFICATION_DESCRIPTIONS: notification_descriptions = { detailed: 'Large changes in probability on markets that you watch', }, profit_loss_updates: { - simple: 'Weekly profit and loss updates', - detailed: 'Weekly profit and loss updates', + simple: 'Weekly portfolio updates', + detailed: 'Weekly portfolio updates', }, referral_bonuses: { simple: 'For referring new users', diff --git a/web/components/notification-settings.tsx b/web/components/notification-settings.tsx index 047d15dd..f0b9591e 100644 --- a/web/components/notification-settings.tsx +++ b/web/components/notification-settings.tsx @@ -62,8 +62,8 @@ export function NotificationSettings(props: { 'tagged_user', // missing tagged on contract description email 'contract_from_followed_user', 'unique_bettors_on_your_contract', + 'profit_loss_updates', // TODO: add these - // 'profit_loss_updates', - changes in markets you have shares in // biggest winner, here are the rest of your markets // 'referral_bonuses', @@ -153,6 +153,7 @@ export function NotificationSettings(props: { 'trending_markets', 'thank_you_for_purchases', 'onboarding_flow', + 'profit_loss_updates', ], } From 9815e7301fa0426347a03e6fbc35c5109c67af45 Mon Sep 17 00:00:00 2001 From: IanPhilips <IanPhilips@users.noreply.github.com> Date: Fri, 30 Sep 2022 19:48:04 +0000 Subject: [PATCH 072/135] Auto-prettification --- web/components/contract/bountied-contract-badge.tsx | 10 +++++++--- web/components/contract/contract-tabs.tsx | 4 +--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/web/components/contract/bountied-contract-badge.tsx b/web/components/contract/bountied-contract-badge.tsx index 4b19df4c..fd738895 100644 --- a/web/components/contract/bountied-contract-badge.tsx +++ b/web/components/contract/bountied-contract-badge.tsx @@ -12,15 +12,19 @@ export function BountiedContractBadge() { ) } -export function BountiedContractSmallBadge(props: { contract: Contract, showAmount?: boolean }) { +export function BountiedContractSmallBadge(props: { + contract: Contract + showAmount?: boolean +}) { const { contract, showAmount } = props const { openCommentBounties } = contract if (!openCommentBounties) return <div /> return ( <Tooltip text={CommentBountiesTooltipText(openCommentBounties)}> - <span className="bg-indigo-300 inline-flex cursor-default items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium text-white"> - <CurrencyDollarIcon className={'h3 w-3'} />{showAmount && formatMoney(openCommentBounties)} Bounty + <span className="inline-flex cursor-default items-center gap-1 rounded-full bg-indigo-300 px-2 py-0.5 text-xs font-medium text-white"> + <CurrencyDollarIcon className={'h3 w-3'} /> + {showAmount && formatMoney(openCommentBounties)} Bounty </span> </Tooltip> ) diff --git a/web/components/contract/contract-tabs.tsx b/web/components/contract/contract-tabs.tsx index 6bea13ed..a4287b5c 100644 --- a/web/components/contract/contract-tabs.tsx +++ b/web/components/contract/contract-tabs.tsx @@ -29,9 +29,7 @@ import { Button } from 'web/components/button' import { MINUTE_MS } from 'common/util/time' import { useUser } from 'web/hooks/use-user' import { Tooltip } from 'web/components/tooltip' -import { - BountiedContractSmallBadge, -} from 'web/components/contract/bountied-contract-badge' +import { BountiedContractSmallBadge } from 'web/components/contract/bountied-contract-badge' import { Row } from '../layout/row' export function ContractTabs(props: { From 37beb584ef3e1b4908477c041a79708883cfea51 Mon Sep 17 00:00:00 2001 From: Sinclair Chen <abc.sinclair@gmail.com> Date: Fri, 30 Sep 2022 12:44:32 -0700 Subject: [PATCH 073/135] fix comment bounty overflow style --- web/components/contract/bountied-contract-badge.tsx | 7 +++++-- web/components/contract/contract-details.tsx | 4 ++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/web/components/contract/bountied-contract-badge.tsx b/web/components/contract/bountied-contract-badge.tsx index fd738895..72dc70e1 100644 --- a/web/components/contract/bountied-contract-badge.tsx +++ b/web/components/contract/bountied-contract-badge.tsx @@ -21,8 +21,11 @@ export function BountiedContractSmallBadge(props: { if (!openCommentBounties) return <div /> return ( - <Tooltip text={CommentBountiesTooltipText(openCommentBounties)}> - <span className="inline-flex cursor-default items-center gap-1 rounded-full bg-indigo-300 px-2 py-0.5 text-xs font-medium text-white"> + <Tooltip + text={CommentBountiesTooltipText(openCommentBounties)} + placement="bottom" + > + <span className="inline-flex items-center gap-1 whitespace-nowrap rounded-full bg-indigo-300 px-2 py-0.5 text-xs font-medium text-white"> <CurrencyDollarIcon className={'h3 w-3'} /> {showAmount && formatMoney(openCommentBounties)} Bounty </span> diff --git a/web/components/contract/contract-details.tsx b/web/components/contract/contract-details.tsx index 7c84fadf..b06c6381 100644 --- a/web/components/contract/contract-details.tsx +++ b/web/components/contract/contract-details.tsx @@ -178,7 +178,7 @@ export function MarketSubheader(props: { /> )} </Row> - <Row className="text-2xs text-greyscale-4 gap-2 sm:text-xs"> + <Row className="text-2xs text-greyscale-4 flex-wrap gap-2 sm:text-xs"> <CloseOrResolveTime contract={contract} resolvedDate={resolvedDate} @@ -335,7 +335,7 @@ export function GroupDisplay(props: { groupToDisplay?: GroupLink | null }) { if (groupToDisplay) { return ( <Link prefetch={false} href={groupPath(groupToDisplay.slug)}> - <a className="bg-greyscale-4 hover:bg-greyscale-3 max-w-[140px] truncate rounded-full py-0.5 px-2 text-xs text-white sm:max-w-[250px]"> + <a className="bg-greyscale-4 hover:bg-greyscale-3 max-w-[140px] truncate whitespace-nowrap rounded-full py-0.5 px-2 text-xs text-white sm:max-w-[250px]"> {groupToDisplay.name} </a> </Link> From 1e2df9905455759747d1ab3a558a44250844bd27 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Fri, 30 Sep 2022 15:05:37 -0500 Subject: [PATCH 074/135] Change format money to round up if within epsilon --- common/util/format.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/common/util/format.ts b/common/util/format.ts index 4f123535..ee59d3e7 100644 --- a/common/util/format.ts +++ b/common/util/format.ts @@ -8,7 +8,12 @@ const formatter = new Intl.NumberFormat('en-US', { }) export function formatMoney(amount: number) { - const newAmount = Math.round(amount) === 0 ? 0 : Math.floor(amount) // handle -0 case + const newAmount = + // handle -0 case + Math.round(amount) === 0 + ? 0 + : // Handle 499.9999999999999 case + Math.floor(amount + 0.00000000001 * Math.sign(amount)) return ENV_CONFIG.moneyMoniker + formatter.format(newAmount).replace('$', '') } From a219680701c6d8121f5df2cff4ce6caa8a2fb29b Mon Sep 17 00:00:00 2001 From: ingawei <46611122+ingawei@users.noreply.github.com> Date: Fri, 30 Sep 2022 15:16:27 -0500 Subject: [PATCH 075/135] Inga/scroll to top (#965) - adding scroll to top button for markets, removing predict button at the bottom of comments --- web/components/bet-button.tsx | 8 +++- web/components/play-money-disclaimer.tsx | 2 +- web/components/scroll-to-top-button.tsx | 47 ++++++++++++++++++++++++ web/pages/[username]/[contractSlug].tsx | 22 +---------- 4 files changed, 57 insertions(+), 22 deletions(-) create mode 100644 web/components/scroll-to-top-button.tsx diff --git a/web/components/bet-button.tsx b/web/components/bet-button.tsx index 808b450f..3c401767 100644 --- a/web/components/bet-button.tsx +++ b/web/components/bet-button.tsx @@ -17,6 +17,7 @@ import { BetSignUpPrompt } from './sign-up-prompt' import { User } from 'web/lib/firebase/users' import { SellRow } from './sell-row' import { useUnfilledBets } from 'web/hooks/use-bets' +import { PlayMoneyDisclaimer } from './play-money-disclaimer' /** Button that opens BetPanel in a new modal */ export default function BetButton(props: { @@ -85,7 +86,12 @@ export function BinaryMobileBetting(props: { contract: BinaryContract }) { if (user) { return <SignedInBinaryMobileBetting contract={contract} user={user} /> } else { - return <BetSignUpPrompt className="w-full" /> + return ( + <Col className="w-full"> + <BetSignUpPrompt className="w-full" /> + <PlayMoneyDisclaimer /> + </Col> + ) } } diff --git a/web/components/play-money-disclaimer.tsx b/web/components/play-money-disclaimer.tsx index 860075d0..190d03db 100644 --- a/web/components/play-money-disclaimer.tsx +++ b/web/components/play-money-disclaimer.tsx @@ -3,7 +3,7 @@ import { InfoBox } from './info-box' export const PlayMoneyDisclaimer = () => ( <InfoBox title="Play-money trading" - className="mt-4 max-w-md" + className="mt-4" text="Mana (M$) is the play-money used by our platform to keep track of your trades. It's completely free for you and your friends to get started!" /> ) diff --git a/web/components/scroll-to-top-button.tsx b/web/components/scroll-to-top-button.tsx new file mode 100644 index 00000000..18eb525c --- /dev/null +++ b/web/components/scroll-to-top-button.tsx @@ -0,0 +1,47 @@ +import { ArrowUpIcon } from '@heroicons/react/solid' +import clsx from 'clsx' +import { useEffect, useState } from 'react' +import { Row } from './layout/row' + +export function ScrollToTopButton(props: { className?: string }) { + const { className } = props + const [visible, setVisible] = useState(false) + + useEffect(() => { + const onScroll = () => { + if (window.scrollY > 500) { + setVisible(true) + } else { + setVisible(false) + } + } + window.addEventListener('scroll', onScroll, { passive: true }) + + return () => { + window.removeEventListener('scroll', onScroll) + } + }, []) + + const scrollToTop = () => { + window.scrollTo({ + top: 0, + behavior: 'smooth', + }) + } + + return ( + <button + className={clsx( + 'border-greyscale-2 bg-greyscale-1 hover:bg-greyscale-2 rounded-full border py-2 pr-3 pl-2 text-sm transition-colors', + visible ? 'inline' : 'hidden', + className + )} + onClick={scrollToTop} + > + <Row className="text-greyscale-6 gap-2 align-middle"> + <ArrowUpIcon className="text-greyscale-4 h-5 w-5" /> + Scroll to top + </Row> + </button> + ) +} diff --git a/web/pages/[username]/[contractSlug].tsx b/web/pages/[username]/[contractSlug].tsx index 93b53447..9b8b21cb 100644 --- a/web/pages/[username]/[contractSlug].tsx +++ b/web/pages/[username]/[contractSlug].tsx @@ -42,12 +42,10 @@ import { ContractsGrid } from 'web/components/contract/contracts-grid' import { Title } from 'web/components/title' import { usePrefetch } from 'web/hooks/use-prefetch' import { useAdmin } from 'web/hooks/use-admin' -import { BetSignUpPrompt } from 'web/components/sign-up-prompt' -import { PlayMoneyDisclaimer } from 'web/components/play-money-disclaimer' -import BetButton from 'web/components/bet-button' import { BetsSummary } from 'web/components/bet-summary' import { listAllComments } from 'web/lib/firebase/comments' import { ContractComment } from 'common/comment' +import { ScrollToTopButton } from 'web/components/scroll-to-top-button' export const getStaticProps = fromPropz(getStaticPropz) export async function getStaticPropz(props: { @@ -210,7 +208,6 @@ export function ContractPageContent( {showConfetti && ( <FullscreenConfetti recycle={false} numberOfPieces={300} /> )} - {ogCardProps && ( <SEO title={question} @@ -219,7 +216,6 @@ export function ContractPageContent( ogCardProps={ogCardProps} /> )} - <Col className="w-full justify-between rounded border-0 border-gray-100 bg-white py-6 pl-1 pr-2 sm:px-2 md:px-6 md:py-8"> {backToHome && ( <button @@ -276,23 +272,9 @@ export function ContractPageContent( userBets={userBets} comments={comments} /> - - {!user ? ( - <Col className="mt-4 max-w-sm items-center xl:hidden"> - <BetSignUpPrompt /> - <PlayMoneyDisclaimer /> - </Col> - ) : ( - outcomeType === 'BINARY' && - allowTrade && ( - <BetButton - contract={contract as CPMMBinaryContract} - className="mb-2 !mt-0 xl:hidden" - /> - ) - )} </Col> <RecommendedContractsWidget contract={contract} /> + <ScrollToTopButton className="fixed bottom-16 right-2 z-20 lg:bottom-2 xl:hidden" /> </Page> ) } From 3d146dd57d91266fb7de72717ee6bb55d3bb3fd5 Mon Sep 17 00:00:00 2001 From: Sinclair Chen <abc.sinclair@gmail.com> Date: Fri, 30 Sep 2022 14:52:51 -0700 Subject: [PATCH 076/135] decrease trending group count --- web/pages/home/index.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/web/pages/home/index.tsx b/web/pages/home/index.tsx index de5e95f2..2ddc3026 100644 --- a/web/pages/home/index.tsx +++ b/web/pages/home/index.tsx @@ -366,16 +366,15 @@ function DailyStats(props: { export function TrendingGroupsSection(props: { user: User | null | undefined - full?: boolean className?: string }) { - const { user, full, className } = props + const { user, className } = props const memberGroupIds = useMemberGroupIds(user) || [] const groups = useTrendingGroups().filter( (g) => !memberGroupIds.includes(g.id) ) - const count = full ? 100 : 25 + const count = 7 const chosenGroups = groups.slice(0, count) if (chosenGroups.length === 0) { From 1fc2f15daeab5183a458187fe399c755a3948768 Mon Sep 17 00:00:00 2001 From: Austin Chen <akrolsmir@gmail.com> Date: Fri, 30 Sep 2022 18:46:54 -0400 Subject: [PATCH 077/135] Try extending /stats to 180 days --- functions/src/update-stats.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/functions/src/update-stats.ts b/functions/src/update-stats.ts index 6f410886..a01bc87e 100644 --- a/functions/src/update-stats.ts +++ b/functions/src/update-stats.ts @@ -18,7 +18,7 @@ import { average } from '../../common/util/math' const firestore = admin.firestore() -const numberOfDays = 90 +const numberOfDays = 180 const getBetsQuery = (startTime: number, endTime: number) => firestore From 38b7c898f6571b1e3a3e93fe1f921ad743112bfb Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Fri, 30 Sep 2022 16:16:04 -0700 Subject: [PATCH 078/135] More refactoring to make chart tooltips more flexible (#975) --- web/components/charts/contract/binary.tsx | 14 ++--- web/components/charts/contract/choice.tsx | 16 +++--- web/components/charts/contract/numeric.tsx | 9 ++-- .../charts/contract/pseudo-numeric.tsx | 16 +++--- web/components/charts/generic-charts.tsx | 53 ++++++++----------- web/components/charts/helpers.tsx | 52 +++++++++++------- 6 files changed, 88 insertions(+), 72 deletions(-) diff --git a/web/components/charts/contract/binary.tsx b/web/components/charts/contract/binary.tsx index a3f04a29..564ef68c 100644 --- a/web/components/charts/contract/binary.tsx +++ b/web/components/charts/contract/binary.tsx @@ -25,19 +25,19 @@ const getBetPoints = (bets: Bet[]) => { return sortBy(bets, (b) => b.createdTime).map((b) => ({ x: new Date(b.createdTime), y: b.probAfter, - datum: b, + obj: b, })) } -const BinaryChartTooltip = (props: TooltipProps<HistoryPoint<Bet>>) => { - const { p, xScale } = props - const { x, y, datum } = p +const BinaryChartTooltip = (props: TooltipProps<Date, HistoryPoint<Bet>>) => { + const { data, mouseX, xScale } = props const [start, end] = xScale.domain() + const d = xScale.invert(mouseX) return ( <Row className="items-center gap-2"> - {datum && <Avatar size="xs" avatarUrl={datum.userAvatarUrl} />} - <span className="font-semibold">{formatDateInRange(x, start, end)}</span> - <span className="text-greyscale-6">{formatPct(y)}</span> + {data.obj && <Avatar size="xs" avatarUrl={data.obj.userAvatarUrl} />} + <span className="font-semibold">{formatDateInRange(d, start, end)}</span> + <span className="text-greyscale-6">{formatPct(data.y)}</span> </Row> ) } diff --git a/web/components/charts/contract/choice.tsx b/web/components/charts/contract/choice.tsx index 7c9ec07a..665a01cd 100644 --- a/web/components/charts/contract/choice.tsx +++ b/web/components/charts/contract/choice.tsx @@ -114,7 +114,7 @@ const getBetPoints = (answers: Answer[], bets: Bet[]) => { points.push({ x: new Date(bet.createdTime), y: answers.map((a) => sharesByOutcome[a.id] ** 2 / sharesSquared), - datum: bet, + obj: bet, }) } return points @@ -181,12 +181,12 @@ export const ChoiceContractChart = (props: { const yScale = scaleLinear([0, 1], [height - MARGIN_Y, 0]) const ChoiceTooltip = useMemo( - () => (props: TooltipProps<MultiPoint<Bet>>) => { - const { p, xScale } = props - const { x, y, datum } = p + () => (props: TooltipProps<Date, MultiPoint<Bet>>) => { + const { data, mouseX, xScale } = props const [start, end] = xScale.domain() + const d = xScale.invert(mouseX) const legendItems = sortBy( - y.map((p, i) => ({ + data.y.map((p, i) => ({ color: CATEGORY_COLORS[i], label: answers[i].text, value: formatPct(p), @@ -197,9 +197,11 @@ export const ChoiceContractChart = (props: { return ( <> <Row className="items-center gap-2"> - {datum && <Avatar size="xxs" avatarUrl={datum.userAvatarUrl} />} + {data.obj && ( + <Avatar size="xxs" avatarUrl={data.obj.userAvatarUrl} /> + )} <span className="text-semibold text-base"> - {formatDateInRange(x, start, end)} + {formatDateInRange(d, start, end)} </span> </Row> <Legend className="max-w-xs" items={legendItems} /> diff --git a/web/components/charts/contract/numeric.tsx b/web/components/charts/contract/numeric.tsx index ac300361..f8051308 100644 --- a/web/components/charts/contract/numeric.tsx +++ b/web/components/charts/contract/numeric.tsx @@ -21,12 +21,15 @@ const getNumericChartData = (contract: NumericContract) => { })) } -const NumericChartTooltip = (props: TooltipProps<DistributionPoint>) => { - const { x, y } = props.p +const NumericChartTooltip = ( + props: TooltipProps<number, DistributionPoint> +) => { + const { data, mouseX, xScale } = props + const x = xScale.invert(mouseX) return ( <> <span className="text-semibold">{formatLargeNumber(x)}</span> - <span className="text-greyscale-6">{formatPct(y, 2)}</span> + <span className="text-greyscale-6">{formatPct(data.y, 2)}</span> </> ) } diff --git a/web/components/charts/contract/pseudo-numeric.tsx b/web/components/charts/contract/pseudo-numeric.tsx index adf2e493..04b1fafb 100644 --- a/web/components/charts/contract/pseudo-numeric.tsx +++ b/web/components/charts/contract/pseudo-numeric.tsx @@ -37,19 +37,21 @@ const getBetPoints = (bets: Bet[], scaleP: (p: number) => number) => { return sortBy(bets, (b) => b.createdTime).map((b) => ({ x: new Date(b.createdTime), y: scaleP(b.probAfter), - datum: b, + obj: b, })) } -const PseudoNumericChartTooltip = (props: TooltipProps<HistoryPoint<Bet>>) => { - const { p, xScale } = props - const { x, y, datum } = p +const PseudoNumericChartTooltip = ( + props: TooltipProps<Date, HistoryPoint<Bet>> +) => { + const { data, mouseX, xScale } = props const [start, end] = xScale.domain() + const d = xScale.invert(mouseX) return ( <Row className="items-center gap-2"> - {datum && <Avatar size="xs" avatarUrl={datum.userAvatarUrl} />} - <span className="font-semibold">{formatDateInRange(x, start, end)}</span> - <span className="text-greyscale-6">{formatLargeNumber(y)}</span> + {data.obj && <Avatar size="xs" avatarUrl={data.obj.userAvatarUrl} />} + <span className="font-semibold">{formatDateInRange(d, start, end)}</span> + <span className="text-greyscale-6">{formatLargeNumber(data.y)}</span> </Row> ) } diff --git a/web/components/charts/generic-charts.tsx b/web/components/charts/generic-charts.tsx index 5ae30ad4..152b264c 100644 --- a/web/components/charts/generic-charts.tsx +++ b/web/components/charts/generic-charts.tsx @@ -13,6 +13,7 @@ import { import { range } from 'lodash' import { + ContinuousScale, SVGChart, AreaPath, AreaWithTopStroke, @@ -31,6 +32,19 @@ const getTickValues = (min: number, max: number, n: number) => { return [min, ...range(1, n - 1).map((i) => min + step * i), max] } +const betAtPointSelector = <X, Y, P extends Point<X, Y>>( + data: P[], + xScale: ContinuousScale<X> +) => { + const bisect = bisector((p: P) => p.x) + return (posX: number) => { + const x = xScale.invert(posX) + const item = data[bisect.left(data, x) - 1] + const result = item ? { ...item, x: posX } : undefined + return result + } +} + export const DistributionChart = <P extends DistributionPoint>(props: { data: P[] w: number @@ -39,7 +53,7 @@ export const DistributionChart = <P extends DistributionPoint>(props: { xScale: ScaleContinuousNumeric<number, number> yScale: ScaleContinuousNumeric<number, number> onMouseOver?: (p: P | undefined) => void - Tooltip?: TooltipComponent<P> + Tooltip?: TooltipComponent<number, P> }) => { const { color, data, yScale, w, h, Tooltip } = props @@ -50,7 +64,6 @@ export const DistributionChart = <P extends DistributionPoint>(props: { const px = useCallback((p: P) => xScale(p.x), [xScale]) const py0 = yScale(yScale.domain()[0]) const py1 = useCallback((p: P) => yScale(p.y), [yScale]) - const xBisector = bisector((p: P) => p.x) const { xAxis, yAxis } = useMemo(() => { const xAxis = axisBottom<number>(xScale).ticks(w / 100) @@ -58,6 +71,8 @@ export const DistributionChart = <P extends DistributionPoint>(props: { return { xAxis, yAxis } }, [w, xScale, yScale]) + const onMouseOver = useEvent(betAtPointSelector(data, xScale)) + const onSelect = useEvent((ev: D3BrushEvent<P>) => { if (ev.selection) { const [mouseX0, mouseX1] = ev.selection as [number, number] @@ -69,14 +84,6 @@ export const DistributionChart = <P extends DistributionPoint>(props: { } }) - const onMouseOver = useEvent((mouseX: number) => { - const queryX = xScale.invert(mouseX) - const item = data[xBisector.left(data, queryX) - 1] - const result = item ? { ...item, x: queryX } : undefined - props.onMouseOver?.(result) - return result - }) - return ( <SVGChart w={w} @@ -107,7 +114,7 @@ export const MultiValueHistoryChart = <P extends MultiPoint>(props: { xScale: ScaleTime<number, number> yScale: ScaleContinuousNumeric<number, number> onMouseOver?: (p: P | undefined) => void - Tooltip?: TooltipComponent<P> + Tooltip?: TooltipComponent<Date, P> pct?: boolean }) => { const { colors, data, yScale, w, h, Tooltip, pct } = props @@ -119,7 +126,6 @@ export const MultiValueHistoryChart = <P extends MultiPoint>(props: { const px = useCallback((p: SP) => xScale(p.data.x), [xScale]) const py0 = useCallback((p: SP) => yScale(p[0]), [yScale]) const py1 = useCallback((p: SP) => yScale(p[1]), [yScale]) - const xBisector = bisector((p: P) => p.x) const { xAxis, yAxis } = useMemo(() => { const [min, max] = yScale.domain() @@ -141,6 +147,8 @@ export const MultiValueHistoryChart = <P extends MultiPoint>(props: { return d3Stack(data) }, [data]) + const onMouseOver = useEvent(betAtPointSelector(data, xScale)) + const onSelect = useEvent((ev: D3BrushEvent<P>) => { if (ev.selection) { const [mouseX0, mouseX1] = ev.selection as [number, number] @@ -152,14 +160,6 @@ export const MultiValueHistoryChart = <P extends MultiPoint>(props: { } }) - const onMouseOver = useEvent((mouseX: number) => { - const queryX = xScale.invert(mouseX) - const item = data[xBisector.left(data, queryX) - 1] - const result = item ? { ...item, x: queryX } : undefined - props.onMouseOver?.(result) - return result - }) - return ( <SVGChart w={w} @@ -193,7 +193,7 @@ export const SingleValueHistoryChart = <P extends HistoryPoint>(props: { xScale: ScaleTime<number, number> yScale: ScaleContinuousNumeric<number, number> onMouseOver?: (p: P | undefined) => void - Tooltip?: TooltipComponent<P> + Tooltip?: TooltipComponent<Date, P> pct?: boolean }) => { const { color, data, yScale, w, h, Tooltip, pct } = props @@ -204,7 +204,6 @@ export const SingleValueHistoryChart = <P extends HistoryPoint>(props: { const px = useCallback((p: P) => xScale(p.x), [xScale]) const py0 = yScale(yScale.domain()[0]) const py1 = useCallback((p: P) => yScale(p.y), [yScale]) - const xBisector = bisector((p: P) => p.x) const { xAxis, yAxis } = useMemo(() => { const [min, max] = yScale.domain() @@ -218,6 +217,8 @@ export const SingleValueHistoryChart = <P extends HistoryPoint>(props: { return { xAxis, yAxis } }, [w, h, pct, xScale, yScale]) + const onMouseOver = useEvent(betAtPointSelector(data, xScale)) + const onSelect = useEvent((ev: D3BrushEvent<P>) => { if (ev.selection) { const [mouseX0, mouseX1] = ev.selection as [number, number] @@ -229,14 +230,6 @@ export const SingleValueHistoryChart = <P extends HistoryPoint>(props: { } }) - const onMouseOver = useEvent((mouseX: number) => { - const queryX = xScale.invert(mouseX) - const item = data[xBisector.left(data, queryX) - 1] - const result = item ? { ...item, x: queryX } : undefined - props.onMouseOver?.(result) - return result - }) - return ( <SVGChart w={w} diff --git a/web/components/charts/helpers.tsx b/web/components/charts/helpers.tsx index ba9865b2..ea221c80 100644 --- a/web/components/charts/helpers.tsx +++ b/web/components/charts/helpers.tsx @@ -17,7 +17,12 @@ import clsx from 'clsx' import { Contract } from 'common/contract' -export type Point<X, Y, T = unknown> = { x: X; y: Y; datum?: T } +export type Point<X, Y, T = unknown> = { x: X; y: Y; obj?: T } + +export interface ContinuousScale<T> extends AxisScale<T> { + invert(n: number): T +} + export type XScale<P> = P extends Point<infer X, infer _> ? AxisScale<X> : never export type YScale<P> = P extends Point<infer _, infer Y> ? AxisScale<Y> : never @@ -118,18 +123,18 @@ export const AreaWithTopStroke = <P,>(props: { ) } -export const SVGChart = <X, Y, P extends Point<X, Y>>(props: { +export const SVGChart = <X, TT>(props: { children: ReactNode w: number h: number xAxis: Axis<X> yAxis: Axis<number> onSelect?: (ev: D3BrushEvent<any>) => void - onMouseOver?: (mouseX: number, mouseY: number) => P | undefined - Tooltip?: TooltipComponent<P> + onMouseOver?: (mouseX: number, mouseY: number) => TT | undefined + Tooltip?: TooltipComponent<X, TT> }) => { const { children, w, h, xAxis, yAxis, onMouseOver, onSelect, Tooltip } = props - const [mouseState, setMouseState] = useState<{ pos: TooltipPosition; p: P }>() + const [mouse, setMouse] = useState<{ x: number; y: number; data: TT }>() const overlayRef = useRef<SVGGElement>(null) const innerW = w - MARGIN_X const innerH = h - MARGIN_Y @@ -148,7 +153,7 @@ export const SVGChart = <X, Y, P extends Point<X, Y>>(props: { if (!justSelected.current) { justSelected.current = true onSelect(ev) - setMouseState(undefined) + setMouse(undefined) if (overlayRef.current) { select(overlayRef.current).call(brush.clear) } @@ -168,26 +173,32 @@ export const SVGChart = <X, Y, P extends Point<X, Y>>(props: { const onPointerMove = (ev: React.PointerEvent) => { if (ev.pointerType === 'mouse' && onMouseOver) { - const [mouseX, mouseY] = pointer(ev) - const p = onMouseOver(mouseX, mouseY) - if (p != null) { - const pos = getTooltipPosition(mouseX, mouseY, innerW, innerH) - setMouseState({ pos, p }) + const [x, y] = pointer(ev) + const data = onMouseOver(x, y) + if (data !== undefined) { + setMouse({ x, y, data }) } else { - setMouseState(undefined) + setMouse(undefined) } } } const onPointerLeave = () => { - setMouseState(undefined) + setMouse(undefined) } return ( <div className="relative"> - {mouseState && Tooltip && ( - <TooltipContainer pos={mouseState.pos}> - <Tooltip xScale={xAxis.scale()} p={mouseState.p} /> + {mouse && Tooltip && ( + <TooltipContainer + pos={getTooltipPosition(mouse.x, mouse.y, innerW, innerH)} + > + <Tooltip + xScale={xAxis.scale()} + mouseX={mouse.x} + mouseY={mouse.y} + data={mouse.data} + /> </TooltipContainer> )} <svg width={w} height={h} viewBox={`0 0 ${w} ${h}`}> @@ -243,8 +254,13 @@ export const getTooltipPosition = ( return result } -export type TooltipProps<P> = { p: P; xScale: XScale<P> } -export type TooltipComponent<P> = React.ComponentType<TooltipProps<P>> +export type TooltipProps<X, T> = { + mouseX: number + mouseY: number + xScale: ContinuousScale<X> + data: T +} +export type TooltipComponent<X, T> = React.ComponentType<TooltipProps<X, T>> export const TooltipContainer = (props: { pos: TooltipPosition className?: string From 89e26d077e58565afbb4a5d98ecbcd1af53dbed7 Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Fri, 30 Sep 2022 16:57:48 -0700 Subject: [PATCH 079/135] Clean up chart sizing code (#977) * Clean up chart sizing code * Do all the chart sizing work in same batch --- web/components/charts/contract/binary.tsx | 40 ++++------ web/components/charts/contract/choice.tsx | 39 ++++----- web/components/charts/contract/index.tsx | 3 +- web/components/charts/contract/numeric.tsx | 37 ++++----- .../charts/contract/pseudo-numeric.tsx | 44 ++++------ web/components/contract/contract-overview.tsx | 80 +++++++++++++++---- web/hooks/use-element-width.tsx | 17 ---- web/pages/embed/[username]/[contractSlug].tsx | 11 ++- 8 files changed, 136 insertions(+), 135 deletions(-) delete mode 100644 web/hooks/use-element-width.tsx diff --git a/web/components/charts/contract/binary.tsx b/web/components/charts/contract/binary.tsx index 564ef68c..7e192767 100644 --- a/web/components/charts/contract/binary.tsx +++ b/web/components/charts/contract/binary.tsx @@ -1,4 +1,4 @@ -import { useMemo, useRef } from 'react' +import { useMemo } from 'react' import { last, sortBy } from 'lodash' import { scaleTime, scaleLinear } from 'd3-scale' @@ -6,7 +6,6 @@ import { Bet } from 'common/bet' import { getProbability, getInitialProbability } from 'common/calculate' import { BinaryContract } from 'common/contract' import { DAY_MS } from 'common/util/time' -import { useIsMobile } from 'web/hooks/use-is-mobile' import { TooltipProps, MARGIN_X, @@ -17,7 +16,6 @@ import { formatPct, } from '../helpers' import { HistoryPoint, SingleValueHistoryChart } from '../generic-charts' -import { useElementWidth } from 'web/hooks/use-element-width' import { Row } from 'web/components/layout/row' import { Avatar } from 'web/components/avatar' @@ -45,10 +43,11 @@ const BinaryChartTooltip = (props: TooltipProps<Date, HistoryPoint<Bet>>) => { export const BinaryContractChart = (props: { contract: BinaryContract bets: Bet[] - height?: number + width: number + height: number onMouseOver?: (p: HistoryPoint<Bet> | undefined) => void }) => { - const { contract, bets, onMouseOver } = props + const { contract, bets, width, height, onMouseOver } = props const [start, end] = getDateRange(contract) const startP = getInitialProbability(contract) const endP = getProbability(contract) @@ -67,28 +66,19 @@ export const BinaryContractChart = (props: { Date.now() ) const visibleRange = [start, rightmostDate] - const isMobile = useIsMobile(800) - const containerRef = useRef<HTMLDivElement>(null) - const width = useElementWidth(containerRef) ?? 0 - const height = props.height ?? (isMobile ? 150 : 250) const xScale = scaleTime(visibleRange, [0, width - MARGIN_X]) const yScale = scaleLinear([0, 1], [height - MARGIN_Y, 0]) - return ( - <div ref={containerRef}> - {width > 0 && ( - <SingleValueHistoryChart - w={width} - h={height} - xScale={xScale} - yScale={yScale} - data={data} - color="#11b981" - onMouseOver={onMouseOver} - Tooltip={BinaryChartTooltip} - pct - /> - )} - </div> + <SingleValueHistoryChart + w={width} + h={height} + xScale={xScale} + yScale={yScale} + data={data} + color="#11b981" + onMouseOver={onMouseOver} + Tooltip={BinaryChartTooltip} + pct + /> ) } diff --git a/web/components/charts/contract/choice.tsx b/web/components/charts/contract/choice.tsx index 665a01cd..65279b70 100644 --- a/web/components/charts/contract/choice.tsx +++ b/web/components/charts/contract/choice.tsx @@ -1,4 +1,4 @@ -import { useMemo, useRef } from 'react' +import { useMemo } from 'react' import { last, sum, sortBy, groupBy } from 'lodash' import { scaleTime, scaleLinear } from 'd3-scale' @@ -6,7 +6,6 @@ import { Bet } from 'common/bet' import { Answer } from 'common/answer' import { FreeResponseContract, MultipleChoiceContract } from 'common/contract' import { getOutcomeProbability } from 'common/calculate' -import { useIsMobile } from 'web/hooks/use-is-mobile' import { DAY_MS } from 'common/util/time' import { TooltipProps, @@ -18,7 +17,6 @@ import { formatDateInRange, } from '../helpers' import { MultiPoint, MultiValueHistoryChart } from '../generic-charts' -import { useElementWidth } from 'web/hooks/use-element-width' import { Row } from 'web/components/layout/row' import { Avatar } from 'web/components/avatar' @@ -146,10 +144,11 @@ const Legend = (props: { className?: string; items: LegendItem[] }) => { export const ChoiceContractChart = (props: { contract: FreeResponseContract | MultipleChoiceContract bets: Bet[] - height?: number + width: number + height: number onMouseOver?: (p: MultiPoint<Bet> | undefined) => void }) => { - const { contract, bets, onMouseOver } = props + const { contract, bets, width, height, onMouseOver } = props const [start, end] = getDateRange(contract) const answers = useMemo( () => getTrackedAnswers(contract, CATEGORY_COLORS.length), @@ -173,10 +172,6 @@ export const ChoiceContractChart = (props: { Date.now() ) const visibleRange = [start, rightmostDate] - const isMobile = useIsMobile(800) - const containerRef = useRef<HTMLDivElement>(null) - const width = useElementWidth(containerRef) ?? 0 - const height = props.height ?? (isMobile ? 250 : 350) const xScale = scaleTime(visibleRange, [0, width - MARGIN_X]) const yScale = scaleLinear([0, 1], [height - MARGIN_Y, 0]) @@ -212,20 +207,16 @@ export const ChoiceContractChart = (props: { ) return ( - <div ref={containerRef}> - {width > 0 && ( - <MultiValueHistoryChart - w={width} - h={height} - xScale={xScale} - yScale={yScale} - data={data} - colors={CATEGORY_COLORS} - onMouseOver={onMouseOver} - Tooltip={ChoiceTooltip} - pct - /> - )} - </div> + <MultiValueHistoryChart + w={width} + h={height} + xScale={xScale} + yScale={yScale} + data={data} + colors={CATEGORY_COLORS} + onMouseOver={onMouseOver} + Tooltip={ChoiceTooltip} + pct + /> ) } diff --git a/web/components/charts/contract/index.tsx b/web/components/charts/contract/index.tsx index 1f580bae..1efe1eb4 100644 --- a/web/components/charts/contract/index.tsx +++ b/web/components/charts/contract/index.tsx @@ -8,7 +8,8 @@ import { NumericContractChart } from './numeric' export const ContractChart = (props: { contract: Contract bets: Bet[] - height?: number + width: number + height: number }) => { const { contract } = props switch (contract.outcomeType) { diff --git a/web/components/charts/contract/numeric.tsx b/web/components/charts/contract/numeric.tsx index f8051308..2d62cb11 100644 --- a/web/components/charts/contract/numeric.tsx +++ b/web/components/charts/contract/numeric.tsx @@ -1,4 +1,4 @@ -import { useMemo, useRef } from 'react' +import { useMemo } from 'react' import { range } from 'lodash' import { scaleLinear } from 'd3-scale' @@ -6,10 +6,8 @@ import { formatLargeNumber } from 'common/util/format' import { getDpmOutcomeProbabilities } from 'common/calculate-dpm' import { NumericContract } from 'common/contract' import { NUMERIC_GRAPH_COLOR } from 'common/numeric-constants' -import { useIsMobile } from 'web/hooks/use-is-mobile' import { TooltipProps, MARGIN_X, MARGIN_Y, formatPct } from '../helpers' import { DistributionPoint, DistributionChart } from '../generic-charts' -import { useElementWidth } from 'web/hooks/use-element-width' const getNumericChartData = (contract: NumericContract) => { const { totalShares, bucketCount, min, max } = contract @@ -36,33 +34,26 @@ const NumericChartTooltip = ( export const NumericContractChart = (props: { contract: NumericContract - height?: number + width: number + height: number onMouseOver?: (p: DistributionPoint | undefined) => void }) => { - const { contract, onMouseOver } = props + const { contract, width, height, onMouseOver } = props const { min, max } = contract const data = useMemo(() => getNumericChartData(contract), [contract]) - const isMobile = useIsMobile(800) - const containerRef = useRef<HTMLDivElement>(null) - const width = useElementWidth(containerRef) ?? 0 - const height = props.height ?? (isMobile ? 150 : 250) const maxY = Math.max(...data.map((d) => d.y)) const xScale = scaleLinear([min, max], [0, width - MARGIN_X]) const yScale = scaleLinear([0, maxY], [height - MARGIN_Y, 0]) return ( - <div ref={containerRef}> - {width > 0 && ( - <DistributionChart - w={width} - h={height} - xScale={xScale} - yScale={yScale} - data={data} - color={NUMERIC_GRAPH_COLOR} - onMouseOver={onMouseOver} - Tooltip={NumericChartTooltip} - /> - )} - </div> + <DistributionChart + w={width} + h={height} + xScale={xScale} + yScale={yScale} + data={data} + color={NUMERIC_GRAPH_COLOR} + onMouseOver={onMouseOver} + Tooltip={NumericChartTooltip} + /> ) } diff --git a/web/components/charts/contract/pseudo-numeric.tsx b/web/components/charts/contract/pseudo-numeric.tsx index 04b1fafb..e03d4ad9 100644 --- a/web/components/charts/contract/pseudo-numeric.tsx +++ b/web/components/charts/contract/pseudo-numeric.tsx @@ -1,4 +1,4 @@ -import { useMemo, useRef } from 'react' +import { useMemo } from 'react' import { last, sortBy } from 'lodash' import { scaleTime, scaleLog, scaleLinear } from 'd3-scale' @@ -8,7 +8,6 @@ import { getInitialProbability, getProbability } from 'common/calculate' import { formatLargeNumber } from 'common/util/format' import { PseudoNumericContract } from 'common/contract' import { NUMERIC_GRAPH_COLOR } from 'common/numeric-constants' -import { useIsMobile } from 'web/hooks/use-is-mobile' import { TooltipProps, MARGIN_X, @@ -18,7 +17,6 @@ import { formatDateInRange, } from '../helpers' import { HistoryPoint, SingleValueHistoryChart } from '../generic-charts' -import { useElementWidth } from 'web/hooks/use-element-width' import { Row } from 'web/components/layout/row' import { Avatar } from 'web/components/avatar' @@ -59,10 +57,11 @@ const PseudoNumericChartTooltip = ( export const PseudoNumericContractChart = (props: { contract: PseudoNumericContract bets: Bet[] - height?: number + width: number + height: number onMouseOver?: (p: HistoryPoint<Bet> | undefined) => void }) => { - const { contract, bets, onMouseOver } = props + const { contract, bets, width, height, onMouseOver } = props const { min, max, isLogScale } = contract const [start, end] = getDateRange(contract) const scaleP = useMemo( @@ -86,30 +85,21 @@ export const PseudoNumericContractChart = (props: { Date.now() ) const visibleRange = [start, rightmostDate] - const isMobile = useIsMobile(800) - const containerRef = useRef<HTMLDivElement>(null) - const width = useElementWidth(containerRef) ?? 0 - const height = props.height ?? (isMobile ? 150 : 250) - const xScale = scaleTime(visibleRange, [0, width - MARGIN_X]) + const xScale = scaleTime(visibleRange, [0, width ?? 0 - MARGIN_X]) // clamp log scale to make sure zeroes go to the bottom const yScale = isLogScale - ? scaleLog([Math.max(min, 1), max], [height - MARGIN_Y, 0]).clamp(true) - : scaleLinear([min, max], [height - MARGIN_Y, 0]) - + ? scaleLog([Math.max(min, 1), max], [height ?? 0 - MARGIN_Y, 0]).clamp(true) + : scaleLinear([min, max], [height ?? 0 - MARGIN_Y, 0]) return ( - <div ref={containerRef}> - {width > 0 && ( - <SingleValueHistoryChart - w={width} - h={height} - xScale={xScale} - yScale={yScale} - data={data} - onMouseOver={onMouseOver} - Tooltip={PseudoNumericChartTooltip} - color={NUMERIC_GRAPH_COLOR} - /> - )} - </div> + <SingleValueHistoryChart + w={width} + h={height} + xScale={xScale} + yScale={yScale} + data={data} + onMouseOver={onMouseOver} + Tooltip={PseudoNumericChartTooltip} + color={NUMERIC_GRAPH_COLOR} + /> ) } diff --git a/web/components/contract/contract-overview.tsx b/web/components/contract/contract-overview.tsx index add9ba48..3e5f22b2 100644 --- a/web/components/contract/contract-overview.tsx +++ b/web/components/contract/contract-overview.tsx @@ -1,13 +1,8 @@ -import React from 'react' +import React, { useEffect, useRef, useState } from 'react' import { tradingAllowed } from 'web/lib/firebase/contracts' import { Col } from '../layout/col' -import { - BinaryContractChart, - NumericContractChart, - PseudoNumericContractChart, - ChoiceContractChart, -} from 'web/components/charts/contract' +import { ContractChart } from 'web/components/charts/contract' import { useUser } from 'web/hooks/use-user' import { Row } from '../layout/row' import { Linkify } from '../linkify' @@ -48,8 +43,43 @@ const BetWidget = (props: { contract: CPMMContract }) => { ) } -const NumericOverview = (props: { contract: NumericContract }) => { - const { contract } = props +const SizedContractChart = (props: { + contract: Contract + bets: Bet[] + fullHeight: number + mobileHeight: number +}) => { + const { contract, bets, fullHeight, mobileHeight } = props + const containerRef = useRef<HTMLDivElement>(null) + const [chartWidth, setChartWidth] = useState<number>() + const [chartHeight, setChartHeight] = useState<number>() + useEffect(() => { + const handleResize = () => { + setChartHeight(window.innerWidth < 800 ? mobileHeight : fullHeight) + setChartWidth(containerRef.current?.clientWidth) + } + handleResize() + window.addEventListener('resize', handleResize) + return () => { + window.removeEventListener('resize', handleResize) + } + }, [fullHeight, mobileHeight]) + return ( + <div ref={containerRef}> + {chartWidth != null && chartHeight != null && ( + <ContractChart + contract={contract} + bets={bets} + width={chartWidth} + height={chartHeight} + /> + )} + </div> + ) +} + +const NumericOverview = (props: { contract: NumericContract; bets: Bet[] }) => { + const { contract, bets } = props return ( <Col className="gap-1 md:gap-2"> <Col className="gap-3 px-2 sm:gap-4"> @@ -66,7 +96,12 @@ const NumericOverview = (props: { contract: NumericContract }) => { contract={contract} /> </Col> - <NumericContractChart contract={contract} /> + <SizedContractChart + contract={contract} + bets={bets} + fullHeight={250} + mobileHeight={150} + /> </Col> ) } @@ -86,7 +121,12 @@ const BinaryOverview = (props: { contract: BinaryContract; bets: Bet[] }) => { /> </Row> </Col> - <BinaryContractChart contract={contract} bets={bets} /> + <SizedContractChart + contract={contract} + bets={bets} + fullHeight={250} + mobileHeight={150} + /> <Row className="items-center justify-between gap-4 xl:hidden"> {tradingAllowed(contract) && ( <BinaryMobileBetting contract={contract} /> @@ -111,9 +151,12 @@ const ChoiceOverview = (props: { <FreeResponseResolutionOrChance contract={contract} truncate="none" /> )} </Col> - <Col className={'mb-1 gap-y-2'}> - <ChoiceContractChart contract={contract} bets={bets} /> - </Col> + <SizedContractChart + contract={contract} + bets={bets} + fullHeight={350} + mobileHeight={250} + /> </Col> ) } @@ -139,7 +182,12 @@ const PseudoNumericOverview = (props: { {tradingAllowed(contract) && <BetWidget contract={contract} />} </Row> </Col> - <PseudoNumericContractChart contract={contract} bets={bets} /> + <SizedContractChart + contract={contract} + bets={bets} + fullHeight={250} + mobileHeight={150} + /> </Col> ) } @@ -153,7 +201,7 @@ export const ContractOverview = (props: { case 'BINARY': return <BinaryOverview contract={contract} bets={bets} /> case 'NUMERIC': - return <NumericOverview contract={contract} /> + return <NumericOverview contract={contract} bets={bets} /> case 'PSEUDO_NUMERIC': return <PseudoNumericOverview contract={contract} bets={bets} /> case 'FREE_RESPONSE': diff --git a/web/hooks/use-element-width.tsx b/web/hooks/use-element-width.tsx deleted file mode 100644 index 1c373839..00000000 --- a/web/hooks/use-element-width.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { RefObject, useState, useEffect } from 'react' - -// todo: consider consolidation with use-measure-size -export const useElementWidth = <T extends Element>(ref: RefObject<T>) => { - const [width, setWidth] = useState<number>() - useEffect(() => { - const handleResize = () => { - setWidth(ref.current?.clientWidth) - } - handleResize() - window.addEventListener('resize', handleResize) - return () => { - window.removeEventListener('resize', handleResize) - } - }, [ref]) - return width -} diff --git a/web/pages/embed/[username]/[contractSlug].tsx b/web/pages/embed/[username]/[contractSlug].tsx index e925a1f6..cc4bc09d 100644 --- a/web/pages/embed/[username]/[contractSlug].tsx +++ b/web/pages/embed/[username]/[contractSlug].tsx @@ -79,7 +79,7 @@ export function ContractEmbed(props: { contract: Contract; bets: Bet[] }) { const href = `https://${DOMAIN}${contractPath(contract)}` - const { setElem, height: graphHeight } = useMeasureSize() + const { setElem, width: graphWidth, height: graphHeight } = useMeasureSize() const [betPanelOpen, setBetPanelOpen] = useState(false) @@ -132,7 +132,14 @@ export function ContractEmbed(props: { contract: Contract; bets: Bet[] }) { )} <div className="mx-1 mb-2 min-h-0 flex-1" ref={setElem}> - <ContractChart contract={contract} bets={bets} height={graphHeight} /> + {graphWidth != null && graphHeight != null && ( + <ContractChart + contract={contract} + bets={bets} + width={graphWidth} + height={graphHeight} + /> + )} </div> </Col> ) From dc0b6dc6a6917c2bf6976e0b8a1879056787f0fd Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Fri, 30 Sep 2022 18:01:48 -0700 Subject: [PATCH 080/135] Don't render stuff whenever window size changes (#978) --- web/components/amount-input.tsx | 5 ++--- web/components/bet-panel.tsx | 8 +------- 2 files changed, 3 insertions(+), 10 deletions(-) diff --git a/web/components/amount-input.tsx b/web/components/amount-input.tsx index 1c9d1c3b..dc5a6124 100644 --- a/web/components/amount-input.tsx +++ b/web/components/amount-input.tsx @@ -5,7 +5,7 @@ import { formatMoney } from 'common/util/format' import { Col } from './layout/col' import { SiteLink } from './site-link' import { ENV_CONFIG } from 'common/envs/constants' -import { useWindowSize } from 'web/hooks/use-window-size' +import { useIsMobile } from 'web/hooks/use-is-mobile' import { Row } from './layout/row' export function AmountInput(props: { @@ -36,8 +36,7 @@ export function AmountInput(props: { onChange(isInvalid ? undefined : amount) } - const { width } = useWindowSize() - const isMobile = (width ?? 0) < 768 + const isMobile = useIsMobile(768) return ( <> diff --git a/web/components/bet-panel.tsx b/web/components/bet-panel.tsx index e93c0e62..beb7168a 100644 --- a/web/components/bet-panel.tsx +++ b/web/components/bet-panel.tsx @@ -47,7 +47,6 @@ import { Modal } from './layout/modal' import { Title } from './title' import toast from 'react-hot-toast' import { CheckIcon } from '@heroicons/react/solid' -import { useWindowSize } from 'web/hooks/use-window-size' export function BetPanel(props: { contract: CPMMBinaryContract | PseudoNumericContract @@ -179,12 +178,7 @@ export function BuyPanel(props: { const initialProb = getProbability(contract) const isPseudoNumeric = contract.outcomeType === 'PSEUDO_NUMERIC' - const windowSize = useWindowSize() - const initialOutcome = - windowSize.width && windowSize.width >= 1280 ? 'YES' : undefined - const [outcome, setOutcome] = useState<'YES' | 'NO' | undefined>( - initialOutcome - ) + const [outcome, setOutcome] = useState<'YES' | 'NO' | undefined>() const [betAmount, setBetAmount] = useState<number | undefined>(10) const [error, setError] = useState<string | undefined>() const [isSubmitting, setIsSubmitting] = useState(false) From b0b1d72ba61382c2035c8867e735816b0bfaf2f8 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Fri, 30 Sep 2022 20:07:49 -0500 Subject: [PATCH 081/135] Cleaner home page loading! --- web/hooks/use-contracts.ts | 56 ++++++++++++++----- web/lib/service/algolia.ts | 2 + web/pages/home/index.tsx | 112 ++++++++++++++++++++++--------------- 3 files changed, 112 insertions(+), 58 deletions(-) diff --git a/web/hooks/use-contracts.ts b/web/hooks/use-contracts.ts index 11aae65c..7952deba 100644 --- a/web/hooks/use-contracts.ts +++ b/web/hooks/use-contracts.ts @@ -8,12 +8,14 @@ import { getUserBetContracts, getUserBetContractsQuery, listAllContracts, - trendingContractsQuery, } from 'web/lib/firebase/contracts' import { QueryClient, useQuery, useQueryClient } from 'react-query' import { MINUTE_MS, sleep } from 'common/util/time' -import { query, limit } from 'firebase/firestore' -import { dailyScoreIndex } from 'web/lib/service/algolia' +import { + dailyScoreIndex, + newIndex, + trendingIndex, +} from 'web/lib/service/algolia' import { CPMMBinaryContract } from 'common/contract' import { zipObject } from 'lodash' @@ -27,16 +29,50 @@ export const useContracts = () => { return contracts } +export const useTrendingContracts = (maxContracts: number) => { + const { data } = useQuery(['trending-contracts', maxContracts], () => + trendingIndex.search<CPMMBinaryContract>('', { + facetFilters: ['isResolved:false'], + hitsPerPage: maxContracts, + }) + ) + if (!data) return undefined + return data.hits +} + +export const useNewContracts = (maxContracts: number) => { + const { data } = useQuery(['newest-contracts', maxContracts], () => + newIndex.search<CPMMBinaryContract>('', { + facetFilters: ['isResolved:false'], + hitsPerPage: maxContracts, + }) + ) + if (!data) return undefined + return data.hits +} + +export const useContractsByDailyScoreNotBetOn = ( + userId: string | null | undefined, + maxContracts: number +) => { + const { data } = useQuery(['daily-score', userId, maxContracts], () => + dailyScoreIndex.search<CPMMBinaryContract>('', { + facetFilters: ['isResolved:false', `uniqueBettors:-${userId}`], + hitsPerPage: maxContracts, + }) + ) + if (!userId || !data) return undefined + return data.hits.filter((c) => c.dailyScore) +} + 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}`], + facetFilters: ['isResolved:false', `groupLinks.slug:${slug}`], }) ) ) @@ -56,14 +92,6 @@ export const getCachedContracts = async () => staleTime: Infinity, }) -export const useTrendingContracts = (maxContracts: number) => { - const result = useFirestoreQueryData( - ['trending-contracts', maxContracts], - query(trendingContractsQuery, limit(maxContracts)) - ) - return result.data -} - export const useInactiveContracts = () => { const [contracts, setContracts] = useState<Contract[] | undefined>() diff --git a/web/lib/service/algolia.ts b/web/lib/service/algolia.ts index 29cbd6bf..bdace399 100644 --- a/web/lib/service/algolia.ts +++ b/web/lib/service/algolia.ts @@ -14,6 +14,8 @@ export const getIndexName = (sort: string) => { return `${indexPrefix}contracts-${sort}` } +export const trendingIndex = searchClient.initIndex(getIndexName('score')) +export const newIndex = searchClient.initIndex(getIndexName('newest')) export const probChangeDescendingIndex = searchClient.initIndex( getIndexName('prob-change-day') ) diff --git a/web/pages/home/index.tsx b/web/pages/home/index.tsx index 2ddc3026..4ad7f97b 100644 --- a/web/pages/home/index.tsx +++ b/web/pages/home/index.tsx @@ -12,7 +12,6 @@ import { Dictionary, sortBy, sum } from 'lodash' import { Page } from 'web/components/page' import { Col } from 'web/components/layout/col' -import { ContractSearch, SORTS } from 'web/components/contract-search' import { User } from 'common/user' import { useTracking } from 'web/hooks/use-tracking' import { track } from 'web/lib/service/analytics' @@ -43,7 +42,12 @@ 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' +import { + useContractsByDailyScoreNotBetOn, + useContractsByDailyScoreGroups, + useTrendingContracts, + useNewContracts, +} from 'web/hooks/use-contracts' import { ProfitBadge } from 'web/components/profit-badge' import { LoadingIndicator } from 'web/components/loading-indicator' @@ -71,12 +75,18 @@ export default function Home() { } }, [user, sections]) - const groups = useMemberGroupsSubscription(user) + const trendingContracts = useTrendingContracts(6) + const newContracts = useNewContracts(6) + const dailyTrendingContracts = useContractsByDailyScoreNotBetOn(user?.id, 6) + const groups = useMemberGroupsSubscription(user) const groupContracts = useContractsByDailyScoreGroups( groups?.map((g) => g.slug) ) + const isLoading = + !user || !trendingContracts || !newContracts || !dailyTrendingContracts + return ( <Page> <Toaster /> @@ -90,11 +100,15 @@ export default function Home() { <DailyStats user={user} /> </Row> - {!user ? ( + {isLoading ? ( <LoadingIndicator /> ) : ( <> - {sections.map((section) => renderSection(section, user))} + {renderSections(user, sections, { + score: trendingContracts, + newest: newContracts, + 'daily-trending': dailyTrendingContracts, + })} <TrendingGroupsSection user={user} /> @@ -118,8 +132,8 @@ export default function Home() { } const HOME_SECTIONS = [ - { label: 'Daily movers', id: 'daily-movers' }, { label: 'Daily trending', id: 'daily-trending' }, + { label: 'Daily movers', id: 'daily-movers' }, { label: 'Trending', id: 'score' }, { label: 'New', id: 'newest' }, ] @@ -128,11 +142,7 @@ export const getHomeItems = (sections: string[]) => { // Accommodate old home sections. if (!isArray(sections)) sections = [] - const items: { id: string; label: string; group?: Group }[] = [ - ...HOME_SECTIONS, - ] - const itemsById = keyBy(items, 'id') - + const itemsById = keyBy(HOME_SECTIONS, 'id') const sectionItems = filterDefined(sections.map((id) => itemsById[id])) // Add new home section items to the top. @@ -140,7 +150,9 @@ export const getHomeItems = (sections: string[]) => { ...HOME_SECTIONS.filter((item) => !sectionItems.includes(item)) ) // Add unmentioned items to the end. - sectionItems.push(...items.filter((item) => !sectionItems.includes(item))) + sectionItems.push( + ...HOME_SECTIONS.filter((item) => !sectionItems.includes(item)) + ) return { sections: sectionItems, @@ -148,28 +160,46 @@ export const getHomeItems = (sections: string[]) => { } } -function renderSection(section: { id: string; label: string }, user: User) { - const { id, label } = section - if (id === 'daily-movers') { - return <DailyMoversSection key={id} userId={user.id} /> +function renderSections( + user: User, + sections: { id: string; label: string }[], + sectionContracts: { + 'daily-trending': CPMMBinaryContract[] + newest: CPMMBinaryContract[] + score: CPMMBinaryContract[] } - if (id === 'daily-trending') - return ( - <SearchSection - key={id} - label={label} - sort={'daily-score'} - pill="personal" - user={user} - /> - ) - const sort = SORTS.find((sort) => sort.value === id) - if (sort) - return ( - <SearchSection key={id} label={label} sort={sort.value} user={user} /> - ) - - return null +) { + return ( + <> + {sections.map((s) => { + const { id, label } = s + if (id === 'daily-movers') { + return <DailyMoversSection key={id} userId={user.id} /> + } + if (id === 'daily-trending') { + return ( + <ContractsSection + key={id} + label={label} + contracts={sectionContracts[id]} + sort="daily-score" + showProbChange + /> + ) + } + const contracts = + sectionContracts[s.id as keyof typeof sectionContracts] + return ( + <ContractsSection + key={id} + label={label} + contracts={contracts} + sort={id === 'daily-trending' ? 'daily-score' : (id as Sort)} + /> + ) + })} + </> + ) } function renderGroupSections( @@ -237,13 +267,14 @@ function SectionHeader(props: { ) } -function SearchSection(props: { +function ContractsSection(props: { label: string - user: User + contracts: CPMMBinaryContract[] sort: Sort pill?: string + showProbChange?: boolean }) { - const { label, user, sort, pill } = props + const { label, contracts, sort, pill, showProbChange } = props return ( <Col> @@ -251,14 +282,7 @@ function SearchSection(props: { label={label} href={`/search?s=${sort}${pill ? `&p=${pill}` : ''}`} /> - <ContractSearch - user={user} - defaultSort={sort} - defaultPill={pill} - noControls - maxResults={6} - persistPrefix={`home-${sort}`} - /> + <ContractsGrid contracts={contracts} cardUIOptions={{ showProbChange }} /> </Col> ) } From 2f3ae5192eff205728ffe3063ffcdf30160d50bb Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Fri, 30 Sep 2022 20:30:45 -0500 Subject: [PATCH 082/135] embed: disable clicking contract details --- web/components/contract/contract-details.tsx | 42 +++++++++++++++----- 1 file changed, 31 insertions(+), 11 deletions(-) diff --git a/web/components/contract/contract-details.tsx b/web/components/contract/contract-details.tsx index b06c6381..d0aa0ee9 100644 --- a/web/components/contract/contract-details.tsx +++ b/web/components/contract/contract-details.tsx @@ -183,6 +183,7 @@ export function MarketSubheader(props: { contract={contract} resolvedDate={resolvedDate} isCreator={isCreator} + disabled={disabled} /> {!isMobile && ( <Row className={'gap-1'}> @@ -200,8 +201,9 @@ export function CloseOrResolveTime(props: { contract: Contract resolvedDate: any isCreator: boolean + disabled?: boolean }) { - const { contract, resolvedDate, isCreator } = props + const { contract, resolvedDate, isCreator, disabled } = props const { resolutionTime, closeTime } = contract if (!!closeTime || !!resolvedDate) { return ( @@ -225,6 +227,7 @@ export function CloseOrResolveTime(props: { closeTime={closeTime} contract={contract} isCreator={isCreator ?? false} + disabled={disabled} /> </Row> )} @@ -245,7 +248,8 @@ export function MarketGroups(props: { return ( <> <Row className="items-center gap-1"> - <GroupDisplay groupToDisplay={groupToDisplay} /> + <GroupDisplay groupToDisplay={groupToDisplay} disabled={disabled} /> + {!disabled && user && ( <button className="text-greyscale-4 hover:text-greyscale-3" @@ -330,14 +334,29 @@ export function ExtraMobileContractDetails(props: { ) } -export function GroupDisplay(props: { groupToDisplay?: GroupLink | null }) { - const { groupToDisplay } = props +export function GroupDisplay(props: { + groupToDisplay?: GroupLink | null + disabled?: boolean +}) { + const { groupToDisplay, disabled } = props + if (groupToDisplay) { - return ( + const groupSection = ( + <a + className={clsx( + 'bg-greyscale-4 max-w-[140px] truncate whitespace-nowrap rounded-full py-0.5 px-2 text-xs text-white sm:max-w-[250px]', + !disabled && 'hover:bg-greyscale-3 cursor-pointer' + )} + > + {groupToDisplay.name} + </a> + ) + + return disabled ? ( + groupSection + ) : ( <Link prefetch={false} href={groupPath(groupToDisplay.slug)}> - <a className="bg-greyscale-4 hover:bg-greyscale-3 max-w-[140px] truncate whitespace-nowrap rounded-full py-0.5 px-2 text-xs text-white sm:max-w-[250px]"> - {groupToDisplay.name} - </a> + {groupSection} </Link> ) } else @@ -352,8 +371,9 @@ function EditableCloseDate(props: { closeTime: number contract: Contract isCreator: boolean + disabled?: boolean }) { - const { closeTime, contract, isCreator } = props + const { closeTime, contract, isCreator, disabled } = props const dayJsCloseTime = dayjs(closeTime) const dayJsNow = dayjs() @@ -452,8 +472,8 @@ function EditableCloseDate(props: { time={closeTime} > <span - className={isCreator ? 'cursor-pointer' : ''} - onClick={() => isCreator && setIsEditingCloseTime(true)} + className={!disabled && isCreator ? 'cursor-pointer' : ''} + onClick={() => !disabled && isCreator && setIsEditingCloseTime(true)} > {isSameDay ? ( <span className={'capitalize'}> {fromNow(closeTime)}</span> From 2f1221f094e4d093bd53c2e5834a96c77a7769f3 Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Sat, 1 Oct 2022 00:10:17 -0700 Subject: [PATCH 083/135] Size-aware chart tooltip positioning (#980) --- web/components/charts/helpers.tsx | 55 +++++++++++++++++++------------ 1 file changed, 34 insertions(+), 21 deletions(-) diff --git a/web/components/charts/helpers.tsx b/web/components/charts/helpers.tsx index ea221c80..96115dc0 100644 --- a/web/components/charts/helpers.tsx +++ b/web/components/charts/helpers.tsx @@ -16,6 +16,7 @@ import dayjs from 'dayjs' import clsx from 'clsx' import { Contract } from 'common/contract' +import { useMeasureSize } from 'web/hooks/use-measure-size' export type Point<X, Y, T = unknown> = { x: X; y: Y; obj?: T } @@ -135,6 +136,7 @@ export const SVGChart = <X, TT>(props: { }) => { const { children, w, h, xAxis, yAxis, onMouseOver, onSelect, Tooltip } = props const [mouse, setMouse] = useState<{ x: number; y: number; data: TT }>() + const tooltipMeasure = useMeasureSize() const overlayRef = useRef<SVGGElement>(null) const innerW = w - MARGIN_X const innerH = h - MARGIN_Y @@ -188,10 +190,18 @@ export const SVGChart = <X, TT>(props: { } return ( - <div className="relative"> + <div className="relative overflow-hidden"> {mouse && Tooltip && ( <TooltipContainer - pos={getTooltipPosition(mouse.x, mouse.y, innerW, innerH)} + setElem={tooltipMeasure.setElem} + pos={getTooltipPosition( + mouse.x, + mouse.y, + innerW, + innerH, + tooltipMeasure.width, + tooltipMeasure.height + )} > <Tooltip xScale={xAxis.scale()} @@ -227,31 +237,31 @@ export const SVGChart = <X, TT>(props: { ) } -export type TooltipPosition = { - top?: number - right?: number - bottom?: number - left?: number -} +export type TooltipPosition = { left: number; bottom: number } export const getTooltipPosition = ( mouseX: number, mouseY: number, - w: number, - h: number + containerWidth: number, + containerHeight: number, + tooltipWidth?: number, + tooltipHeight?: number ) => { - const result: TooltipPosition = {} - if (mouseX <= (3 * w) / 4) { - result.left = mouseX + 10 // in the left three quarters - } else { - result.right = w - mouseX + 10 // in the right quarter + let left = mouseX + 12 + let bottom = containerHeight - mouseY + 12 + if (tooltipWidth != null) { + const overflow = left + tooltipWidth - containerWidth + if (overflow > 0) { + left -= overflow + } } - if (mouseY <= h / 4) { - result.top = mouseY + 10 // in the top quarter - } else { - result.bottom = h - mouseY + 10 // in the bottom three quarters + if (tooltipHeight != null) { + const overflow = tooltipHeight - mouseY + if (overflow > 0) { + bottom -= overflow + } } - return result + return { left, bottom } } export type TooltipProps<X, T> = { @@ -260,15 +270,18 @@ export type TooltipProps<X, T> = { xScale: ContinuousScale<X> data: T } + export type TooltipComponent<X, T> = React.ComponentType<TooltipProps<X, T>> export const TooltipContainer = (props: { + setElem: (e: HTMLElement | null) => void pos: TooltipPosition className?: string children: React.ReactNode }) => { - const { pos, className, children } = props + const { setElem, pos, className, children } = props return ( <div + ref={setElem} className={clsx( className, 'pointer-events-none absolute z-10 whitespace-pre rounded border border-gray-200 bg-white/80 p-2 px-4 py-2 text-xs sm:text-sm' From b53e4acea6a8b61ba0f85a356c485069561a5b46 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Sat, 1 Oct 2022 13:37:55 -0500 Subject: [PATCH 084/135] API: Cache markets for 15 seconds at least --- web/pages/api/v0/market/[id]/index.ts | 3 ++- web/pages/api/v0/market/[id]/lite.ts | 3 ++- web/pages/api/v0/markets.ts | 4 +++- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/web/pages/api/v0/market/[id]/index.ts b/web/pages/api/v0/market/[id]/index.ts index eb238dab..72e7cdbc 100644 --- a/web/pages/api/v0/market/[id]/index.ts +++ b/web/pages/api/v0/market/[id]/index.ts @@ -4,6 +4,7 @@ import { listAllComments } from 'web/lib/firebase/comments' import { getContractFromId } from 'web/lib/firebase/contracts' import { applyCorsHeaders, CORS_UNRESTRICTED } from 'web/lib/api/cors' import { FullMarket, ApiError, toFullMarket } from '../../_types' +import { marketCacheStrategy } from '../../markets' export default async function handler( req: NextApiRequest, @@ -24,6 +25,6 @@ export default async function handler( return } - res.setHeader('Cache-Control', 'max-age=0') + res.setHeader('Cache-Control', marketCacheStrategy) return res.status(200).json(toFullMarket(contract, comments, bets)) } diff --git a/web/pages/api/v0/market/[id]/lite.ts b/web/pages/api/v0/market/[id]/lite.ts index 7688caa8..6ac28db4 100644 --- a/web/pages/api/v0/market/[id]/lite.ts +++ b/web/pages/api/v0/market/[id]/lite.ts @@ -2,6 +2,7 @@ import { NextApiRequest, NextApiResponse } from 'next' import { getContractFromId } from 'web/lib/firebase/contracts' import { applyCorsHeaders, CORS_UNRESTRICTED } from 'web/lib/api/cors' import { ApiError, toLiteMarket, LiteMarket } from '../../_types' +import { marketCacheStrategy } from '../../markets' export default async function handler( req: NextApiRequest, @@ -18,6 +19,6 @@ export default async function handler( return } - res.setHeader('Cache-Control', 'max-age=0') + res.setHeader('Cache-Control', marketCacheStrategy) return res.status(200).json(toLiteMarket(contract)) } diff --git a/web/pages/api/v0/markets.ts b/web/pages/api/v0/markets.ts index 78c54772..bad6a145 100644 --- a/web/pages/api/v0/markets.ts +++ b/web/pages/api/v0/markets.ts @@ -6,6 +6,8 @@ import { toLiteMarket, ValidationError } from './_types' import { z } from 'zod' import { validate } from './_validate' +export const marketCacheStrategy = 's-maxage=15, stale-while-revalidate=45' + const queryParams = z .object({ limit: z @@ -39,7 +41,7 @@ export default async function handler( try { const contracts = await listAllContracts(limit, before) // Serve from Vercel cache, then update. see https://vercel.com/docs/concepts/functions/edge-caching - res.setHeader('Cache-Control', 's-maxage=1, stale-while-revalidate') + res.setHeader('Cache-Control', marketCacheStrategy) res.status(200).json(contracts.map(toLiteMarket)) } catch (e) { res.status(400).json({ From 759685258aa579265b80cfb7a4c98c6683e26eb2 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Sat, 1 Oct 2022 13:48:13 -0500 Subject: [PATCH 085/135] Turn off autofocus for amount input. (Fixes FR answer bug; IMO better UX) --- web/components/amount-input.tsx | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/web/components/amount-input.tsx b/web/components/amount-input.tsx index dc5a6124..e7c4ce2a 100644 --- a/web/components/amount-input.tsx +++ b/web/components/amount-input.tsx @@ -5,7 +5,6 @@ import { formatMoney } from 'common/util/format' import { Col } from './layout/col' import { SiteLink } from './site-link' import { ENV_CONFIG } from 'common/envs/constants' -import { useIsMobile } from 'web/hooks/use-is-mobile' import { Row } from './layout/row' export function AmountInput(props: { @@ -36,8 +35,6 @@ export function AmountInput(props: { onChange(isInvalid ? undefined : amount) } - const isMobile = useIsMobile(768) - return ( <> <Col className={className}> @@ -49,7 +46,7 @@ export function AmountInput(props: { className={clsx( 'placeholder:text-greyscale-4 border-greyscale-2 rounded-md pl-9', error && 'input-error', - isMobile ? 'w-24' : '', + 'w-24 md:w-auto', inputClassName )} ref={inputRef} @@ -58,7 +55,6 @@ export function AmountInput(props: { inputMode="numeric" placeholder="0" maxLength={6} - autoFocus={!isMobile} value={amount ?? ''} disabled={disabled} onChange={(e) => onAmountChange(e.target.value)} From 2d6fe308b8e7e008eb25acf000b8588ea3206623 Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Sat, 1 Oct 2022 14:14:03 -0500 Subject: [PATCH 086/135] better group sort --- web/components/groups/group-selector.tsx | 40 ++++++++++++------------ 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/web/components/groups/group-selector.tsx b/web/components/groups/group-selector.tsx index a04a91af..caeb5f7d 100644 --- a/web/components/groups/group-selector.tsx +++ b/web/components/groups/group-selector.tsx @@ -32,27 +32,27 @@ export function GroupSelector(props: { const openGroups = useOpenGroups() const memberGroups = useMemberGroups(creator?.id) const memberGroupIds = memberGroups?.map((g) => g.id) ?? [] - const availableGroups = openGroups - .concat( - (memberGroups ?? []).filter( - (g) => !openGroups.map((og) => og.id).includes(g.id) - ) - ) - .filter((group) => !ignoreGroupIds?.includes(group.id)) - .sort((a, b) => b.totalContracts - a.totalContracts) - // put the groups the user is a member of first - .sort((a, b) => { - if (memberGroupIds.includes(a.id)) { - return -1 - } - if (memberGroupIds.includes(b.id)) { - return 1 - } - return 0 - }) - const filteredGroups = availableGroups.filter((group) => - searchInAny(query, group.name) + const sortGroups = (groups: Group[]) => + groups.sort( + (a, b) => + // weight group higher if user is a member + (memberGroupIds.includes(b.id) ? 5 : 1) * b.totalContracts - + (memberGroupIds.includes(a.id) ? 5 : 1) * a.totalContracts + ) + + const availableGroups = sortGroups( + openGroups + .concat( + (memberGroups ?? []).filter( + (g) => !openGroups.map((og) => og.id).includes(g.id) + ) + ) + .filter((group) => !ignoreGroupIds?.includes(group.id)) + ) + + const filteredGroups = sortGroups( + availableGroups.filter((group) => searchInAny(query, group.name)) ) if (!showSelector || !creator) { From 0844e5620a76df2d0bb65257f3e29ae501269a6a Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Sat, 1 Oct 2022 14:30:31 -0500 Subject: [PATCH 087/135] create: remove visilbity section --- web/pages/create.tsx | 72 ++++++++++++++++++++++---------------------- 1 file changed, 36 insertions(+), 36 deletions(-) diff --git a/web/pages/create.tsx b/web/pages/create.tsx index f5d1c605..03b90d7c 100644 --- a/web/pages/create.tsx +++ b/web/pages/create.tsx @@ -280,25 +280,27 @@ export function NewContract(props: { <label className="label"> <span className="mb-1">Answer type</span> </label> - <ChoicesToggleGroup - currentChoice={outcomeType} - setChoice={(choice) => { - if (choice === 'FREE_RESPONSE') - setMarketInfoText( - 'Users can submit their own answers to this market.' - ) - else setMarketInfoText('') - setOutcomeType(choice as outcomeType) - }} - choicesMap={{ - 'Yes / No': 'BINARY', - // 'Multiple choice': 'MULTIPLE_CHOICE', - 'Free response': 'FREE_RESPONSE', - // Numeric: 'PSEUDO_NUMERIC', - }} - isSubmitting={isSubmitting} - className={'col-span-4'} - /> + <Row> + <ChoicesToggleGroup + currentChoice={outcomeType} + setChoice={(choice) => { + if (choice === 'FREE_RESPONSE') + setMarketInfoText( + 'Users can submit their own answers to this market.' + ) + else setMarketInfoText('') + setOutcomeType(choice as outcomeType) + }} + choicesMap={{ + 'Yes / No': 'BINARY', + // 'Multiple choice': 'MULTIPLE_CHOICE', + 'Free response': 'FREE_RESPONSE', + // Numeric: 'PSEUDO_NUMERIC', + }} + isSubmitting={isSubmitting} + className={'col-span-4'} + /> + </Row> {marketInfoText && ( <div className="mt-3 ml-1 text-sm text-indigo-700"> {marketInfoText} @@ -390,23 +392,7 @@ export function NewContract(props: { </> )} - <div className="form-control mb-1 items-start gap-1"> - <label className="label gap-2"> - <span className="mb-1">Visibility</span> - <InfoTooltip text="Whether the market will be listed on the home page." /> - </label> - <ChoicesToggleGroup - currentChoice={visibility} - setChoice={(choice) => setVisibility(choice as visibility)} - choicesMap={{ - Public: 'public', - Unlisted: 'unlisted', - }} - isSubmitting={isSubmitting} - /> - </div> - - <Spacer h={6} /> + <Spacer h={4} /> <Row className={'items-end gap-x-2'}> <GroupSelector @@ -421,6 +407,20 @@ export function NewContract(props: { </SiteLink> )} </Row> + + <Row className="form-control my-2 items-center gap-2 text-sm"> + <span>Display this market on homepage</span> + <input + type="checkbox" + checked={visibility === 'public'} + disabled={isSubmitting} + className="cursor-pointer" + onChange={(e) => + setVisibility(e.target.checked ? 'public' : 'unlisted') + } + /> + </Row> + <Spacer h={6} /> <div className="form-control mb-1 items-start"> From aeeb47bdbee6e3f55ef66175e538538e0ace0c1e Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Sat, 1 Oct 2022 15:06:09 -0500 Subject: [PATCH 088/135] don't block on tipping --- web/components/contract/like-market-button.tsx | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/web/components/contract/like-market-button.tsx b/web/components/contract/like-market-button.tsx index 7e0c765a..b655f244 100644 --- a/web/components/contract/like-market-button.tsx +++ b/web/components/contract/like-market-button.tsx @@ -1,6 +1,6 @@ import { HeartIcon } from '@heroicons/react/outline' import { Button } from 'web/components/button' -import React, { useMemo } from 'react' +import React, { useMemo, useState } from 'react' import { Contract } from 'common/contract' import { User } from 'common/user' import { useUserLikes } from 'web/hooks/use-likes' @@ -20,20 +20,27 @@ export function LikeMarketButton(props: { user: User | null | undefined }) { const { contract, user } = props + const tips = useMarketTipTxns(contract.id).filter( (txn) => txn.fromId === user?.id ) const totalTipped = useMemo(() => { return sum(tips.map((tip) => tip.amount)) }, [tips]) + const likes = useUserLikes(user?.id) + + const [isLiking, setIsLiking] = useState(false) + const userLikedContractIds = likes ?.filter((l) => l.type === 'contract') .map((l) => l.id) const onLike = async () => { if (!user) return firebaseLogin() - await likeContract(user, contract) + + setIsLiking(true) + likeContract(user, contract).catch(() => setIsLiking(false)) toast(`You tipped ${contract.creatorName} ${formatMoney(LIKE_TIP_AMOUNT)}!`) } @@ -56,7 +63,8 @@ export function LikeMarketButton(props: { 'h-5 w-5 sm:h-6 sm:w-6', totalTipped > 0 ? 'mr-2' : '', user && - (userLikedContractIds?.includes(contract.id) || + (isLiking || + userLikedContractIds?.includes(contract.id) || (!likes && contract.likedByUserIds?.includes(user.id))) ? 'fill-red-500 text-red-500' : '' From cb613705e901eb50a3112140452e78571ec1fdf6 Mon Sep 17 00:00:00 2001 From: mantikoros <95266179+mantikoros@users.noreply.github.com> Date: Sat, 1 Oct 2022 15:51:08 -0500 Subject: [PATCH 089/135] Consistent tips (#984) * consistent tip button * hide tips for self * prettier --- web/components/button.tsx | 4 +- .../contract/like-market-button.tsx | 57 +++--------- web/components/contract/tip-button.tsx | 57 ++++++++++++ web/components/feed/feed-comments.tsx | 8 +- web/components/tipper.tsx | 90 ++++--------------- web/posts/post-comments.tsx | 2 +- 6 files changed, 94 insertions(+), 124 deletions(-) create mode 100644 web/components/contract/tip-button.tsx diff --git a/web/components/button.tsx b/web/components/button.tsx index 51e25ea1..ecd8e1c7 100644 --- a/web/components/button.tsx +++ b/web/components/button.tsx @@ -46,7 +46,6 @@ export function Button(props: { <button type={type} className={clsx( - className, 'font-md items-center justify-center rounded-md border border-transparent shadow-sm transition-colors disabled:cursor-not-allowed', sizeClasses, color === 'green' && @@ -66,7 +65,8 @@ export function Button(props: { color === 'gray-white' && 'text-greyscale-6 hover:bg-greyscale-2 border-none shadow-none disabled:opacity-50', color === 'highlight-blue' && - 'text-highlight-blue disabled:bg-greyscale-2 border-none shadow-none' + 'text-highlight-blue disabled:bg-greyscale-2 border-none shadow-none', + className )} disabled={disabled} onClick={onClick} diff --git a/web/components/contract/like-market-button.tsx b/web/components/contract/like-market-button.tsx index b655f244..c8eb4d0b 100644 --- a/web/components/contract/like-market-button.tsx +++ b/web/components/contract/like-market-button.tsx @@ -1,5 +1,3 @@ -import { HeartIcon } from '@heroicons/react/outline' -import { Button } from 'web/components/button' import React, { useMemo, useState } from 'react' import { Contract } from 'common/contract' import { User } from 'common/user' @@ -8,12 +6,10 @@ import toast from 'react-hot-toast' import { formatMoney } from 'common/util/format' import { likeContract } from 'web/lib/firebase/likes' import { LIKE_TIP_AMOUNT } from 'common/like' -import clsx from 'clsx' -import { Col } from 'web/components/layout/col' import { firebaseLogin } from 'web/lib/firebase/users' import { useMarketTipTxns } from 'web/hooks/use-tip-txns' import { sum } from 'lodash' -import { Tooltip } from '../tooltip' +import { TipButton } from './tip-button' export function LikeMarketButton(props: { contract: Contract @@ -45,45 +41,16 @@ export function LikeMarketButton(props: { } return ( - <Tooltip - text={`Tip ${formatMoney(LIKE_TIP_AMOUNT)}`} - placement="bottom" - noTap - noFade - > - <Button - size={'sm'} - className={'max-w-xs self-center'} - color={'gray-white'} - onClick={onLike} - > - <Col className={'relative items-center sm:flex-row'}> - <HeartIcon - className={clsx( - 'h-5 w-5 sm:h-6 sm:w-6', - totalTipped > 0 ? 'mr-2' : '', - user && - (isLiking || - userLikedContractIds?.includes(contract.id) || - (!likes && contract.likedByUserIds?.includes(user.id))) - ? 'fill-red-500 text-red-500' - : '' - )} - /> - {totalTipped > 0 && ( - <div - className={clsx( - 'bg-greyscale-6 absolute ml-3.5 mt-2 h-4 w-4 rounded-full align-middle text-white sm:mt-3 sm:h-5 sm:w-5 sm:px-1', - totalTipped > 99 - ? 'text-[0.4rem] sm:text-[0.5rem]' - : 'sm:text-2xs text-[0.5rem]' - )} - > - {totalTipped} - </div> - )} - </Col> - </Button> - </Tooltip> + <TipButton + onClick={onLike} + tipAmount={LIKE_TIP_AMOUNT} + totalTipped={totalTipped} + userTipped={ + !!user && + (isLiking || + userLikedContractIds?.includes(contract.id) || + (!likes && !!contract.likedByUserIds?.includes(user.id))) + } + /> ) } diff --git a/web/components/contract/tip-button.tsx b/web/components/contract/tip-button.tsx new file mode 100644 index 00000000..79059195 --- /dev/null +++ b/web/components/contract/tip-button.tsx @@ -0,0 +1,57 @@ +import { HeartIcon } from '@heroicons/react/outline' +import { Button } from 'web/components/button' +import { formatMoney } from 'common/util/format' +import clsx from 'clsx' +import { Col } from 'web/components/layout/col' +import { Tooltip } from '../tooltip' + +export function TipButton(props: { + tipAmount: number + totalTipped: number + onClick: () => void + userTipped: boolean + isCompact?: boolean + disabled?: boolean +}) { + const { tipAmount, totalTipped, userTipped, isCompact, onClick, disabled } = + props + + return ( + <Tooltip + text={`Tip ${formatMoney(tipAmount)}`} + placement="bottom" + noTap + noFade + > + <Button + size={'sm'} + className={clsx('max-w-xs self-center', isCompact && 'px-0 py-0')} + color={'gray-white'} + onClick={onClick} + disabled={disabled} + > + <Col className={'relative items-center sm:flex-row'}> + <HeartIcon + className={clsx( + 'h-5 w-5 sm:h-6 sm:w-6', + totalTipped > 0 ? 'mr-2' : '', + userTipped ? 'fill-red-500 text-red-500' : '' + )} + /> + {totalTipped > 0 && ( + <div + className={clsx( + 'bg-greyscale-6 absolute ml-3.5 mt-2 h-4 w-4 rounded-full align-middle text-white sm:mt-3 sm:h-5 sm:w-5 sm:px-1', + totalTipped > 99 + ? 'text-[0.4rem] sm:text-[0.5rem]' + : 'sm:text-2xs text-[0.5rem]' + )} + > + {totalTipped} + </div> + )} + </Col> + </Button> + </Tooltip> + ) +} diff --git a/web/components/feed/feed-comments.tsx b/web/components/feed/feed-comments.tsx index 20d124f8..b9387a03 100644 --- a/web/components/feed/feed-comments.tsx +++ b/web/components/feed/feed-comments.tsx @@ -177,10 +177,6 @@ export function FeedComment(props: { smallImage /> <Row className="mt-2 items-center gap-6 text-xs text-gray-500"> - {tips && <Tipper comment={comment} tips={tips} />} - {(contract.openCommentBounties ?? 0) > 0 && ( - <AwardBountyButton comment={comment} contract={contract} /> - )} {onReplyClick && ( <button className="font-bold hover:underline" @@ -189,6 +185,10 @@ export function FeedComment(props: { Reply </button> )} + {tips && <Tipper comment={comment} tips={tips} />} + {(contract.openCommentBounties ?? 0) > 0 && ( + <AwardBountyButton comment={comment} contract={contract} /> + )} </Row> </div> </Row> diff --git a/web/components/tipper.tsx b/web/components/tipper.tsx index 1dcb0f05..a9c35937 100644 --- a/web/components/tipper.tsx +++ b/web/components/tipper.tsx @@ -1,20 +1,14 @@ -import { - ChevronDoubleRightIcon, - ChevronLeftIcon, - ChevronRightIcon, -} from '@heroicons/react/solid' -import clsx from 'clsx' +import { debounce } from 'lodash' +import { useEffect, useRef, useState } from 'react' + import { Comment } from 'common/comment' import { User } from 'common/user' -import { formatMoney } from 'common/util/format' -import { debounce, sum } from 'lodash' -import { useEffect, useRef, useState } from 'react' import { CommentTips } from 'web/hooks/use-tip-txns' import { useUser } from 'web/hooks/use-user' import { transact } from 'web/lib/firebase/api' import { track } from 'web/lib/service/analytics' +import { TipButton } from './contract/tip-button' import { Row } from './layout/row' -import { Tooltip } from './tooltip' const TIP_SIZE = 10 @@ -26,6 +20,7 @@ export function Tipper(prop: { comment: Comment; tips: CommentTips }) { const savedTip = tips[myId] ?? 0 const [localTip, setLocalTip] = useState(savedTip) + // listen for user being set const initialized = useRef(false) useEffect(() => { @@ -35,8 +30,6 @@ export function Tipper(prop: { comment: Comment; tips: CommentTips }) { } }, [tips, myId]) - const total = sum(Object.values(tips)) - savedTip + localTip - // declare debounced function only on first render const [saveTip] = useState(() => debounce(async (user: User, comment: Comment, change: number) => { @@ -80,69 +73,22 @@ export function Tipper(prop: { comment: Comment; tips: CommentTips }) { me && saveTip(me, comment, localTip - savedTip + delta) } - const canDown = me && localTip > savedTip - const canUp = me && me.id !== comment.userId && me.balance >= localTip + 5 + if (me && comment.userId === me.id) { + return <></> + } + + const canUp = me && me.balance >= localTip + TIP_SIZE + return ( <Row className="items-center gap-0.5"> - <DownTip onClick={canDown ? () => addTip(-TIP_SIZE) : undefined} /> - <span className="font-bold">{Math.floor(total)}</span> - <UpTip - onClick={canUp ? () => addTip(+TIP_SIZE) : undefined} - value={localTip} + <TipButton + tipAmount={TIP_SIZE} + totalTipped={localTip} + onClick={() => addTip(+TIP_SIZE)} + userTipped={localTip > 0} + disabled={!canUp} + isCompact /> - {localTip === 0 ? ( - '' - ) : ( - <span - className={clsx( - 'ml-1 font-semibold', - localTip > 0 ? 'text-primary' : 'text-red-400' - )} - > - ({formatMoney(localTip)} tip) - </span> - )} </Row> ) } - -function DownTip(props: { onClick?: () => void }) { - const { onClick } = props - return ( - <Tooltip - className="h-6 w-6" - placement="bottom" - text={onClick && `-${formatMoney(TIP_SIZE)}`} - noTap - > - <button - className="hover:text-red-600 disabled:text-gray-100" - disabled={!onClick} - onClick={onClick} - > - <ChevronLeftIcon className="h-6 w-6" /> - </button> - </Tooltip> - ) -} - -function UpTip(props: { onClick?: () => void; value: number }) { - const { onClick, value } = props - const IconKind = value > TIP_SIZE ? ChevronDoubleRightIcon : ChevronRightIcon - return ( - <Tooltip - className="h-6 w-6" - placement="bottom" - text={onClick && `Tip ${formatMoney(TIP_SIZE)}`} - noTap - > - <button - className="hover:text-primary disabled:text-gray-100" - disabled={!onClick} - onClick={onClick} - > - <IconKind className={clsx('h-6 w-6', value ? 'text-primary' : '')} /> - </button> - </Tooltip> - ) -} diff --git a/web/posts/post-comments.tsx b/web/posts/post-comments.tsx index f1d50a29..74fbb300 100644 --- a/web/posts/post-comments.tsx +++ b/web/posts/post-comments.tsx @@ -154,7 +154,6 @@ export function PostComment(props: { smallImage /> <Row className="mt-2 items-center gap-6 text-xs text-gray-500"> - <Tipper comment={comment} tips={tips ?? {}} /> {onReplyClick && ( <button className="font-bold hover:underline" @@ -163,6 +162,7 @@ export function PostComment(props: { Reply </button> )} + <Tipper comment={comment} tips={tips ?? {}} /> </Row> </div> </Row> From a445d9b7fa424685f9067c453480c0337735dcc2 Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Sat, 1 Oct 2022 15:54:14 -0500 Subject: [PATCH 090/135] make tip button green --- web/components/contract/tip-button.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/web/components/contract/tip-button.tsx b/web/components/contract/tip-button.tsx index 79059195..086fab06 100644 --- a/web/components/contract/tip-button.tsx +++ b/web/components/contract/tip-button.tsx @@ -33,15 +33,15 @@ export function TipButton(props: { <Col className={'relative items-center sm:flex-row'}> <HeartIcon className={clsx( - 'h-5 w-5 sm:h-6 sm:w-6', + 'h-5 w-5 text-green-700 sm:h-6 sm:w-6', totalTipped > 0 ? 'mr-2' : '', - userTipped ? 'fill-red-500 text-red-500' : '' + userTipped ? 'fill-green-700 text-green-700' : '' )} /> {totalTipped > 0 && ( <div className={clsx( - 'bg-greyscale-6 absolute ml-3.5 mt-2 h-4 w-4 rounded-full align-middle text-white sm:mt-3 sm:h-5 sm:w-5 sm:px-1', + 'bg-greyscale-5 absolute ml-3.5 mt-2 h-4 w-4 rounded-full align-middle text-white sm:mt-3 sm:h-5 sm:w-5 sm:px-1', totalTipped > 99 ? 'text-[0.4rem] sm:text-[0.5rem]' : 'sm:text-2xs text-[0.5rem]' From 09e4864b321f9232544583f107b60abd15da6c18 Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Sat, 1 Oct 2022 15:57:47 -0500 Subject: [PATCH 091/135] consistent tip amount (M$10) --- common/like.ts | 2 +- web/components/tipper.tsx | 9 ++++----- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/common/like.ts b/common/like.ts index 38b25dad..7ec14726 100644 --- a/common/like.ts +++ b/common/like.ts @@ -5,4 +5,4 @@ export type Like = { createdTime: number tipTxnId?: string // only holds most recent tip txn id } -export const LIKE_TIP_AMOUNT = 5 +export const LIKE_TIP_AMOUNT = 10 diff --git a/web/components/tipper.tsx b/web/components/tipper.tsx index a9c35937..1f6b6e53 100644 --- a/web/components/tipper.tsx +++ b/web/components/tipper.tsx @@ -9,8 +9,7 @@ import { transact } from 'web/lib/firebase/api' import { track } from 'web/lib/service/analytics' import { TipButton } from './contract/tip-button' import { Row } from './layout/row' - -const TIP_SIZE = 10 +import { LIKE_TIP_AMOUNT } from 'common/like' export function Tipper(prop: { comment: Comment; tips: CommentTips }) { const { comment, tips } = prop @@ -77,14 +76,14 @@ export function Tipper(prop: { comment: Comment; tips: CommentTips }) { return <></> } - const canUp = me && me.balance >= localTip + TIP_SIZE + const canUp = me && me.balance >= localTip + LIKE_TIP_AMOUNT return ( <Row className="items-center gap-0.5"> <TipButton - tipAmount={TIP_SIZE} + tipAmount={LIKE_TIP_AMOUNT} totalTipped={localTip} - onClick={() => addTip(+TIP_SIZE)} + onClick={() => addTip(+LIKE_TIP_AMOUNT)} userTipped={localTip > 0} disabled={!canUp} isCompact From 670c6faea8f1b17b92fdb5e01b7d63efa60792e8 Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Sat, 1 Oct 2022 16:00:39 -0500 Subject: [PATCH 092/135] tip button: remove border color --- web/components/contract/tip-button.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/components/contract/tip-button.tsx b/web/components/contract/tip-button.tsx index 086fab06..c6eb54ab 100644 --- a/web/components/contract/tip-button.tsx +++ b/web/components/contract/tip-button.tsx @@ -33,7 +33,7 @@ export function TipButton(props: { <Col className={'relative items-center sm:flex-row'}> <HeartIcon className={clsx( - 'h-5 w-5 text-green-700 sm:h-6 sm:w-6', + 'h-5 w-5 sm:h-6 sm:w-6', totalTipped > 0 ? 'mr-2' : '', userTipped ? 'fill-green-700 text-green-700' : '' )} From fac87f8e0c82877a68bf4bcd6a933af7afa630e0 Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Sat, 1 Oct 2022 16:10:17 -0500 Subject: [PATCH 093/135] tips: display total --- web/components/tipper.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/web/components/tipper.tsx b/web/components/tipper.tsx index 1f6b6e53..a8c7cb4e 100644 --- a/web/components/tipper.tsx +++ b/web/components/tipper.tsx @@ -1,4 +1,4 @@ -import { debounce } from 'lodash' +import { debounce, sum } from 'lodash' import { useEffect, useRef, useState } from 'react' import { Comment } from 'common/comment' @@ -29,6 +29,8 @@ export function Tipper(prop: { comment: Comment; tips: CommentTips }) { } }, [tips, myId]) + const total = sum(Object.values(tips)) - savedTip + localTip + // declare debounced function only on first render const [saveTip] = useState(() => debounce(async (user: User, comment: Comment, change: number) => { @@ -82,7 +84,7 @@ export function Tipper(prop: { comment: Comment; tips: CommentTips }) { <Row className="items-center gap-0.5"> <TipButton tipAmount={LIKE_TIP_AMOUNT} - totalTipped={localTip} + totalTipped={total} onClick={() => addTip(+LIKE_TIP_AMOUNT)} userTipped={localTip > 0} disabled={!canUp} From 2baae33a7706a8a89b83e1503f0d25a05b510ba5 Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Sat, 1 Oct 2022 16:16:34 -0500 Subject: [PATCH 094/135] show market tip total --- web/components/contract/extra-contract-actions-row.tsx | 4 +--- web/components/contract/like-market-button.tsx | 6 +++--- web/components/contract/tip-button.tsx | 8 ++++++-- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/web/components/contract/extra-contract-actions-row.tsx b/web/components/contract/extra-contract-actions-row.tsx index 8f4b5579..d9474806 100644 --- a/web/components/contract/extra-contract-actions-row.tsx +++ b/web/components/contract/extra-contract-actions-row.tsx @@ -18,9 +18,7 @@ export function ExtraContractActionsRow(props: { contract: Contract }) { return ( <Row> <FollowMarketButton contract={contract} user={user} /> - {user?.id !== contract.creatorId && ( - <LikeMarketButton contract={contract} user={user} /> - )} + <LikeMarketButton contract={contract} user={user} /> <Tooltip text="Share" placement="bottom" noTap noFade> <Button size="sm" diff --git a/web/components/contract/like-market-button.tsx b/web/components/contract/like-market-button.tsx index c8eb4d0b..d31b630e 100644 --- a/web/components/contract/like-market-button.tsx +++ b/web/components/contract/like-market-button.tsx @@ -17,9 +17,8 @@ export function LikeMarketButton(props: { }) { const { contract, user } = props - const tips = useMarketTipTxns(contract.id).filter( - (txn) => txn.fromId === user?.id - ) + const tips = useMarketTipTxns(contract.id) + const totalTipped = useMemo(() => { return sum(tips.map((tip) => tip.amount)) }, [tips]) @@ -51,6 +50,7 @@ export function LikeMarketButton(props: { userLikedContractIds?.includes(contract.id) || (!likes && !!contract.likedByUserIds?.includes(user.id))) } + disabled={contract.creatorId === user?.id} /> ) } diff --git a/web/components/contract/tip-button.tsx b/web/components/contract/tip-button.tsx index c6eb54ab..0315c676 100644 --- a/web/components/contract/tip-button.tsx +++ b/web/components/contract/tip-button.tsx @@ -18,14 +18,18 @@ export function TipButton(props: { return ( <Tooltip - text={`Tip ${formatMoney(tipAmount)}`} + text={disabled ? 'Tips' : `Tip ${formatMoney(tipAmount)}`} placement="bottom" noTap noFade > <Button size={'sm'} - className={clsx('max-w-xs self-center', isCompact && 'px-0 py-0')} + className={clsx( + 'max-w-xs self-center', + isCompact && 'px-0 py-0', + disabled && 'hover:bg-inherit' + )} color={'gray-white'} onClick={onClick} disabled={disabled} From 0b0b84a6ad7da0b5d5fa3f4f53f3bcf29d9d18af Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Sat, 1 Oct 2022 16:22:19 -0500 Subject: [PATCH 095/135] show tips on own comments again --- web/components/tipper.tsx | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/web/components/tipper.tsx b/web/components/tipper.tsx index a8c7cb4e..9d5cac84 100644 --- a/web/components/tipper.tsx +++ b/web/components/tipper.tsx @@ -74,11 +74,8 @@ export function Tipper(prop: { comment: Comment; tips: CommentTips }) { me && saveTip(me, comment, localTip - savedTip + delta) } - if (me && comment.userId === me.id) { - return <></> - } - - const canUp = me && me.balance >= localTip + LIKE_TIP_AMOUNT + const canUp = + me && comment.userId !== me.id && me.balance >= localTip + LIKE_TIP_AMOUNT return ( <Row className="items-center gap-0.5"> From 1d645e5ff88989773f9779f029932d6afed04836 Mon Sep 17 00:00:00 2001 From: Sinclair Chen <abc.sinclair@gmail.com> Date: Sun, 2 Oct 2022 08:52:53 -0700 Subject: [PATCH 096/135] trim copy on sort & bounty tooltips --- web/components/contract/bountied-contract-badge.tsx | 12 +++++++++--- web/components/contract/contract-tabs.tsx | 2 +- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/web/components/contract/bountied-contract-badge.tsx b/web/components/contract/bountied-contract-badge.tsx index 72dc70e1..79589990 100644 --- a/web/components/contract/bountied-contract-badge.tsx +++ b/web/components/contract/bountied-contract-badge.tsx @@ -22,7 +22,10 @@ export function BountiedContractSmallBadge(props: { return ( <Tooltip - text={CommentBountiesTooltipText(openCommentBounties)} + text={CommentBountiesTooltipText( + contract.creatorName, + openCommentBounties + )} placement="bottom" > <span className="inline-flex items-center gap-1 whitespace-nowrap rounded-full bg-indigo-300 px-2 py-0.5 text-xs font-medium text-white"> @@ -33,8 +36,11 @@ export function BountiedContractSmallBadge(props: { ) } -export const CommentBountiesTooltipText = (openCommentBounties: number) => - `The creator of this market may award ${formatMoney( +export const CommentBountiesTooltipText = ( + creator: string, + openCommentBounties: number +) => + `${creator} may award ${formatMoney( COMMENT_BOUNTY_AMOUNT )} for good comments. ${formatMoney( openCommentBounties diff --git a/web/components/contract/contract-tabs.tsx b/web/components/contract/contract-tabs.tsx index a4287b5c..5a4efb94 100644 --- a/web/components/contract/contract-tabs.tsx +++ b/web/components/contract/contract-tabs.tsx @@ -159,7 +159,7 @@ const CommentsTabContent = memo(function CommentsTabContent(props: { <Tooltip text={ sort === 'Best' - ? 'Comments with tips or bounties will be shown first. Your comments made within the last 10 minutes will temporarily appear (to you) first.' + ? 'Highest tips + bounties first. Your new comments briefly appear to you first.' : '' } > From fd31b7eaca20c8cb049600e229cc400b8f1c3c35 Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Sun, 2 Oct 2022 12:50:49 -0500 Subject: [PATCH 097/135] set comment sort default to newest --- web/components/contract/contract-tabs.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/components/contract/contract-tabs.tsx b/web/components/contract/contract-tabs.tsx index 5a4efb94..1a67d86a 100644 --- a/web/components/contract/contract-tabs.tsx +++ b/web/components/contract/contract-tabs.tsx @@ -75,7 +75,7 @@ const CommentsTabContent = memo(function CommentsTabContent(props: { const { contract } = props const tips = useTipTxns({ contractId: contract.id }) const comments = useComments(contract.id) ?? props.comments - const [sort, setSort] = useState<'Newest' | 'Best'>('Best') + const [sort, setSort] = useState<'Newest' | 'Best'>('Newest') const me = useUser() if (comments == null) { return <LoadingIndicator /> From 57b592b5aa9254bcb8c2851ac299496fbcab252b Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Sun, 2 Oct 2022 12:55:58 -0500 Subject: [PATCH 098/135] show toast after comment tips --- web/components/tipper.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/web/components/tipper.tsx b/web/components/tipper.tsx index 9d5cac84..b201f946 100644 --- a/web/components/tipper.tsx +++ b/web/components/tipper.tsx @@ -1,5 +1,6 @@ -import { debounce, sum } from 'lodash' import { useEffect, useRef, useState } from 'react' +import toast from 'react-hot-toast' +import { debounce, sum } from 'lodash' import { Comment } from 'common/comment' import { User } from 'common/user' @@ -10,6 +11,7 @@ import { track } from 'web/lib/service/analytics' import { TipButton } from './contract/tip-button' import { Row } from './layout/row' import { LIKE_TIP_AMOUNT } from 'common/like' +import { formatMoney } from 'common/util/format' export function Tipper(prop: { comment: Comment; tips: CommentTips }) { const { comment, tips } = prop @@ -72,6 +74,7 @@ export function Tipper(prop: { comment: Comment; tips: CommentTips }) { const addTip = (delta: number) => { setLocalTip(localTip + delta) me && saveTip(me, comment, localTip - savedTip + delta) + toast(`You tipped ${comment.userName} ${formatMoney(LIKE_TIP_AMOUNT)}!`) } const canUp = From 4c2f9011d02d960feadb7c91978a04e99e715297 Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Sun, 2 Oct 2022 13:39:19 -0500 Subject: [PATCH 099/135] track embed hostname --- web/pages/embed/[username]/[contractSlug].tsx | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/web/pages/embed/[username]/[contractSlug].tsx b/web/pages/embed/[username]/[contractSlug].tsx index cc4bc09d..f578631f 100644 --- a/web/pages/embed/[username]/[contractSlug].tsx +++ b/web/pages/embed/[username]/[contractSlug].tsx @@ -1,7 +1,7 @@ import { Bet } from 'common/bet' import { Contract } from 'common/contract' import { DOMAIN } from 'common/envs/constants' -import { useState } from 'react' +import { useEffect, useState } from 'react' import { BetInline } from 'web/components/bet-inline' import { Button } from 'web/components/button' import { @@ -19,7 +19,6 @@ import { SiteLink } from 'web/components/site-link' import { useContractWithPreload } from 'web/hooks/use-contract' import { useMeasureSize } from 'web/hooks/use-measure-size' import { fromPropz, usePropz } from 'web/hooks/use-propz' -import { useTracking } from 'web/hooks/use-tracking' import { listAllBets } from 'web/lib/firebase/bets' import { contractPath, @@ -27,6 +26,7 @@ import { tradingAllowed, } from 'web/lib/firebase/contracts' import Custom404 from '../../404' +import { track } from 'web/lib/service/analytics' export const getStaticProps = fromPropz(getStaticPropz) export async function getStaticPropz(props: { @@ -68,11 +68,14 @@ export function ContractEmbed(props: { contract: Contract; bets: Bet[] }) { const { contract, bets } = props const { question, outcomeType } = contract - useTracking('view market embed', { - slug: contract.slug, - contractId: contract.id, - creatorId: contract.creatorId, - }) + useEffect(() => { + track('view market embed', { + slug: contract.slug, + contractId: contract.id, + creatorId: contract.creatorId, + hostname: window.location.hostname, + }) + }, [contract.creatorId, contract.id, contract.slug]) const isBinary = outcomeType === 'BINARY' const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC' From 290a34bc640bfbc29c0a539ff56db43c24754e4f Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Sun, 2 Oct 2022 13:39:29 -0500 Subject: [PATCH 100/135] useTracking --- web/hooks/use-tracking.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/hooks/use-tracking.ts b/web/hooks/use-tracking.ts index e62209c0..3d1d97ba 100644 --- a/web/hooks/use-tracking.ts +++ b/web/hooks/use-tracking.ts @@ -1,5 +1,5 @@ -import { track } from '@amplitude/analytics-browser' import { useEffect } from 'react' +import { track } from 'web/lib/service/analytics' import { inIframe } from './use-is-iframe' export const useTracking = ( @@ -10,5 +10,5 @@ export const useTracking = ( useEffect(() => { if (excludeIframe && inIframe()) return track(eventName, eventProperties) - }, []) + }, [eventName, eventProperties, excludeIframe]) } From af66d94c84b49a8e506af66d0166cd80adc1ab36 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Sun, 2 Oct 2022 13:42:43 -0500 Subject: [PATCH 101/135] Manifold labs --- web/pages/labs/index.tsx | 43 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 web/pages/labs/index.tsx diff --git a/web/pages/labs/index.tsx b/web/pages/labs/index.tsx new file mode 100644 index 00000000..6ea861a3 --- /dev/null +++ b/web/pages/labs/index.tsx @@ -0,0 +1,43 @@ +import Masonry from 'react-masonry-css' +import { Page } from 'web/components/page' +import { SiteLink } from 'web/components/site-link' +import { Title } from 'web/components/title' + +export default function LabsPage() { + return ( + <Page> + <Title text="Manifold Labs" /> + + <Masonry + breakpointCols={{ default: 2, 768: 1 }} + className="-ml-4 flex w-auto" + columnClassName="pl-4 bg-clip-padding" + > + <LabCard + title="Dating docs" + description="Browse dating docs or create your own" + href="/date-docs" + /> + </Masonry> + </Page> + ) +} + +const LabCard = (props: { + title: string + description: string + href: string +}) => { + const { title, description, href } = props + return ( + <SiteLink + href={href} + className="group flex h-full w-full flex-col rounded-lg bg-white p-4 shadow-md transition-shadow duration-200 hover:no-underline hover:shadow-lg" + > + <h3 className="text-lg font-semibold group-hover:underline group-hover:decoration-indigo-400 group-hover:decoration-2"> + {title} + </h3> + <p className="mt-2 text-gray-600">{description}</p> + </SiteLink> + ) +} From 33dfce3e160d01bdb0b64bca26bb8ef172872038 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Sun, 2 Oct 2022 13:43:14 -0500 Subject: [PATCH 102/135] Remove dating docs from More menu --- web/components/nav/sidebar.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/web/components/nav/sidebar.tsx b/web/components/nav/sidebar.tsx index 71842559..b0a9862b 100644 --- a/web/components/nav/sidebar.tsx +++ b/web/components/nav/sidebar.tsx @@ -164,7 +164,6 @@ function getMoreDesktopNavigation(user?: User | null) { { name: 'Charity', href: '/charity' }, { name: 'Send M$', href: '/links' }, { name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' }, - { name: 'Dating docs', href: '/date-docs' }, { name: 'Help & About', href: 'https://help.manifold.markets/' }, { name: 'Sign out', @@ -227,7 +226,6 @@ function getMoreMobileNav() { { name: 'Charity', href: '/charity' }, { name: 'Send M$', href: '/links' }, { name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' }, - { name: 'Dating docs', href: '/date-docs' }, ], signOut ) From 758dbfe398dfaefdd3de6aacb874b600dc75a7f6 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Sun, 2 Oct 2022 13:51:42 -0500 Subject: [PATCH 103/135] Add labs cards --- web/pages/labs/index.tsx | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/web/pages/labs/index.tsx b/web/pages/labs/index.tsx index 6ea861a3..3ea7276d 100644 --- a/web/pages/labs/index.tsx +++ b/web/pages/labs/index.tsx @@ -18,6 +18,17 @@ export default function LabsPage() { description="Browse dating docs or create your own" href="/date-docs" /> + <LabCard + title="Senate race" + description="See the state-by-state breakdown of the US Senate midterm elections" + href="/midterms" + /> + <LabCard + title="Magic the Guessering" + description="How well can you match these card names to their art?" + href="/mtg" + /> + <LabCard title="Cowp" description="???" href="/cowp" /> </Masonry> </Page> ) @@ -32,12 +43,12 @@ const LabCard = (props: { return ( <SiteLink href={href} - className="group flex h-full w-full flex-col rounded-lg bg-white p-4 shadow-md transition-shadow duration-200 hover:no-underline hover:shadow-lg" + className="group mb-4 flex flex-col gap-2 rounded-lg bg-white p-4 shadow-md transition-shadow duration-200 hover:no-underline hover:shadow-lg" > <h3 className="text-lg font-semibold group-hover:underline group-hover:decoration-indigo-400 group-hover:decoration-2"> {title} </h3> - <p className="mt-2 text-gray-600">{description}</p> + <p className="text-gray-600">{description}</p> </SiteLink> ) } From 0ffd6c129a955637b270bc8840fa2f3e1ee9dc3b Mon Sep 17 00:00:00 2001 From: Sinclair Chen <abc.sinclair@gmail.com> Date: Sun, 2 Oct 2022 11:55:47 -0700 Subject: [PATCH 104/135] Make small embeds into cards (#976) * Fix embed style (adjust input, strikethrough) * Turn small embeds into contract cards * Use media query instead of conditional render * Open embed card clicks in new tab --- web/components/amount-input.tsx | 4 +-- web/components/contract/contract-card.tsx | 7 ++++- web/components/contract/contract-overview.tsx | 6 +--- web/pages/embed/[username]/[contractSlug].tsx | 31 +++++++++++++++++-- 4 files changed, 37 insertions(+), 11 deletions(-) diff --git a/web/components/amount-input.tsx b/web/components/amount-input.tsx index e7c4ce2a..45c73c5e 100644 --- a/web/components/amount-input.tsx +++ b/web/components/amount-input.tsx @@ -38,8 +38,8 @@ export function AmountInput(props: { return ( <> <Col className={className}> - <label className="font-sm md:font-lg"> - <span className={clsx('text-greyscale-4 absolute ml-2 mt-[9px]')}> + <label className="font-sm md:font-lg relative"> + <span className="text-greyscale-4 absolute top-1/2 my-auto ml-2 -translate-y-1/2"> {label} </span> <input diff --git a/web/components/contract/contract-card.tsx b/web/components/contract/contract-card.tsx index aa130321..a8caf7bd 100644 --- a/web/components/contract/contract-card.tsx +++ b/web/components/contract/contract-card.tsx @@ -46,6 +46,7 @@ export function ContractCard(props: { hideGroupLink?: boolean trackingPostfix?: string noLinkAvatar?: boolean + newTab?: boolean }) { const { showTime, @@ -56,6 +57,7 @@ export function ContractCard(props: { hideGroupLink, trackingPostfix, noLinkAvatar, + newTab, } = props const contract = useContractWithPreload(props.contract) ?? props.contract const { question, outcomeType } = contract @@ -189,6 +191,7 @@ export function ContractCard(props: { } )} className="absolute top-0 left-0 right-0 bottom-0" + target={newTab ? '_blank' : '_self'} /> </Link> )} @@ -211,7 +214,9 @@ export function BinaryResolutionOrChance(props: { const probChanged = before !== after return ( - <Col className={clsx(large ? 'text-4xl' : 'text-3xl', className)}> + <Col + className={clsx('items-end', large ? 'text-4xl' : 'text-3xl', className)} + > {resolution ? ( <> <div diff --git a/web/components/contract/contract-overview.tsx b/web/components/contract/contract-overview.tsx index 3e5f22b2..2a6d5172 100644 --- a/web/components/contract/contract-overview.tsx +++ b/web/components/contract/contract-overview.tsx @@ -114,11 +114,7 @@ const BinaryOverview = (props: { contract: BinaryContract; bets: Bet[] }) => { <ContractDetails contract={contract} /> <Row className="justify-between gap-4"> <OverviewQuestion text={contract.question} /> - <BinaryResolutionOrChance - className="flex items-end" - contract={contract} - large - /> + <BinaryResolutionOrChance contract={contract} large /> </Row> </Col> <SizedContractChart diff --git a/web/pages/embed/[username]/[contractSlug].tsx b/web/pages/embed/[username]/[contractSlug].tsx index f578631f..03bc4ce9 100644 --- a/web/pages/embed/[username]/[contractSlug].tsx +++ b/web/pages/embed/[username]/[contractSlug].tsx @@ -6,6 +6,7 @@ import { BetInline } from 'web/components/bet-inline' import { Button } from 'web/components/button' import { BinaryResolutionOrChance, + ContractCard, FreeResponseResolutionOrChance, NumericResolutionOrExpectation, PseudoNumericResolutionOrExpectation, @@ -64,10 +65,13 @@ export default function ContractEmbedPage(props: { return <ContractEmbed contract={contract} bets={bets} /> } -export function ContractEmbed(props: { contract: Contract; bets: Bet[] }) { - const { contract, bets } = props - const { question, outcomeType } = contract +interface EmbedProps { + contract: Contract + bets: Bet[] +} +export function ContractEmbed(props: EmbedProps) { + const { contract } = props useEffect(() => { track('view market embed', { slug: contract.slug, @@ -77,6 +81,27 @@ export function ContractEmbed(props: { contract: Contract; bets: Bet[] }) { }) }, [contract.creatorId, contract.id, contract.slug]) + // return (height < 250px) ? Card : SmolView + return ( + <> + <div className="contents [@media(min-height:250px)]:hidden"> + <ContractCard + contract={contract} + className="h-screen" + noLinkAvatar + newTab + /> + </div> + <div className="hidden [@media(min-height:250px)]:contents"> + <ContractSmolView {...props} /> + </div> + </> + ) +} + +function ContractSmolView({ contract, bets }: EmbedProps) { + const { question, outcomeType } = contract + const isBinary = outcomeType === 'BINARY' const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC' From 701d0a06cda87dd02566273a87f3f08e8f4bc7b1 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Sun, 2 Oct 2022 14:08:05 -0500 Subject: [PATCH 105/135] Test loading user from localstorage on first render --- web/components/auth-context.tsx | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/web/components/auth-context.tsx b/web/components/auth-context.tsx index 19ced0b2..b3f4f2f8 100644 --- a/web/components/auth-context.tsx +++ b/web/components/auth-context.tsx @@ -58,14 +58,9 @@ export function AuthProvider(props: { serverUser?: AuthUser }) { const { children, serverUser } = props - const [authUser, setAuthUser] = useStateCheckEquality<AuthUser>(serverUser) - - useEffect(() => { - if (serverUser === undefined) { - const cachedUser = localStorage.getItem(CACHED_USER_KEY) - setAuthUser(cachedUser && JSON.parse(cachedUser)) - } - }, [setAuthUser, serverUser]) + const [authUser, setAuthUser] = useStateCheckEquality<AuthUser>( + serverUser ?? getStoredUser() + ) useEffect(() => { if (authUser != null) { @@ -134,3 +129,10 @@ export function AuthProvider(props: { <AuthContext.Provider value={authUser}>{children}</AuthContext.Provider> ) } + +const getStoredUser = () => { + if (typeof window === 'undefined') return undefined + + const json = localStorage.getItem(CACHED_USER_KEY) + return json ? JSON.parse(json) : undefined +} From 37e8cfbbedd7e58688622bbbd96814aa6017681f Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Sun, 2 Oct 2022 14:12:33 -0500 Subject: [PATCH 106/135] Tweak padding --- web/pages/labs/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/pages/labs/index.tsx b/web/pages/labs/index.tsx index 3ea7276d..9cd79f56 100644 --- a/web/pages/labs/index.tsx +++ b/web/pages/labs/index.tsx @@ -5,7 +5,7 @@ import { Title } from 'web/components/title' export default function LabsPage() { return ( - <Page> + <Page className="px-4"> <Title text="Manifold Labs" /> <Masonry From 747977556be757471c948fcd67ec57f814307035 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Sun, 2 Oct 2022 14:13:19 -0500 Subject: [PATCH 107/135] Add /labs to More menu --- web/components/nav/sidebar.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/web/components/nav/sidebar.tsx b/web/components/nav/sidebar.tsx index b0a9862b..49f3dbb6 100644 --- a/web/components/nav/sidebar.tsx +++ b/web/components/nav/sidebar.tsx @@ -147,6 +147,7 @@ function getMoreDesktopNavigation(user?: User | null) { [ { name: 'Tournaments', href: '/tournaments' }, { name: 'Charity', href: '/charity' }, + { name: 'Labs', href: '/labs' }, { name: 'Blog', href: 'https://news.manifold.markets' }, { name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' }, { name: 'Twitter', href: 'https://twitter.com/ManifoldMarkets' }, @@ -162,6 +163,7 @@ function getMoreDesktopNavigation(user?: User | null) { [ { name: 'Referrals', href: '/referrals' }, { name: 'Charity', href: '/charity' }, + { name: 'Labs', href: '/labs' }, { name: 'Send M$', href: '/links' }, { name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' }, { name: 'Help & About', href: 'https://help.manifold.markets/' }, @@ -224,6 +226,7 @@ function getMoreMobileNav() { { name: 'Groups', href: '/groups' }, { name: 'Referrals', href: '/referrals' }, { name: 'Charity', href: '/charity' }, + { name: 'Labs', href: '/labs' }, { name: 'Send M$', href: '/links' }, { name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' }, ], From 0fb263efa4a19fbd0241536385bc8e6b704c1053 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Sun, 2 Oct 2022 14:16:54 -0500 Subject: [PATCH 108/135] Revert "Test loading user from localstorage on first render" This reverts commit 701d0a06cda87dd02566273a87f3f08e8f4bc7b1. --- web/components/auth-context.tsx | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/web/components/auth-context.tsx b/web/components/auth-context.tsx index b3f4f2f8..19ced0b2 100644 --- a/web/components/auth-context.tsx +++ b/web/components/auth-context.tsx @@ -58,9 +58,14 @@ export function AuthProvider(props: { serverUser?: AuthUser }) { const { children, serverUser } = props - const [authUser, setAuthUser] = useStateCheckEquality<AuthUser>( - serverUser ?? getStoredUser() - ) + const [authUser, setAuthUser] = useStateCheckEquality<AuthUser>(serverUser) + + useEffect(() => { + if (serverUser === undefined) { + const cachedUser = localStorage.getItem(CACHED_USER_KEY) + setAuthUser(cachedUser && JSON.parse(cachedUser)) + } + }, [setAuthUser, serverUser]) useEffect(() => { if (authUser != null) { @@ -129,10 +134,3 @@ export function AuthProvider(props: { <AuthContext.Provider value={authUser}>{children}</AuthContext.Provider> ) } - -const getStoredUser = () => { - if (typeof window === 'undefined') return undefined - - const json = localStorage.getItem(CACHED_USER_KEY) - return json ? JSON.parse(json) : undefined -} From 42aea03415ef6868fdc9bd544b64c6672dcb75bb Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Sun, 2 Oct 2022 14:41:44 -0500 Subject: [PATCH 109/135] Add search bar to home --- web/pages/home/index.tsx | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/web/pages/home/index.tsx b/web/pages/home/index.tsx index 4ad7f97b..f4cd73bc 100644 --- a/web/pages/home/index.tsx +++ b/web/pages/home/index.tsx @@ -40,7 +40,6 @@ import { filterDefined } from 'common/util/array' 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 { useContractsByDailyScoreNotBetOn, @@ -91,12 +90,15 @@ export default function Home() { <Page> <Toaster /> - <Col className="pm:mx-10 gap-4 px-4 pb-12 pt-4 sm:pt-0"> + <Col className="pm:mx-10 gap-4 px-4 pb-8 pt-4 sm:pt-0"> <Row className={'mb-2 w-full items-center justify-between gap-8'}> - <Row className="items-center gap-2"> - <Title className="!mt-0 !mb-0" text="Home" /> - <CustomizeButton justIcon /> - </Row> + <input + type="text" + placeholder={'Search'} + className="input input-bordered w-full sm:flex" + onClick={() => Router.push('/search')} + /> + <CustomizeButton justIcon /> <DailyStats user={user} /> </Row> @@ -362,7 +364,7 @@ function DailyStats(props: { } return ( - <Row className={'gap-4'}> + <Row className={'flex-shrink-0 gap-4'}> <Col> <div className="text-gray-500">Daily profit</div> <Row className={clsx(className, 'items-center text-lg')}> From 359a768e1438ca21a527a66b66f9040e6b3477fc Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Sun, 2 Oct 2022 14:49:08 -0500 Subject: [PATCH 110/135] Move challenges into /labs --- web/components/nav/sidebar.tsx | 56 ++++++++++++++-------------------- web/pages/labs/index.tsx | 9 ++++++ 2 files changed, 32 insertions(+), 33 deletions(-) diff --git a/web/components/nav/sidebar.tsx b/web/components/nav/sidebar.tsx index 49f3dbb6..6f712384 100644 --- a/web/components/nav/sidebar.tsx +++ b/web/components/nav/sidebar.tsx @@ -20,7 +20,6 @@ import NotificationsIcon from 'web/components/notifications-icon' import { IS_PRIVATE_MANIFOLD } from 'common/envs/constants' import { CreateQuestionButton } from 'web/components/create-question-button' import { withTracking } from 'web/lib/service/analytics' -import { CHALLENGES_ENABLED } from 'common/challenge' import { buildArray } from 'common/util/array' import TrophyIcon from 'web/lib/icons/trophy-icon' import { SignInButton } from '../sign-in-button' @@ -143,15 +142,12 @@ function getMoreDesktopNavigation(user?: User | null) { return buildArray( { name: 'Leaderboards', href: '/leaderboards' }, { name: 'Groups', href: '/groups' }, - CHALLENGES_ENABLED && { name: 'Challenges', href: '/challenges' }, - [ - { name: 'Tournaments', href: '/tournaments' }, - { name: 'Charity', href: '/charity' }, - { name: 'Labs', href: '/labs' }, - { name: 'Blog', href: 'https://news.manifold.markets' }, - { name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' }, - { name: 'Twitter', href: 'https://twitter.com/ManifoldMarkets' }, - ] + { name: 'Tournaments', href: '/tournaments' }, + { name: 'Charity', href: '/charity' }, + { name: 'Labs', href: '/labs' }, + { name: 'Blog', href: 'https://news.manifold.markets' }, + { name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' }, + { name: 'Twitter', href: 'https://twitter.com/ManifoldMarkets' } ) } @@ -159,20 +155,17 @@ function getMoreDesktopNavigation(user?: User | null) { return buildArray( { name: 'Leaderboards', href: '/leaderboards' }, { name: 'Groups', href: '/groups' }, - CHALLENGES_ENABLED && { name: 'Challenges', href: '/challenges' }, - [ - { name: 'Referrals', href: '/referrals' }, - { name: 'Charity', href: '/charity' }, - { name: 'Labs', href: '/labs' }, - { name: 'Send M$', href: '/links' }, - { name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' }, - { name: 'Help & About', href: 'https://help.manifold.markets/' }, - { - name: 'Sign out', - href: '#', - onClick: logout, - }, - ] + { name: 'Referrals', href: '/referrals' }, + { name: 'Charity', href: '/charity' }, + { name: 'Labs', href: '/labs' }, + { name: 'Send M$', href: '/links' }, + { name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' }, + { name: 'Help & About', href: 'https://help.manifold.markets/' }, + { + name: 'Sign out', + href: '#', + onClick: logout, + } ) } @@ -221,15 +214,12 @@ function getMoreMobileNav() { if (IS_PRIVATE_MANIFOLD) return [signOut] return buildArray<MenuItem>( - CHALLENGES_ENABLED && { name: 'Challenges', href: '/challenges' }, - [ - { name: 'Groups', href: '/groups' }, - { name: 'Referrals', href: '/referrals' }, - { name: 'Charity', href: '/charity' }, - { name: 'Labs', href: '/labs' }, - { name: 'Send M$', href: '/links' }, - { name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' }, - ], + { name: 'Groups', href: '/groups' }, + { name: 'Referrals', href: '/referrals' }, + { name: 'Charity', href: '/charity' }, + { name: 'Labs', href: '/labs' }, + { name: 'Send M$', href: '/links' }, + { name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' }, signOut ) } diff --git a/web/pages/labs/index.tsx b/web/pages/labs/index.tsx index 9cd79f56..8d5ab1ad 100644 --- a/web/pages/labs/index.tsx +++ b/web/pages/labs/index.tsx @@ -1,3 +1,4 @@ +import { CHALLENGES_ENABLED } from 'common/challenge' import Masonry from 'react-masonry-css' import { Page } from 'web/components/page' import { SiteLink } from 'web/components/site-link' @@ -13,6 +14,14 @@ export default function LabsPage() { className="-ml-4 flex w-auto" columnClassName="pl-4 bg-clip-padding" > + {CHALLENGES_ENABLED && ( + <LabCard + title="Challenges" + description="One-on-one bets between friends" + href="/challenges" + /> + )} + <LabCard title="Dating docs" description="Browse dating docs or create your own" From a7f6cb7cfa301981f39f4335fec2ab1d741be09f Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Sun, 2 Oct 2022 14:51:28 -0500 Subject: [PATCH 111/135] Fix labs layout --- web/pages/labs/index.tsx | 63 +++++++++++++++++++++------------------- 1 file changed, 33 insertions(+), 30 deletions(-) diff --git a/web/pages/labs/index.tsx b/web/pages/labs/index.tsx index 8d5ab1ad..9a423b56 100644 --- a/web/pages/labs/index.tsx +++ b/web/pages/labs/index.tsx @@ -1,44 +1,47 @@ import { CHALLENGES_ENABLED } from 'common/challenge' import Masonry from 'react-masonry-css' +import { Col } from 'web/components/layout/col' import { Page } from 'web/components/page' import { SiteLink } from 'web/components/site-link' import { Title } from 'web/components/title' export default function LabsPage() { return ( - <Page className="px-4"> - <Title text="Manifold Labs" /> + <Page> + <Col className="px-4"> + <Title className="sm:!mt-0" text="Manifold Labs" /> + + <Masonry + breakpointCols={{ default: 2, 768: 1 }} + className="-ml-4 flex w-auto" + columnClassName="pl-4 bg-clip-padding" + > + {CHALLENGES_ENABLED && ( + <LabCard + title="Challenges" + description="One-on-one bets between friends" + href="/challenges" + /> + )} - <Masonry - breakpointCols={{ default: 2, 768: 1 }} - className="-ml-4 flex w-auto" - columnClassName="pl-4 bg-clip-padding" - > - {CHALLENGES_ENABLED && ( <LabCard - title="Challenges" - description="One-on-one bets between friends" - href="/challenges" + title="Dating docs" + description="Browse dating docs or create your own" + href="/date-docs" /> - )} - - <LabCard - title="Dating docs" - description="Browse dating docs or create your own" - href="/date-docs" - /> - <LabCard - title="Senate race" - description="See the state-by-state breakdown of the US Senate midterm elections" - href="/midterms" - /> - <LabCard - title="Magic the Guessering" - description="How well can you match these card names to their art?" - href="/mtg" - /> - <LabCard title="Cowp" description="???" href="/cowp" /> - </Masonry> + <LabCard + title="Senate race" + description="See the state-by-state breakdown of the US Senate midterm elections" + href="/midterms" + /> + <LabCard + title="Magic the Guessering" + description="How well can you match these card names to their art?" + href="/mtg" + /> + <LabCard title="Cowp" description="???" href="/cowp" /> + </Masonry> + </Col> </Page> ) } From 10e361bcac314e17c1b0c0685da7b054a37900ef Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Sun, 2 Oct 2022 14:59:02 -0500 Subject: [PATCH 112/135] Load daily movers at top level as well --- web/pages/home/index.tsx | 29 ++++++++++++++++++----------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/web/pages/home/index.tsx b/web/pages/home/index.tsx index f4cd73bc..fdfa620d 100644 --- a/web/pages/home/index.tsx +++ b/web/pages/home/index.tsx @@ -74,6 +74,7 @@ export default function Home() { } }, [user, sections]) + const dailyMovers = useProbChanges({ bettorId: user?.id }) const trendingContracts = useTrendingContracts(6) const newContracts = useNewContracts(6) const dailyTrendingContracts = useContractsByDailyScoreNotBetOn(user?.id, 6) @@ -84,7 +85,11 @@ export default function Home() { ) const isLoading = - !user || !trendingContracts || !newContracts || !dailyTrendingContracts + !user || + !dailyMovers || + !trendingContracts || + !newContracts || + !dailyTrendingContracts return ( <Page> @@ -110,6 +115,7 @@ export default function Home() { score: trendingContracts, newest: newContracts, 'daily-trending': dailyTrendingContracts, + 'daily-movers': dailyMovers, })} <TrendingGroupsSection user={user} /> @@ -166,6 +172,7 @@ function renderSections( user: User, sections: { id: string; label: string }[], sectionContracts: { + 'daily-movers': CPMMBinaryContract[] 'daily-trending': CPMMBinaryContract[] newest: CPMMBinaryContract[] score: CPMMBinaryContract[] @@ -175,22 +182,23 @@ function renderSections( <> {sections.map((s) => { const { id, label } = s + const contracts = + sectionContracts[s.id as keyof typeof sectionContracts] + if (id === 'daily-movers') { - return <DailyMoversSection key={id} userId={user.id} /> + return <DailyMoversSection key={id} contracts={contracts} /> } if (id === 'daily-trending') { return ( <ContractsSection key={id} label={label} - contracts={sectionContracts[id]} + contracts={contracts} sort="daily-score" showProbChange /> ) } - const contracts = - sectionContracts[s.id as keyof typeof sectionContracts] return ( <ContractsSection key={id} @@ -325,13 +333,12 @@ function GroupSection(props: { ) } -function DailyMoversSection(props: { userId: string }) { - const { userId } = props - const changes = useProbChanges({ bettorId: userId })?.filter( - (c) => Math.abs(c.probChanges.day) >= 0.01 - ) +function DailyMoversSection(props: { contracts: CPMMBinaryContract[] }) { + const { contracts } = props - if (changes && changes.length === 0) { + const changes = contracts.filter((c) => Math.abs(c.probChanges.day) >= 0.01) + + if (changes.length === 0) { return null } From 9a90cc38354e7717d837b42cda8957a260b605e5 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Sun, 2 Oct 2022 15:03:29 -0500 Subject: [PATCH 113/135] Move manalinks into labs --- web/components/nav/sidebar.tsx | 2 -- web/pages/labs/index.tsx | 10 ++++++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/web/components/nav/sidebar.tsx b/web/components/nav/sidebar.tsx index 6f712384..efe7994b 100644 --- a/web/components/nav/sidebar.tsx +++ b/web/components/nav/sidebar.tsx @@ -158,7 +158,6 @@ function getMoreDesktopNavigation(user?: User | null) { { name: 'Referrals', href: '/referrals' }, { name: 'Charity', href: '/charity' }, { name: 'Labs', href: '/labs' }, - { name: 'Send M$', href: '/links' }, { name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' }, { name: 'Help & About', href: 'https://help.manifold.markets/' }, { @@ -218,7 +217,6 @@ function getMoreMobileNav() { { name: 'Referrals', href: '/referrals' }, { name: 'Charity', href: '/charity' }, { name: 'Labs', href: '/labs' }, - { name: 'Send M$', href: '/links' }, { name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' }, signOut ) diff --git a/web/pages/labs/index.tsx b/web/pages/labs/index.tsx index 9a423b56..f08a3b8e 100644 --- a/web/pages/labs/index.tsx +++ b/web/pages/labs/index.tsx @@ -24,6 +24,12 @@ export default function LabsPage() { /> )} + <LabCard + title="Manalinks" + description="Send M$ to anyone" + href="/links" + /> + <LabCard title="Dating docs" description="Browse dating docs or create your own" @@ -31,12 +37,12 @@ export default function LabsPage() { /> <LabCard title="Senate race" - description="See the state-by-state breakdown of the US Senate midterm elections" + description="A state-by-state breakdown of the US Senate midterm elections" href="/midterms" /> <LabCard title="Magic the Guessering" - description="How well can you match these card names to their art?" + description="Match MTG card names to their art" href="/mtg" /> <LabCard title="Cowp" description="???" href="/cowp" /> From 64951e691e77ed555a82f4e2ca962f397943572e Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Sun, 2 Oct 2022 15:09:20 -0500 Subject: [PATCH 114/135] update midterms dashboard --- web/pages/labs/index.tsx | 13 ++-- web/pages/midterms.tsx | 149 +++++++++++++++++++++++++++++++++++++-- 2 files changed, 151 insertions(+), 11 deletions(-) diff --git a/web/pages/labs/index.tsx b/web/pages/labs/index.tsx index f08a3b8e..6b39c59c 100644 --- a/web/pages/labs/index.tsx +++ b/web/pages/labs/index.tsx @@ -16,6 +16,12 @@ export default function LabsPage() { className="-ml-4 flex w-auto" columnClassName="pl-4 bg-clip-padding" > + <LabCard + title="US 2022 Midterms" + description="See Manifold's state-by-state breakdown of senate and governor races" + href="/midterms" + /> + {CHALLENGES_ENABLED && ( <LabCard title="Challenges" @@ -35,16 +41,13 @@ export default function LabsPage() { description="Browse dating docs or create your own" href="/date-docs" /> - <LabCard - title="Senate race" - description="A state-by-state breakdown of the US Senate midterm elections" - href="/midterms" - /> + <LabCard title="Magic the Guessering" description="Match MTG card names to their art" href="/mtg" /> + <LabCard title="Cowp" description="???" href="/cowp" /> </Masonry> </Col> diff --git a/web/pages/midterms.tsx b/web/pages/midterms.tsx index d508743c..d1dfb509 100644 --- a/web/pages/midterms.tsx +++ b/web/pages/midterms.tsx @@ -1,4 +1,5 @@ import { Col } from 'web/components/layout/col' +import { Spacer } from 'web/components/layout/spacer' import { Page } from 'web/components/page' import { Title } from 'web/components/title' import { @@ -75,23 +76,133 @@ const senateMidterms: StateElectionMarket[] = [ }, ] +const governorMidterms: StateElectionMarket[] = [ + { + state: 'TX', + creatorUsername: 'LarsDoucet', + slug: 'republicans-will-win-the-2022-texas', + isWinRepublican: true, + }, + { + state: 'GA', + creatorUsername: 'MattP', + slug: 'will-stacey-abrams-win-the-2022-geo', + isWinRepublican: false, + }, + { + state: 'FL', + creatorUsername: 'Tetraspace', + slug: 'if-charlie-crist-is-the-democratic', + isWinRepublican: false, + }, + { + state: 'PA', + creatorUsername: 'JonathanMast', + slug: 'will-josh-shapiro-win-the-2022-penn', + isWinRepublican: false, + }, + { + state: 'PA', + creatorUsername: 'JonathanMast', + slug: 'will-josh-shapiro-win-the-2022-penn', + isWinRepublican: false, + }, + { + state: 'CO', + creatorUsername: 'ScottLawrence', + slug: 'will-jared-polis-be-reelected-as-co', + isWinRepublican: false, + }, + { + state: 'OR', + creatorUsername: 'Tetraspace', + slug: 'if-tina-kotek-is-the-2022-democrati', + isWinRepublican: false, + }, + { + state: 'MD', + creatorUsername: 'Tetraspace', + slug: 'if-wes-moore-is-the-2022-democratic', + isWinRepublican: false, + }, + { + state: 'AK', + creatorUsername: 'SG', + slug: 'will-a-republican-win-the-2022-alas', + isWinRepublican: true, + }, + { + state: 'AZ', + creatorUsername: 'SG', + slug: 'will-a-republican-win-the-2022-ariz', + isWinRepublican: true, + }, + { + state: 'AZ', + creatorUsername: 'SG', + slug: 'will-a-republican-win-the-2022-ariz', + isWinRepublican: true, + }, + { + state: 'WI', + creatorUsername: 'SG', + slug: 'will-a-democrat-win-the-2022-wiscon', + isWinRepublican: false, + }, + { + state: 'NV', + creatorUsername: 'SG', + slug: 'will-a-democrat-win-the-2022-nevada', + isWinRepublican: false, + }, + { + state: 'KS', + creatorUsername: 'SG', + slug: 'will-a-democrat-win-the-2022-kansas', + isWinRepublican: false, + }, + { + state: 'NV', + creatorUsername: 'SG', + slug: 'will-a-democrat-win-the-2022-new-me', + isWinRepublican: false, + }, + { + state: 'ME', + creatorUsername: 'SG', + slug: 'will-a-democrat-win-the-2022-maine', + isWinRepublican: false, + }, +] + const App = () => { return ( <Page className=""> <Col className="items-center justify-center"> - <Title text="2022 US Senate Midterms" className="mt-8" /> + <Title text="2022 US Midterm Elections" className="mt-2" /> + <div className="mt-2 text-2xl">Senate</div> <StateElectionMap markets={senateMidterms} /> - <iframe - src="https://manifold.markets/embed/NathanpmYoung/will-the-democrats-control-the-sena" + src="https://manifold.markets/TomShlomi/will-the-gop-control-the-us-senate" title="Will the Democrats control the Senate after the Midterms?" frameBorder="0" width={800} height={400} className="mt-8" ></iframe> - - <div className="mt-8 text-2xl">Related markets</div> + <Spacer h={8} /> + <div className="mt-8 text-2xl">Governors</div> + <StateElectionMap markets={governorMidterms} /> + <iframe + src="https://manifold.markets/ManifoldMarkets/democrats-go-down-at-least-one-gove" + title="Democrats go down at least one governor on net in 2022" + frameBorder="0" + width={800} + height={400} + className="mt-8" + ></iframe> + <Spacer h={8} /> + <div className="mt-8 text-2xl">House</div> <iframe src="https://manifold.markets/BoltonBailey/will-democrats-maintain-control-of" title="Will the Democrats control the House after the Midterms?" @@ -100,7 +211,16 @@ const App = () => { height={400} className="mt-8" ></iframe> - + <Spacer h={8} /> + <div className="mt-8 text-2xl">Related markets</div> + <iframe + src="https://manifold.markets/BoltonBailey/balance-of-power-in-us-congress-aft" + title="Balance of Power in US Congress after 2022 Midterms" + frameBorder="0" + width={800} + height={400} + className="mt-8" + ></iframe> <iframe src="https://manifold.markets/SG/will-a-democrat-win-the-2024-us-pre" title="Will a Democrat win the 2024 US presidential election?" @@ -109,6 +229,23 @@ const App = () => { height={400} className="mt-8" ></iframe> + <iframe + src="https://manifold.markets/Ibozz91/will-the-2022-alaska-house-general" + title="Will the 2022 Alaska House General Nonspecial Election result in a Condorcet failure?" + frameBorder="0" + width={800} + height={400} + className="mt-8" + ></iframe> + + <iframe + src="https://manifold.markets/NathanpmYoung/how-many-supreme-court-justices-wil-1e597c3853ad" + title="Will the 2022 Alaska House General Nonspecial Election result in a Condorcet failure?" + frameBorder="0" + width={800} + height={400} + className="mt-8" + ></iframe> </Col> </Page> ) From 043b18da0e2d4dc5de06027d07e7e6bb6c430b8a Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Sun, 2 Oct 2022 15:13:01 -0500 Subject: [PATCH 115/135] Add referral link to your user page --- web/components/user-page.tsx | 31 +++++++++++++++++++++++++++++- web/pages/date-docs/[username].tsx | 4 ++-- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/web/components/user-page.tsx b/web/components/user-page.tsx index f9f77cf6..48b617ab 100644 --- a/web/components/user-page.tsx +++ b/web/components/user-page.tsx @@ -8,13 +8,14 @@ import { PencilIcon, ScaleIcon, } from '@heroicons/react/outline' +import toast from 'react-hot-toast' import { User } from 'web/lib/firebase/users' import { useUser } from 'web/hooks/use-user' import { CreatorContractsList } from './contract/contracts-grid' import { SEO } from './SEO' import { Page } from './page' -import { SiteLink } from './site-link' +import { linkClass, SiteLink } from './site-link' import { Avatar } from './avatar' import { Col } from './layout/col' import { Linkify } from './linkify' @@ -35,6 +36,9 @@ import { hasCompletedStreakToday, } from 'web/components/profile/betting-streak-modal' import { LoansModal } from './profile/loans-modal' +import { copyToClipboard } from 'web/lib/util/copy' +import { track } from 'web/lib/service/analytics' +import { DOMAIN } from 'common/envs/constants' export function UserPage(props: { user: User }) { const { user } = props @@ -63,6 +67,7 @@ export function UserPage(props: { user: User }) { }, []) const profit = user.profitCached.allTime + const referralUrl = `https://${DOMAIN}?referrer=${user?.username}` return ( <Page key={user.id}> @@ -184,6 +189,30 @@ export function UserPage(props: { user: User }) { </Row> </SiteLink> )} + + {isCurrentUser && ( + <div + className={clsx( + linkClass, + 'text-greyscale-4 cursor-pointer text-sm' + )} + onClick={(e) => { + e.preventDefault() + copyToClipboard(referralUrl) + toast.success('Referral link copied!', { + icon: ( + <LinkIcon className="mr-2 h-6 w-6" aria-hidden="true" /> + ), + }) + track('copy referral link') + }} + > + <Row className="items-center gap-1"> + <LinkIcon className="h-4 w-4" /> + Earn M$250 per referral + </Row> + </div> + )} </Row> )} <QueryUncontrolledTabs diff --git a/web/pages/date-docs/[username].tsx b/web/pages/date-docs/[username].tsx index 17a41445..350e79b7 100644 --- a/web/pages/date-docs/[username].tsx +++ b/web/pages/date-docs/[username].tsx @@ -2,6 +2,8 @@ import { getDateDoc } from 'web/lib/firebase/posts' import { ArrowLeftIcon, LinkIcon } from '@heroicons/react/outline' import { Page } from 'web/components/page' import dayjs from 'dayjs' +import toast from 'react-hot-toast' +import clsx from 'clsx' import { DateDoc } from 'common/post' import { Content } from 'web/components/editor' @@ -12,10 +14,8 @@ import { User } from 'web/lib/firebase/users' import { DOMAIN } from 'common/envs/constants' import Custom404 from '../404' import { ShareIcon } from '@heroicons/react/solid' -import clsx from 'clsx' import { Button } from 'web/components/button' import { track } from '@amplitude/analytics-browser' -import toast from 'react-hot-toast' import { copyToClipboard } from 'web/lib/util/copy' import { useUser } from 'web/hooks/use-user' import { PostCommentsActivity, RichEditPost } from '../post/[...slugs]' From 7bf59bcdd0861daa5242cc8c1d4ae1ccea79fece Mon Sep 17 00:00:00 2001 From: jahooma <jahooma@users.noreply.github.com> Date: Sun, 2 Oct 2022 20:15:07 +0000 Subject: [PATCH 116/135] Auto-prettification --- web/pages/labs/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/pages/labs/index.tsx b/web/pages/labs/index.tsx index 6b39c59c..bb02c2b5 100644 --- a/web/pages/labs/index.tsx +++ b/web/pages/labs/index.tsx @@ -41,7 +41,7 @@ export default function LabsPage() { description="Browse dating docs or create your own" href="/date-docs" /> - + <LabCard title="Magic the Guessering" description="Match MTG card names to their art" From 42b27fcedd64155078a559cfc82294834ae3881b Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Sun, 2 Oct 2022 15:13:31 -0500 Subject: [PATCH 117/135] update midterms dashboard --- web/pages/midterms.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/web/pages/midterms.tsx b/web/pages/midterms.tsx index d1dfb509..99c56e85 100644 --- a/web/pages/midterms.tsx +++ b/web/pages/midterms.tsx @@ -1,6 +1,7 @@ import { Col } from 'web/components/layout/col' import { Spacer } from 'web/components/layout/spacer' import { Page } from 'web/components/page' +import { SEO } from 'web/components/SEO' import { Title } from 'web/components/title' import { StateElectionMarket, From 9ecf10496c1d25161a4d65848a72a0b435fb551d Mon Sep 17 00:00:00 2001 From: mantikoros <mantikoros@users.noreply.github.com> Date: Sun, 2 Oct 2022 20:17:27 +0000 Subject: [PATCH 118/135] Auto-remove unused imports --- web/pages/midterms.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/web/pages/midterms.tsx b/web/pages/midterms.tsx index 99c56e85..d1dfb509 100644 --- a/web/pages/midterms.tsx +++ b/web/pages/midterms.tsx @@ -1,7 +1,6 @@ import { Col } from 'web/components/layout/col' import { Spacer } from 'web/components/layout/spacer' import { Page } from 'web/components/page' -import { SEO } from 'web/components/SEO' import { Title } from 'web/components/title' import { StateElectionMarket, From 4d996c2476821911807b3bcab9806301b239a53e Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Sun, 2 Oct 2022 15:20:46 -0500 Subject: [PATCH 119/135] Margin tweak --- web/components/user-page.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/web/components/user-page.tsx b/web/components/user-page.tsx index 48b617ab..dea7036d 100644 --- a/web/components/user-page.tsx +++ b/web/components/user-page.tsx @@ -200,9 +200,7 @@ export function UserPage(props: { user: User }) { e.preventDefault() copyToClipboard(referralUrl) toast.success('Referral link copied!', { - icon: ( - <LinkIcon className="mr-2 h-6 w-6" aria-hidden="true" /> - ), + icon: <LinkIcon className="h-6 w-6" aria-hidden="true" />, }) track('copy referral link') }} From 234820ecd4f64dde79c8e25a6c667124e72592a5 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Sun, 2 Oct 2022 15:24:02 -0500 Subject: [PATCH 120/135] Add /labs SEO --- web/pages/labs/index.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/web/pages/labs/index.tsx b/web/pages/labs/index.tsx index bb02c2b5..6fd15b95 100644 --- a/web/pages/labs/index.tsx +++ b/web/pages/labs/index.tsx @@ -2,12 +2,18 @@ import { CHALLENGES_ENABLED } from 'common/challenge' import Masonry from 'react-masonry-css' import { Col } from 'web/components/layout/col' import { Page } from 'web/components/page' +import { SEO } from 'web/components/SEO' import { SiteLink } from 'web/components/site-link' import { Title } from 'web/components/title' export default function LabsPage() { return ( <Page> + <SEO + title="Manifold labs" + description="Cool experimental features for you to check out!" + url="/labs" + /> <Col className="px-4"> <Title className="sm:!mt-0" text="Manifold Labs" /> From 39638a38884d82c41a177e9321f3942c7bf7141f Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Sun, 2 Oct 2022 15:27:29 -0500 Subject: [PATCH 121/135] Update mtg link --- web/pages/labs/index.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/web/pages/labs/index.tsx b/web/pages/labs/index.tsx index 6fd15b95..890648b0 100644 --- a/web/pages/labs/index.tsx +++ b/web/pages/labs/index.tsx @@ -1,4 +1,5 @@ import { CHALLENGES_ENABLED } from 'common/challenge' +import { DOMAIN } from 'common/envs/constants' import Masonry from 'react-masonry-css' import { Col } from 'web/components/layout/col' import { Page } from 'web/components/page' @@ -51,7 +52,7 @@ export default function LabsPage() { <LabCard title="Magic the Guessering" description="Match MTG card names to their art" - href="/mtg" + href={`https://${DOMAIN}/mtg/index.html`} /> <LabCard title="Cowp" description="???" href="/cowp" /> From 11bd658c68551504fcf99e8998c408487cd6db9d Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Sun, 2 Oct 2022 16:47:46 -0500 Subject: [PATCH 122/135] hide comment sort, trade tab if no items --- web/components/contract/contract-tabs.tsx | 38 ++++++++++++----------- 1 file changed, 20 insertions(+), 18 deletions(-) diff --git a/web/components/contract/contract-tabs.tsx b/web/components/contract/contract-tabs.tsx index 1a67d86a..bf18fdbe 100644 --- a/web/components/contract/contract-tabs.tsx +++ b/web/components/contract/contract-tabs.tsx @@ -53,7 +53,7 @@ export function ContractTabs(props: { title: 'Comments', content: <CommentsTabContent contract={contract} comments={comments} />, }, - { + bets.length > 0 && { title: capitalize(PAST_BETS), content: <BetsTabContent contract={contract} bets={bets} />, }, @@ -150,25 +150,27 @@ const CommentsTabContent = memo(function CommentsTabContent(props: { <> <ContractCommentInput className="mb-5" contract={contract} /> - <Row className="mb-4 items-center"> - <Button - size={'xs'} - color={'gray-white'} - onClick={() => setSort(sort === 'Newest' ? 'Best' : 'Newest')} - > - <Tooltip - text={ - sort === 'Best' - ? 'Highest tips + bounties first. Your new comments briefly appear to you first.' - : '' - } + {comments.length > 0 && ( + <Row className="mb-4 items-center"> + <Button + size={'xs'} + color={'gray-white'} + onClick={() => setSort(sort === 'Newest' ? 'Best' : 'Newest')} > - Sorted by: {sort} - </Tooltip> - </Button> + <Tooltip + text={ + sort === 'Best' + ? 'Highest tips + bounties first. Your new comments briefly appear to you first.' + : '' + } + > + Sort by: {sort} + </Tooltip> + </Button> - <BountiedContractSmallBadge contract={contract} showAmount /> - </Row> + <BountiedContractSmallBadge contract={contract} showAmount /> + </Row> + )} {topLevelComments.map((parent) => ( <FeedCommentThread From 2c223160ed07f22845fb0a47c73c3731a66b9003 Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Sun, 2 Oct 2022 16:57:57 -0500 Subject: [PATCH 123/135] comment button styling --- web/components/comment-input.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/components/comment-input.tsx b/web/components/comment-input.tsx index 3ba6f2ce..6304b58d 100644 --- a/web/components/comment-input.tsx +++ b/web/components/comment-input.tsx @@ -126,7 +126,7 @@ export function CommentInputTextArea(props: { <TextEditor editor={editor} upload={upload}> {user && !isSubmitting && ( <button - className="btn btn-ghost btn-sm disabled:bg-inherit! px-2 disabled:text-gray-300" + className="px-2 text-gray-400 hover:text-gray-500 disabled:bg-inherit disabled:text-gray-300" disabled={!editor || editor.isEmpty} onClick={submit} > From 8c1131ebab70572abeedaa28547757d88d5520e3 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Sun, 2 Oct 2022 17:04:04 -0500 Subject: [PATCH 124/135] Tweak home search bar spacing on mobile --- web/pages/home/index.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/web/pages/home/index.tsx b/web/pages/home/index.tsx index fdfa620d..b00d3f2f 100644 --- a/web/pages/home/index.tsx +++ b/web/pages/home/index.tsx @@ -96,11 +96,13 @@ export default function Home() { <Toaster /> <Col className="pm:mx-10 gap-4 px-4 pb-8 pt-4 sm:pt-0"> - <Row className={'mb-2 w-full items-center justify-between gap-8'}> + <Row + className={'mb-2 w-full items-center justify-between gap-4 sm:gap-8'} + > <input type="text" placeholder={'Search'} - className="input input-bordered w-full sm:flex" + className="input input-bordered w-full" onClick={() => Router.push('/search')} /> <CustomizeButton justIcon /> From efb9ef760247d4b89ca056ea90530a87b2bfdb64 Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Sun, 2 Oct 2022 17:04:20 -0500 Subject: [PATCH 125/135] add padding to embeds --- web/pages/embed/[username]/[contractSlug].tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/pages/embed/[username]/[contractSlug].tsx b/web/pages/embed/[username]/[contractSlug].tsx index 03bc4ce9..585a45ae 100644 --- a/web/pages/embed/[username]/[contractSlug].tsx +++ b/web/pages/embed/[username]/[contractSlug].tsx @@ -114,7 +114,7 @@ function ContractSmolView({ contract, bets }: EmbedProps) { const [probAfter, setProbAfter] = useState<number>() return ( - <Col className="h-[100vh] w-full bg-white"> + <Col className="h-[100vh] w-full bg-white p-4"> <Row className="justify-between gap-4 px-2"> <div className="text-xl text-indigo-700 md:text-2xl"> <SiteLink href={href}>{question}</SiteLink> From 86ceea831b61063f330fd33a842be3ac29c36075 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Sun, 2 Oct 2022 17:10:18 -0500 Subject: [PATCH 126/135] Add stats to /labs --- web/pages/labs/index.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/web/pages/labs/index.tsx b/web/pages/labs/index.tsx index 890648b0..e7dd08f1 100644 --- a/web/pages/labs/index.tsx +++ b/web/pages/labs/index.tsx @@ -49,6 +49,12 @@ export default function LabsPage() { href="/date-docs" /> + <LabCard + title="Stats" + description="Check up on Manifold's usage stats" + href="/stats" + /> + <LabCard title="Magic the Guessering" description="Match MTG card names to their art" From 40c51c3d59f3ea44dc74f5b62a75907d8e027690 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Sun, 2 Oct 2022 17:14:11 -0500 Subject: [PATCH 127/135] Add emojis to /labs --- web/pages/labs/index.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/web/pages/labs/index.tsx b/web/pages/labs/index.tsx index e7dd08f1..bd1dbb35 100644 --- a/web/pages/labs/index.tsx +++ b/web/pages/labs/index.tsx @@ -24,44 +24,44 @@ export default function LabsPage() { columnClassName="pl-4 bg-clip-padding" > <LabCard - title="US 2022 Midterms" + title="๐Ÿ‡บ๐Ÿ‡ธ US 2022 Midterms" description="See Manifold's state-by-state breakdown of senate and governor races" href="/midterms" /> {CHALLENGES_ENABLED && ( <LabCard - title="Challenges" + title="๐Ÿ’ฅ Challenges" description="One-on-one bets between friends" href="/challenges" /> )} <LabCard - title="Manalinks" + title="๐Ÿ’ธ Manalinks" description="Send M$ to anyone" href="/links" /> <LabCard - title="Dating docs" + title="๐Ÿ’Œ Dating docs" description="Browse dating docs or create your own" href="/date-docs" /> <LabCard - title="Stats" + title="๐Ÿ“ˆ Stats" description="Check up on Manifold's usage stats" href="/stats" /> <LabCard - title="Magic the Guessering" + title="๐ŸŽฒ Magic the Guessering" description="Match MTG card names to their art" href={`https://${DOMAIN}/mtg/index.html`} /> - <LabCard title="Cowp" description="???" href="/cowp" /> + <LabCard title="๐Ÿฎ Cowp" description="???" href="/cowp" /> </Masonry> </Col> </Page> From 1f8c72b4c9270203477f507a8e7049e1292e6bc8 Mon Sep 17 00:00:00 2001 From: FRC <pico2x@gmail.com> Date: Mon, 3 Oct 2022 00:02:31 +0100 Subject: [PATCH 128/135] Overview page on groups (#961) * Frontpage on groups wip * Fix James's nits --- common/group.ts | 1 + firestore.rules | 2 +- functions/src/create-group.ts | 1 + functions/src/scripts/convert-tag-to-group.ts | 1 + web/components/contract-search.tsx | 7 +- web/components/contract-select-modal.tsx | 2 +- web/components/contract/contracts-grid.tsx | 8 +- ...about-post.tsx => group-overview-post.tsx} | 2 +- web/components/groups/group-overview.tsx | 378 ++++++++++++++++++ web/components/pinned-select-modal.tsx | 164 ++++++++ web/components/post-card.tsx | 82 ++++ web/pages/group/[...slugs]/index.tsx | 198 ++------- 12 files changed, 668 insertions(+), 178 deletions(-) rename web/components/groups/{group-about-post.tsx => group-overview-post.tsx} (98%) create mode 100644 web/components/groups/group-overview.tsx create mode 100644 web/components/pinned-select-modal.tsx create mode 100644 web/components/post-card.tsx diff --git a/common/group.ts b/common/group.ts index 5220a1e8..8f5728d3 100644 --- a/common/group.ts +++ b/common/group.ts @@ -23,6 +23,7 @@ export type Group = { score: number }[] } + pinnedItems: { itemId: string; type: 'post' | 'contract' }[] } export const MAX_GROUP_NAME_LENGTH = 75 diff --git a/firestore.rules b/firestore.rules index 26649fa6..bf0375e6 100644 --- a/firestore.rules +++ b/firestore.rules @@ -176,7 +176,7 @@ service cloud.firestore { allow update: if (request.auth.uid == resource.data.creatorId || isAdmin()) && request.resource.data.diff(resource.data) .affectedKeys() - .hasOnly(['name', 'about', 'anyoneCanJoin', 'aboutPostId' ]); + .hasOnly(['name', 'about', 'anyoneCanJoin', 'aboutPostId', 'pinnedItems' ]); allow delete: if request.auth.uid == resource.data.creatorId; match /groupContracts/{contractId} { diff --git a/functions/src/create-group.ts b/functions/src/create-group.ts index 76dc1298..4b3f7446 100644 --- a/functions/src/create-group.ts +++ b/functions/src/create-group.ts @@ -62,6 +62,7 @@ export const creategroup = newEndpoint({}, async (req, auth) => { totalContracts: 0, totalMembers: memberIds.length, postIds: [], + pinnedItems: [], } await groupRef.create(group) diff --git a/functions/src/scripts/convert-tag-to-group.ts b/functions/src/scripts/convert-tag-to-group.ts index b2e4c4d8..e1330fe1 100644 --- a/functions/src/scripts/convert-tag-to-group.ts +++ b/functions/src/scripts/convert-tag-to-group.ts @@ -42,6 +42,7 @@ const createGroup = async ( totalContracts: contracts.length, totalMembers: 1, postIds: [], + pinnedItems: [], } await groupRef.create(group) // create a GroupMemberDoc for the creator diff --git a/web/components/contract-search.tsx b/web/components/contract-search.tsx index ba589d0e..43f17599 100644 --- a/web/components/contract-search.tsx +++ b/web/components/contract-search.tsx @@ -3,10 +3,7 @@ import { SearchOptions } from '@algolia/client-search' import { useRouter } from 'next/router' import { Contract } from 'common/contract' import { PAST_BETS, User } from 'common/user' -import { - ContractHighlightOptions, - ContractsGrid, -} from './contract/contracts-grid' +import { CardHighlightOptions, ContractsGrid } from './contract/contracts-grid' import { ShowTime } from './contract/contract-details' import { Row } from './layout/row' import { @@ -82,7 +79,7 @@ export function ContractSearch(props: { defaultFilter?: filter defaultPill?: string additionalFilter?: AdditionalFilter - highlightOptions?: ContractHighlightOptions + highlightOptions?: CardHighlightOptions onContractClick?: (contract: Contract) => void hideOrderSelector?: boolean cardUIOptions?: { diff --git a/web/components/contract-select-modal.tsx b/web/components/contract-select-modal.tsx index ea08de01..ea0505a8 100644 --- a/web/components/contract-select-modal.tsx +++ b/web/components/contract-select-modal.tsx @@ -91,7 +91,7 @@ export function SelectMarketsModal(props: { noLinkAvatar: true, }} highlightOptions={{ - contractIds: contracts.map((c) => c.id), + itemIds: contracts.map((c) => c.id), highlightClassName: '!bg-indigo-100 outline outline-2 outline-indigo-300', }} diff --git a/web/components/contract/contracts-grid.tsx b/web/components/contract/contracts-grid.tsx index d6c9c5fa..d6206766 100644 --- a/web/components/contract/contracts-grid.tsx +++ b/web/components/contract/contracts-grid.tsx @@ -12,8 +12,8 @@ import { VisibilityObserver } from '../visibility-observer' import Masonry from 'react-masonry-css' import { CPMMBinaryContract } from 'common/contract' -export type ContractHighlightOptions = { - contractIds?: string[] +export type CardHighlightOptions = { + itemIds?: string[] highlightClassName?: string } @@ -28,7 +28,7 @@ export function ContractsGrid(props: { noLinkAvatar?: boolean showProbChange?: boolean } - highlightOptions?: ContractHighlightOptions + highlightOptions?: CardHighlightOptions trackingPostfix?: string breakpointColumns?: { [key: string]: number } }) { @@ -43,7 +43,7 @@ export function ContractsGrid(props: { } = props const { hideQuickBet, hideGroupLink, noLinkAvatar, showProbChange } = cardUIOptions || {} - const { contractIds, highlightClassName } = highlightOptions || {} + const { itemIds: contractIds, highlightClassName } = highlightOptions || {} const onVisibilityUpdated = useCallback( (visible) => { if (visible && loadMore) { diff --git a/web/components/groups/group-about-post.tsx b/web/components/groups/group-overview-post.tsx similarity index 98% rename from web/components/groups/group-about-post.tsx rename to web/components/groups/group-overview-post.tsx index 4d3046e9..55f0efca 100644 --- a/web/components/groups/group-about-post.tsx +++ b/web/components/groups/group-overview-post.tsx @@ -13,7 +13,7 @@ import { deletePost, updatePost } from 'web/lib/firebase/posts' import { useState } from 'react' import { usePost } from 'web/hooks/use-post' -export function GroupAboutPost(props: { +export function GroupOverviewPost(props: { group: Group isEditable: boolean post: Post | null diff --git a/web/components/groups/group-overview.tsx b/web/components/groups/group-overview.tsx new file mode 100644 index 00000000..9b0f7240 --- /dev/null +++ b/web/components/groups/group-overview.tsx @@ -0,0 +1,378 @@ +import { track } from '@amplitude/analytics-browser' +import { + ArrowSmRightIcon, + PlusCircleIcon, + XCircleIcon, +} from '@heroicons/react/outline' + +import PencilIcon from '@heroicons/react/solid/PencilIcon' + +import { Contract } from 'common/contract' +import { Group } from 'common/group' +import { Post } from 'common/post' +import { useEffect, useState } from 'react' +import { ReactNode } from 'react' +import { getPost } from 'web/lib/firebase/posts' +import { ContractSearch } from '../contract-search' +import { ContractCard } from '../contract/contract-card' + +import Masonry from 'react-masonry-css' + +import { Col } from '../layout/col' +import { Row } from '../layout/row' +import { SiteLink } from '../site-link' +import { GroupOverviewPost } from './group-overview-post' +import { getContractFromId } from 'web/lib/firebase/contracts' +import { groupPath, updateGroup } from 'web/lib/firebase/groups' +import { PinnedSelectModal } from '../pinned-select-modal' +import { Button } from '../button' +import { User } from 'common/user' +import { UserLink } from '../user-link' +import { EditGroupButton } from './edit-group-button' +import { JoinOrLeaveGroupButton } from './groups-button' +import { Linkify } from '../linkify' +import { ChoicesToggleGroup } from '../choices-toggle-group' +import { CopyLinkButton } from '../copy-link-button' +import { REFERRAL_AMOUNT } from 'common/economy' +import toast from 'react-hot-toast' +import { ENV_CONFIG } from 'common/envs/constants' +import { PostCard } from '../post-card' + +const MAX_TRENDING_POSTS = 6 + +export function GroupOverview(props: { + group: Group + isEditable: boolean + posts: Post[] + aboutPost: Post | null + creator: User + user: User | null | undefined + memberIds: string[] +}) { + const { group, isEditable, posts, aboutPost, creator, user, memberIds } = + props + return ( + <Col className="pm:mx-10 gap-4 px-4 pb-12 pt-4 sm:pt-0"> + <GroupOverviewPinned + group={group} + posts={posts} + isEditable={isEditable} + /> + + {(group.aboutPostId != null || isEditable) && ( + <> + <SectionHeader label={'About'} href={'/post/' + group.slug} /> + <GroupOverviewPost + group={group} + isEditable={isEditable} + post={aboutPost} + /> + </> + )} + <SectionHeader label={'Trending'} /> + <ContractSearch + user={user} + defaultSort={'score'} + noControls + maxResults={MAX_TRENDING_POSTS} + defaultFilter={'all'} + additionalFilter={{ groupSlug: group.slug }} + persistPrefix={`group-trending-${group.slug}`} + /> + <GroupAbout + group={group} + creator={creator} + isEditable={isEditable} + user={user} + memberIds={memberIds} + /> + </Col> + ) +} + +function GroupOverviewPinned(props: { + group: Group + posts: Post[] + isEditable: boolean +}) { + const { group, posts, isEditable } = props + const [pinned, setPinned] = useState<JSX.Element[]>([]) + const [open, setOpen] = useState(false) + const [editMode, setEditMode] = useState(false) + + useEffect(() => { + async function getPinned() { + if (group.pinnedItems == null) { + updateGroup(group, { pinnedItems: [] }) + } else { + const itemComponents = await Promise.all( + group.pinnedItems.map(async (element) => { + if (element.type === 'post') { + const post = await getPost(element.itemId) + if (post) { + return <PostCard post={post as Post} /> + } + } else if (element.type === 'contract') { + const contract = await getContractFromId(element.itemId) + if (contract) { + return <ContractCard contract={contract as Contract} /> + } + } + }) + ) + setPinned( + itemComponents.filter( + (element) => element != undefined + ) as JSX.Element[] + ) + } + } + getPinned() + }, [group, group.pinnedItems]) + + async function onSubmit(selectedItems: { itemId: string; type: string }[]) { + await updateGroup(group, { + pinnedItems: [ + ...group.pinnedItems, + ...(selectedItems as { itemId: string; type: 'contract' | 'post' }[]), + ], + }) + setOpen(false) + } + + return isEditable || pinned.length > 0 ? ( + <> + <Row className="mb-3 items-center justify-between"> + <SectionHeader label={'Pinned'} /> + {isEditable && ( + <Button + color="gray" + size="xs" + onClick={() => { + setEditMode(!editMode) + }} + > + {editMode ? ( + 'Done' + ) : ( + <> + <PencilIcon className="inline h-4 w-4" /> + Edit + </> + )} + </Button> + )} + </Row> + <div> + <Masonry + breakpointCols={{ default: 2, 768: 1 }} + className="-ml-4 flex w-auto" + columnClassName="pl-4 bg-clip-padding" + > + {pinned.length == 0 && !editMode && ( + <div className="flex flex-col items-center justify-center"> + <p className="text-center text-gray-400"> + No pinned items yet. Click the edit button to add some! + </p> + </div> + )} + {pinned.map((element, index) => ( + <div className="relative my-2"> + {element} + + {editMode && ( + <CrossIcon + onClick={() => { + const newPinned = group.pinnedItems.filter((item) => { + return item.itemId !== group.pinnedItems[index].itemId + }) + updateGroup(group, { pinnedItems: newPinned }) + }} + /> + )} + </div> + ))} + {editMode && group.pinnedItems && pinned.length < 6 && ( + <div className=" py-2"> + <Row + className={ + 'relative gap-3 rounded-lg border-4 border-dotted p-2 hover:cursor-pointer hover:bg-gray-100' + } + > + <button + className="flex w-full justify-center" + onClick={() => setOpen(true)} + > + <PlusCircleIcon + className="h-12 w-12 text-gray-600" + aria-hidden="true" + /> + </button> + </Row> + </div> + )} + </Masonry> + </div> + <PinnedSelectModal + open={open} + group={group} + posts={posts} + setOpen={setOpen} + title="Pin a post or market" + description={ + <div className={'text-md my-4 text-gray-600'}> + Pin posts or markets to the overview of this group. + </div> + } + onSubmit={onSubmit} + /> + </> + ) : ( + <></> + ) +} + +function SectionHeader(props: { + label: string + href?: string + children?: ReactNode +}) { + const { label, href, children } = props + const content = ( + <> + {label}{' '} + <ArrowSmRightIcon + className="mb-0.5 inline h-6 w-6 text-gray-500" + aria-hidden="true" + /> + </> + ) + + return ( + <Row className="mb-3 items-center justify-between"> + {href ? ( + <SiteLink + className="text-xl" + href={href} + onClick={() => track('group click section header', { section: href })} + > + {content} + </SiteLink> + ) : ( + <span className="text-xl">{content}</span> + )} + {children} + </Row> + ) +} + +export function GroupAbout(props: { + group: Group + creator: User + user: User | null | undefined + isEditable: boolean + memberIds: string[] +}) { + const { group, creator, isEditable, user, memberIds } = props + const anyoneCanJoinChoices: { [key: string]: string } = { + Closed: 'false', + Open: 'true', + } + const [anyoneCanJoin, setAnyoneCanJoin] = useState(group.anyoneCanJoin) + function updateAnyoneCanJoin(newVal: boolean) { + if (group.anyoneCanJoin == newVal || !isEditable) return + setAnyoneCanJoin(newVal) + toast.promise(updateGroup(group, { ...group, anyoneCanJoin: newVal }), { + loading: 'Updating group...', + success: 'Updated group!', + error: "Couldn't update group", + }) + } + const postFix = user ? '?referrer=' + user.username : '' + const shareUrl = `https://${ENV_CONFIG.domain}${groupPath( + group.slug + )}${postFix}` + const isMember = user ? memberIds.includes(user.id) : false + + return ( + <> + <Col className="gap-2 rounded-b bg-white p-2"> + <Row className={'flex-wrap justify-between'}> + <div className={'inline-flex items-center'}> + <div className="mr-1 text-gray-500">Created by</div> + <UserLink + className="text-neutral" + name={creator.name} + username={creator.username} + /> + </div> + {isEditable ? ( + <EditGroupButton className={'ml-1'} group={group} /> + ) : ( + user && ( + <Row> + <JoinOrLeaveGroupButton + group={group} + user={user} + isMember={isMember} + /> + </Row> + ) + )} + </Row> + <div className={'block sm:hidden'}> + <Linkify text={group.about} /> + </div> + <Row className={'items-center gap-1'}> + <span className={'text-gray-500'}>Membership</span> + {user && user.id === creator.id ? ( + <ChoicesToggleGroup + currentChoice={anyoneCanJoin.toString()} + choicesMap={anyoneCanJoinChoices} + setChoice={(choice) => + updateAnyoneCanJoin(choice.toString() === 'true') + } + toggleClassName={'h-10'} + className={'ml-2'} + /> + ) : ( + <span className={'text-gray-700'}> + {anyoneCanJoin ? 'Open to all' : 'Closed (by invite only)'} + </span> + )} + </Row> + + {anyoneCanJoin && user && ( + <Col className="my-4 px-2"> + <div className="text-lg">Invite</div> + <div className={'mb-2 text-gray-500'}> + Invite a friend to this group and get M${REFERRAL_AMOUNT} if they + sign up! + </div> + + <CopyLinkButton + url={shareUrl} + tracking="copy group share link" + buttonClassName="btn-md rounded-l-none" + toastClassName={'-left-28 mt-1'} + /> + </Col> + )} + </Col> + </> + ) +} + +function CrossIcon(props: { onClick: () => void }) { + const { onClick } = props + + return ( + <div> + <button className=" text-gray-500 hover:text-gray-700" onClick={onClick}> + <div className="absolute top-0 left-0 right-0 bottom-0 bg-gray-200 bg-opacity-50"> + <XCircleIcon className="h-12 w-12 text-gray-600" /> + </div> + </button> + </div> + ) +} diff --git a/web/components/pinned-select-modal.tsx b/web/components/pinned-select-modal.tsx new file mode 100644 index 00000000..98c91a7c --- /dev/null +++ b/web/components/pinned-select-modal.tsx @@ -0,0 +1,164 @@ +import { Contract } from 'common/contract' +import { Group } from 'common/group' +import { Post } from 'common/post' +import { useState } from 'react' +import { PostCardList } from 'web/pages/group/[...slugs]' +import { Button } from './button' +import { PillButton } from './buttons/pill-button' +import { ContractSearch } from './contract-search' +import { Col } from './layout/col' +import { Modal } from './layout/modal' +import { Row } from './layout/row' +import { LoadingIndicator } from './loading-indicator' + +export function PinnedSelectModal(props: { + title: string + description?: React.ReactNode + open: boolean + setOpen: (open: boolean) => void + onSubmit: ( + selectedItems: { itemId: string; type: string }[] + ) => void | Promise<void> + contractSearchOptions?: Partial<Parameters<typeof ContractSearch>[0]> + group: Group + posts: Post[] +}) { + const { + title, + description, + open, + setOpen, + onSubmit, + contractSearchOptions, + posts, + group, + } = props + + const [selectedItem, setSelectedItem] = useState<{ + itemId: string + type: string + } | null>(null) + const [loading, setLoading] = useState(false) + const [selectedTab, setSelectedTab] = useState<'contracts' | 'posts'>('posts') + + async function selectContract(contract: Contract) { + selectItem(contract.id, 'contract') + } + + async function selectPost(post: Post) { + selectItem(post.id, 'post') + } + + async function selectItem(itemId: string, type: string) { + setSelectedItem({ itemId: itemId, type: type }) + } + + async function onFinish() { + setLoading(true) + if (selectedItem) { + await onSubmit([ + { + itemId: selectedItem.itemId, + type: selectedItem.type, + }, + ]) + setLoading(false) + setOpen(false) + setSelectedItem(null) + } + } + + return ( + <Modal open={open} setOpen={setOpen} className={'sm:p-0'} size={'lg'}> + <Col className="h-[85vh] w-full gap-4 rounded-md bg-white"> + <div className="p-8 pb-0"> + <Row> + <div className={'text-xl text-indigo-700'}>{title}</div> + + {!loading && ( + <Row className="grow justify-end gap-4"> + {selectedItem && ( + <Button onClick={onFinish} color="indigo"> + Add to Pinned + </Button> + )} + <Button + onClick={() => { + setSelectedItem(null) + setOpen(false) + }} + color="gray" + > + Cancel + </Button> + </Row> + )} + </Row> + {description} + </div> + + {loading && ( + <div className="w-full justify-center"> + <LoadingIndicator /> + </div> + )} + <div> + <Row className="justify-center gap-4"> + <PillButton + onSelect={() => setSelectedTab('contracts')} + selected={selectedTab === 'contracts'} + > + Contracts + </PillButton> + <PillButton + onSelect={() => setSelectedTab('posts')} + selected={selectedTab === 'posts'} + > + Posts + </PillButton> + </Row> + </div> + + {selectedTab === 'contracts' ? ( + <div className="overflow-y-auto px-2 sm:px-8"> + <ContractSearch + hideOrderSelector + onContractClick={selectContract} + cardUIOptions={{ + hideGroupLink: true, + hideQuickBet: true, + noLinkAvatar: true, + }} + highlightOptions={{ + itemIds: [selectedItem?.itemId ?? ''], + highlightClassName: + '!bg-indigo-100 outline outline-2 outline-indigo-300', + }} + additionalFilter={{ groupSlug: group.slug }} + persistPrefix={`group-${group.slug}`} + headerClassName="bg-white sticky" + {...contractSearchOptions} + /> + </div> + ) : ( + <> + <div className="mt-2 px-2"> + <PostCardList + posts={posts} + onPostClick={selectPost} + highlightOptions={{ + itemIds: [selectedItem?.itemId ?? ''], + highlightClassName: + '!bg-indigo-100 outline outline-2 outline-indigo-300', + }} + /> + {posts.length === 0 && ( + <div className="text-center text-gray-500">No posts yet</div> + )} + </div> + </> + )} + </Col> + </Modal> + ) +} diff --git a/web/components/post-card.tsx b/web/components/post-card.tsx new file mode 100644 index 00000000..a20748eb --- /dev/null +++ b/web/components/post-card.tsx @@ -0,0 +1,82 @@ +import { track } from '@amplitude/analytics-browser' +import clsx from 'clsx' +import { Post } from 'common/post' +import Link from 'next/link' +import { useUserById } from 'web/hooks/use-user' +import { postPath } from 'web/lib/firebase/posts' +import { fromNow } from 'web/lib/util/time' +import { Avatar } from './avatar' +import { CardHighlightOptions } from './contract/contracts-grid' +import { Row } from './layout/row' +import { UserLink } from './user-link' + +export function PostCard(props: { + post: Post + onPostClick?: (post: Post) => void + highlightOptions?: CardHighlightOptions +}) { + const { post, onPostClick, highlightOptions } = props + const creatorId = post.creatorId + + const user = useUserById(creatorId) + const { itemIds: itemIds, highlightClassName } = highlightOptions || {} + + if (!user) return <> </> + + return ( + <div className="relative py-1"> + <Row + className={clsx( + ' relative gap-3 rounded-lg bg-white py-2 shadow-md hover:cursor-pointer hover:bg-gray-100', + itemIds?.includes(post.id) && highlightClassName + )} + > + <div className="flex-shrink-0"> + <Avatar className="h-12 w-12" username={user?.username} /> + </div> + <div className=""> + <div className="text-sm text-gray-500"> + <UserLink + className="text-neutral" + name={user?.name} + username={user?.username} + /> + <span className="mx-1">โ€ข</span> + <span className="text-gray-500">{fromNow(post.createdTime)}</span> + </div> + <div className="text-lg font-medium text-gray-900">{post.title}</div> + </div> + </Row> + {onPostClick ? ( + <a + className="absolute top-0 left-0 right-0 bottom-0" + onClick={(e) => { + // Let the browser handle the link click (opens in new tab). + if (e.ctrlKey || e.metaKey) return + + e.preventDefault() + track('select post card'), + { + slug: post.slug, + postId: post.id, + } + onPostClick(post) + }} + /> + ) : ( + <Link href={postPath(post.slug)}> + <a + onClick={() => { + track('select post card'), + { + slug: post.slug, + postId: post.id, + } + }} + className="absolute top-0 left-0 right-0 bottom-0" + /> + </Link> + )} + </div> + ) +} diff --git a/web/pages/group/[...slugs]/index.tsx b/web/pages/group/[...slugs]/index.tsx index a23ce602..658f1809 100644 --- a/web/pages/group/[...slugs]/index.tsx +++ b/web/pages/group/[...slugs]/index.tsx @@ -11,12 +11,11 @@ import { groupPath, joinGroup, listMemberIds, - updateGroup, } from 'web/lib/firebase/groups' import { Row } from 'web/components/layout/row' import { firebaseLogin, getUser, User } from 'web/lib/firebase/users' import { Col } from 'web/components/layout/col' -import { useUser, useUserById } from 'web/hooks/use-user' +import { useUser } from 'web/hooks/use-user' import { useGroup, useGroupContractIds, @@ -24,27 +23,17 @@ import { } from 'web/hooks/use-group' import { Leaderboard } from 'web/components/leaderboard' import { formatMoney } from 'common/util/format' -import { EditGroupButton } from 'web/components/groups/edit-group-button' import Custom404 from '../../404' import { SEO } from 'web/components/SEO' -import { Linkify } from 'web/components/linkify' import { fromPropz, usePropz } from 'web/hooks/use-propz' -import { ChoicesToggleGroup } from 'web/components/choices-toggle-group' import { ContractSearch } from 'web/components/contract-search' -import { JoinOrLeaveGroupButton } from 'web/components/groups/groups-button' -import { CopyLinkButton } from 'web/components/copy-link-button' -import { ENV_CONFIG } from 'common/envs/constants' import { useSaveReferral } from 'web/hooks/use-save-referral' import { Button } from 'web/components/button' import { listAllCommentsOnGroup } from 'web/lib/firebase/comments' import { GroupComment } from 'common/comment' -import { REFERRAL_AMOUNT } from 'common/economy' -import { UserLink } from 'web/components/user-link' -import { GroupAboutPost } from 'web/components/groups/group-about-post' -import { getPost, listPosts, postPath } from 'web/lib/firebase/posts' +import { getPost, listPosts } from 'web/lib/firebase/posts' import { Post } from 'common/post' -import { Spacer } from 'web/components/layout/spacer' import { usePost, usePosts } from 'web/hooks/use-post' import { useAdmin } from 'web/hooks/use-admin' import { track } from '@amplitude/analytics-browser' @@ -53,10 +42,11 @@ import { SelectMarketsModal } from 'web/components/contract-select-modal' import { BETTORS } from 'common/user' import { Page } from 'web/components/page' import { Tabs } from 'web/components/layout/tabs' -import { Avatar } from 'web/components/avatar' import { Title } from 'web/components/title' -import { fromNow } from 'web/lib/util/time' import { CreatePost } from 'web/components/create-post' +import { GroupOverview } from 'web/components/groups/group-overview' +import { CardHighlightOptions } from 'web/components/contract/contracts-grid' +import { PostCard } from 'web/components/post-card' export const getStaticProps = fromPropz(getStaticPropz) export async function getStaticPropz(props: { params: { slugs: string[] } }) { @@ -203,24 +193,18 @@ export default function GroupPage(props: { </> ) - const aboutTab = ( - <Col> - {(group.aboutPostId != null || isCreator || isAdmin) && ( - <GroupAboutPost - group={group} - isEditable={!!isCreator || isAdmin} - post={aboutPost} - /> - )} - <Spacer h={3} /> + const overviewPage = ( + <> <GroupOverview group={group} + posts={groupPosts} + isEditable={!!isCreator || isAdmin} + aboutPost={aboutPost} creator={creator} - isCreator={!!isCreator} user={user} memberIds={memberIds} /> - </Col> + </> ) const questionsTab = ( @@ -261,14 +245,14 @@ export default function GroupPage(props: { title: 'Leaderboards', content: leaderboardTab, }, - { - title: 'About', - content: aboutTab, - }, { title: 'Posts', content: postsPage, }, + { + title: 'Overview', + content: overviewPage, + }, ] return ( @@ -326,103 +310,6 @@ function JoinOrAddQuestionsButtons(props: { ) : null } -function GroupOverview(props: { - group: Group - creator: User - user: User | null | undefined - isCreator: boolean - memberIds: string[] -}) { - const { group, creator, isCreator, user, memberIds } = props - const anyoneCanJoinChoices: { [key: string]: string } = { - Closed: 'false', - Open: 'true', - } - const [anyoneCanJoin, setAnyoneCanJoin] = useState(group.anyoneCanJoin) - function updateAnyoneCanJoin(newVal: boolean) { - if (group.anyoneCanJoin == newVal || !isCreator) return - setAnyoneCanJoin(newVal) - toast.promise(updateGroup(group, { ...group, anyoneCanJoin: newVal }), { - loading: 'Updating group...', - success: 'Updated group!', - error: "Couldn't update group", - }) - } - const postFix = user ? '?referrer=' + user.username : '' - const shareUrl = `https://${ENV_CONFIG.domain}${groupPath( - group.slug - )}${postFix}` - const isMember = user ? memberIds.includes(user.id) : false - - return ( - <> - <Col className="gap-2 rounded-b bg-white p-2"> - <Row className={'flex-wrap justify-between'}> - <div className={'inline-flex items-center'}> - <div className="mr-1 text-gray-500">Created by</div> - <UserLink - className="text-neutral" - name={creator.name} - username={creator.username} - /> - </div> - {isCreator ? ( - <EditGroupButton className={'ml-1'} group={group} /> - ) : ( - user && ( - <Row> - <JoinOrLeaveGroupButton - group={group} - user={user} - isMember={isMember} - /> - </Row> - ) - )} - </Row> - <div className={'block sm:hidden'}> - <Linkify text={group.about} /> - </div> - <Row className={'items-center gap-1'}> - <span className={'text-gray-500'}>Membership</span> - {user && user.id === creator.id ? ( - <ChoicesToggleGroup - currentChoice={anyoneCanJoin.toString()} - choicesMap={anyoneCanJoinChoices} - setChoice={(choice) => - updateAnyoneCanJoin(choice.toString() === 'true') - } - toggleClassName={'h-10'} - className={'ml-2'} - /> - ) : ( - <span className={'text-gray-700'}> - {anyoneCanJoin ? 'Open to all' : 'Closed (by invite only)'} - </span> - )} - </Row> - - {anyoneCanJoin && user && ( - <Col className="my-4 px-2"> - <div className="text-lg">Invite</div> - <div className={'mb-2 text-gray-500'}> - Invite a friend to this group and get M${REFERRAL_AMOUNT} if they - sign up! - </div> - - <CopyLinkButton - url={shareUrl} - tracking="copy group share link" - buttonClassName="btn-md rounded-l-none" - toastClassName={'-left-28 mt-1'} - /> - </Col> - )} - </Col> - </> - ) -} - function GroupLeaderboard(props: { topUsers: { user: User; score: number }[] title: string @@ -449,7 +336,7 @@ function GroupLeaderboard(props: { ) } -function GroupPosts(props: { posts: Post[]; group: Group }) { +export function GroupPosts(props: { posts: Post[]; group: Group }) { const { posts, group } = props const [showCreatePost, setShowCreatePost] = useState(false) const user = useUser() @@ -475,9 +362,7 @@ function GroupPosts(props: { posts: Post[]; group: Group }) { </Row> <div className="mt-2"> - {posts.map((post) => ( - <PostCard key={post.id} post={post} /> - ))} + <PostCardList posts={posts} /> {posts.length === 0 && ( <div className="text-center text-gray-500">No posts yet</div> )} @@ -488,41 +373,22 @@ function GroupPosts(props: { posts: Post[]; group: Group }) { return showCreatePost ? createPost : postList } -function PostCard(props: { post: Post }) { - const { post } = props - const creatorId = post.creatorId - - const user = useUserById(creatorId) - - if (!user) return <> </> - +export function PostCardList(props: { + posts: Post[] + highlightOptions?: CardHighlightOptions + onPostClick?: (post: Post) => void +}) { + const { posts, onPostClick, highlightOptions } = props return ( - <div className="py-1"> - <Link href={postPath(post.slug)}> - <Row - className={ - 'relative gap-3 rounded-lg bg-white p-2 shadow-md hover:cursor-pointer hover:bg-gray-100' - } - > - <div className="flex-shrink-0"> - <Avatar className="h-12 w-12" username={user?.username} /> - </div> - <div className=""> - <div className="text-sm text-gray-500"> - <UserLink - className="text-neutral" - name={user?.name} - username={user?.username} - /> - <span className="mx-1">โ€ข</span> - <span className="text-gray-500">{fromNow(post.createdTime)}</span> - </div> - <div className="text-lg font-medium text-gray-900"> - {post.title} - </div> - </div> - </Row> - </Link> + <div className="w-full"> + {posts.map((post) => ( + <PostCard + key={post.id} + post={post} + onPostClick={onPostClick} + highlightOptions={highlightOptions} + /> + ))} </div> ) } From a82f447965c503c2284f373a3382506986fac8ea Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Sun, 2 Oct 2022 18:20:25 -0500 Subject: [PATCH 129/135] Fix free response comment threading --- web/components/contract/contract-tabs.tsx | 52 +++++++++++++---------- web/pages/labs/index.tsx | 2 +- 2 files changed, 30 insertions(+), 24 deletions(-) diff --git a/web/components/contract/contract-tabs.tsx b/web/components/contract/contract-tabs.tsx index bf18fdbe..33c66c57 100644 --- a/web/components/contract/contract-tabs.tsx +++ b/web/components/contract/contract-tabs.tsx @@ -77,13 +77,34 @@ const CommentsTabContent = memo(function CommentsTabContent(props: { const comments = useComments(contract.id) ?? props.comments const [sort, setSort] = useState<'Newest' | 'Best'>('Newest') const me = useUser() + if (comments == null) { return <LoadingIndicator /> } + + const tipsOrBountiesAwarded = + Object.keys(tips).length > 0 || comments.some((c) => c.bountiesAwarded) + + const sortedComments = sortBy(comments, (c) => + sort === 'Newest' + ? c.createdTime + : // Is this too magic? If there are tips/bounties, 'Best' shows your own comments made within the last 10 minutes first, then sorts by score + tipsOrBountiesAwarded && + c.createdTime > Date.now() - 10 * MINUTE_MS && + c.userId === me?.id + ? -Infinity + : -((c.bountiesAwarded ?? 0) + sum(Object.values(tips[c.id] ?? []))) + ) + + const commentsByParent = groupBy( + sortedComments, + (c) => c.replyToCommentId ?? '_' + ) + const topLevelComments = commentsByParent['_'] ?? [] + // Top level comments are reverse-chronological, while replies are chronological + if (sort === 'Newest') topLevelComments.reverse() + if (contract.outcomeType === 'FREE_RESPONSE') { - const generalComments = comments.filter( - (c) => c.answerOutcome === undefined && c.betId === undefined - ) const sortedAnswers = sortBy( contract.answers, (a) => -getOutcomeProbability(contract, a.id) @@ -92,6 +113,9 @@ const CommentsTabContent = memo(function CommentsTabContent(props: { comments, (c) => c.answerOutcome ?? c.betOutcome ?? '_' ) + const generalTopLevelComments = topLevelComments.filter( + (c) => c.answerOutcome === undefined && c.betId === undefined + ) return ( <> {sortedAnswers.map((answer) => ( @@ -115,12 +139,12 @@ const CommentsTabContent = memo(function CommentsTabContent(props: { <div className="text-md mt-8 mb-2 text-left">General Comments</div> <div className="mb-4 w-full border-b border-gray-200" /> <ContractCommentInput className="mb-5" contract={contract} /> - {generalComments.map((comment) => ( + {generalTopLevelComments.map((comment) => ( <FeedCommentThread key={comment.id} contract={contract} parentComment={comment} - threadComments={[]} + threadComments={commentsByParent[comment.id] ?? []} tips={tips} /> ))} @@ -128,24 +152,6 @@ const CommentsTabContent = memo(function CommentsTabContent(props: { </> ) } else { - const tipsOrBountiesAwarded = - Object.keys(tips).length > 0 || comments.some((c) => c.bountiesAwarded) - - const commentsByParent = groupBy( - sortBy(comments, (c) => - sort === 'Newest' - ? -c.createdTime - : // Is this too magic? If there are tips/bounties, 'Best' shows your own comments made within the last 10 minutes first, then sorts by score - tipsOrBountiesAwarded && - c.createdTime > Date.now() - 10 * MINUTE_MS && - c.userId === me?.id - ? -Infinity - : -((c.bountiesAwarded ?? 0) + sum(Object.values(tips[c.id] ?? []))) - ), - (c) => c.replyToCommentId ?? '_' - ) - - const topLevelComments = commentsByParent['_'] ?? [] return ( <> <ContractCommentInput className="mb-5" contract={contract} /> diff --git a/web/pages/labs/index.tsx b/web/pages/labs/index.tsx index bd1dbb35..79f44a64 100644 --- a/web/pages/labs/index.tsx +++ b/web/pages/labs/index.tsx @@ -16,7 +16,7 @@ export default function LabsPage() { url="/labs" /> <Col className="px-4"> - <Title className="sm:!mt-0" text="Manifold Labs" /> + <Title className="sm:!mt-0" text="๐Ÿงช Manifold Labs" /> <Masonry breakpointCols={{ default: 2, 768: 1 }} From bf8dca25b23e674531c2b4dff8a3dc114089519e Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Sun, 2 Oct 2022 16:56:29 -0700 Subject: [PATCH 130/135] Rewrite stats graphs using new machinery (#985) * Make curve configurable on generic charts * Extract SizedContainer helper component * Use new charts for stats page * Move analytics charts component * Fix up start date logic for graphs excluding data --- web/components/analytics/charts.tsx | 139 ------------------ web/components/charts/contract/binary.tsx | 2 + web/components/charts/contract/choice.tsx | 2 + .../charts/contract/pseudo-numeric.tsx | 2 + web/components/charts/generic-charts.tsx | 19 ++- web/components/charts/helpers.tsx | 12 +- web/components/charts/stats.tsx | 76 ++++++++++ web/components/contract/contract-overview.tsx | 35 ++--- web/components/sized-container.tsx | 35 +++++ web/pages/stats.tsx | 137 +++++++---------- 10 files changed, 200 insertions(+), 259 deletions(-) delete mode 100644 web/components/analytics/charts.tsx create mode 100644 web/components/charts/stats.tsx create mode 100644 web/components/sized-container.tsx diff --git a/web/components/analytics/charts.tsx b/web/components/analytics/charts.tsx deleted file mode 100644 index 131ce2a0..00000000 --- a/web/components/analytics/charts.tsx +++ /dev/null @@ -1,139 +0,0 @@ -import { Point, ResponsiveLine } from '@nivo/line' -import clsx from 'clsx' -import { formatPercent } from 'common/util/format' -import dayjs from 'dayjs' -import { zip } from 'lodash' -import { useWindowSize } from 'web/hooks/use-window-size' -import { Col } from '../layout/col' - -export function DailyCountChart(props: { - startDate: number - dailyCounts: number[] - small?: boolean -}) { - const { dailyCounts, startDate, small } = props - const { width } = useWindowSize() - - const dates = dailyCounts.map((_, i) => - dayjs(startDate).add(i, 'day').toDate() - ) - - const points = zip(dates, dailyCounts).map(([date, betCount]) => ({ - x: date, - y: betCount, - })) - const data = [{ id: 'Count', data: points, color: '#11b981' }] - - const bottomAxisTicks = width && width < 600 ? 6 : undefined - - return ( - <div - className={clsx( - 'h-[250px] w-full overflow-hidden', - !small && 'md:h-[400px]' - )} - > - <ResponsiveLine - data={data} - yScale={{ type: 'linear', stacked: false }} - xScale={{ - type: 'time', - }} - axisBottom={{ - tickValues: bottomAxisTicks, - format: (date) => dayjs(date).format('MMM DD'), - }} - colors={{ datum: 'color' }} - pointSize={0} - pointBorderWidth={1} - pointBorderColor="#fff" - enableSlices="x" - enableGridX={!!width && width >= 800} - enableArea - margin={{ top: 20, right: 28, bottom: 22, left: 40 }} - sliceTooltip={({ slice }) => { - const point = slice.points[0] - return <Tooltip point={point} /> - }} - /> - </div> - ) -} - -export function DailyPercentChart(props: { - startDate: number - dailyPercent: number[] - small?: boolean - excludeFirstDays?: number -}) { - const { dailyPercent, startDate, small, excludeFirstDays } = props - const { width } = useWindowSize() - - const dates = dailyPercent.map((_, i) => - dayjs(startDate).add(i, 'day').toDate() - ) - - const points = zip(dates, dailyPercent) - .map(([date, percent]) => ({ - x: date, - y: percent, - })) - .slice(excludeFirstDays ?? 0) - const data = [{ id: 'Percent', data: points, color: '#11b981' }] - - const bottomAxisTicks = width && width < 600 ? 6 : undefined - - return ( - <div - className={clsx( - 'h-[250px] w-full overflow-hidden', - !small && 'md:h-[400px]' - )} - > - <ResponsiveLine - data={data} - yScale={{ type: 'linear', stacked: false }} - xScale={{ - type: 'time', - }} - axisLeft={{ - format: formatPercent, - }} - axisBottom={{ - tickValues: bottomAxisTicks, - format: (date) => dayjs(date).format('MMM DD'), - }} - colors={{ datum: 'color' }} - pointSize={0} - pointBorderWidth={1} - pointBorderColor="#fff" - enableSlices="x" - enableGridX={!!width && width >= 800} - enableArea - margin={{ top: 20, right: 28, bottom: 22, left: 40 }} - sliceTooltip={({ slice }) => { - const point = slice.points[0] - return <Tooltip point={point} isPercent /> - }} - /> - </div> - ) -} - -function Tooltip(props: { point: Point; isPercent?: boolean }) { - const { point, isPercent } = props - return ( - <Col className="border border-gray-300 bg-white py-2 px-3"> - <div - className="pb-1" - style={{ - color: point.serieColor, - }} - > - <strong>{point.serieId}</strong>{' '} - {isPercent ? formatPercent(+point.data.y) : Math.round(+point.data.y)} - </div> - <div>{dayjs(point.data.x).format('MMM DD')}</div> - </Col> - ) -} diff --git a/web/components/charts/contract/binary.tsx b/web/components/charts/contract/binary.tsx index 7e192767..c9b3bb0b 100644 --- a/web/components/charts/contract/binary.tsx +++ b/web/components/charts/contract/binary.tsx @@ -1,6 +1,7 @@ import { useMemo } from 'react' import { last, sortBy } from 'lodash' import { scaleTime, scaleLinear } from 'd3-scale' +import { curveStepAfter } from 'd3-shape' import { Bet } from 'common/bet' import { getProbability, getInitialProbability } from 'common/calculate' @@ -76,6 +77,7 @@ export const BinaryContractChart = (props: { yScale={yScale} data={data} color="#11b981" + curve={curveStepAfter} onMouseOver={onMouseOver} Tooltip={BinaryChartTooltip} pct diff --git a/web/components/charts/contract/choice.tsx b/web/components/charts/contract/choice.tsx index 65279b70..99e02fa8 100644 --- a/web/components/charts/contract/choice.tsx +++ b/web/components/charts/contract/choice.tsx @@ -1,6 +1,7 @@ import { useMemo } from 'react' import { last, sum, sortBy, groupBy } from 'lodash' import { scaleTime, scaleLinear } from 'd3-scale' +import { curveStepAfter } from 'd3-shape' import { Bet } from 'common/bet' import { Answer } from 'common/answer' @@ -214,6 +215,7 @@ export const ChoiceContractChart = (props: { yScale={yScale} data={data} colors={CATEGORY_COLORS} + curve={curveStepAfter} onMouseOver={onMouseOver} Tooltip={ChoiceTooltip} pct diff --git a/web/components/charts/contract/pseudo-numeric.tsx b/web/components/charts/contract/pseudo-numeric.tsx index e03d4ad9..9e06b368 100644 --- a/web/components/charts/contract/pseudo-numeric.tsx +++ b/web/components/charts/contract/pseudo-numeric.tsx @@ -1,6 +1,7 @@ import { useMemo } from 'react' import { last, sortBy } from 'lodash' import { scaleTime, scaleLog, scaleLinear } from 'd3-scale' +import { curveStepAfter } from 'd3-shape' import { Bet } from 'common/bet' import { DAY_MS } from 'common/util/time' @@ -97,6 +98,7 @@ export const PseudoNumericContractChart = (props: { xScale={xScale} yScale={yScale} data={data} + curve={curveStepAfter} onMouseOver={onMouseOver} Tooltip={PseudoNumericChartTooltip} color={NUMERIC_GRAPH_COLOR} diff --git a/web/components/charts/generic-charts.tsx b/web/components/charts/generic-charts.tsx index 152b264c..cb930484 100644 --- a/web/components/charts/generic-charts.tsx +++ b/web/components/charts/generic-charts.tsx @@ -4,11 +4,11 @@ import { axisBottom, axisLeft } from 'd3-axis' import { D3BrushEvent } from 'd3-brush' import { ScaleTime, ScaleContinuousNumeric } from 'd3-scale' import { + CurveFactory, + SeriesPoint, curveLinear, - curveStepAfter, stack, stackOrderReverse, - SeriesPoint, } from 'd3-shape' import { range } from 'lodash' @@ -52,10 +52,11 @@ export const DistributionChart = <P extends DistributionPoint>(props: { color: string xScale: ScaleContinuousNumeric<number, number> yScale: ScaleContinuousNumeric<number, number> + curve?: CurveFactory onMouseOver?: (p: P | undefined) => void Tooltip?: TooltipComponent<number, P> }) => { - const { color, data, yScale, w, h, Tooltip } = props + const { color, data, yScale, w, h, curve, Tooltip } = props const [viewXScale, setViewXScale] = useState<ScaleContinuousNumeric<number, number>>() @@ -100,7 +101,7 @@ export const DistributionChart = <P extends DistributionPoint>(props: { px={px} py0={py0} py1={py1} - curve={curveLinear} + curve={curve ?? curveLinear} /> </SVGChart> ) @@ -113,11 +114,12 @@ export const MultiValueHistoryChart = <P extends MultiPoint>(props: { colors: readonly string[] xScale: ScaleTime<number, number> yScale: ScaleContinuousNumeric<number, number> + curve?: CurveFactory onMouseOver?: (p: P | undefined) => void Tooltip?: TooltipComponent<Date, P> pct?: boolean }) => { - const { colors, data, yScale, w, h, Tooltip, pct } = props + const { colors, data, yScale, w, h, curve, Tooltip, pct } = props const [viewXScale, setViewXScale] = useState<ScaleTime<number, number>>() const xScale = viewXScale ?? props.xScale @@ -177,7 +179,7 @@ export const MultiValueHistoryChart = <P extends MultiPoint>(props: { px={px} py0={py0} py1={py1} - curve={curveStepAfter} + curve={curve ?? curveLinear} fill={colors[i]} /> ))} @@ -192,11 +194,12 @@ export const SingleValueHistoryChart = <P extends HistoryPoint>(props: { color: string xScale: ScaleTime<number, number> yScale: ScaleContinuousNumeric<number, number> + curve?: CurveFactory onMouseOver?: (p: P | undefined) => void Tooltip?: TooltipComponent<Date, P> pct?: boolean }) => { - const { color, data, yScale, w, h, Tooltip, pct } = props + const { color, data, yScale, w, h, curve, Tooltip, pct } = props const [viewXScale, setViewXScale] = useState<ScaleTime<number, number>>() const xScale = viewXScale ?? props.xScale @@ -246,7 +249,7 @@ export const SingleValueHistoryChart = <P extends HistoryPoint>(props: { px={px} py0={py0} py1={py1} - curve={curveStepAfter} + curve={curve ?? curveLinear} /> </SVGChart> ) diff --git a/web/components/charts/helpers.tsx b/web/components/charts/helpers.tsx index 96115dc0..b40ab7db 100644 --- a/web/components/charts/helpers.tsx +++ b/web/components/charts/helpers.tsx @@ -10,7 +10,7 @@ import { import { pointer, select } from 'd3-selection' import { Axis, AxisScale } from 'd3-axis' import { brushX, D3BrushEvent } from 'd3-brush' -import { area, line, curveStepAfter, CurveFactory } from 'd3-shape' +import { area, line, CurveFactory } from 'd3-shape' import { nanoid } from 'nanoid' import dayjs from 'dayjs' import clsx from 'clsx' @@ -73,11 +73,11 @@ const LinePathInternal = <P,>( data: P[] px: number | ((p: P) => number) py: number | ((p: P) => number) - curve?: CurveFactory + curve: CurveFactory } & SVGProps<SVGPathElement> ) => { const { data, px, py, curve, ...rest } = props - const d3Line = line<P>(px, py).curve(curve ?? curveStepAfter) + const d3Line = line<P>(px, py).curve(curve) // eslint-disable-next-line @typescript-eslint/no-non-null-assertion return <path {...rest} fill="none" d={d3Line(data)!} /> } @@ -89,11 +89,11 @@ const AreaPathInternal = <P,>( px: number | ((p: P) => number) py0: number | ((p: P) => number) py1: number | ((p: P) => number) - curve?: CurveFactory + curve: CurveFactory } & SVGProps<SVGPathElement> ) => { const { data, px, py0, py1, curve, ...rest } = props - const d3Area = area<P>(px, py0, py1).curve(curve ?? curveStepAfter) + const d3Area = area<P>(px, py0, py1).curve(curve) // eslint-disable-next-line @typescript-eslint/no-non-null-assertion return <path {...rest} d={d3Area(data)!} /> } @@ -105,7 +105,7 @@ export const AreaWithTopStroke = <P,>(props: { px: number | ((p: P) => number) py0: number | ((p: P) => number) py1: number | ((p: P) => number) - curve?: CurveFactory + curve: CurveFactory }) => { const { color, data, px, py0, py1, curve } = props return ( diff --git a/web/components/charts/stats.tsx b/web/components/charts/stats.tsx new file mode 100644 index 00000000..a630657a --- /dev/null +++ b/web/components/charts/stats.tsx @@ -0,0 +1,76 @@ +import { useMemo } from 'react' +import { scaleTime, scaleLinear } from 'd3-scale' +import { min, max } from 'lodash' +import dayjs from 'dayjs' + +import { formatPercent } from 'common/util/format' +import { Row } from '../layout/row' +import { HistoryPoint, SingleValueHistoryChart } from './generic-charts' +import { TooltipProps, MARGIN_X, MARGIN_Y } from './helpers' +import { SizedContainer } from 'web/components/sized-container' + +const getPoints = (startDate: number, dailyValues: number[]) => { + const startDateDayJs = dayjs(startDate) + return dailyValues.map((y, i) => ({ + x: startDateDayJs.add(i, 'day').toDate(), + y: y, + })) +} + +const DailyCountTooltip = (props: TooltipProps<Date, HistoryPoint>) => { + const { data, mouseX, xScale } = props + const d = xScale.invert(mouseX) + return ( + <Row className="items-center gap-2"> + <span className="font-semibold">{dayjs(d).format('MMM DD')}</span> + <span className="text-greyscale-6">{data.y}</span> + </Row> + ) +} + +const DailyPercentTooltip = (props: TooltipProps<Date, HistoryPoint>) => { + const { data, mouseX, xScale } = props + const d = xScale.invert(mouseX) + return ( + <Row className="items-center gap-2"> + <span className="font-semibold">{dayjs(d).format('MMM DD')}</span> + <span className="text-greyscale-6">{formatPercent(data.y)}</span> + </Row> + ) +} + +export function DailyChart(props: { + startDate: number + dailyValues: number[] + excludeFirstDays?: number + pct?: boolean +}) { + const { dailyValues, startDate, excludeFirstDays, pct } = props + + const data = useMemo( + () => getPoints(startDate, dailyValues).slice(excludeFirstDays ?? 0), + [startDate, dailyValues, excludeFirstDays] + ) + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const minDate = min(data.map((d) => d.x))! + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const maxDate = max(data.map((d) => d.x))! + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const maxValue = max(data.map((d) => d.y))! + return ( + <SizedContainer fullHeight={250} mobileHeight={250}> + {(width, height) => ( + <SingleValueHistoryChart + w={width} + h={height} + xScale={scaleTime([minDate, maxDate], [0, width - MARGIN_X])} + yScale={scaleLinear([0, maxValue], [height - MARGIN_Y, 0])} + data={data} + Tooltip={pct ? DailyPercentTooltip : DailyCountTooltip} + color="#11b981" + pct={pct} + /> + )} + </SizedContainer> + ) +} diff --git a/web/components/contract/contract-overview.tsx b/web/components/contract/contract-overview.tsx index 2a6d5172..95600a8e 100644 --- a/web/components/contract/contract-overview.tsx +++ b/web/components/contract/contract-overview.tsx @@ -1,5 +1,3 @@ -import React, { useEffect, useRef, useState } from 'react' - import { tradingAllowed } from 'web/lib/firebase/contracts' import { Col } from '../layout/col' import { ContractChart } from 'web/components/charts/contract' @@ -24,6 +22,7 @@ import { BinaryContract, } from 'common/contract' import { ContractDetails } from './contract-details' +import { SizedContainer } from 'web/components/sized-container' const OverviewQuestion = (props: { text: string }) => ( <Linkify className="text-lg text-indigo-700 sm:text-2xl" text={props.text} /> @@ -49,32 +48,18 @@ const SizedContractChart = (props: { fullHeight: number mobileHeight: number }) => { - const { contract, bets, fullHeight, mobileHeight } = props - const containerRef = useRef<HTMLDivElement>(null) - const [chartWidth, setChartWidth] = useState<number>() - const [chartHeight, setChartHeight] = useState<number>() - useEffect(() => { - const handleResize = () => { - setChartHeight(window.innerWidth < 800 ? mobileHeight : fullHeight) - setChartWidth(containerRef.current?.clientWidth) - } - handleResize() - window.addEventListener('resize', handleResize) - return () => { - window.removeEventListener('resize', handleResize) - } - }, [fullHeight, mobileHeight]) + const { fullHeight, mobileHeight, contract, bets } = props return ( - <div ref={containerRef}> - {chartWidth != null && chartHeight != null && ( + <SizedContainer fullHeight={fullHeight} mobileHeight={mobileHeight}> + {(width, height) => ( <ContractChart + width={width} + height={height} contract={contract} bets={bets} - width={chartWidth} - height={chartHeight} /> )} - </div> + </SizedContainer> ) } @@ -114,7 +99,11 @@ const BinaryOverview = (props: { contract: BinaryContract; bets: Bet[] }) => { <ContractDetails contract={contract} /> <Row className="justify-between gap-4"> <OverviewQuestion text={contract.question} /> - <BinaryResolutionOrChance contract={contract} large /> + <BinaryResolutionOrChance + className="flex items-end" + contract={contract} + large + /> </Row> </Col> <SizedContractChart diff --git a/web/components/sized-container.tsx b/web/components/sized-container.tsx new file mode 100644 index 00000000..26532047 --- /dev/null +++ b/web/components/sized-container.tsx @@ -0,0 +1,35 @@ +import { ReactNode, useEffect, useRef, useState } from 'react' + +export const SizedContainer = (props: { + fullHeight: number + mobileHeight: number + mobileThreshold?: number + children: (width: number, height: number) => ReactNode +}) => { + const { children, fullHeight, mobileHeight } = props + const threshold = props.mobileThreshold ?? 800 + const containerRef = useRef<HTMLDivElement>(null) + const [width, setWidth] = useState<number>() + const [height, setHeight] = useState<number>() + useEffect(() => { + if (containerRef.current) { + const handleResize = () => { + setHeight(window.innerWidth <= threshold ? mobileHeight : fullHeight) + setWidth(containerRef.current?.clientWidth) + } + handleResize() + const resizeObserver = new ResizeObserver(handleResize) + resizeObserver.observe(containerRef.current) + window.addEventListener('resize', handleResize) + return () => { + window.removeEventListener('resize', handleResize) + resizeObserver.disconnect() + } + } + }, [threshold, fullHeight, mobileHeight]) + return ( + <div ref={containerRef}> + {width != null && height != null && children(width, height)} + </div> + ) +} diff --git a/web/pages/stats.tsx b/web/pages/stats.tsx index 19fab509..125af4bd 100644 --- a/web/pages/stats.tsx +++ b/web/pages/stats.tsx @@ -1,8 +1,5 @@ import { useEffect, useState } from 'react' -import { - DailyCountChart, - DailyPercentChart, -} from 'web/components/analytics/charts' +import { DailyChart } from 'web/components/charts/stats' import { Col } from 'web/components/layout/col' import { Spacer } from 'web/components/layout/spacer' import { Tabs } from 'web/components/layout/tabs' @@ -96,40 +93,36 @@ export function CustomAnalytics(props: Stats) { { title: 'Daily', content: ( - <DailyCountChart - dailyCounts={dailyActiveUsers} + <DailyChart + dailyValues={dailyActiveUsers} startDate={startDate} - small /> ), }, { title: 'Daily (7d avg)', content: ( - <DailyCountChart - dailyCounts={dailyActiveUsersWeeklyAvg} + <DailyChart + dailyValues={dailyActiveUsersWeeklyAvg} startDate={startDate} - small /> ), }, { title: 'Weekly', content: ( - <DailyCountChart - dailyCounts={weeklyActiveUsers} + <DailyChart + dailyValues={weeklyActiveUsers} startDate={startDate} - small /> ), }, { title: 'Monthly', content: ( - <DailyCountChart - dailyCounts={monthlyActiveUsers} + <DailyChart + dailyValues={monthlyActiveUsers} startDate={startDate} - small /> ), }, @@ -149,44 +142,44 @@ export function CustomAnalytics(props: Stats) { { title: 'D1', content: ( - <DailyPercentChart - dailyPercent={d1} + <DailyChart + dailyValues={d1} startDate={startDate} - small excludeFirstDays={1} + pct /> ), }, { title: 'D1 (7d avg)', content: ( - <DailyPercentChart - dailyPercent={d1WeeklyAvg} + <DailyChart + dailyValues={d1WeeklyAvg} startDate={startDate} - small excludeFirstDays={7} + pct /> ), }, { title: 'W1', content: ( - <DailyPercentChart - dailyPercent={weekOnWeekRetention} + <DailyChart + dailyValues={weekOnWeekRetention} startDate={startDate} - small excludeFirstDays={14} + pct /> ), }, { title: 'M1', content: ( - <DailyPercentChart - dailyPercent={monthlyRetention} + <DailyChart + dailyValues={monthlyRetention} startDate={startDate} - small excludeFirstDays={60} + pct /> ), }, @@ -207,33 +200,33 @@ export function CustomAnalytics(props: Stats) { { title: 'ND1', content: ( - <DailyPercentChart - dailyPercent={nd1} + <DailyChart + dailyValues={nd1} startDate={startDate} excludeFirstDays={1} - small + pct /> ), }, { title: 'ND1 (7d avg)', content: ( - <DailyPercentChart - dailyPercent={nd1WeeklyAvg} + <DailyChart + dailyValues={nd1WeeklyAvg} startDate={startDate} excludeFirstDays={7} - small + pct /> ), }, { title: 'NW1', content: ( - <DailyPercentChart - dailyPercent={nw1} + <DailyChart + dailyValues={nw1} startDate={startDate} excludeFirstDays={14} - small + pct /> ), }, @@ -249,41 +242,31 @@ export function CustomAnalytics(props: Stats) { { title: capitalize(PAST_BETS), content: ( - <DailyCountChart - dailyCounts={dailyBetCounts} - startDate={startDate} - small - /> + <DailyChart dailyValues={dailyBetCounts} startDate={startDate} /> ), }, { title: 'Markets created', content: ( - <DailyCountChart - dailyCounts={dailyContractCounts} + <DailyChart + dailyValues={dailyContractCounts} startDate={startDate} - small /> ), }, { title: 'Comments', content: ( - <DailyCountChart - dailyCounts={dailyCommentCounts} + <DailyChart + dailyValues={dailyCommentCounts} startDate={startDate} - small /> ), }, { title: 'Signups', content: ( - <DailyCountChart - dailyCounts={dailySignups} - startDate={startDate} - small - /> + <DailyChart dailyValues={dailySignups} startDate={startDate} /> ), }, ]} @@ -304,22 +287,22 @@ export function CustomAnalytics(props: Stats) { { title: 'Daily', content: ( - <DailyPercentChart - dailyPercent={dailyActivationRate} + <DailyChart + dailyValues={dailyActivationRate} startDate={startDate} excludeFirstDays={1} - small + pct /> ), }, { title: 'Daily (7d avg)', content: ( - <DailyPercentChart - dailyPercent={dailyActivationRateWeeklyAvg} + <DailyChart + dailyValues={dailyActivationRateWeeklyAvg} startDate={startDate} excludeFirstDays={7} - small + pct /> ), }, @@ -335,33 +318,33 @@ export function CustomAnalytics(props: Stats) { { title: 'Daily / Weekly', content: ( - <DailyPercentChart - dailyPercent={dailyDividedByWeekly} + <DailyChart + dailyValues={dailyDividedByWeekly} startDate={startDate} - small excludeFirstDays={7} + pct /> ), }, { title: 'Daily / Monthly', content: ( - <DailyPercentChart - dailyPercent={dailyDividedByMonthly} + <DailyChart + dailyValues={dailyDividedByMonthly} startDate={startDate} - small excludeFirstDays={30} + pct /> ), }, { title: 'Weekly / Monthly', content: ( - <DailyPercentChart - dailyPercent={weeklyDividedByMonthly} + <DailyChart + dailyValues={weeklyDividedByMonthly} startDate={startDate} - small excludeFirstDays={30} + pct /> ), }, @@ -380,31 +363,19 @@ export function CustomAnalytics(props: Stats) { { title: 'Daily', content: ( - <DailyCountChart - dailyCounts={manaBet.daily} - startDate={startDate} - small - /> + <DailyChart dailyValues={manaBet.daily} startDate={startDate} /> ), }, { title: 'Weekly', content: ( - <DailyCountChart - dailyCounts={manaBet.weekly} - startDate={startDate} - small - /> + <DailyChart dailyValues={manaBet.weekly} startDate={startDate} /> ), }, { title: 'Monthly', content: ( - <DailyCountChart - dailyCounts={manaBet.monthly} - startDate={startDate} - small - /> + <DailyChart dailyValues={manaBet.monthly} startDate={startDate} /> ), }, ]} From 503038d2a2e58f43eba264956c9fe41dff96c224 Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Sun, 2 Oct 2022 16:59:49 -0700 Subject: [PATCH 131/135] Fix a dumb bug on pseudo-numeric charts --- web/components/charts/contract/pseudo-numeric.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/web/components/charts/contract/pseudo-numeric.tsx b/web/components/charts/contract/pseudo-numeric.tsx index 9e06b368..e3edb11f 100644 --- a/web/components/charts/contract/pseudo-numeric.tsx +++ b/web/components/charts/contract/pseudo-numeric.tsx @@ -86,11 +86,11 @@ export const PseudoNumericContractChart = (props: { Date.now() ) const visibleRange = [start, rightmostDate] - const xScale = scaleTime(visibleRange, [0, width ?? 0 - MARGIN_X]) + const xScale = scaleTime(visibleRange, [0, width - MARGIN_X]) // clamp log scale to make sure zeroes go to the bottom const yScale = isLogScale - ? scaleLog([Math.max(min, 1), max], [height ?? 0 - MARGIN_Y, 0]).clamp(true) - : scaleLinear([min, max], [height ?? 0 - MARGIN_Y, 0]) + ? scaleLog([Math.max(min, 1), max], [height - MARGIN_Y, 0]).clamp(true) + : scaleLinear([min, max], [height - MARGIN_Y, 0]) return ( <SingleValueHistoryChart w={width} From f1ae54355d2d0d38e8b73f34069f1ccfc2277cbd Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Sun, 2 Oct 2022 20:44:16 -0500 Subject: [PATCH 132/135] cowp: pointer cursor --- web/pages/cowp.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/pages/cowp.tsx b/web/pages/cowp.tsx index 21494c37..a854f141 100644 --- a/web/pages/cowp.tsx +++ b/web/pages/cowp.tsx @@ -11,7 +11,7 @@ const App = () => { url="/cowp" /> <Link href="https://www.youtube.com/watch?v=FavUpD_IjVY"> - <img src="https://i.imgur.com/Lt54IiU.png" /> + <img src="https://i.imgur.com/Lt54IiU.png" className="cursor-pointer" /> </Link> </Page> ) From 80693620f00da10afc38ec1bc9970ec84264d787 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Sun, 2 Oct 2022 22:46:04 -0500 Subject: [PATCH 133/135] Make alt contract card listen for updates --- web/components/contract/contract-card.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/web/components/contract/contract-card.tsx b/web/components/contract/contract-card.tsx index a8caf7bd..4b4a32b6 100644 --- a/web/components/contract/contract-card.tsx +++ b/web/components/contract/contract-card.tsx @@ -393,7 +393,9 @@ export function ContractCardProbChange(props: { noLinkAvatar?: boolean className?: string }) { - const { contract, noLinkAvatar, className } = props + const { noLinkAvatar, className } = props + const contract = useContractWithPreload(props.contract) as CPMMBinaryContract + return ( <Col className={clsx( From b517817ee395217dc0f4f853c8efa7801db9f482 Mon Sep 17 00:00:00 2001 From: Pico2x <pico2x@gmail.com> Date: Mon, 3 Oct 2022 08:47:21 +0100 Subject: [PATCH 134/135] Fix indentation iphone --- web/pages/group/[...slugs]/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/pages/group/[...slugs]/index.tsx b/web/pages/group/[...slugs]/index.tsx index 658f1809..9ad72e85 100644 --- a/web/pages/group/[...slugs]/index.tsx +++ b/web/pages/group/[...slugs]/index.tsx @@ -263,7 +263,7 @@ export default function GroupPage(props: { url={groupPath(group.slug)} /> <TopGroupNavBar group={group} /> - <div className={'relative p-2 pt-0 md:pt-2'}> + <div className={'relative p-1 pt-0 md:pt-2'}> {/* TODO: Switching tabs should also update the group path */} <Tabs className={'mb-2'} tabs={tabs} defaultIndex={tabIndex} /> </div> From 3fb43c16c41681c565ab20a7c1ea861e918dcf8a Mon Sep 17 00:00:00 2001 From: Pico2x <pico2x@gmail.com> Date: Mon, 3 Oct 2022 10:02:38 +0100 Subject: [PATCH 135/135] Revert "Merge branch 'main' of https://github.com/manifoldmarkets/manifold" This reverts commit 603201a00ff3ea4ae145c907fefa0c5ccce33c0c, reversing changes made to b517817ee395217dc0f4f853c8efa7801db9f482. --- web/components/analytics/charts.tsx | 139 ++++++++++++++++++ web/components/charts/contract/binary.tsx | 2 - web/components/charts/contract/choice.tsx | 2 - .../charts/contract/pseudo-numeric.tsx | 8 +- web/components/charts/generic-charts.tsx | 19 +-- web/components/charts/helpers.tsx | 12 +- web/components/charts/stats.tsx | 76 ---------- web/components/contract/contract-card.tsx | 4 +- web/components/contract/contract-overview.tsx | 35 +++-- web/components/contract/contract-tabs.tsx | 52 +++---- web/components/sized-container.tsx | 35 ----- web/pages/cowp.tsx | 2 +- web/pages/labs/index.tsx | 2 +- web/pages/stats.tsx | 137 ++++++++++------- 14 files changed, 288 insertions(+), 237 deletions(-) create mode 100644 web/components/analytics/charts.tsx delete mode 100644 web/components/charts/stats.tsx delete mode 100644 web/components/sized-container.tsx diff --git a/web/components/analytics/charts.tsx b/web/components/analytics/charts.tsx new file mode 100644 index 00000000..131ce2a0 --- /dev/null +++ b/web/components/analytics/charts.tsx @@ -0,0 +1,139 @@ +import { Point, ResponsiveLine } from '@nivo/line' +import clsx from 'clsx' +import { formatPercent } from 'common/util/format' +import dayjs from 'dayjs' +import { zip } from 'lodash' +import { useWindowSize } from 'web/hooks/use-window-size' +import { Col } from '../layout/col' + +export function DailyCountChart(props: { + startDate: number + dailyCounts: number[] + small?: boolean +}) { + const { dailyCounts, startDate, small } = props + const { width } = useWindowSize() + + const dates = dailyCounts.map((_, i) => + dayjs(startDate).add(i, 'day').toDate() + ) + + const points = zip(dates, dailyCounts).map(([date, betCount]) => ({ + x: date, + y: betCount, + })) + const data = [{ id: 'Count', data: points, color: '#11b981' }] + + const bottomAxisTicks = width && width < 600 ? 6 : undefined + + return ( + <div + className={clsx( + 'h-[250px] w-full overflow-hidden', + !small && 'md:h-[400px]' + )} + > + <ResponsiveLine + data={data} + yScale={{ type: 'linear', stacked: false }} + xScale={{ + type: 'time', + }} + axisBottom={{ + tickValues: bottomAxisTicks, + format: (date) => dayjs(date).format('MMM DD'), + }} + colors={{ datum: 'color' }} + pointSize={0} + pointBorderWidth={1} + pointBorderColor="#fff" + enableSlices="x" + enableGridX={!!width && width >= 800} + enableArea + margin={{ top: 20, right: 28, bottom: 22, left: 40 }} + sliceTooltip={({ slice }) => { + const point = slice.points[0] + return <Tooltip point={point} /> + }} + /> + </div> + ) +} + +export function DailyPercentChart(props: { + startDate: number + dailyPercent: number[] + small?: boolean + excludeFirstDays?: number +}) { + const { dailyPercent, startDate, small, excludeFirstDays } = props + const { width } = useWindowSize() + + const dates = dailyPercent.map((_, i) => + dayjs(startDate).add(i, 'day').toDate() + ) + + const points = zip(dates, dailyPercent) + .map(([date, percent]) => ({ + x: date, + y: percent, + })) + .slice(excludeFirstDays ?? 0) + const data = [{ id: 'Percent', data: points, color: '#11b981' }] + + const bottomAxisTicks = width && width < 600 ? 6 : undefined + + return ( + <div + className={clsx( + 'h-[250px] w-full overflow-hidden', + !small && 'md:h-[400px]' + )} + > + <ResponsiveLine + data={data} + yScale={{ type: 'linear', stacked: false }} + xScale={{ + type: 'time', + }} + axisLeft={{ + format: formatPercent, + }} + axisBottom={{ + tickValues: bottomAxisTicks, + format: (date) => dayjs(date).format('MMM DD'), + }} + colors={{ datum: 'color' }} + pointSize={0} + pointBorderWidth={1} + pointBorderColor="#fff" + enableSlices="x" + enableGridX={!!width && width >= 800} + enableArea + margin={{ top: 20, right: 28, bottom: 22, left: 40 }} + sliceTooltip={({ slice }) => { + const point = slice.points[0] + return <Tooltip point={point} isPercent /> + }} + /> + </div> + ) +} + +function Tooltip(props: { point: Point; isPercent?: boolean }) { + const { point, isPercent } = props + return ( + <Col className="border border-gray-300 bg-white py-2 px-3"> + <div + className="pb-1" + style={{ + color: point.serieColor, + }} + > + <strong>{point.serieId}</strong>{' '} + {isPercent ? formatPercent(+point.data.y) : Math.round(+point.data.y)} + </div> + <div>{dayjs(point.data.x).format('MMM DD')}</div> + </Col> + ) +} diff --git a/web/components/charts/contract/binary.tsx b/web/components/charts/contract/binary.tsx index c9b3bb0b..7e192767 100644 --- a/web/components/charts/contract/binary.tsx +++ b/web/components/charts/contract/binary.tsx @@ -1,7 +1,6 @@ import { useMemo } from 'react' import { last, sortBy } from 'lodash' import { scaleTime, scaleLinear } from 'd3-scale' -import { curveStepAfter } from 'd3-shape' import { Bet } from 'common/bet' import { getProbability, getInitialProbability } from 'common/calculate' @@ -77,7 +76,6 @@ export const BinaryContractChart = (props: { yScale={yScale} data={data} color="#11b981" - curve={curveStepAfter} onMouseOver={onMouseOver} Tooltip={BinaryChartTooltip} pct diff --git a/web/components/charts/contract/choice.tsx b/web/components/charts/contract/choice.tsx index 99e02fa8..65279b70 100644 --- a/web/components/charts/contract/choice.tsx +++ b/web/components/charts/contract/choice.tsx @@ -1,7 +1,6 @@ import { useMemo } from 'react' import { last, sum, sortBy, groupBy } from 'lodash' import { scaleTime, scaleLinear } from 'd3-scale' -import { curveStepAfter } from 'd3-shape' import { Bet } from 'common/bet' import { Answer } from 'common/answer' @@ -215,7 +214,6 @@ export const ChoiceContractChart = (props: { yScale={yScale} data={data} colors={CATEGORY_COLORS} - curve={curveStepAfter} onMouseOver={onMouseOver} Tooltip={ChoiceTooltip} pct diff --git a/web/components/charts/contract/pseudo-numeric.tsx b/web/components/charts/contract/pseudo-numeric.tsx index e3edb11f..e03d4ad9 100644 --- a/web/components/charts/contract/pseudo-numeric.tsx +++ b/web/components/charts/contract/pseudo-numeric.tsx @@ -1,7 +1,6 @@ import { useMemo } from 'react' import { last, sortBy } from 'lodash' import { scaleTime, scaleLog, scaleLinear } from 'd3-scale' -import { curveStepAfter } from 'd3-shape' import { Bet } from 'common/bet' import { DAY_MS } from 'common/util/time' @@ -86,11 +85,11 @@ export const PseudoNumericContractChart = (props: { Date.now() ) const visibleRange = [start, rightmostDate] - const xScale = scaleTime(visibleRange, [0, width - MARGIN_X]) + const xScale = scaleTime(visibleRange, [0, width ?? 0 - MARGIN_X]) // clamp log scale to make sure zeroes go to the bottom const yScale = isLogScale - ? scaleLog([Math.max(min, 1), max], [height - MARGIN_Y, 0]).clamp(true) - : scaleLinear([min, max], [height - MARGIN_Y, 0]) + ? scaleLog([Math.max(min, 1), max], [height ?? 0 - MARGIN_Y, 0]).clamp(true) + : scaleLinear([min, max], [height ?? 0 - MARGIN_Y, 0]) return ( <SingleValueHistoryChart w={width} @@ -98,7 +97,6 @@ export const PseudoNumericContractChart = (props: { xScale={xScale} yScale={yScale} data={data} - curve={curveStepAfter} onMouseOver={onMouseOver} Tooltip={PseudoNumericChartTooltip} color={NUMERIC_GRAPH_COLOR} diff --git a/web/components/charts/generic-charts.tsx b/web/components/charts/generic-charts.tsx index cb930484..152b264c 100644 --- a/web/components/charts/generic-charts.tsx +++ b/web/components/charts/generic-charts.tsx @@ -4,11 +4,11 @@ import { axisBottom, axisLeft } from 'd3-axis' import { D3BrushEvent } from 'd3-brush' import { ScaleTime, ScaleContinuousNumeric } from 'd3-scale' import { - CurveFactory, - SeriesPoint, curveLinear, + curveStepAfter, stack, stackOrderReverse, + SeriesPoint, } from 'd3-shape' import { range } from 'lodash' @@ -52,11 +52,10 @@ export const DistributionChart = <P extends DistributionPoint>(props: { color: string xScale: ScaleContinuousNumeric<number, number> yScale: ScaleContinuousNumeric<number, number> - curve?: CurveFactory onMouseOver?: (p: P | undefined) => void Tooltip?: TooltipComponent<number, P> }) => { - const { color, data, yScale, w, h, curve, Tooltip } = props + const { color, data, yScale, w, h, Tooltip } = props const [viewXScale, setViewXScale] = useState<ScaleContinuousNumeric<number, number>>() @@ -101,7 +100,7 @@ export const DistributionChart = <P extends DistributionPoint>(props: { px={px} py0={py0} py1={py1} - curve={curve ?? curveLinear} + curve={curveLinear} /> </SVGChart> ) @@ -114,12 +113,11 @@ export const MultiValueHistoryChart = <P extends MultiPoint>(props: { colors: readonly string[] xScale: ScaleTime<number, number> yScale: ScaleContinuousNumeric<number, number> - curve?: CurveFactory onMouseOver?: (p: P | undefined) => void Tooltip?: TooltipComponent<Date, P> pct?: boolean }) => { - const { colors, data, yScale, w, h, curve, Tooltip, pct } = props + const { colors, data, yScale, w, h, Tooltip, pct } = props const [viewXScale, setViewXScale] = useState<ScaleTime<number, number>>() const xScale = viewXScale ?? props.xScale @@ -179,7 +177,7 @@ export const MultiValueHistoryChart = <P extends MultiPoint>(props: { px={px} py0={py0} py1={py1} - curve={curve ?? curveLinear} + curve={curveStepAfter} fill={colors[i]} /> ))} @@ -194,12 +192,11 @@ export const SingleValueHistoryChart = <P extends HistoryPoint>(props: { color: string xScale: ScaleTime<number, number> yScale: ScaleContinuousNumeric<number, number> - curve?: CurveFactory onMouseOver?: (p: P | undefined) => void Tooltip?: TooltipComponent<Date, P> pct?: boolean }) => { - const { color, data, yScale, w, h, curve, Tooltip, pct } = props + const { color, data, yScale, w, h, Tooltip, pct } = props const [viewXScale, setViewXScale] = useState<ScaleTime<number, number>>() const xScale = viewXScale ?? props.xScale @@ -249,7 +246,7 @@ export const SingleValueHistoryChart = <P extends HistoryPoint>(props: { px={px} py0={py0} py1={py1} - curve={curve ?? curveLinear} + curve={curveStepAfter} /> </SVGChart> ) diff --git a/web/components/charts/helpers.tsx b/web/components/charts/helpers.tsx index b40ab7db..96115dc0 100644 --- a/web/components/charts/helpers.tsx +++ b/web/components/charts/helpers.tsx @@ -10,7 +10,7 @@ import { import { pointer, select } from 'd3-selection' import { Axis, AxisScale } from 'd3-axis' import { brushX, D3BrushEvent } from 'd3-brush' -import { area, line, CurveFactory } from 'd3-shape' +import { area, line, curveStepAfter, CurveFactory } from 'd3-shape' import { nanoid } from 'nanoid' import dayjs from 'dayjs' import clsx from 'clsx' @@ -73,11 +73,11 @@ const LinePathInternal = <P,>( data: P[] px: number | ((p: P) => number) py: number | ((p: P) => number) - curve: CurveFactory + curve?: CurveFactory } & SVGProps<SVGPathElement> ) => { const { data, px, py, curve, ...rest } = props - const d3Line = line<P>(px, py).curve(curve) + const d3Line = line<P>(px, py).curve(curve ?? curveStepAfter) // eslint-disable-next-line @typescript-eslint/no-non-null-assertion return <path {...rest} fill="none" d={d3Line(data)!} /> } @@ -89,11 +89,11 @@ const AreaPathInternal = <P,>( px: number | ((p: P) => number) py0: number | ((p: P) => number) py1: number | ((p: P) => number) - curve: CurveFactory + curve?: CurveFactory } & SVGProps<SVGPathElement> ) => { const { data, px, py0, py1, curve, ...rest } = props - const d3Area = area<P>(px, py0, py1).curve(curve) + const d3Area = area<P>(px, py0, py1).curve(curve ?? curveStepAfter) // eslint-disable-next-line @typescript-eslint/no-non-null-assertion return <path {...rest} d={d3Area(data)!} /> } @@ -105,7 +105,7 @@ export const AreaWithTopStroke = <P,>(props: { px: number | ((p: P) => number) py0: number | ((p: P) => number) py1: number | ((p: P) => number) - curve: CurveFactory + curve?: CurveFactory }) => { const { color, data, px, py0, py1, curve } = props return ( diff --git a/web/components/charts/stats.tsx b/web/components/charts/stats.tsx deleted file mode 100644 index a630657a..00000000 --- a/web/components/charts/stats.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import { useMemo } from 'react' -import { scaleTime, scaleLinear } from 'd3-scale' -import { min, max } from 'lodash' -import dayjs from 'dayjs' - -import { formatPercent } from 'common/util/format' -import { Row } from '../layout/row' -import { HistoryPoint, SingleValueHistoryChart } from './generic-charts' -import { TooltipProps, MARGIN_X, MARGIN_Y } from './helpers' -import { SizedContainer } from 'web/components/sized-container' - -const getPoints = (startDate: number, dailyValues: number[]) => { - const startDateDayJs = dayjs(startDate) - return dailyValues.map((y, i) => ({ - x: startDateDayJs.add(i, 'day').toDate(), - y: y, - })) -} - -const DailyCountTooltip = (props: TooltipProps<Date, HistoryPoint>) => { - const { data, mouseX, xScale } = props - const d = xScale.invert(mouseX) - return ( - <Row className="items-center gap-2"> - <span className="font-semibold">{dayjs(d).format('MMM DD')}</span> - <span className="text-greyscale-6">{data.y}</span> - </Row> - ) -} - -const DailyPercentTooltip = (props: TooltipProps<Date, HistoryPoint>) => { - const { data, mouseX, xScale } = props - const d = xScale.invert(mouseX) - return ( - <Row className="items-center gap-2"> - <span className="font-semibold">{dayjs(d).format('MMM DD')}</span> - <span className="text-greyscale-6">{formatPercent(data.y)}</span> - </Row> - ) -} - -export function DailyChart(props: { - startDate: number - dailyValues: number[] - excludeFirstDays?: number - pct?: boolean -}) { - const { dailyValues, startDate, excludeFirstDays, pct } = props - - const data = useMemo( - () => getPoints(startDate, dailyValues).slice(excludeFirstDays ?? 0), - [startDate, dailyValues, excludeFirstDays] - ) - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const minDate = min(data.map((d) => d.x))! - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const maxDate = max(data.map((d) => d.x))! - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const maxValue = max(data.map((d) => d.y))! - return ( - <SizedContainer fullHeight={250} mobileHeight={250}> - {(width, height) => ( - <SingleValueHistoryChart - w={width} - h={height} - xScale={scaleTime([minDate, maxDate], [0, width - MARGIN_X])} - yScale={scaleLinear([0, maxValue], [height - MARGIN_Y, 0])} - data={data} - Tooltip={pct ? DailyPercentTooltip : DailyCountTooltip} - color="#11b981" - pct={pct} - /> - )} - </SizedContainer> - ) -} diff --git a/web/components/contract/contract-card.tsx b/web/components/contract/contract-card.tsx index 4b4a32b6..a8caf7bd 100644 --- a/web/components/contract/contract-card.tsx +++ b/web/components/contract/contract-card.tsx @@ -393,9 +393,7 @@ export function ContractCardProbChange(props: { noLinkAvatar?: boolean className?: string }) { - const { noLinkAvatar, className } = props - const contract = useContractWithPreload(props.contract) as CPMMBinaryContract - + const { contract, noLinkAvatar, className } = props return ( <Col className={clsx( diff --git a/web/components/contract/contract-overview.tsx b/web/components/contract/contract-overview.tsx index 95600a8e..2a6d5172 100644 --- a/web/components/contract/contract-overview.tsx +++ b/web/components/contract/contract-overview.tsx @@ -1,3 +1,5 @@ +import React, { useEffect, useRef, useState } from 'react' + import { tradingAllowed } from 'web/lib/firebase/contracts' import { Col } from '../layout/col' import { ContractChart } from 'web/components/charts/contract' @@ -22,7 +24,6 @@ import { BinaryContract, } from 'common/contract' import { ContractDetails } from './contract-details' -import { SizedContainer } from 'web/components/sized-container' const OverviewQuestion = (props: { text: string }) => ( <Linkify className="text-lg text-indigo-700 sm:text-2xl" text={props.text} /> @@ -48,18 +49,32 @@ const SizedContractChart = (props: { fullHeight: number mobileHeight: number }) => { - const { fullHeight, mobileHeight, contract, bets } = props + const { contract, bets, fullHeight, mobileHeight } = props + const containerRef = useRef<HTMLDivElement>(null) + const [chartWidth, setChartWidth] = useState<number>() + const [chartHeight, setChartHeight] = useState<number>() + useEffect(() => { + const handleResize = () => { + setChartHeight(window.innerWidth < 800 ? mobileHeight : fullHeight) + setChartWidth(containerRef.current?.clientWidth) + } + handleResize() + window.addEventListener('resize', handleResize) + return () => { + window.removeEventListener('resize', handleResize) + } + }, [fullHeight, mobileHeight]) return ( - <SizedContainer fullHeight={fullHeight} mobileHeight={mobileHeight}> - {(width, height) => ( + <div ref={containerRef}> + {chartWidth != null && chartHeight != null && ( <ContractChart - width={width} - height={height} contract={contract} bets={bets} + width={chartWidth} + height={chartHeight} /> )} - </SizedContainer> + </div> ) } @@ -99,11 +114,7 @@ const BinaryOverview = (props: { contract: BinaryContract; bets: Bet[] }) => { <ContractDetails contract={contract} /> <Row className="justify-between gap-4"> <OverviewQuestion text={contract.question} /> - <BinaryResolutionOrChance - className="flex items-end" - contract={contract} - large - /> + <BinaryResolutionOrChance contract={contract} large /> </Row> </Col> <SizedContractChart diff --git a/web/components/contract/contract-tabs.tsx b/web/components/contract/contract-tabs.tsx index 33c66c57..bf18fdbe 100644 --- a/web/components/contract/contract-tabs.tsx +++ b/web/components/contract/contract-tabs.tsx @@ -77,34 +77,13 @@ const CommentsTabContent = memo(function CommentsTabContent(props: { const comments = useComments(contract.id) ?? props.comments const [sort, setSort] = useState<'Newest' | 'Best'>('Newest') const me = useUser() - if (comments == null) { return <LoadingIndicator /> } - - const tipsOrBountiesAwarded = - Object.keys(tips).length > 0 || comments.some((c) => c.bountiesAwarded) - - const sortedComments = sortBy(comments, (c) => - sort === 'Newest' - ? c.createdTime - : // Is this too magic? If there are tips/bounties, 'Best' shows your own comments made within the last 10 minutes first, then sorts by score - tipsOrBountiesAwarded && - c.createdTime > Date.now() - 10 * MINUTE_MS && - c.userId === me?.id - ? -Infinity - : -((c.bountiesAwarded ?? 0) + sum(Object.values(tips[c.id] ?? []))) - ) - - const commentsByParent = groupBy( - sortedComments, - (c) => c.replyToCommentId ?? '_' - ) - const topLevelComments = commentsByParent['_'] ?? [] - // Top level comments are reverse-chronological, while replies are chronological - if (sort === 'Newest') topLevelComments.reverse() - if (contract.outcomeType === 'FREE_RESPONSE') { + const generalComments = comments.filter( + (c) => c.answerOutcome === undefined && c.betId === undefined + ) const sortedAnswers = sortBy( contract.answers, (a) => -getOutcomeProbability(contract, a.id) @@ -113,9 +92,6 @@ const CommentsTabContent = memo(function CommentsTabContent(props: { comments, (c) => c.answerOutcome ?? c.betOutcome ?? '_' ) - const generalTopLevelComments = topLevelComments.filter( - (c) => c.answerOutcome === undefined && c.betId === undefined - ) return ( <> {sortedAnswers.map((answer) => ( @@ -139,12 +115,12 @@ const CommentsTabContent = memo(function CommentsTabContent(props: { <div className="text-md mt-8 mb-2 text-left">General Comments</div> <div className="mb-4 w-full border-b border-gray-200" /> <ContractCommentInput className="mb-5" contract={contract} /> - {generalTopLevelComments.map((comment) => ( + {generalComments.map((comment) => ( <FeedCommentThread key={comment.id} contract={contract} parentComment={comment} - threadComments={commentsByParent[comment.id] ?? []} + threadComments={[]} tips={tips} /> ))} @@ -152,6 +128,24 @@ const CommentsTabContent = memo(function CommentsTabContent(props: { </> ) } else { + const tipsOrBountiesAwarded = + Object.keys(tips).length > 0 || comments.some((c) => c.bountiesAwarded) + + const commentsByParent = groupBy( + sortBy(comments, (c) => + sort === 'Newest' + ? -c.createdTime + : // Is this too magic? If there are tips/bounties, 'Best' shows your own comments made within the last 10 minutes first, then sorts by score + tipsOrBountiesAwarded && + c.createdTime > Date.now() - 10 * MINUTE_MS && + c.userId === me?.id + ? -Infinity + : -((c.bountiesAwarded ?? 0) + sum(Object.values(tips[c.id] ?? []))) + ), + (c) => c.replyToCommentId ?? '_' + ) + + const topLevelComments = commentsByParent['_'] ?? [] return ( <> <ContractCommentInput className="mb-5" contract={contract} /> diff --git a/web/components/sized-container.tsx b/web/components/sized-container.tsx deleted file mode 100644 index 26532047..00000000 --- a/web/components/sized-container.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { ReactNode, useEffect, useRef, useState } from 'react' - -export const SizedContainer = (props: { - fullHeight: number - mobileHeight: number - mobileThreshold?: number - children: (width: number, height: number) => ReactNode -}) => { - const { children, fullHeight, mobileHeight } = props - const threshold = props.mobileThreshold ?? 800 - const containerRef = useRef<HTMLDivElement>(null) - const [width, setWidth] = useState<number>() - const [height, setHeight] = useState<number>() - useEffect(() => { - if (containerRef.current) { - const handleResize = () => { - setHeight(window.innerWidth <= threshold ? mobileHeight : fullHeight) - setWidth(containerRef.current?.clientWidth) - } - handleResize() - const resizeObserver = new ResizeObserver(handleResize) - resizeObserver.observe(containerRef.current) - window.addEventListener('resize', handleResize) - return () => { - window.removeEventListener('resize', handleResize) - resizeObserver.disconnect() - } - } - }, [threshold, fullHeight, mobileHeight]) - return ( - <div ref={containerRef}> - {width != null && height != null && children(width, height)} - </div> - ) -} diff --git a/web/pages/cowp.tsx b/web/pages/cowp.tsx index a854f141..21494c37 100644 --- a/web/pages/cowp.tsx +++ b/web/pages/cowp.tsx @@ -11,7 +11,7 @@ const App = () => { url="/cowp" /> <Link href="https://www.youtube.com/watch?v=FavUpD_IjVY"> - <img src="https://i.imgur.com/Lt54IiU.png" className="cursor-pointer" /> + <img src="https://i.imgur.com/Lt54IiU.png" /> </Link> </Page> ) diff --git a/web/pages/labs/index.tsx b/web/pages/labs/index.tsx index 79f44a64..bd1dbb35 100644 --- a/web/pages/labs/index.tsx +++ b/web/pages/labs/index.tsx @@ -16,7 +16,7 @@ export default function LabsPage() { url="/labs" /> <Col className="px-4"> - <Title className="sm:!mt-0" text="๐Ÿงช Manifold Labs" /> + <Title className="sm:!mt-0" text="Manifold Labs" /> <Masonry breakpointCols={{ default: 2, 768: 1 }} diff --git a/web/pages/stats.tsx b/web/pages/stats.tsx index 125af4bd..19fab509 100644 --- a/web/pages/stats.tsx +++ b/web/pages/stats.tsx @@ -1,5 +1,8 @@ import { useEffect, useState } from 'react' -import { DailyChart } from 'web/components/charts/stats' +import { + DailyCountChart, + DailyPercentChart, +} from 'web/components/analytics/charts' import { Col } from 'web/components/layout/col' import { Spacer } from 'web/components/layout/spacer' import { Tabs } from 'web/components/layout/tabs' @@ -93,36 +96,40 @@ export function CustomAnalytics(props: Stats) { { title: 'Daily', content: ( - <DailyChart - dailyValues={dailyActiveUsers} + <DailyCountChart + dailyCounts={dailyActiveUsers} startDate={startDate} + small /> ), }, { title: 'Daily (7d avg)', content: ( - <DailyChart - dailyValues={dailyActiveUsersWeeklyAvg} + <DailyCountChart + dailyCounts={dailyActiveUsersWeeklyAvg} startDate={startDate} + small /> ), }, { title: 'Weekly', content: ( - <DailyChart - dailyValues={weeklyActiveUsers} + <DailyCountChart + dailyCounts={weeklyActiveUsers} startDate={startDate} + small /> ), }, { title: 'Monthly', content: ( - <DailyChart - dailyValues={monthlyActiveUsers} + <DailyCountChart + dailyCounts={monthlyActiveUsers} startDate={startDate} + small /> ), }, @@ -142,44 +149,44 @@ export function CustomAnalytics(props: Stats) { { title: 'D1', content: ( - <DailyChart - dailyValues={d1} + <DailyPercentChart + dailyPercent={d1} startDate={startDate} + small excludeFirstDays={1} - pct /> ), }, { title: 'D1 (7d avg)', content: ( - <DailyChart - dailyValues={d1WeeklyAvg} + <DailyPercentChart + dailyPercent={d1WeeklyAvg} startDate={startDate} + small excludeFirstDays={7} - pct /> ), }, { title: 'W1', content: ( - <DailyChart - dailyValues={weekOnWeekRetention} + <DailyPercentChart + dailyPercent={weekOnWeekRetention} startDate={startDate} + small excludeFirstDays={14} - pct /> ), }, { title: 'M1', content: ( - <DailyChart - dailyValues={monthlyRetention} + <DailyPercentChart + dailyPercent={monthlyRetention} startDate={startDate} + small excludeFirstDays={60} - pct /> ), }, @@ -200,33 +207,33 @@ export function CustomAnalytics(props: Stats) { { title: 'ND1', content: ( - <DailyChart - dailyValues={nd1} + <DailyPercentChart + dailyPercent={nd1} startDate={startDate} excludeFirstDays={1} - pct + small /> ), }, { title: 'ND1 (7d avg)', content: ( - <DailyChart - dailyValues={nd1WeeklyAvg} + <DailyPercentChart + dailyPercent={nd1WeeklyAvg} startDate={startDate} excludeFirstDays={7} - pct + small /> ), }, { title: 'NW1', content: ( - <DailyChart - dailyValues={nw1} + <DailyPercentChart + dailyPercent={nw1} startDate={startDate} excludeFirstDays={14} - pct + small /> ), }, @@ -242,31 +249,41 @@ export function CustomAnalytics(props: Stats) { { title: capitalize(PAST_BETS), content: ( - <DailyChart dailyValues={dailyBetCounts} startDate={startDate} /> + <DailyCountChart + dailyCounts={dailyBetCounts} + startDate={startDate} + small + /> ), }, { title: 'Markets created', content: ( - <DailyChart - dailyValues={dailyContractCounts} + <DailyCountChart + dailyCounts={dailyContractCounts} startDate={startDate} + small /> ), }, { title: 'Comments', content: ( - <DailyChart - dailyValues={dailyCommentCounts} + <DailyCountChart + dailyCounts={dailyCommentCounts} startDate={startDate} + small /> ), }, { title: 'Signups', content: ( - <DailyChart dailyValues={dailySignups} startDate={startDate} /> + <DailyCountChart + dailyCounts={dailySignups} + startDate={startDate} + small + /> ), }, ]} @@ -287,22 +304,22 @@ export function CustomAnalytics(props: Stats) { { title: 'Daily', content: ( - <DailyChart - dailyValues={dailyActivationRate} + <DailyPercentChart + dailyPercent={dailyActivationRate} startDate={startDate} excludeFirstDays={1} - pct + small /> ), }, { title: 'Daily (7d avg)', content: ( - <DailyChart - dailyValues={dailyActivationRateWeeklyAvg} + <DailyPercentChart + dailyPercent={dailyActivationRateWeeklyAvg} startDate={startDate} excludeFirstDays={7} - pct + small /> ), }, @@ -318,33 +335,33 @@ export function CustomAnalytics(props: Stats) { { title: 'Daily / Weekly', content: ( - <DailyChart - dailyValues={dailyDividedByWeekly} + <DailyPercentChart + dailyPercent={dailyDividedByWeekly} startDate={startDate} + small excludeFirstDays={7} - pct /> ), }, { title: 'Daily / Monthly', content: ( - <DailyChart - dailyValues={dailyDividedByMonthly} + <DailyPercentChart + dailyPercent={dailyDividedByMonthly} startDate={startDate} + small excludeFirstDays={30} - pct /> ), }, { title: 'Weekly / Monthly', content: ( - <DailyChart - dailyValues={weeklyDividedByMonthly} + <DailyPercentChart + dailyPercent={weeklyDividedByMonthly} startDate={startDate} + small excludeFirstDays={30} - pct /> ), }, @@ -363,19 +380,31 @@ export function CustomAnalytics(props: Stats) { { title: 'Daily', content: ( - <DailyChart dailyValues={manaBet.daily} startDate={startDate} /> + <DailyCountChart + dailyCounts={manaBet.daily} + startDate={startDate} + small + /> ), }, { title: 'Weekly', content: ( - <DailyChart dailyValues={manaBet.weekly} startDate={startDate} /> + <DailyCountChart + dailyCounts={manaBet.weekly} + startDate={startDate} + small + /> ), }, { title: 'Monthly', content: ( - <DailyChart dailyValues={manaBet.monthly} startDate={startDate} /> + <DailyCountChart + dailyCounts={manaBet.monthly} + startDate={startDate} + small + /> ), }, ]}