diff --git a/common/calculate-metrics.ts b/common/calculate-metrics.ts index b27ac977..7c2153c1 100644 --- a/common/calculate-metrics.ts +++ b/common/calculate-metrics.ts @@ -21,6 +21,25 @@ const computeInvestmentValue = ( }) } +export const computeInvestmentValueCustomProb = ( + bets: Bet[], + contract: Contract, + p: number +) => { + return sumBy(bets, (bet) => { + if (!contract || contract.isResolved) return 0 + if (bet.sale || bet.isSold) return 0 + const { outcome, shares } = bet + + const betP = outcome === 'YES' ? p : 1 - p + + const payout = betP * shares + const value = payout - (bet.loanAmount ?? 0) + if (isNaN(value)) return 0 + return value + }) +} + const computeTotalPool = (userContracts: Contract[], startTime = 0) => { const periodFilteredContracts = userContracts.filter( (contract) => contract.createdTime >= startTime 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/common/payouts-dpm.ts b/common/payouts-dpm.ts index bf6f5ebc..48850dca 100644 --- a/common/payouts-dpm.ts +++ b/common/payouts-dpm.ts @@ -168,7 +168,7 @@ export const getPayoutsMultiOutcome = ( const winnings = (shares / sharesByOutcome[outcome]) * prob * poolTotal const profit = winnings - amount - const payout = amount + (1 - DPM_FEES) * Math.max(0, profit) + const payout = amount + (1 - DPM_FEES) * profit return { userId, profit, payout } }) diff --git a/common/post.ts b/common/post.ts index 05eab685..45503b22 100644 --- a/common/post.ts +++ b/common/post.ts @@ -9,4 +9,11 @@ export type Post = { slug: string } +export type DateDoc = Post & { + bounty: number + birthday: number + type: 'date-doc' + contractSlug: string +} + export const MAX_POST_TITLE_LENGTH = 480 diff --git a/common/user.ts b/common/user.ts index 0372d99b..b1365929 100644 --- a/common/user.ts +++ b/common/user.ts @@ -57,6 +57,7 @@ export type PrivateUser = { email?: string weeklyTrendingEmailSent?: boolean + weeklyPortfolioUpdateEmailSent?: boolean manaBonusEmailSent?: boolean initialDeviceToken?: string initialIpAddress?: string diff --git a/docs/docs/api.md b/docs/docs/api.md index 038d9fe5..05bef5a1 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. @@ -64,24 +65,23 @@ 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` Lists all markets, ordered by creation date descending. @@ -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 @@ -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: @@ -582,12 +591,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` @@ -600,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: @@ -752,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 diff --git a/functions/package.json b/functions/package.json index 65d1fc07..0397c5db 100644 --- a/functions/package.json +++ b/functions/package.json @@ -47,7 +47,8 @@ "@types/mailgun-js": "0.22.12", "@types/module-alias": "2.0.1", "@types/node-fetch": "2.6.2", - "firebase-functions-test": "0.3.3" + "firebase-functions-test": "0.3.3", + "puppeteer": "18.0.5" }, "private": true } 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-answer.ts b/functions/src/create-answer.ts index cc05d817..911f3b8c 100644 --- a/functions/src/create-answer.ts +++ b/functions/src/create-answer.ts @@ -7,6 +7,7 @@ import { getNewMultiBetInfo } from '../../common/new-bet' import { Answer, MAX_ANSWER_LENGTH } from '../../common/answer' import { getValues } from './utils' import { APIError, newEndpoint, validate } from './api' +import { addUserToContractFollowers } from './follow-market' const bodySchema = z.object({ contractId: z.string().max(MAX_ANSWER_LENGTH), @@ -96,6 +97,8 @@ export const createanswer = newEndpoint(opts, async (req, auth) => { return answer }) + await addUserToContractFollowers(contractId, auth.uid) + return answer }) 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..e9d6ae8f 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,20 @@ 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(), + 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 +63,36 @@ 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, + // Dating group! + groupId: 'j3ZE8fkeqiKmRGumy3O1', + }, + 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/functions/src/email-templates/weekly-portfolio-update-no-movers.html b/functions/src/email-templates/weekly-portfolio-update-no-movers.html new file mode 100644 index 00000000..15303992 --- /dev/null +++ b/functions/src/email-templates/weekly-portfolio-update-no-movers.html @@ -0,0 +1,411 @@ + + + + + Weekly Portfolio Update on Manifold + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + +
+ + +
+ + + + + + + + + + + +
+ + + + + + + +
+ + + + banner logo + + + +
+ +
+ +
+ + +
+ +
+ +
+ + + + + + + +
+ +
+ + + + + + + + + + + + + + + + + + +
+
+

+ Hi {{name}},

+
+
+
+

+ + We ran the numbers and here's how you did this past week! + +

+
+
+ Profit +
+

+ {{profit}} +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+ 🔥 Prediction streak + + {{prediction_streak}} +
+ 💸 Tips received + + {{tips_received}} +
+ 📈 Markets traded + + {{markets_traded}} +
+ ❓ Markets created + + {{markets_created}} +
+ 🥳 Traders attracted + + {{unique_bettors}} +
+ +
+
+
+
+ + +
+ + + + + + +
+ + +
+ + + + + + +
+ +
+ + + + + + +
+ + + + + + + + + +
+
+

+ This e-mail has been sent to + {{name}}, + click here to unsubscribe from this type of notification. +

+
+
+
+
+ +
+
+ +
+
\ No newline at end of file diff --git a/functions/src/email-templates/weekly-portfolio-update.html b/functions/src/email-templates/weekly-portfolio-update.html new file mode 100644 index 00000000..fd99837f --- /dev/null +++ b/functions/src/email-templates/weekly-portfolio-update.html @@ -0,0 +1,510 @@ + + + + + Weekly Portfolio Update on Manifold + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + +
+ + +
+ + + + + + + + + + + +
+ + + + + + + +
+ + + + banner logo + + + +
+ +
+ +
+ + +
+ +
+ +
+ + + + + + + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+

+ Hi {{name}},

+
+
+
+

+ + We ran the numbers and here's how you did this past week! + +

+
+
+ Profit +
+

+ {{profit}} +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+ 🔥 Prediction streak + + {{prediction_streak}} +
+ 💸 Tips received + + {{tips_received}} +
+ 📈 Markets traded + + {{markets_traded}} +
+ ❓ Markets created + + {{markets_created}} +
+ 🥳 Traders attracted + + {{unique_bettors}} +
+ +
+
+

+ + And here's some of the biggest changes in your portfolio: + +

+
+
+ + + + + + + + + + + + + + + + + + +
+ + {{question1Title}} + + + +

+ {{question1Prob}} + +

+ {{question1Change}} + +

+

+
+ + {{question2Title}} + + + +

+ {{question2Prob}} + +

+ {{question2Change}} + +

+

+
+ + {{question3Title}} + + + +

+ {{question3Prob}} + +

+ {{question3Change}} + +

+

+
+ + {{question4Title}} + + + +

+ {{question4Prob}} + +

+ {{question4Change}} + +

+

+
+ +
+
+
+
+ + +
+ + + + + + +
+ + +
+ + + + + + +
+ +
+ + + + + + +
+ + + + + + + + + +
+
+

+ This e-mail has been sent to + {{name}}, + click here to unsubscribe from this type of notification. +

+
+
+
+
+ +
+
+ +
+
diff --git a/functions/src/emails.ts b/functions/src/emails.ts index 98309ebe..6888cfb1 100644 --- a/functions/src/emails.ts +++ b/functions/src/emails.ts @@ -12,14 +12,15 @@ import { getValueFromBucket } from '../../common/calculate-dpm' import { formatNumericProbability } from '../../common/pseudo-numeric' import { sendTemplateEmail, sendTextEmail } from './send-email' -import { getUser } from './utils' +import { contractUrl, getUser } from './utils' import { buildCardUrl, getOpenGraphProps } from '../../common/contract-details' import { notification_reason_types } from '../../common/notification' import { Dictionary } from 'lodash' +import { getNotificationDestinationsForUser } from '../../common/user-notification-preferences' import { - getNotificationDestinationsForUser, - notification_preference, -} from '../../common/user-notification-preferences' + PerContractInvestmentsData, + OverallPerformanceData, +} from './weekly-portfolio-emails' export const sendMarketResolutionEmail = async ( reason: notification_reason_types, @@ -152,9 +153,10 @@ export const sendWelcomeEmail = async ( const { name } = user const firstName = name.split(' ')[0] - const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${ - 'onboarding_flow' as notification_preference - }` + const { unsubscribeUrl } = getNotificationDestinationsForUser( + privateUser, + 'onboarding_flow' + ) return await sendTemplateEmail( privateUser.email, @@ -220,9 +222,11 @@ export const sendOneWeekBonusEmail = async ( const { name } = user const firstName = name.split(' ')[0] - const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${ - 'onboarding_flow' as notification_preference - }` + const { unsubscribeUrl } = getNotificationDestinationsForUser( + privateUser, + 'onboarding_flow' + ) + return await sendTemplateEmail( privateUser.email, 'Manifold Markets one week anniversary gift', @@ -252,10 +256,10 @@ export const sendCreatorGuideEmail = async ( const { name } = user const firstName = name.split(' ')[0] - - const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${ - 'onboarding_flow' as notification_preference - }` + const { unsubscribeUrl } = getNotificationDestinationsForUser( + privateUser, + 'onboarding_flow' + ) return await sendTemplateEmail( privateUser.email, 'Create your own prediction market', @@ -286,10 +290,10 @@ export const sendThankYouEmail = async ( const { name } = user const firstName = name.split(' ')[0] - - const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${ - 'thank_you_for_purchases' as notification_preference - }` + const { unsubscribeUrl } = getNotificationDestinationsForUser( + privateUser, + 'thank_you_for_purchases' + ) return await sendTemplateEmail( privateUser.email, @@ -469,9 +473,10 @@ export const sendInterestingMarketsEmail = async ( ) return - const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${ - 'trending_markets' as notification_preference - }` + const { unsubscribeUrl } = getNotificationDestinationsForUser( + privateUser, + 'trending_markets' + ) const { name } = user const firstName = name.split(' ')[0] @@ -507,10 +512,6 @@ export const sendInterestingMarketsEmail = async ( ) } -function contractUrl(contract: Contract) { - return `https://manifold.markets/${contract.creatorUsername}/${contract.slug}` -} - function imageSourceUrl(contract: Contract) { return buildCardUrl(getOpenGraphProps(contract)) } @@ -612,3 +613,47 @@ export const sendNewUniqueBettorsEmail = async ( } ) } + +export const sendWeeklyPortfolioUpdateEmail = async ( + user: User, + privateUser: PrivateUser, + investments: PerContractInvestmentsData[], + overallPerformance: OverallPerformanceData +) => { + if ( + !privateUser || + !privateUser.email || + !privateUser.notificationPreferences.profit_loss_updates.includes('email') + ) + return + + const { unsubscribeUrl } = getNotificationDestinationsForUser( + privateUser, + 'profit_loss_updates' + ) + + const { name } = user + const firstName = name.split(' ')[0] + const templateData: Record = { + name: firstName, + unsubscribeUrl, + ...overallPerformance, + } + investments.forEach((investment, i) => { + 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 + }) + + await sendTemplateEmail( + // privateUser.email, + 'iansphilips@gmail.com', + `Here's your weekly portfolio update!`, + investments.length === 0 + ? 'portfolio-update-no-movers' + : 'portfolio-update', + templateData + ) +} diff --git a/functions/src/index.ts b/functions/src/index.ts index 2cb6f515..9a8ec232 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -27,9 +27,10 @@ export * from './on-delete-group' export * from './score-contracts' export * from './weekly-markets-emails' export * from './reset-betting-streaks' -export * from './reset-weekly-emails-flag' +export * from './reset-weekly-emails-flags' export * from './on-update-contract-follow' export * from './on-update-like' +export * from './weekly-portfolio-emails' // v2 export * from './health' diff --git a/functions/src/market-close-notifications.ts b/functions/src/market-close-notifications.ts index 7878e410..21b52fbc 100644 --- a/functions/src/market-close-notifications.ts +++ b/functions/src/market-close-notifications.ts @@ -60,7 +60,7 @@ async function sendMarketCloseEmails() { 'contract', 'closed', user, - 'closed' + contract.id.slice(6, contract.id.length), + contract.id + '-closed-at-' + contract.closeTime, contract.closeTime?.toString() ?? new Date().toString(), { contract } ) diff --git a/functions/src/reset-weekly-emails-flag.ts b/functions/src/reset-weekly-emails-flags.ts similarity index 87% rename from functions/src/reset-weekly-emails-flag.ts rename to functions/src/reset-weekly-emails-flags.ts index 947e3e88..1b2109a1 100644 --- a/functions/src/reset-weekly-emails-flag.ts +++ b/functions/src/reset-weekly-emails-flags.ts @@ -2,7 +2,7 @@ import * as functions from 'firebase-functions' import * as admin from 'firebase-admin' import { getAllPrivateUsers } from './utils' -export const resetWeeklyEmailsFlag = functions +export const resetWeeklyEmailsFlags = functions .runWith({ timeoutSeconds: 300, memory: '4GB', @@ -17,6 +17,7 @@ export const resetWeeklyEmailsFlag = functions privateUsers.map(async (user) => { return firestore.collection('private-users').doc(user.id).update({ weeklyTrendingEmailSent: false, + weeklyPortfolioUpdateEmailSent: false, }) }) ) diff --git a/functions/src/score-contracts.ts b/functions/src/score-contracts.ts index 52ef39d4..497a4ba0 100644 --- a/functions/src/score-contracts.ts +++ b/functions/src/score-contracts.ts @@ -5,6 +5,7 @@ import { Bet } from '../../common/bet' import { Contract } from '../../common/contract' import { log } from './utils' import { removeUndefinedProps } from '../../common/util/object' +import { DAY_MS, HOUR_MS } from '../../common/util/time' export const scoreContracts = functions .runWith({ memory: '4GB', timeoutSeconds: 540 }) @@ -16,11 +17,12 @@ const firestore = admin.firestore() async function scoreContractsInternal() { const now = Date.now() - const lastHour = now - 60 * 60 * 1000 - const last3Days = now - 1000 * 60 * 60 * 24 * 3 + const hourAgo = now - HOUR_MS + const dayAgo = now - DAY_MS + const threeDaysAgo = now - DAY_MS * 3 const activeContractsSnap = await firestore .collection('contracts') - .where('lastUpdatedTime', '>', lastHour) + .where('lastUpdatedTime', '>', hourAgo) .get() const activeContracts = activeContractsSnap.docs.map( (doc) => doc.data() as Contract @@ -41,15 +43,21 @@ async function scoreContractsInternal() { for (const contract of contracts) { const bets = await firestore .collection(`contracts/${contract.id}/bets`) - .where('createdTime', '>', last3Days) + .where('createdTime', '>', threeDaysAgo) .get() const bettors = bets.docs .map((doc) => doc.data() as Bet) .map((bet) => bet.userId) const popularityScore = uniq(bettors).length + const wasCreatedToday = contract.createdTime > dayAgo + let dailyScore: number | undefined - if (contract.outcomeType === 'BINARY' && contract.mechanism === 'cpmm-1') { + if ( + contract.outcomeType === 'BINARY' && + contract.mechanism === 'cpmm-1' && + !wasCreatedToday + ) { const percentChange = Math.abs(contract.probChanges.day) dailyScore = popularityScore * percentChange } 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/functions/src/scripts/contest/bulk-create-markets.ts b/functions/src/scripts/contest/bulk-create-markets.ts new file mode 100644 index 00000000..ba7245fe --- /dev/null +++ b/functions/src/scripts/contest/bulk-create-markets.ts @@ -0,0 +1,115 @@ +// Run with `npx ts-node src/scripts/contest/create-markets.ts` + +import { data } from './criticism-and-red-teaming' + +// 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' + +type CEPSubmission = { + title: string + author?: string + link: string +} + +// Use the API to create a new market for this Cause Exploration Prize submission +async function postMarket(submission: CEPSubmission) { + const { title, author } = submission + const response = await fetch('https://dev.manifold.markets/api/v0/market', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Key ${API_KEY}`, + }, + body: JSON.stringify({ + outcomeType: 'BINARY', + question: `"${title}" by ${author ?? 'anonymous'}`, + description: makeDescription(submission), + closeTime: Date.parse('2022-09-30').valueOf(), + initialProb: 10, + // Super secret options: + // groupId: 'y2hcaGybXT1UfobK3XTx', // [DEV] CEP Tournament + // groupId: 'cMcpBQ2p452jEcJD2SFw', // [PROD] Predict CEP + groupId: 'h3MhjYbSSG6HbxY8ZTwE', // [DEV] CART + // groupId: 'K86LmEmidMKdyCHdHNv4', // [PROD] CART + visibility: 'unlisted', + // TODO: Increase liquidity? + }), + }) + const data = await response.json() + console.log('Created market:', data.slug) +} + +async function postAll() { + for (const submission of data.slice(0, 3)) { + await postMarket(submission) + } +} +postAll() + +/* Example curl request: +$ curl https://manifold.markets/api/v0/market -X POST -H 'Content-Type: application/json' \ + -H 'Authorization: Key {...}' + --data-raw '{"outcomeType":"BINARY", \ + "question":"Is there life on Mars?", \ + "description":"I'm not going to type some long ass example description.", \ + "closeTime":1700000000000, \ + "initialProb":25}' +*/ + +function makeDescription(submission: CEPSubmission) { + const { title, author, link } = submission + return { + content: [ + { + content: [ + { text: `Will ${author ?? 'anonymous'}'s post "`, type: 'text' }, + { + marks: [ + { + attrs: { + target: '_blank', + href: link, + class: + 'no-underline !text-indigo-700 z-10 break-words hover:underline hover:decoration-indigo-400 hover:decoration-2', + }, + type: 'link', + }, + ], + type: 'text', + text: title, + }, + { text: '" win any prize in the ', type: 'text' }, + { + text: 'EA Criticism and Red Teaming Contest', + type: 'text', + marks: [ + { + attrs: { + target: '_blank', + class: + 'no-underline !text-indigo-700 z-10 break-words hover:underline hover:decoration-indigo-400 hover:decoration-2', + href: 'https://forum.effectivealtruism.org/posts/8hvmvrgcxJJ2pYR4X/announcing-a-contest-ea-criticism-and-red-teaming', + }, + type: 'link', + }, + ], + }, + { text: '?', type: 'text' }, + ], + type: 'paragraph', + }, + { type: 'paragraph' }, + { + type: 'iframe', + attrs: { + allowfullscreen: true, + src: link, + frameborder: 0, + }, + }, + ], + type: 'doc', + } +} diff --git a/functions/src/scripts/contest/criticism-and-red-teaming.ts b/functions/src/scripts/contest/criticism-and-red-teaming.ts new file mode 100644 index 00000000..0f19705a --- /dev/null +++ b/functions/src/scripts/contest/criticism-and-red-teaming.ts @@ -0,0 +1,1219 @@ +export const data = [ + // { + // "title": "Announcing a contest: EA Criticism and Red Teaming", + // "author": "Lizka", + // "link": "https://forum.effectivealtruism.org/posts/8hvmvrgcxJJ2pYR4X/announcing-a-contest-ea-criticism-and-red-teaming" + // }, + // { + // "title": "Pre-announcing a contest for critiques and red teaming", + // "author": "Lizka", + // "link": "https://forum.effectivealtruism.org/posts/Fx8pWSLKGwuqsfuRQ/pre-announcing-a-contest-for-critiques-and-red-teaming" + // }, + // { + // "title": "Resource for criticisms and red teaming", + // "author": "Lizka", + // "link": "https://forum.effectivealtruism.org/posts/uuQDgiJJaswEyyzan/resource-for-criticisms-and-red-teaming" + // }, + { + title: + 'Deworming and decay: replicating GiveWell’s cost-effectiveness analysis ', + author: 'JoelMcGuire', + link: 'https://forum.effectivealtruism.org/posts/MKiqGvijAXfcBHCYJ/deworming-and-decay-replicating-givewell-s-cost', + }, + { + title: 'Critiques of EA that I want to read', + author: 'abrahamrowe', + link: 'https://forum.effectivealtruism.org/posts/n3WwTz4dbktYwNQ2j/critiques-of-ea-that-i-want-to-read', + }, + { + title: 'My take on What We Owe the Future', + author: 'elifland', + link: 'https://forum.effectivealtruism.org/posts/9Y6Y6qoAigRC7A8eX/my-take-on-what-we-owe-the-future', + }, + { + title: + 'A Critical Review of Open Philanthropy’s Bet On Criminal Justice Reform', + author: 'NunoSempere', + link: 'https://forum.effectivealtruism.org/posts/h2N9qEbvQ6RHABcae/a-critical-review-of-open-philanthropy-s-bet-on-criminal', + }, + { + title: 'Leaning into EA Disillusionment', + author: 'Helen', + link: 'https://forum.effectivealtruism.org/posts/MjTB4MvtedbLjgyja/leaning-into-ea-disillusionment', + }, + { + title: 'Red Teaming CEA’s Community Building Work', + author: 'AnonymousEAForumAccount', + link: 'https://forum.effectivealtruism.org/posts/hbejbRBpd6quqnTAB/red-teaming-cea-s-community-building-work-2', + }, + { + title: + 'A philosophical review of Open Philanthropy’s Cause Prioritisation Framework', + author: 'MichaelPlant', + link: 'https://forum.effectivealtruism.org/posts/bdiDW83SFAsoA4EeB/a-philosophical-review-of-open-philanthropy-s-cause', + }, + { + title: 'The Future Might Not Be So Great', + author: 'Jacy', + link: 'https://forum.effectivealtruism.org/posts/WebLP36BYDbMAKoa5/the-future-might-not-be-so-great', + }, + { + title: 'Potatoes: A Critical Review', + author: 'Pablo Villalobos', + link: 'https://forum.effectivealtruism.org/posts/iZrrWGvx2s2uPtica/potatoes-a-critical-review', + }, + { + title: 'Effective Altruism as Coordination & Field Incubation', + author: 'DavidNash', + link: 'https://forum.effectivealtruism.org/posts/Zm6iaaJhoZsoZ2uMD/effective-altruism-as-coordination-and-field-incubation', + }, + { + title: 'Enlightenment Values in a Vulnerable World', + author: 'Maxwell Tabarrok', + link: 'https://forum.effectivealtruism.org/posts/A4fMkKhBxio83NtBL/enlightenment-values-in-a-vulnerable-world', + }, + { + title: "21 criticisms of EA I'm thinking about", + author: 'Peter Wildeford', + link: 'https://forum.effectivealtruism.org/posts/X47rn28Xy5TRfGgSj/21-criticisms-of-ea-i-m-thinking-about', + }, + { + title: 'Longtermism and Computational Complexity', + author: 'David Kinney', + link: 'https://forum.effectivealtruism.org/posts/RRyHcupuDafFNXt6p/longtermism-and-computational-complexity', + }, + { + title: 'The Community Manifesto', + author: 'dianaqianmorgan', + link: 'https://forum.effectivealtruism.org/posts/cY3wBXoJoeHXJ7XYt/the-community-manifesto', + }, + { + title: 'Existential risk pessimism and the time of perils', + author: 'David Thorstad', + link: 'https://forum.effectivealtruism.org/posts/N6hcw8CxK7D3FCD5v/existential-risk-pessimism-and-the-time-of-perils-4', + }, + { + title: "A critical review of GiveWell's 2022 cost-effectiveness model", + author: 'Froolow', + link: 'https://forum.effectivealtruism.org/posts/6dtwkwBrHBGtc3xes/a-critical-review-of-givewell-s-2022-cost-effectiveness', + }, + { + title: 'How EA is perceived is crucial to its future trajectory', + author: 'GidonKadosh', + link: 'https://forum.effectivealtruism.org/posts/82ig8odF9ooccfJfa/how-ea-is-perceived-is-crucial-to-its-future-trajectory', + }, + { + title: + 'Before There Was Effective Altruism, There Was Effective Philanthropy', + author: 'ColdButtonIssues', + link: 'https://forum.effectivealtruism.org/posts/CdrKtaAX69iJuJD2r/before-there-was-effective-altruism-there-was-effective', + }, + { + title: + 'A concern about the “evolutionary anchor” of Ajeya Cotra’s report on AI timelines.', + author: 'NunoSempere', + link: 'https://forum.effectivealtruism.org/posts/FHTyixYNnGaQfEexH/a-concern-about-the-evolutionary-anchor-of-ajeya-cotra-s', + }, + { + title: + 'EA is becoming increasingly inaccessible, at the worst possible time', + author: 'Ann Garth', + link: 'https://forum.effectivealtruism.org/posts/duPDKhtXTJNAJBaSf/ea-is-becoming-increasingly-inaccessible-at-the-worst', + }, + { + title: 'Red-teaming contest: demographics and power structures in EA', + author: 'TheOtherHannah', + link: 'https://forum.effectivealtruism.org/posts/oD3zus6LhbhBj6z2F/red-teaming-contest-demographics-and-power-structures-in-ea', + }, + { + title: 'The Nietzschean Challenge to Effective Altruism', + author: 'Richard Y Chappell', + link: 'https://forum.effectivealtruism.org/posts/bedstSbqaP8aDBfDr/the-nietzschean-challenge-to-effective-altruism', + }, + { + title: + 'The Case for Funding New Long-Term Randomized Controlled Trials of Deworming', + author: 'MHR', + link: 'https://forum.effectivealtruism.org/posts/CyxZmwQ7gADwjBHG6/the-case-for-funding-new-long-term-randomized-controlled', + }, + { + title: 'Population Ethics Without Axiology: A Framework', + author: 'Lukas_Gloor', + link: 'https://forum.effectivealtruism.org/posts/dQvDxDMyueLyydHw4/population-ethics-without-axiology-a-framework', + }, + { + title: 'Questioning the Value of Extinction Risk Reduction', + author: 'Red Team 8', + link: 'https://forum.effectivealtruism.org/posts/eeDsHDoM9De4iGGLw/questioning-the-value-of-extinction-risk-reduction-1', + }, + { + title: 'Red teaming introductory EA courses', + author: 'Philip Hall Andersen', + link: 'https://forum.effectivealtruism.org/posts/JDEDsCaQd2CYm7QEi/red-teaming-introductory-ea-courses', + }, + { + title: 'Systemic Cascading Risks: Relevance in Longtermism & Value Lock-In', + author: 'Richard Ren', + link: 'https://forum.effectivealtruism.org/posts/mWGodAi9Mv2a2EbNj/systemic-cascading-risks-relevance-in-longtermism-and-value', + }, + { + title: + 'community building solely as a tool for impact creates toxic communities', + author: 'ruthgrace', + link: 'https://forum.effectivealtruism.org/posts/EpMQBmQv7e4yaDYYN/community-building-solely-as-a-tool-for-impact-creates-toxic', + }, + { + title: + 'Are you really in a race? The Cautionary Tales of Szilárd and Ellsberg', + author: 'HaydnBelfield', + link: 'https://forum.effectivealtruism.org/posts/cXBznkfoPJAjacFoT/are-you-really-in-a-race-the-cautionary-tales-of-szilard-and', + }, + { + title: + "Quantifying Uncertainty in GiveWell's GiveDirectly Cost-Effectiveness Analysis", + author: 'Hazelfire', + link: 'https://forum.effectivealtruism.org/posts/ycLhq4Bmep8ssr4wR/quantifying-uncertainty-in-givewell-s-givedirectly-cost', + }, + { + title: 'Changing the world through slack & hobbies', + author: 'Steven Byrnes', + link: 'https://forum.effectivealtruism.org/posts/ZkhABk4rRMqsNmwvf/changing-the-world-through-slack-and-hobbies', + }, + { + title: + 'Some concerns about policy work funding and the Long Term Future Fund', + author: 'weeatquince', + link: 'https://forum.effectivealtruism.org/posts/Xfon9oxyMFv47kFnc/some-concerns-about-policy-work-funding-and-the-long-term', + }, + { + title: + 'Why Effective Altruists Should Put a Higher Priority on Funding Academic Research', + author: 'Stuart Buck', + link: 'https://forum.effectivealtruism.org/posts/uTQKFNXrMXuwGe4vw/why-effective-altruists-should-put-a-higher-priority-on', + }, + { + title: 'Remuneration In Effective Altruism', + author: 'Stefan_Schubert', + link: 'https://forum.effectivealtruism.org/posts/wWnRtjDiyjRRgaFDb/remuneration-in-effective-altruism', + }, + { + title: "You Don't Need To Justify Everything", + author: 'ThomasW', + link: 'https://forum.effectivealtruism.org/posts/HX9ZDGwwSxAab46N9/you-don-t-need-to-justify-everything', + }, + { + title: 'EAs underestimate uncertainty in cause prioritisation', + author: 'freedomandutility', + link: 'https://forum.effectivealtruism.org/posts/Ekd3oATEZkBbJ95uD/eas-underestimate-uncertainty-in-cause-prioritisation', + }, + { + title: '"Doing Good Best" isn\'t the EA ideal', + author: 'Davidmanheim', + link: 'https://forum.effectivealtruism.org/posts/f9NpDx65zY6Qk9ofe/doing-good-best-isn-t-the-ea-ideal', + }, + { + title: 'The discount rate is not zero', + author: 'Thomaaas', + link: 'https://forum.effectivealtruism.org/posts/zLZMsthcqfmv5J6Ev/the-discount-rate-is-not-zero', + }, + { + title: 'Questioning the Foundations of EA', + author: 'Wei_Dai', + link: 'https://forum.effectivealtruism.org/posts/zvNwSG2Xvy8x5Rtba/questioning-the-foundations-of-ea', + }, + { + title: + 'Notes on how prizes may fail and how to reduce the risk of them failing', + author: 'Peter Wildeford', + link: 'https://forum.effectivealtruism.org/posts/h2WcJf7pg5Qdfhsm3/notes-on-how-prizes-may-fail-and-how-to-reduce-the-risk-of', + }, + { + title: 'EA Culture and Causes: Less is More', + author: 'Allen Bell', + link: 'https://forum.effectivealtruism.org/posts/FWHDX32ecr9aF4xKw/ea-culture-and-causes-less-is-more', + }, + { + title: 'Things usually end slowly', + author: 'OllieBase', + link: 'https://forum.effectivealtruism.org/posts/qLwtCuh6nDCsrsrMK/things-usually-end-slowly', + }, + { + title: + 'Doing good is a privilege. This needs to change if we want to do good long-term. ', + author: 'SofiaBalderson', + link: 'https://forum.effectivealtruism.org/posts/gicYG5ymk4pPzrKAd/doing-good-is-a-privilege-this-needs-to-change-if-we-want-to', + }, + { + title: 'Animal Zoo', + author: 'bericlair', + link: 'https://forum.effectivealtruism.org/posts/YfmdnkmnoWBhmBaQL/animal-zoo', + }, + { + title: 'Summaries are underrated', + author: 'Nathan Young', + link: 'https://forum.effectivealtruism.org/posts/nDawZHxDR3j53zdbf/summaries-are-underrated', + }, + { + title: 'Longtermism, risk, and extinction', + author: 'Richard Pettigrew', + link: 'https://forum.effectivealtruism.org/posts/xAoZotkzcY5mvmXFY/longtermism-risk-and-extinction', + }, + { + title: + 'Prioritisation should consider potential for ongoing evaluation alongside expected value and evidence quality', + author: 'freedomandutility', + link: 'https://forum.effectivealtruism.org/posts/orfgdYRZXNKQtqzmh/prioritisation-should-consider-potential-for-ongoing', + }, + { + title: + '“Existential Risk” is badly named and leads to narrow focus on astronomical waste', + author: 'freedomandutility', + link: 'https://forum.effectivealtruism.org/posts/qFdifovCmckujxEsq/existential-risk-is-badly-named-and-leads-to-narrow-focus-on', + }, + { + title: + 'The great energy descent (short version) - An important thing EA might have missed', + author: 'Corentin Biteau', + link: 'https://forum.effectivealtruism.org/posts/wXzc75txE5hbHqYug/the-great-energy-descent-short-version-an-important-thing-ea', + }, + { + title: 'The Long Reflection as the Great Stagnation ', + author: 'Larks', + link: 'https://forum.effectivealtruism.org/posts/o5Q8dXfnHTozW9jkY/the-long-reflection-as-the-great-stagnation', + }, + { + title: 'Community posts: The Forum needs a way to work in public', + author: 'Nathan Young', + link: 'https://forum.effectivealtruism.org/posts/NxWssGagWoQWErRer/community-posts-the-forum-needs-a-way-to-work-in-public', + }, + { + title: 'Improving Karma: $8mn of possible value (my estimate)', + author: 'Nathan Young', + link: 'https://forum.effectivealtruism.org/posts/YajssmjwKndBTahQx/improving-karma-usd8mn-of-possible-value-my-estimate', + }, + { + title: 'Leveraging labor shortages as a pathway to career impact', + author: 'IanDavidMoss', + link: 'https://forum.effectivealtruism.org/posts/xdMn6FeQGjrXDPnQj/leveraging-labor-shortages-as-a-pathway-to-career-impact', + }, + { + title: 'How to dissolve moral cluelessness about donating mosquito nets', + author: 'ben.smith', + link: 'https://forum.effectivealtruism.org/posts/9XgLq4eQHMWybDsrv/how-to-dissolve-moral-cluelessness-about-donating-mosquito-1', + }, + { + title: '[TikTok] Comparability between suffering and happiness', + author: 'Ben_West', + link: 'https://forum.effectivealtruism.org/posts/ASmtarf3ADYb2Xmrt/tiktok-comparability-between-suffering-and-happiness', + }, + { + title: + 'Red teaming a model for estimating the value of longtermist interventions - A critique of Tarsney\'s "The Epistemic Challenge to Longtermism"', + author: 'Anjay F', + link: 'https://forum.effectivealtruism.org/posts/u9CvMCCmQRgjBD828/red-teaming-a-model-for-estimating-the-value-of-longtermist', + }, + { + title: + 'Criticism of EA Criticisms: Is the real disagreement about cause prio?', + author: 'Akash', + link: 'https://forum.effectivealtruism.org/posts/qgQaWub8iR2EERq7i/criticism-of-ea-criticisms-is-the-real-disagreement-about', + }, + { + title: 'Effective Altruism Should Seek Less Criticism', + author: 'The Chaostician', + link: 'https://forum.effectivealtruism.org/posts/oQA7Z6JKHAwvWz9wk/effective-altruism-should-seek-less-criticism', + }, + { + title: + 'The great energy descent - Part 1: Can renewables replace fossil fuels?', + author: 'Corentin Biteau', + link: 'https://forum.effectivealtruism.org/posts/qG8k5pzhaDk6FhcYv/the-great-energy-descent-part-1-can-renewables-replace', + }, + { + title: 'We’re searching for meaning, not happiness (et al.)', + author: 'Joshua Clingo', + link: 'https://forum.effectivealtruism.org/posts/gmTYoTmggojK5bywA/we-re-searching-for-meaning-not-happiness-et-al', + }, + { + title: + 'Earning to give should have focused more on “entrepreneurship to give”', + author: 'freedomandutility', + link: 'https://forum.effectivealtruism.org/posts/JXDi8tL6uoKPhg4uw/earning-to-give-should-have-focused-more-on-entrepreneurship', + }, + { + title: 'Longtermism neglects anti-ageing research', + author: 'freedomandutility', + link: 'https://forum.effectivealtruism.org/posts/pbPmhWhGxsGSzwpNE/longtermism-neglects-anti-ageing-research', + }, + { + title: + 'Popular EA Authors Should Give Libraries More Copies of Their EBooks', + author: 'RedTeamPseudonym', + link: 'https://forum.effectivealtruism.org/posts/AAL2zPtg7T6bjWijT/popular-ea-authors-should-give-libraries-more-copies-of', + }, + { + title: 'this one weird trick creates infinite utility', + author: 'Hmash', + link: 'https://forum.effectivealtruism.org/posts/BwuLaA97BAtLcbuuF/this-one-weird-trick-creates-infinite-utility', + }, + { + title: 'Think again: Should EA be a social movement?', + author: 'An A', + link: 'https://forum.effectivealtruism.org/posts/giznESHxt45SvuhZw/think-again-should-ea-be-a-social-movement', + }, + { + title: 'Rethinking longtermism and global development', + author: 'BrownHairedEevee', + link: 'https://forum.effectivealtruism.org/posts/GAEjbu2eHRXPHwTxF/rethinking-longtermism-and-global-development', + }, + { + title: 'Hiring: The Ignored Resource of Rejected EA Job Candidates', + author: 'RedTeamPseudonym', + link: 'https://forum.effectivealtruism.org/posts/ekLyLdiCCcD6BqbJR/hiring-the-ignored-resource-of-rejected-ea-job-candidates-1', + }, + { + title: 'Suggestions for 80,000K ', + author: 'RedTeamPseudonym', + link: 'https://forum.effectivealtruism.org/posts/MdvGwL4hTM2B96x4d/suggestions-for-80-000k', + }, + { + title: "We're funding task-adjusted survival (DALYs)", + author: 'brb243', + link: 'https://forum.effectivealtruism.org/posts/Gs8HS8QFBhtgAa8qo/we-re-funding-task-adjusted-survival-dalys', + }, + { + title: + 'A Need for Final Chapter Revision in Intro EA Fellowship Curricula & Other Ways to Fix Holes in The Funnel', + author: 'RedTeamPseudonym', + link: 'https://forum.effectivealtruism.org/posts/AaXDbHZhJYgxLCjYZ/a-need-for-final-chapter-revision-in-intro-ea-fellowship', + }, + { + title: 'Moral Injury, Mental Health, and Obsession in EA', + author: 'ECJ', + link: 'https://forum.effectivealtruism.org/posts/jiiyBcoZXXXT7eFHm/moral-injury-mental-health-and-obsession-in-ea', + }, + { + title: 'Are we already past the precipice?', + author: 'Dem0sthenes', + link: 'https://forum.effectivealtruism.org/posts/e6prpQSojPW3jC7YD/are-we-already-past-the-precipice', + }, + { + title: 'EA has a lying problem [Link Post]', + author: 'Nathan Young', + link: 'https://forum.effectivealtruism.org/posts/8dWms5YxYwZW9xneL/ea-has-a-lying-problem-link-post', + }, + { + title: + "Senior EA 'ops' roles: if you want to undo the bottleneck, hire differently", + author: 'AnonymousThrowAway', + link: 'https://forum.effectivealtruism.org/posts/X8YMxbWNsF5FNaCFz/senior-ea-ops-roles-if-you-want-to-undo-the-bottleneck-hire', + }, + { + title: "On Deference and Yudkowsky's AI Risk Estimates", + author: 'Ben Garfinkel', + link: 'https://forum.effectivealtruism.org/posts/NBgpPaz5vYe3tH4ga/on-deference-and-yudkowsky-s-ai-risk-estimates', + }, + { + title: 'EA is too reliant on personal connections', + author: 'sawyer', + link: 'https://forum.effectivealtruism.org/posts/dvcpKuajunxdaZ6se/ea-is-too-reliant-on-personal-connections', + }, + { + title: 'Michael Nielsen\'s "Notes on effective altruism"', + author: 'Pablo', + link: 'https://forum.effectivealtruism.org/posts/JBAPssaYMMRfNqYt7/michael-nielsen-s-notes-on-effective-altruism', + }, + { + title: 'Effective altruism in the garden of ends', + author: 'tyleralterman', + link: 'https://forum.effectivealtruism.org/posts/AjxqsDmhGiW9g8ju6/effective-altruism-in-the-garden-of-ends', + }, + { + title: 'Critique of MacAskill’s “Is It Good to Make Happy People?”', + author: 'Magnus Vinding', + link: 'https://forum.effectivealtruism.org/posts/vZ4kB8gpvkfHLfz8d/critique-of-macaskill-s-is-it-good-to-make-happy-people', + }, + { + title: 'Effective altruism is no longer the right name for the movement', + author: 'ParthThaya', + link: 'https://forum.effectivealtruism.org/posts/2FB8tK9da89qksZ9E/effective-altruism-is-no-longer-the-right-name-for-the-1', + }, + { + title: 'Prioritizing x-risks may require caring about future people', + author: 'elifland', + link: 'https://forum.effectivealtruism.org/posts/rvvwCcixmEep4RSjg/prioritizing-x-risks-may-require-caring-about-future-people', + }, + { + title: 'Ways money can make things worse', + author: 'Jan_Kulveit', + link: 'https://forum.effectivealtruism.org/posts/YKEPXLQhYjm3nP7Td/ways-money-can-make-things-worse', + }, + { + title: "EA Shouldn't Try to Exercise Direct Political Power", + author: 'iamasockpuppet', + link: 'https://forum.effectivealtruism.org/posts/BgNnctp6deoGdKtbr/ea-shouldn-t-try-to-exercise-direct-political-power', + }, + { + title: 'EA on nuclear war and expertise', + author: 'bean', + link: 'https://forum.effectivealtruism.org/posts/bCB88GKeXTaxozr6y/ea-on-nuclear-war-and-expertise', + }, + { + title: 'The most important climate change uncertainty', + author: 'cwa', + link: 'https://forum.effectivealtruism.org/posts/nBN6NENeudd2uJBCQ/the-most-important-climate-change-uncertainty', + }, + { + title: "Critique of OpenPhil's macroeconomic policy advocacy", + author: 'Hauke Hillebrandt', + link: 'https://forum.effectivealtruism.org/posts/cDdcNzyizzdZD4hbR/critique-of-openphil-s-macroeconomic-policy-advocacy', + }, + { + title: + 'Methods for improving uncertainty analysis in EA cost-effectiveness models', + author: 'Froolow', + link: 'https://forum.effectivealtruism.org/posts/CuuCGzuzwD6cdu9mo/methods-for-improving-uncertainty-analysis-in-ea-cost', + }, + { + title: + 'Did OpenPhil ever publish their in-depth review of their three-year OpenAI grant?', + author: 'Markus Amalthea Magnuson', + link: 'https://forum.effectivealtruism.org/posts/sZhhW2AECqT5JikdE/did-openphil-ever-publish-their-in-depth-review-of-their', + }, + { + title: 'Go Republican, Young EA!', + author: 'ColdButtonIssues', + link: 'https://forum.effectivealtruism.org/posts/myympkZ6SuT59vuEQ/go-republican-young-ea', + }, + { + title: + 'Are too many young, highly-engaged longtermist EAs doing movement-building?', + author: 'Anonymous_EA', + link: 'https://forum.effectivealtruism.org/posts/Lfy89vKqHatQdJgDZ/are-too-many-young-highly-engaged-longtermist-eas-doing', + }, + { + title: "EA's Culture and Thinking are Severely Limiting its Impact", + author: 'Peter Elam', + link: 'https://forum.effectivealtruism.org/posts/jhCGX8Gwq44TmyPJv/ea-s-culture-and-thinking-are-severely-limiting-its-impact', + }, + { + title: 'Criticism of EA Criticism Contest', + author: 'Zvi ', + link: 'https://forum.effectivealtruism.org/posts/qjMPATBLM5p4ABcEB/criticism-of-ea-criticism-contest', + }, + { + title: + 'The EA community might be neglecting the value of influencing people', + author: 'JulianHazell', + link: 'https://forum.effectivealtruism.org/posts/3szWd8HwWccJb9z5L/the-ea-community-might-be-neglecting-the-value-of', + }, + { + title: 'Slowing down AI progress is an underexplored alignment strategy', + author: 'Michael Huang', + link: 'https://forum.effectivealtruism.org/posts/6LNvQYyNQpDQmnnux/slowing-down-ai-progress-is-an-underexplored-alignment', + }, + { + title: 'Some core assumptions of effective altruism, according to me', + author: 'peterhartree', + link: 'https://forum.effectivealtruism.org/posts/av7MiEhi983SjoXTe/some-core-assumptions-of-effective-altruism-according-to-me', + }, + { + title: 'Transcript of Twitter Discussion on EA from June 2022', + author: 'Zvi ', + link: 'https://forum.effectivealtruism.org/posts/MpJcvzHfQyFLxLZNh/transcript-of-twitter-discussion-on-ea-from-june-2022', + }, + { + title: 'EA culture is special; we should proceed with intentionality', + author: 'James Lin', + link: 'https://forum.effectivealtruism.org/posts/KuKzqhxLzaREL7KKi/ea-culture-is-special-we-should-proceed-with-intentionality', + }, + { + title: 'Four Concerns Regarding Longtermism', + author: 'Pat Andriola', + link: 'https://forum.effectivealtruism.org/posts/ESzGcWfkMtJgF2CCA/four-concerns-regarding-longtermism', + }, + { + title: 'Chesterton Fences and EA’s X-risks', + author: 'jehan', + link: 'https://forum.effectivealtruism.org/posts/j4RnXAQgyMCSLzBkW/chesterton-fences-and-ea-s-x-risks', + }, + { + title: 'Introduction to Pragmatic AI Safety [Pragmatic AI Safety #1]', + author: 'ThomasW', + link: 'https://forum.effectivealtruism.org/posts/MskKEsj8nWREoMjQK/introduction-to-pragmatic-ai-safety-pragmatic-ai-safety-1', + }, + { + title: 'EA needs to understand its “failures” better', + author: 'mariushobbhahn', + link: 'https://forum.effectivealtruism.org/posts/Nwut6L6eAGmrFSaT4/ea-needs-to-understand-its-failures-better', + }, + { + title: 'An Evaluation of Animal Charity Evaluators ', + author: 'eaanonymous1234', + link: 'https://forum.effectivealtruism.org/posts/pfSiMpkmskRB4WxYW/an-evaluation-of-animal-charity-evaluators', + }, + { + title: 'What is the overhead of grantmaking?', + author: 'MathiasKB', + link: 'https://forum.effectivealtruism.org/posts/RXm2mxvq3ReXmsHm4/what-is-the-overhead-of-grantmaking', + }, + { + title: + 'A Critique of The Precipice: Chapter 6 - The Risk Landscape [Red Team Challenge]', + author: 'Sarah Weiler', + link: 'https://forum.effectivealtruism.org/posts/faW24r7ocbcPisgCH/a-critique-of-the-precipice-chapter-6-the-risk-landscape-red', + }, + { + title: + 'Wheeling and dealing: An internal bargaining approach to moral uncertainty', + author: 'MichaelPlant', + link: 'https://forum.effectivealtruism.org/posts/kxEAkcEvyiwmjirjN/wheeling-and-dealing-an-internal-bargaining-approach-to', + }, + { + title: 'Let’s not glorify people for how they look.', + author: 'Florence', + link: 'https://forum.effectivealtruism.org/posts/8ii5SD7HBL4EdYw5K/let-s-not-glorify-people-for-how-they-look-2', + }, + { + title: 'The first AGI will be a buggy mess', + author: 'titotal', + link: 'https://forum.effectivealtruism.org/posts/pXjpZep49M6GGxFQF/the-first-agi-will-be-a-buggy-mess', + }, + { + title: '[Cause Exploration Prizes] The importance of Intercausal Impacts', + author: 'Sebastian Joy 樂百善', + link: 'https://forum.effectivealtruism.org/posts/MayveXrHbvXMBRo78/cause-exploration-prizes-the-importance-of-intercausal', + }, + { + title: 'The Windfall Clause has a remedies problem', + author: 'John Bridge', + link: 'https://forum.effectivealtruism.org/posts/wBzfLyfJFfocmdrwL/the-windfall-clause-has-a-remedies-problem', + }, + { + title: 'Future Paths for Effective Altruism', + author: 'James Broughel', + link: 'https://forum.effectivealtruism.org/posts/yzAoHcTzf3AjeGYsP/future-paths-for-effective-altruism', + }, + { + title: 'The Effective Altruism culture', + author: 'PabloAMC', + link: 'https://forum.effectivealtruism.org/posts/NkF9rAjZpkDajqDDt/the-effective-altruism-culture', + }, + { + title: + 'The Role of Individual Consumption Decisions in Animal Welfare and Climate are Analogous', + author: 'Gabriel Weil', + link: 'https://forum.effectivealtruism.org/posts/HWpwfTF5M84jo4iyo/the-role-of-individual-consumption-decisions-in-animal', + }, + { + title: 'Criticism of the main framework in AI alignment', + author: 'Michele Campolo', + link: 'https://forum.effectivealtruism.org/posts/Cs8qhNakLuLXY4GvE/criticism-of-the-main-framework-in-ai-alignment', + }, + { + title: 'Crowdsourced Criticisms: What does EA think about EA?', + author: 'Hmash', + link: 'https://forum.effectivealtruism.org/posts/jK2Qends7GnyaRhm2/crowdsourced-criticisms-what-does-ea-think-about-ea', + }, + { + title: + 'EAs should recommend cost-effective interventions in more cause areas (not just the most pressing ones) \n\n', + author: 'Amber Dawn', + link: 'https://forum.effectivealtruism.org/posts/JiEyCNoGD3WwTgDkG/eas-should-recommend-cost-effective-interventions-in-more', + }, + { + title: + 'AGI Battle Royale: Why “slow takeover” scenarios devolve into a chaotic multi-AGI fight to the death', + author: 'titotal', + link: 'https://forum.effectivealtruism.org/posts/TxrzhfRr6EXiZHv4G/agi-battle-royale-why-slow-takeover-scenarios-devolve-into-a', + }, + { + title: 'Effective means to combat autocracies', + author: 'Junius Brutus', + link: 'https://forum.effectivealtruism.org/posts/kawE7rFmp3SkzLxpx/effective-means-to-combat-autocracies', + }, + { + title: 'Editing wild animals is underexplored in What We Owe the Future', + author: 'Michael Huang', + link: 'https://forum.effectivealtruism.org/posts/cWnQMagKFqJoaGA5M/editing-wild-animals-is-underexplored-in-what-we-owe-the', + }, + { + title: 'Reasons for my negative feelings towards the AI risk discussion', + author: 'fergusq', + link: 'https://forum.effectivealtruism.org/posts/hLbWWuDr3EbeQqrmg/reasons-for-my-negative-feelings-towards-the-ai-risk', + }, + { + title: + 'We need more discussion and clarity on how university groups create value', + author: 'Oscar Galvin', + link: 'https://forum.effectivealtruism.org/posts/HNHHNCDLEsDNjNwvm/we-need-more-discussion-and-clarity-on-how-university-groups', + }, + { + title: 'What 80000 Hours gets wrong about solar geoengineering', + author: 'Gideon Futerman', + link: 'https://forum.effectivealtruism.org/posts/6dbET4f9LbJZZTuDW/what-80000-hours-gets-wrong-about-solar-geoengineering', + }, + { + title: + 'Concerns/Thoughts over international aid, longtermism and philosophical notes on speaking with Larry Temkin.', + author: 'Ben Yeoh', + link: 'https://forum.effectivealtruism.org/posts/uhaKXdkAcuXJZHSci/concerns-thoughts-over-international-aid-longtermism-and', + }, + { + title: 'On longtermism, Bayesianism, and the doomsday argument', + author: 'iporphyry', + link: 'https://forum.effectivealtruism.org/posts/f2RzSd2ukFZyNB86L/on-longtermism-bayesianism-and-the-doomsday-argument', + }, + { + title: 'Friendship is Optimal: EAGs should be online', + author: 'Emrik', + link: 'https://forum.effectivealtruism.org/posts/35nRwEyzCKDfh3dCr/friendship-is-optimal-eags-should-be-online', + }, + { + title: 'A Critique of AI Takeover Scenarios', + author: 'Fods12', + link: 'https://forum.effectivealtruism.org/posts/j7X8nQ7YvvA7Pi4BX/a-critique-of-ai-takeover-scenarios', + }, + { + title: 'The dangers of high salaries within EA organisations', + author: 'James Ozden', + link: 'https://forum.effectivealtruism.org/posts/WXD3bRDBkcBhJ5Wcr/the-dangers-of-high-salaries-within-ea-organisations', + }, + { + title: 'Low-key Longtermism', + author: 'Jonathan Rystrom', + link: 'https://forum.effectivealtruism.org/posts/BaynHfmkjrfL8DXcK/low-key-longtermism', + }, + { + title: + 'The Credibility of Apocalyptic Claims: A Critique of Techno-Futurism within Existential Risk', + author: 'Ember', + link: 'https://forum.effectivealtruism.org/posts/a2XaDeadFe6eHfDwG/the-credibility-of-apocalyptic-claims-a-critique-of-techno', + }, + { + title: + 'The Role of "Economism" in the Belief-Formation Systems of Effective Altruism', + author: 'Thomas Aitken', + link: 'https://forum.effectivealtruism.org/posts/cR4pCrATD5SSN35Sm/the-role-of-economism-in-the-belief-formation-systems-of', + }, + { + title: + 'A slightly (I think?) different slant on why EA elitism bias/top-university focus/lack of diversity is a problem', + author: 'RedTeamPseudonym', + link: 'https://forum.effectivealtruism.org/posts/LCfQCvtFyAEnxCnMf/a-slightly-i-think-different-slant-on-why-ea-elitism-bias', + }, + { + title: 'Chaining the evil genie: why "outer" AI safety is probably easy', + author: 'titotal', + link: 'https://forum.effectivealtruism.org/posts/AoPR8BFrAFgGGN9iZ/chaining-the-evil-genie-why-outer-ai-safety-is-probably-easy', + }, + { + title: 'Should EA shift away (a bit) from elite universities?', + author: 'Joseph Lemien', + link: 'https://forum.effectivealtruism.org/posts/Rts8vKvbxkngPbFh7/should-ea-shift-away-a-bit-from-elite-universities', + }, + { + title: 'Aesthetics as Epistemic Humility', + author: 'Étienne Fortier-Dubois', + link: 'https://forum.effectivealtruism.org/posts/bo6Jsvmq9oiykbDrM/aesthetics-as-epistemic-humility', + }, + { + title: + "What if states don't listen? A fundamental gap in x-risk reduction strategies ", + author: 'HTC', + link: 'https://forum.effectivealtruism.org/posts/sFxtu6ZKAScDSqLrK/what-if-states-don-t-listen-a-fundamental-gap-in-x-risk', + }, + { + title: 'Eliminate or Adjust Strong Upvotes to Improve the Forum', + author: 'Afternoon Coffee', + link: 'https://forum.effectivealtruism.org/posts/2XGFdBkxa5Hm5LWZq/eliminate-or-adjust-strong-upvotes-to-improve-the-forum', + }, + { + title: 'On the Philosophical Foundations of EA', + author: 'mm6', + link: 'https://forum.effectivealtruism.org/posts/gLWmeKTe68ZHnomwy/on-the-philosophical-foundations-of-ea', + }, + { + title: + "Why I Hope (Certain) Hedonic Utilitarians Don't Control the Long-term Future", + author: 'Jared_Riggs', + link: 'https://forum.effectivealtruism.org/posts/PJKecg5ugYnyWhezC/why-i-hope-certain-hedonic-utilitarians-don-t-control-the', + }, + { + title: 'Be More Succinct', + author: 'RedTeamPseudonym', + link: 'https://forum.effectivealtruism.org/posts/eNa8GpEi5HX94CZ2n/be-more-succinct', + }, + { + title: 'Against Anthropic Shadow', + author: 'tobycrisford', + link: 'https://forum.effectivealtruism.org/posts/A47EWTS6oBKLqxBpw/against-anthropic-shadow', + }, + { + title: + 'Ideological tensions between Effective Altruism and The UK Civil Service', + author: 'KZ X', + link: 'https://forum.effectivealtruism.org/posts/J7cAFqq9g9LzSe5E3/ideological-tensions-between-effective-altruism-and-the-uk', + }, + { + title: 'We’re really bad at guessing the future', + author: 'Benj Azose', + link: 'https://forum.effectivealtruism.org/posts/DkmNPpqTJKmudBHnp/we-re-really-bad-at-guessing-the-future', + }, + { + title: + 'Effective Altruism, the Principle of Explosion and Epistemic Fragility', + author: 'Eigengender', + link: 'https://forum.effectivealtruism.org/posts/zG4pnJBCMi5t49Eya/effective-altruism-the-principle-of-explosion-and-epistemic', + }, + { + title: + 'Should EA influence governments to enact more effective interventions?', + author: 'Markus Amalthea Magnuson', + link: 'https://forum.effectivealtruism.org/posts/pGPcfjxazPGFJyYHW/should-ea-influence-governments-to-enact-more-effective', + }, + { + title: 'Should large EA nonprofits consider splitting?', + author: 'Arepo', + link: 'https://forum.effectivealtruism.org/posts/J3pZ7fY6yvypvJrJE/should-large-ea-nonprofits-consider-splitting', + }, + { + title: + "Evaluating large-scale movement building: A better way to critique Open Philanthropy's criminal justice reform", + author: 'ruthgrace', + link: 'https://forum.effectivealtruism.org/posts/7ajePuRKiCo7fA92B/evaluating-large-scale-movement-building-a-better-way-to', + }, + { + title: 'Evaluation of Longtermist Institutional Reform', + author: 'Dwarkesh Patel', + link: 'https://forum.effectivealtruism.org/posts/v4Z6phNcDsdXtzj2K/evaluation-of-longtermist-institutional-reform', + }, + { + title: 'A Quick List of Some Problems in AI Alignment As A Field', + author: 'NicholasKross', + link: 'https://forum.effectivealtruism.org/posts/JFmhYuso5s9PgrQET/a-quick-list-of-some-problems-in-ai-alignment-as-a-field', + }, + { + title: 'Fixing bad incentives in EA', + author: 'IncentivesAccount', + link: 'https://forum.effectivealtruism.org/posts/3PrTiXhhNBdGtR9qf/fixing-bad-incentives-in-ea', + }, + { + title: 'The danger of good stories', + author: 'DuncanS', + link: 'https://forum.effectivealtruism.org/posts/eZK95zxwpzNySRebC/the-danger-of-good-stories', + }, + { + title: 'A dilemma for Maximize Expected Choiceworthiness (MEC)', + author: 'Calvin_Baker', + link: 'https://forum.effectivealtruism.org/posts/Gk7NhzFy2hHFdFTYr/a-dilemma-for-maximize-expected-choiceworthiness-mec', + }, + { + title: 'Should you still use the ITN framework? [Red Teaming Contest]', + author: 'frib', + link: 'https://forum.effectivealtruism.org/posts/hjH94Ji4CrpKadoCi/should-you-still-use-the-itn-framework-red-teaming-contest', + }, + { + title: 'Proposed tweak to the longtermism pitch', + author: 'TheOtherHannah', + link: 'https://forum.effectivealtruism.org/posts/nAvpSXELT2FZMD9aA/proposed-tweak-to-the-longtermism-pitch', + }, + { + title: 'EA vs. FIRE – reconciling these two movements', + author: 'Stewed_Walrus', + link: 'https://forum.effectivealtruism.org/posts/j2ccaxmHcjiwGDs9T/ea-vs-fire-reconciling-these-two-movements', + }, + { + title: 'Should young EAs really focus on career capital?', + author: 'Michael B.', + link: 'https://forum.effectivealtruism.org/posts/RYBFyDWAYZL4YCkW2/should-young-eas-really-focus-on-career-capital', + }, + { + title: 'Prediction Markets are Somewhat Overrated Within EA', + author: 'Francis', + link: 'https://forum.effectivealtruism.org/posts/4LsrNczpF6mfrHP4M/prediction-markets-are-somewhat-overrated-within-ea', + }, + { + title: 'Capitalism, power and epistemology: a critique of EA', + author: 'Matthew_Doran', + link: 'https://forum.effectivealtruism.org/posts/xWFhD6uQuZehrDKeY/capitalism-power-and-epistemology-a-critique-of-ea', + }, + { + title: 'EA Worries and Criticism ', + author: 'Connor Tabarrok', + link: 'https://forum.effectivealtruism.org/posts/A5tRZC2mduJfpMhud/ea-worries-and-criticism', + }, + { + title: 'EA criticism contest: Why I am not an effective altruist', + author: 'ErikHoel', + link: 'https://forum.effectivealtruism.org/posts/PZ6pEaNkzAg62ze69/ea-criticism-contest-why-i-am-not-an-effective-altruist', + }, + { + title: 'Nuclear Fine-Tuning: How Many Worlds Have Been Destroyed?', + author: 'Ember', + link: 'https://forum.effectivealtruism.org/posts/Gg2YsjGe3oahw2kxE/nuclear-fine-tuning-how-many-worlds-have-been-destroyed', + }, + { + title: 'An epistemic critique of longtermism', + author: 'Nathan_Barnard', + link: 'https://forum.effectivealtruism.org/posts/2455tgtiBsm5KXBfv/an-epistemic-critique-of-longtermism', + }, + { + title: 'Red Team: Write More.', + author: 'Weaver', + link: 'https://forum.effectivealtruism.org/posts/5A5cMh223b9s4uHwE/red-team-write-more', + }, + { + title: 'End-To-End Encryption For EA', + author: 'Talking Tree', + link: 'https://forum.effectivealtruism.org/posts/tekdQKdfFe3YJTwML/end-to-end-encryption-for-ea', + }, + { + title: 'Effective Altruism is Unkind', + author: 'Oliver Scott Curry', + link: 'https://forum.effectivealtruism.org/posts/cC6tGHctzrMmEAH8j/effective-altruism-is-unkind', + }, + { + title: 'Towards a more ecumenical EA movement ', + author: 'Locke', + link: 'https://forum.effectivealtruism.org/posts/NR2Y2B8Y4Wxn8pAS8/towards-a-more-ecumenical-ea-movement', + }, + { + title: + 'Effective altruism is similar to the AI alignment problem and suffers from the same difficulties [Criticism and Red Teaming Contest entry]', + author: 'turchin', + link: 'https://forum.effectivealtruism.org/posts/g8fn7oyvki4psJeYR/effective-altruism-is-similar-to-the-ai-alignment-problem', + }, + { + title: + 'The great energy descent - Part 2: Limits to growth and why we probably won’t reach the stars', + author: 'Corentin Biteau', + link: 'https://forum.effectivealtruism.org/posts/8sW4h368DsoooHBNP/the-great-energy-descent-part-2-limits-to-growth-and-why-we', + }, + { + title: 'What a Large and Welcoming EA Could Accomplish', + author: 'Peter Elam', + link: 'https://forum.effectivealtruism.org/posts/K24widt85ZbGqzZKN/what-a-large-and-welcoming-ea-could-accomplish', + }, + { + title: 'Should we call ourselves effective altruists?', + author: 'Sam_Coggins', + link: 'https://forum.effectivealtruism.org/posts/YyDRSARnXz8r5dgca/should-we-call-ourselves-effective-altruists', + }, + { + title: 'Compounding assumptions and what it mean to be altruistic', + author: 'Badger', + link: 'https://forum.effectivealtruism.org/posts/4RGuqDxui2xWkXnda/compounding-assumptions-and-what-it-mean-to-be-altruistic', + }, + { + title: + 'The great energy descent - Post 3: What we can do, what we can’t do', + author: 'Corentin Biteau', + link: 'https://forum.effectivealtruism.org/posts/9zTLPy3zqJ7YfS7kn/the-great-energy-descent-post-3-what-we-can-do-what-we-can-t', + }, + { + title: 'Enantiodromia', + author: 'ChristianKleineidam', + link: 'https://forum.effectivealtruism.org/posts/b4ASDM434qh3rxLki/enantiodromia', + }, + { + title: 'Deontology, the Paralysis Argument and altruistic longtermism', + author: "William D'Alessandro", + link: 'https://forum.effectivealtruism.org/posts/DKe5eQhJoLNMWgaQv/deontology-the-paralysis-argument-and-altruistic-longtermism', + }, + { + title: 'Path dependence and its impact on long-term outcomes', + author: 'Archanaa', + link: 'https://forum.effectivealtruism.org/posts/jadS8deYknecGSebp/path-dependence-and-its-impact-on-long-term-outcomes', + }, + { + title: 'Histories of Value Lock-in and Ideology Critique', + author: 'clem', + link: 'https://forum.effectivealtruism.org/posts/poWd3CcGeQPas3Zbo/histories-of-value-lock-in-and-ideology-critique', + }, + { + title: 'A Case Against Strong Longtermism', + author: 'A. Wolff', + link: 'https://forum.effectivealtruism.org/posts/LADQ6dTGsQ2BBMrBv/a-case-against-strong-longtermism-1', + }, + { + title: 'The totalitarian implications of Effective Altruism', + author: 'Ed_Talks', + link: 'https://forum.effectivealtruism.org/posts/guyuidDdxNNxFegbJ/the-totalitarian-implications-of-effective-altruism-1', + }, + { + title: 'Forecasting Through Fiction', + author: 'Yitz', + link: 'https://forum.effectivealtruism.org/posts/DhJhtxMX6SdYAsWiY/forecasting-through-fiction', + }, + { + title: 'EA Undervalues Unseen Data', + author: 'tcelferact', + link: 'https://forum.effectivealtruism.org/posts/MpYPCq9dW8wovYpRY/ea-undervalues-unseen-data', + }, + { + title: 'The Happiness Maximizer:\nWhy EA is an x-risk', + author: 'Obasi Shaw', + link: 'https://forum.effectivealtruism.org/posts/ByHc6jdXF9skwevYf/the-happiness-maximizer-why-ea-is-an-x-risk', + }, + { + title: 'EA is a fight against Knightian uncertainty', + author: 'Rohit (Strange Loop)', + link: 'https://forum.effectivealtruism.org/posts/vic7EdWCGKd4fYtYd/ea-is-a-fight-against-knightian-uncertainty', + }, + { + title: + 'The Malthusian Gradient: Why some third-world interventions may be doing more harm than good', + author: 'JoePater', + link: 'https://forum.effectivealtruism.org/posts/juFzy7CWhu6ApQMAA/the-malthusian-gradient-why-some-third-world-interventions', + }, + { + title: 'The Hidden Impossibilities Of Being An Effective Altruist.', + author: 'Refined Insights ', + link: 'https://forum.effectivealtruism.org/posts/fsxEDLM2oPzSREM4G/the-hidden-impossibilities-of-being-an-effective-altruist', + }, + { + title: 'A critique of strong longtermism', + author: 'Pablo Rosado', + link: 'https://forum.effectivealtruism.org/posts/ryJys2fAz7J4vAwFC/a-critique-of-strong-longtermism', + }, + { + title: 'Making EA More Effective', + author: 'Peter Kelly', + link: 'https://forum.effectivealtruism.org/posts/Ag6bsmqxwqWTSjcHX/making-ea-more-effective', + }, + { + title: "A part of the system's apology", + author: 'Niv Cohen', + link: 'https://forum.effectivealtruism.org/posts/vNTD4mBAzfyZFJkfW/a-part-of-the-system-s-apology', + }, + { + title: 'The Wages of North-Atlantic Bias', + author: 'Sach Wry', + link: 'https://forum.effectivealtruism.org/posts/FA4tC72qAB5k37uFC/the-wages-of-north-atlantic-bias', + }, + { + title: 'Keeping it Real', + author: 'calumdavey', + link: 'https://forum.effectivealtruism.org/posts/ewEiyspZeqjZC7Yh7/keeping-it-real', + }, + { + title: 'Hobbit Manifesto', + author: 'Clay Cube', + link: 'https://forum.effectivealtruism.org/posts/3caZ7LhMsvsS7kRrz/hobbit-manifesto', + }, + { + title: + "How avoiding drastic career changes could support EA's epistemic health and long-term efficacy.", + author: 'nat goldthwaite', + link: 'https://forum.effectivealtruism.org/posts/3txJdk6ZcNmcRBjWP/how-avoiding-drastic-career-changes-could-support-ea-s', + }, + { + title: + "Present-day good intentions aren't sufficient to make the longterm future good in expectation", + author: 'trurl', + link: 'https://forum.effectivealtruism.org/posts/FBNk5ibcWwYcavkh4/present-day-good-intentions-aren-t-sufficient-to-make-the', + }, + { + title: + 'A podcast episode exploring critiques of effective altruism (with Michael Nielsen and Ajeya Cotra)', + author: 'spencerg', + link: 'https://forum.effectivealtruism.org/posts/2dHk3zBmmnNTefjWB/a-podcast-episode-exploring-critiques-of-effective-altruism', + }, + { + title: 'Follow-up: Crowdsourced Criticisms', + author: 'Hmash', + link: 'https://forum.effectivealtruism.org/posts/kXCsTDB5s7QRnWS8f/follow-up-crowdsourced-criticisms', + }, + { + title: 'Why the EA aversion to local altruistic action?', + author: 'Locke', + link: 'https://forum.effectivealtruism.org/posts/LnuuN7zuBSZvEo845/why-the-ea-aversion-to-local-altruistic-action', + }, + { + title: 'Effective Altruists and Religion: A Proposal for Experimentation', + author: 'Kbrown', + link: 'https://forum.effectivealtruism.org/posts/YA6fCNwB2c5cydrtG/effective-altruists-and-religion-a-proposal-for', + }, + { + title: + 'On the institutional critique of effective altruism: a response (mainly) to Brian Berkey ', + author: 'zzz1407', + link: 'https://forum.effectivealtruism.org/posts/GgNgnzjqceDghhozf/on-the-institutional-critique-of-effective-altruism-a-1', + }, + { + title: + 'We Can’t Do Long Term Utilitarian Calculations Until We Know if AIs Can Be Conscious or Not', + author: 'Mike20731', + link: 'https://forum.effectivealtruism.org/posts/Zsz3BYQTJjJdZd4DR/we-can-t-do-long-term-utilitarian-calculations-until-we-know', + }, + { + title: 'The ordinal utility argument against effective altruism ', + author: 'Barracuda', + link: 'https://forum.effectivealtruism.org/posts/rNYCcRLzkQtQEBnLa/the-ordinal-utility-argument-against-effective-altruism', + }, + { + title: + 'Reciprocity & the causes of diminishing returns: cause exploration submission', + link: 'https://forum.effectivealtruism.org/posts/x9towRLtvYidkXugk/reciprocity-and-the-causes-of-diminishing-returns-cause', + }, + { + title: + 'Altruism is systems change, so why isn’t EA? Constructive criticism.', + link: 'https://forum.effectivealtruism.org/posts/xZrvbwhSLmsGmHHSD/altruism-is-systems-change-so-why-isn-t-ea-constructive', + }, + { + title: 'The reasonableness of special concerns', + author: 'jwt', + link: 'https://forum.effectivealtruism.org/posts/CFGYLDgvsYQhsyZ42/the-reasonableness-of-special-concerns', + }, + { + title: 'The EA community should utilize the concept of beliefs more often', + author: 'Noah Scales', + link: 'https://forum.effectivealtruism.org/posts/9SKqeNSvAKozeMvGq/the-ea-community-should-utilize-the-concept-of-beliefs-more', + }, + { + title: 'Why bother doing the most good?', + author: 'Dov', + link: 'https://forum.effectivealtruism.org/posts/ZPcKeZbcC5SgLGLwg/why-bother-doing-the-most-good', + }, + { + title: + 'Framing EA as tending towards longtermism might be diminishing its potential impact', + author: 'Mm', + link: 'https://forum.effectivealtruism.org/posts/guGteQYvwcuDAECPA/framing-ea-as-tending-towards-longtermism-might-be', + }, + { + title: 'Bernard Williams: Ethics and the limits of impartiality', + author: 'peterhartree', + link: 'https://forum.effectivealtruism.org/posts/G6EWTrArPDf74sr3S/bernard-williams-ethics-and-the-limits-of-impartiality', + }, + { + title: 'Love and AI: Relational Brain/Mind Dynamics in AI Development', + author: 'JeffreyK', + link: 'https://forum.effectivealtruism.org/posts/MdfLn33GpNWGN7CSE/love-and-ai-relational-brain-mind-dynamics-in-ai-development', + }, + { + title: 'When 2/3rds of the world goes against you', + author: 'JeffreyK', + link: 'https://forum.effectivealtruism.org/posts/6va2EfHkQ3bTmdDyn/when-2-3rds-of-the-world-goes-against-you', + }, + { + title: 'My views on EA --> attempt to a constructive criticism', + author: 'Jin Jo', + link: 'https://forum.effectivealtruism.org/posts/LcDcqX6KWGHm3tSgr/my-views-on-ea-greater-than-attempt-to-a-constructive', + }, + { + title: 'Empirical critique of EA from another direction', + author: 'tonz', + link: 'https://forum.effectivealtruism.org/posts/nsqhmwmwZmWvFA2wb/empirical-critique-of-ea-from-another-direction', + }, + { + title: 'Critique: Cost-Benefit of Weirdness', + author: 'Mike Elias', + link: 'https://forum.effectivealtruism.org/posts/kw8ZmziAwcqPW2jt6/critique-cost-benefit-of-weirdness', + }, + { + title: 'Hits- or misses-based giving', + author: 'brb243', + link: 'https://forum.effectivealtruism.org/posts/XzawnaT4jyqpkEihz/hits-or-misses-based-giving', + }, + { + title: 'Mind your step', + author: 'Talsome', + link: 'https://forum.effectivealtruism.org/posts/rGNaz4GtWCzPbCWCB/mind-your-step', + }, + { + title: 'Against Impartial Altruism', + author: 'Sam K', + link: 'https://forum.effectivealtruism.org/posts/f5ZxK2k9gyZthHGND/against-impartial-altruism', + }, + { + title: 'Criticism of EA and longtermism', + author: 'St. Ignorant', + link: 'https://forum.effectivealtruism.org/posts/DuG8rBSAErSmSN7uE/criticism-of-ea-and-longtermism', + }, + { + title: + 'Against Longtermism: \nI welcome our robot overlords, and you should too!', + author: 'MattBall', + link: 'https://forum.effectivealtruism.org/posts/Cuu4Jjmp7QqL4a5Ls/against-longtermism-i-welcome-our-robot-overlords-and-you', + }, + { + title: 'Effective Altruism Criticisms', + author: 'Gavin Palmer (heroLFG.com)', + link: 'https://forum.effectivealtruism.org/posts/mMaAcvNLQPC3aTqB6/effective-altruism-criticisms', + }, + { + title: '"Of Human Bondage" and Morality', + author: 'Casaubon', + link: 'https://forum.effectivealtruism.org/posts/ZJnKCToBojYqqQphb/of-human-bondage-and-morality', + }, + { + title: + 'Portfolios, Locality, and Career - Three Critiques of Effective Altruism', + author: 'Philip Apps', + link: 'https://forum.effectivealtruism.org/posts/AoL2h2ZqTSNevdtRM/portfolios-locality-and-career-three-critiques-of-effective', + }, + { + title: 'Effective altruism in a non-ideal world', + author: 'Eric Kramer', + link: 'https://forum.effectivealtruism.org/posts/2nApcLJsZeABu38uW/effective-altruism-in-a-non-ideal-world', + }, + { + title: 'The future of humanity', + author: 'Dem0sthenes', + link: 'https://forum.effectivealtruism.org/posts/nLyG65eQepKKeGbrg/the-future-of-humanity', + }, + { + title: + 'Investigating Ideology: want to earn money, help EA and/or me? Then check this out; it may be a mighty neglected cause ', + author: 'Dov', + link: 'https://forum.effectivealtruism.org/posts/twaKWNjAc4KEz3kMq/investigating-ideology-want-to-earn-money-help-ea-and-or-me', + }, + { + title: 'Accepting the Inevitability of Ambitious Egoism (”AE”)', + author: 'Dem0sthenes', + link: 'https://forum.effectivealtruism.org/posts/bQsxsaEcvxzEML9ZW/accepting-the-inevitability-of-ambitious-egoism-ae', + }, + { + title: 'Book Review: What We Owe The Future (Erik Hoel)', + author: 'ErikHoel', + link: 'https://forum.effectivealtruism.org/posts/AyPTZLTwm5hN2Kfcb/book-review-what-we-owe-the-future-erik-hoel', + }, + { + title: + 'Effective Altruism Goes Political: Normative Conflicts and Practical Judgment', + author: 'Michael Haiden', + link: 'https://forum.effectivealtruism.org/posts/aisE9yhZHuiM9Cdn7/effective-altruism-goes-political-normative-conflicts-and-1', + }, + { + title: 'Values lock-in is already happening (without AGI)', + link: 'https://forum.effectivealtruism.org/posts/ogwD28mzJy8dkwtmc/values-lock-in-is-already-happening-without-agi', + }, + { + title: 'EA Should Rename Itself', + author: 'Name Rectifier', + link: 'https://forum.effectivealtruism.org/posts/swkxLtjG9z7RY7i9x/ea-should-rename-itself', + }, + { + title: 'What we are for? Community, Correction and Scale [wip]', + author: 'Nathan Young', + link: 'https://forum.effectivealtruism.org/posts/QCv5GNcQFeH34iN2w/what-we-are-for-community-correction-and-scale-wip', + }, + { + title: '“One should love one’s neighbor more than oneself.”', + author: 'Barracuda', + link: 'https://forum.effectivealtruism.org/posts/bxbu8v83gw3MDzCBX/one-should-love-one-s-neighbor-more-than-oneself', + }, + { + title: 'Run For President', + author: 'Brian Moore', + link: 'https://forum.effectivealtruism.org/posts/ZniCnE8XhCMLeGHj8/run-for-president', + }, + { + title: 'Effective Altruism Risks Perpetuating a Harmful Worldview', + author: 'Theo Cox', + link: 'https://forum.effectivealtruism.org/posts/QRaf9iWvGbfKgWBvY/effective-altruism-risks-perpetuating-a-harmful-worldview', + }, +] diff --git a/functions/src/scripts/contest/scrape-ea.ts b/functions/src/scripts/contest/scrape-ea.ts new file mode 100644 index 00000000..c22f4ac7 --- /dev/null +++ b/functions/src/scripts/contest/scrape-ea.ts @@ -0,0 +1,55 @@ +// Run with `npx ts-node src/scripts/contest/scrape-ea.ts` +import * as fs from 'fs' +import * as puppeteer from 'puppeteer' + +export function scrapeEA(contestLink: string, fileName: string) { + ;(async () => { + const browser = await puppeteer.launch({ headless: true }) + const page = await browser.newPage() + await page.goto(contestLink) + + let loadMoreButton = await page.$('.LoadMore-root') + + while (loadMoreButton) { + await loadMoreButton.click() + await page.waitForNetworkIdle() + loadMoreButton = await page.$('.LoadMore-root') + } + + /* Run javascript inside the page */ + const data = await page.evaluate(() => { + const list = [] + const items = document.querySelectorAll('.PostsItem2-root') + + for (const item of items) { + const link = + 'https://forum.effectivealtruism.org' + + item?.querySelector('a')?.getAttribute('href') + + // Replace '&' with '&' + const clean = (str: string | undefined) => str?.replace(/&/g, '&') + + list.push({ + title: clean(item?.querySelector('a>span>span')?.innerHTML), + author: item?.querySelector('a.UsersNameDisplay-userName')?.innerHTML, + link: link, + }) + } + + return list + }) + + fs.writeFileSync( + `./src/scripts/contest/${fileName}.ts`, + `export const data = ${JSON.stringify(data, null, 2)}` + ) + + console.log(data) + await browser.close() + })() +} + +scrapeEA( + 'https://forum.effectivealtruism.org/topics/criticism-and-red-teaming-contest', + 'criticism-and-red-teaming' +) diff --git a/functions/src/serve.ts b/functions/src/serve.ts index 6d062d40..99ac6281 100644 --- a/functions/src/serve.ts +++ b/functions/src/serve.ts @@ -28,6 +28,7 @@ import { stripewebhook, createcheckoutsession } from './stripe' import { getcurrentuser } from './get-current-user' import { createpost } from './create-post' import { savetwitchcredentials } from './save-twitch-credentials' +import { testscheduledfunction } from './test-scheduled-function' type Middleware = (req: Request, res: Response, next: NextFunction) => void const app = express() @@ -69,6 +70,7 @@ addJsonEndpointRoute('/getcurrentuser', getcurrentuser) addJsonEndpointRoute('/savetwitchcredentials', savetwitchcredentials) addEndpointRoute('/stripewebhook', stripewebhook, express.raw()) addEndpointRoute('/createpost', createpost) +addEndpointRoute('/testscheduledfunction', testscheduledfunction) app.listen(PORT) console.log(`Serving functions on port ${PORT}.`) diff --git a/functions/src/test-scheduled-function.ts b/functions/src/test-scheduled-function.ts new file mode 100644 index 00000000..41aa9fe9 --- /dev/null +++ b/functions/src/test-scheduled-function.ts @@ -0,0 +1,17 @@ +import { APIError, newEndpoint } from './api' +import { sendPortfolioUpdateEmailsToAllUsers } from './weekly-portfolio-emails' +import { isProd } from './utils' + +// Function for testing scheduled functions locally +export const testscheduledfunction = newEndpoint( + { method: 'GET', memory: '4GiB' }, + async (_req) => { + if (isProd()) + throw new APIError(400, 'This function is only available in dev mode') + + // Replace your function here + await sendPortfolioUpdateEmailsToAllUsers() + + return { success: true } + } +) 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(firestore.collection('users')), - getValues(firestore.collection('contracts')), - getValues(firestore.collectionGroup('bets')), - getValues( - firestore - .collectionGroup('portfolioHistory') - .where('timestamp', '>', Date.now() - 31 * DAY_MS) // so it includes just over a month ago - ), - getValues(firestore.collection('groups')), - ]) + console.log('Loading users') + const users = await getValues(firestore.collection('users')) + console.log('Loading contracts') + const contracts = await getValues(firestore.collection('contracts')) + + console.log('Loading portfolio history') + const allPortfolioHistories = await getValues( + 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(firestore.collection('groups')) + + console.log('Loading bets') + const contractBets = await batchedWaitAll( + contracts + .filter((c) => c.id) + .map( + (c) => () => + getValues( + 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.` diff --git a/functions/src/utils.ts b/functions/src/utils.ts index 6bb8349a..efc22e53 100644 --- a/functions/src/utils.ts +++ b/functions/src/utils.ts @@ -170,3 +170,7 @@ export const chargeUser = ( export const getContractPath = (contract: Contract) => { return `/${contract.creatorUsername}/${contract.slug}` } + +export function contractUrl(contract: Contract) { + return `https://manifold.markets/${contract.creatorUsername}/${contract.slug}` +} diff --git a/functions/src/weekly-markets-emails.ts b/functions/src/weekly-markets-emails.ts index bec5949c..7c6f21a4 100644 --- a/functions/src/weekly-markets-emails.ts +++ b/functions/src/weekly-markets-emails.ts @@ -46,12 +46,14 @@ async function sendTrendingMarketsEmailsToAllUsers() { ? await getAllPrivateUsers() : filterDefined([await getPrivateUser('6hHpzvRG0pMq8PNJs7RZj2qlZGn2')]) // get all users that haven't unsubscribed from weekly emails - const privateUsersToSendEmailsTo = privateUsers.filter((user) => { - return ( - user.notificationPreferences.trending_markets.includes('email') && - !user.weeklyTrendingEmailSent - ) - }) + const privateUsersToSendEmailsTo = privateUsers + .filter((user) => { + return ( + user.notificationPreferences.trending_markets.includes('email') && + !user.weeklyTrendingEmailSent + ) + }) + .slice(150) // Send the emails out in batches log( 'Sending weekly trending emails to', privateUsersToSendEmailsTo.length, @@ -74,6 +76,7 @@ async function sendTrendingMarketsEmailsToAllUsers() { trendingContracts.map((c) => c.question).join('\n ') ) + // TODO: convert to Promise.all for (const privateUser of privateUsersToSendEmailsTo) { if (!privateUser.email) { log(`No email for ${privateUser.username}`) @@ -84,6 +87,9 @@ async function sendTrendingMarketsEmailsToAllUsers() { }) if (contractsAvailableToSend.length < numContractsToSend) { log('not enough new, unbet-on contracts to send to user', privateUser.id) + await firestore.collection('private-users').doc(privateUser.id).update({ + weeklyTrendingEmailSent: true, + }) continue } // choose random subset of contracts to send to user diff --git a/functions/src/weekly-portfolio-emails.ts b/functions/src/weekly-portfolio-emails.ts new file mode 100644 index 00000000..198fa7ca --- /dev/null +++ b/functions/src/weekly-portfolio-emails.ts @@ -0,0 +1,295 @@ +import * as functions from 'firebase-functions' +import * as admin from 'firebase-admin' + +import { Contract, CPMMContract } from '../../common/contract' +import { + getAllPrivateUsers, + getPrivateUser, + getUser, + getValue, + getValues, + isProd, + log, +} from './utils' +import { filterDefined } from '../../common/util/array' +import { DAY_MS } from '../../common/util/time' +import { partition, sortBy, sum, uniq } from 'lodash' +import { Bet } from '../../common/bet' +import { computeInvestmentValueCustomProb } from '../../common/calculate-metrics' +import { sendWeeklyPortfolioUpdateEmail } from './emails' +import { contractUrl } from './utils' +import { Txn } from '../../common/txn' +import { formatMoney } from '../../common/util/format' +import { getContractBetMetrics } from '../../common/calculate' + +export const weeklyPortfolioUpdateEmails = functions + .runWith({ secrets: ['MAILGUN_KEY'], memory: '4GB' }) + // every minute on Friday for an hour at 12pm PT (UTC -07:00) + .pubsub.schedule('* 19 * * 5') + .timeZone('Etc/UTC') + .onRun(async () => { + await sendPortfolioUpdateEmailsToAllUsers() + }) + +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')]) + // get all users that haven't unsubscribed from weekly emails + const privateUsersToSendEmailsTo = privateUsers + .filter((user) => { + return isProd() + ? user.notificationPreferences.profit_loss_updates.includes('email') && + !user.weeklyPortfolioUpdateEmailSent + : true + }) + // Send emails in batches + .slice(0, 200) + log( + 'Sending weekly portfolio emails to', + privateUsersToSendEmailsTo.length, + 'users' + ) + + const usersBets: { [userId: string]: Bet[] } = {} + // get all bets made by each user + await Promise.all( + privateUsersToSendEmailsTo.map(async (user) => { + return getValues( + firestore.collectionGroup('bets').where('userId', '==', user.id) + ).then((bets) => { + usersBets[user.id] = bets + }) + }) + ) + + const usersToContractsCreated: { [userId: string]: Contract[] } = {} + // Get all contracts created by each user + await Promise.all( + privateUsersToSendEmailsTo.map(async (user) => { + return getValues( + firestore + .collection('contracts') + .where('creatorId', '==', user.id) + .where('createdTime', '>', Date.now() - 7 * DAY_MS) + ).then((contracts) => { + usersToContractsCreated[user.id] = contracts + }) + }) + ) + + // Get all txns the users received over the past week + const usersToTxnsReceived: { [userId: string]: Txn[] } = {} + await Promise.all( + privateUsersToSendEmailsTo.map(async (user) => { + return getValues( + firestore + .collection(`txns`) + .where('toId', '==', user.id) + .where('createdTime', '>', Date.now() - 7 * DAY_MS) + ).then((txn) => { + usersToTxnsReceived[user.id] = txn + }) + }) + ) + + // Get a flat map of all the bets that users made to get the contracts they bet on + const contractsUsersBetOn = filterDefined( + await Promise.all( + uniq( + Object.values(usersBets).flatMap((bets) => + bets.map((bet) => bet.contractId) + ) + ).map((contractId) => + getValue(firestore.collection('contracts').doc(contractId)) + ) + ) + ) + log('Found', contractsUsersBetOn.length, 'contracts') + let count = 0 + await Promise.all( + privateUsersToSendEmailsTo.map(async (privateUser) => { + const user = await getUser(privateUser.id) + if (!user) return + const userBets = usersBets[privateUser.id] as Bet[] + const contractsUserBetOn = contractsUsersBetOn.filter((contract) => + userBets.some((bet) => bet.contractId === contract.id) + ) + const contractsBetOnInLastWeek = uniq( + userBets + .filter((bet) => bet.createdTime > Date.now() - 7 * DAY_MS) + .map((bet) => bet.contractId) + ) + const totalTips = sum( + usersToTxnsReceived[privateUser.id] + .filter((txn) => txn.category === 'TIP') + .map((txn) => txn.amount) + ) + const greenBg = 'rgba(0,160,0,0.2)' + const redBg = 'rgba(160,0,0,0.2)' + const clearBg = 'rgba(255,255,255,0)' + const roundedProfit = + Math.round(user.profitCached.weekly) === 0 + ? 0 + : Math.floor(user.profitCached.weekly) + const performanceData = { + profit: formatMoney(user.profitCached.weekly), + profit_style: `background-color: ${ + roundedProfit > 0 ? greenBg : roundedProfit === 0 ? clearBg : redBg + }`, + markets_created: + usersToContractsCreated[privateUser.id].length.toString(), + tips_received: formatMoney(totalTips), + unique_bettors: usersToTxnsReceived[privateUser.id] + .filter((txn) => txn.category === 'UNIQUE_BETTOR_BONUS') + .length.toString(), + markets_traded: contractsBetOnInLastWeek.length.toString(), + prediction_streak: + (user.currentBettingStreak?.toString() ?? '0') + ' days', + // More options: bonuses, tips given, + } as OverallPerformanceData + + const investmentValueDifferences = sortBy( + filterDefined( + contractsUserBetOn.map((contract) => { + const cpmmContract = contract as CPMMContract + if (cpmmContract === undefined || cpmmContract.prob === undefined) + return + 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 + + // TODO: returns 0 for resolved markets - doesn't include them + const betsMadeAWeekAgoValue = computeInvestmentValueCustomProb( + previousBets, + contract, + marketProbabilityAWeekAgo + ) + const currentBetsMadeAWeekAgoValue = + computeInvestmentValueCustomProb( + previousBets, + contract, + currentMarketProbability + ) + const betsMadeInLastWeekProfit = getContractBetMetrics( + contract, + betsInLastWeek + ).profit + const marketChange = + currentMarketProbability - marketProbabilityAWeekAgo + const profit = + betsMadeInLastWeekProfit + + (currentBetsMadeAWeekAgoValue - betsMadeAWeekAgoValue) + return { + currentValue: currentBetsMadeAWeekAgoValue, + pastValue: betsMadeAWeekAgoValue, + difference: profit, + contractSlug: contract.slug, + marketProbAWeekAgo: marketProbabilityAWeekAgo, + questionTitle: contract.question, + questionUrl: contractUrl(contract), + questionProb: cpmmContract.resolution + ? cpmmContract.resolution + : Math.round(cpmmContract.prob * 100) + '%', + questionChange: + (marketChange > 0 ? '+' : '') + + Math.round(marketChange * 100) + + '%', + questionChangeStyle: `color: ${ + profit > 0 ? 'rgba(0,160,0,1)' : '#a80000' + };`, + } as PerContractInvestmentsData + }) + ), + (differences) => Math.abs(differences.difference) + ).reverse() + + log( + 'Found', + investmentValueDifferences.length, + 'investment differences for user', + privateUser.id + ) + + const [winningInvestments, losingInvestments] = partition( + investmentValueDifferences.filter( + (diff) => + diff.pastValue > 0.01 && + Math.abs(diff.difference / diff.pastValue) > 0.01 // difference is greater than 1% + ), + (investmentsData: PerContractInvestmentsData) => { + return investmentsData.difference > 0 + } + ) + // pick 3 winning investments and 3 losing investments + const topInvestments = winningInvestments.slice(0, 2) + const worstInvestments = losingInvestments.slice(0, 2) + // if no bets in the last week ANd no market movers AND no markets created, don't send email + if ( + contractsBetOnInLastWeek.length === 0 && + topInvestments.length === 0 && + worstInvestments.length === 0 && + usersToContractsCreated[privateUser.id].length === 0 + ) { + log('No bets in last week, no market movers, no markets created') + await firestore.collection('private-users').doc(privateUser.id).update({ + weeklyPortfolioUpdateEmailSent: true, + }) + return + } + await sendWeeklyPortfolioUpdateEmail( + user, + privateUser, + topInvestments.concat(worstInvestments) as PerContractInvestmentsData[], + performanceData + ) + await firestore.collection('private-users').doc(privateUser.id).update({ + weeklyPortfolioUpdateEmailSent: true, + }) + log('Sent weekly portfolio update email to', privateUser.email) + count++ + log('sent out emails to user count:', count) + }) + ) +} + +export type PerContractInvestmentsData = { + questionTitle: string + questionUrl: string + questionProb: string + questionChange: string + questionChangeStyle: string + currentValue: number + pastValue: number + difference: number +} + +export type OverallPerformanceData = { + profit: string + prediction_streak: string + markets_traded: string + profit_style: string + tips_received: string + markets_created: string + unique_bettors: string +} diff --git a/web/components/amount-input.tsx b/web/components/amount-input.tsx index 2ad745a8..1c9d1c3b 100644 --- a/web/components/amount-input.tsx +++ b/web/components/amount-input.tsx @@ -6,6 +6,7 @@ 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 { Row } from './layout/row' export function AmountInput(props: { amount: number | undefined @@ -34,46 +35,53 @@ export function AmountInput(props: { const isInvalid = !str || isNaN(amount) onChange(isInvalid ? undefined : amount) } + const { width } = useWindowSize() const isMobile = (width ?? 0) < 768 - return ( - - - {error && ( -
- {error === 'Insufficient balance' ? ( - <> - Not enough funds. - - Buy more? - - - ) : ( - error - )} -
- )} - + return ( + <> + + + + {error && ( +
+ {error === 'Insufficient balance' ? ( + <> + Not enough funds. + + Buy more? + + + ) : ( + error + )} +
+ )} + + ) } @@ -136,27 +144,29 @@ export function BuyAmountInput(props: { return ( <> - - {showSlider && ( - onAmountChange(parseRaw(parseInt(e.target.value)))} - className="range range-lg only-thumb z-40 mb-2 xl:hidden" - step="5" + + - )} + {showSlider && ( + onAmountChange(parseRaw(parseInt(e.target.value)))} + className="range range-lg only-thumb my-auto align-middle xl:hidden" + step="5" + /> + )} + ) } diff --git a/web/components/answers/answer-bet-panel.tsx b/web/components/answers/answer-bet-panel.tsx index 3339ded5..85f61034 100644 --- a/web/components/answers/answer-bet-panel.tsx +++ b/web/components/answers/answer-bet-panel.tsx @@ -182,17 +182,17 @@ export function AnswerBetPanel(props: { - {user ? ( ) : ( 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 ( -
- - 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, - }, - }, - ], - }, - ]} - /> -
- ) -}) - -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/answers/answers-panel.tsx b/web/components/answers/answers-panel.tsx index 51cf5799..a1cef4c3 100644 --- a/web/components/answers/answers-panel.tsx +++ b/web/components/answers/answers-panel.tsx @@ -38,13 +38,26 @@ export function AnswersPanel(props: { const answers = (useAnswers(contract.id) ?? contract.answers).filter( (a) => a.number != 0 || contract.outcomeType === 'MULTIPLE_CHOICE' ) - const [winningAnswers, notWinningAnswers] = partition( - answers, - (a) => a.id === resolution || (resolutions && resolutions[a.id]) + const hasZeroBetAnswers = answers.some((answer) => totalBets[answer.id] < 1) + + const [winningAnswers, losingAnswers] = partition( + answers.filter((a) => (showAllAnswers ? true : totalBets[a.id] > 0)), + (answer) => + answer.id === resolution || (resolutions && resolutions[answer.id]) ) - const [visibleAnswers, invisibleAnswers] = partition( - sortBy(notWinningAnswers, (a) => -getOutcomeProbability(contract, a.id)), - (a) => showAllAnswers || totalBets[a.id] > 0 + const sortedAnswers = [ + ...sortBy(winningAnswers, (answer) => + resolutions ? -1 * resolutions[answer.id] : 0 + ), + ...sortBy( + resolution ? [] : losingAnswers, + (answer) => -1 * getDpmOutcomeProbability(contract.totalShares, answer.id) + ), + ] + + const answerItems = sortBy( + losingAnswers.length > 0 ? losingAnswers : sortedAnswers, + (answer) => -getOutcomeProbability(contract, answer.id) ) const user = useUser() @@ -94,13 +107,13 @@ export function AnswersPanel(props: { return ( {(resolveOption || resolution) && - sortBy(winningAnswers, (a) => -(resolutions?.[a.id] ?? 0)).map((a) => ( + sortedAnswers.map((answer) => ( - {visibleAnswers.map((a) => ( - + {answerItems.map((item) => ( + ))} - {invisibleAnswers.length > 0 && !showAllAnswers && ( + {hasZeroBetAnswers && !showAllAnswers && ( + + + <LimitOrderPanel + hidden={!seeLimit} + contract={contract} + user={user} + unfilledBets={unfilledBets} + /> + <LimitBets + contract={contract} + bets={unfilledBets as LimitBet[]} + className="mt-4" + /> + </Modal> + </Col> </Col> ) } @@ -389,7 +457,6 @@ function LimitOrderPanel(props: { const betChoice = 'YES' const [error, setError] = useState<string | undefined>() const [isSubmitting, setIsSubmitting] = useState(false) - const [wasSubmitted, setWasSubmitted] = useState(false) const rangeError = lowLimitProb !== undefined && @@ -437,7 +504,6 @@ function LimitOrderPanel(props: { const noAmount = shares * (1 - (noLimitProb ?? 0)) function onBetChange(newAmount: number | undefined) { - setWasSubmitted(false) setBetAmount(newAmount) } @@ -482,7 +548,6 @@ function LimitOrderPanel(props: { .then((r) => { console.log('placed bet. Result:', r) setIsSubmitting(false) - setWasSubmitted(true) setBetAmount(undefined) setLowLimitProb(undefined) setHighLimitProb(undefined) @@ -718,8 +783,6 @@ function LimitOrderPanel(props: { : `Submit order${hasTwoBets ? 's' : ''}`} </button> )} - - {wasSubmitted && <div className="mt-4">Order submitted!</div>} </Col> ) } @@ -866,11 +929,7 @@ export function SellPanel(props: { <> <AmountInput amount={ - amount - ? Math.round(amount) === 0 - ? 0 - : Math.floor(amount) - : undefined + amount ? (Math.round(amount) === 0 ? 0 : Math.floor(amount)) : 0 } onChange={onAmountChange} label="Qty" 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 ( + <Col className={clsx(className, 'gap-4')}> + <Row className="flex-wrap gap-4 sm:flex-nowrap sm:gap-6"> + {resolution ? ( + <Col> + <div className="text-sm text-gray-500">Payout</div> + <div className="whitespace-nowrap"> + {formatMoney(payout)}{' '} + <ProfitBadge profitPercent={profitPercent} /> + </div> + </Col> + ) : isBinary ? ( + <Col> + <div className="whitespace-nowrap text-sm text-gray-500"> + Position{' '} + <InfoTooltip text="Number of shares you own on net. 1 YES share = M$1 if the market resolves YES." /> + </div> + <div className="whitespace-nowrap"> + {position > 1e-7 ? ( + <> + <YesLabel /> {formatWithCommas(position)} + </> + ) : position < -1e-7 ? ( + <> + <NoLabel /> {formatWithCommas(-position)} + </> + ) : ( + '——' + )} + </div> + </Col> + ) : ( + <Col> + <div className="whitespace-nowrap text-sm text-gray-500"> + Expectation{''} + <InfoTooltip text="The estimated payout of your position using the current market probability." /> + </div> + <div className="whitespace-nowrap">{formatMoney(payout)}</div> + </Col> + )} + + <Col className="hidden sm:inline"> + <div className="whitespace-nowrap text-sm text-gray-500"> + Invested{' '} + <InfoTooltip text="Cash currently invested in this market." /> + </div> + <div className="whitespace-nowrap">{formatMoney(invested)}</div> + </Col> + + {isBinary && !resolution && ( + <Col> + <div className="whitespace-nowrap text-sm text-gray-500"> + Expectation{' '} + <InfoTooltip text="The estimated payout of your position using the current market probability." /> + </div> + <div className="whitespace-nowrap">{formatMoney(expectation)}</div> + </Col> + )} + + <Col> + <div className="whitespace-nowrap text-sm text-gray-500"> + Profit{' '} + <InfoTooltip text="Includes both realized & unrealized gains/losses." /> + </div> + <div className="whitespace-nowrap"> + {formatMoney(profit)} + <ProfitBadge profitPercent={profitPercent} /> + </div> + </Col> + </Row> + </Col> + ) +} diff --git a/web/components/bets-list.tsx b/web/components/bets-list.tsx index 97d11758..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' @@ -77,7 +77,7 @@ export function BetsList(props: { user: User }) { }, [contractList]) const [sort, setSort] = useState<BetSort>('newest') - const [filter, setFilter] = useState<BetFilter>('open') + const [filter, setFilter] = useState<BetFilter>('all') const [page, setPage] = useState(0) const start = page * CONTRACTS_PER_PAGE const end = start + CONTRACTS_PER_PAGE @@ -155,34 +155,25 @@ export function BetsList(props: { user: User }) { (c) => contractsMetrics[c.id].netPayout ) - const totalPnl = user.profitCached.allTime - const totalProfitPercent = (totalPnl / user.totalDeposits) * 100 const investedProfitPercent = ((currentBetsValue - currentInvested) / (currentInvested + 0.1)) * 100 return ( <Col> - <Col className="mx-4 gap-4 sm:flex-row sm:justify-between md:mx-0"> - <Row className="gap-8"> - <Col> - <div className="text-sm text-gray-500">Investment value</div> - <div className="text-lg"> - {formatMoney(currentNetInvestment)}{' '} - <ProfitBadge profitPercent={investedProfitPercent} /> - </div> - </Col> - <Col> - <div className="text-sm text-gray-500">Total profit</div> - <div className="text-lg"> - {formatMoney(totalPnl)}{' '} - <ProfitBadge profitPercent={totalProfitPercent} /> - </div> - </Col> - </Row> + <Row className="justify-between gap-4 sm:flex-row"> + <Col> + <div className="text-greyscale-6 text-xs sm:text-sm"> + Investment value + </div> + <div className="text-lg"> + {formatMoney(currentNetInvestment)}{' '} + <ProfitBadge profitPercent={investedProfitPercent} /> + </div> + </Col> - <Row className="gap-8"> + <Row className="gap-2"> <select - className="select select-bordered self-start" + className="border-greyscale-4 self-start overflow-hidden rounded border px-2 py-2 text-sm" value={filter} onChange={(e) => setFilter(e.target.value as BetFilter)} > @@ -195,7 +186,7 @@ export function BetsList(props: { user: User }) { </select> <select - className="select select-bordered self-start" + className="border-greyscale-4 self-start overflow-hidden rounded px-2 py-2 text-sm" value={sort} onChange={(e) => setSort(e.target.value as BetSort)} > @@ -205,7 +196,7 @@ export function BetsList(props: { user: User }) { <option value="closeTime">Close date</option> </select> </Row> - </Col> + </Row> <Col className="mt-6 divide-y"> {displayedContracts.length === 0 ? ( @@ -346,8 +337,7 @@ function ContractBets(props: { <BetsSummary className="mt-8 mr-5 flex-1 sm:mr-8" contract={contract} - bets={bets} - isYourBets={isYourBets} + userBets={bets} /> {contract.mechanism === 'cpmm-1' && limitBets.length > 0 && ( @@ -373,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 ( - <Col className={clsx(className, 'gap-4')}> - <Row className="flex-wrap gap-4 sm:flex-nowrap sm:gap-6"> - <Col> - <div className="whitespace-nowrap text-sm text-gray-500"> - Invested - </div> - <div className="whitespace-nowrap">{formatMoney(invested)}</div> - </Col> - <Col> - <div className="whitespace-nowrap text-sm text-gray-500">Profit</div> - <div className="whitespace-nowrap"> - {formatMoney(profit)} <ProfitBadge profitPercent={profitPercent} /> - </div> - </Col> - {canSell && ( - <> - <button - className="btn btn-sm self-end" - onClick={() => setShowSellModal(true)} - > - Sell - </button> - {showSellModal && ( - <SellSharesModal - contract={contract} - user={user} - userBets={bets} - shares={totalShares[sharesOutcome]} - sharesOutcome={sharesOutcome} - setOpen={setShowSellModal} - /> - )} - </> - )} - </Row> - <Row className="flex-wrap-none gap-4"> - {resolution ? ( - <Col> - <div className="text-sm text-gray-500">Payout</div> - <div className="whitespace-nowrap"> - {formatMoney(payout)}{' '} - <ProfitBadge profitPercent={profitPercent} /> - </div> - </Col> - ) : isBinary ? ( - <> - <Col> - <div className="whitespace-nowrap text-sm text-gray-500"> - Payout if <YesLabel /> - </div> - <div className="whitespace-nowrap"> - {formatMoney(yesWinnings)} - </div> - </Col> - <Col> - <div className="whitespace-nowrap text-sm text-gray-500"> - Payout if <NoLabel /> - </div> - <div className="whitespace-nowrap">{formatMoney(noWinnings)}</div> - </Col> - </> - ) : ( - <Col> - <div className="whitespace-nowrap text-sm text-gray-500"> - Expected value - </div> - <div className="whitespace-nowrap">{formatMoney(payout)}</div> - </Col> - )} - </Row> - </Col> - ) -} - export function ContractBetsTable(props: { contract: Contract bets: Bet[] @@ -610,18 +481,24 @@ function BetRow(props: { const isNumeric = outcomeType === 'NUMERIC' const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC' - const saleAmount = saleBet?.sale?.amount + // calculateSaleAmount is very slow right now so that's why we memoized this + const payout = useMemo(() => { + const saleBetAmount = saleBet?.sale?.amount + if (saleBetAmount) { + return saleBetAmount + } else if (contract.isResolved) { + return resolvedPayout(contract, bet) + } else { + return calculateSaleAmount(contract, bet, unfilledBets) + } + }, [contract, bet, saleBet, unfilledBets]) const saleDisplay = isAnte ? ( 'ANTE' - ) : saleAmount !== undefined ? ( - <>{formatMoney(saleAmount)} (sold)</> + ) : saleBet ? ( + <>{formatMoney(payout)} (sold)</> ) : ( - formatMoney( - isResolved - ? resolvedPayout(contract, bet) - : calculateSaleAmount(contract, bet, unfilledBets) - ) + formatMoney(payout) ) const payoutIfChosenDisplay = @@ -753,30 +630,3 @@ function SellButton(props: { </ConfirmationButton> ) } - -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 ( - <span - className={clsx( - 'ml-1 inline-flex items-center rounded-full px-3 py-0.5 text-sm font-medium', - colors, - className - )} - > - {(profitPercent > 0 ? '+' : '') + - profitPercent.toFixed(round ? 0 : 1) + - '%'} - </span> - ) -} diff --git a/web/components/charts/contract/binary.tsx b/web/components/charts/contract/binary.tsx new file mode 100644 index 00000000..8a378799 --- /dev/null +++ b/web/components/charts/contract/binary.tsx @@ -0,0 +1,95 @@ +import { useMemo, useRef } from 'react' +import { last, sortBy } from 'lodash' +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, + getDateRange, + getRightmostVisibleDate, + formatDateInRange, + formatPct, +} from '../helpers' +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) => ({ + x: new Date(b.createdTime), + y: b.probAfter, + datum: b, + })) +} + +const BinaryChartTooltip = (props: SingleValueHistoryTooltipProps<Bet>) => { + const { p, xScale } = props + const { x, y, datum } = p + 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> + ) +} + +export const BinaryContractChart = (props: { + contract: BinaryContract + bets: Bet[] + height?: number +}) => { + const { contract, bets } = props + const [startDate, endDate] = getDateRange(contract) + const startP = getInitialProbability(contract) + const endP = getProbability(contract) + const betPoints = useMemo(() => getBetPoints(bets), [bets]) + const data = useMemo( + () => [ + { x: startDate, y: startP }, + ...betPoints, + { x: endDate ?? new Date(Date.now() + DAY_MS), y: endP }, + ], + [startDate, startP, endDate, endP, betPoints] + ) + + const rightmostDate = getRightmostVisibleDate( + endDate, + last(betPoints)?.x, + new Date(Date.now()) + ) + const visibleRange = [startDate, 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]) + + return ( + <div ref={containerRef}> + {width > 0 && ( + <SingleValueHistoryChart + w={width} + h={height} + xScale={xScale} + yScale={yScale} + data={data} + color="#11b981" + Tooltip={BinaryChartTooltip} + pct + /> + )} + </div> + ) +} diff --git a/web/components/charts/contract/choice.tsx b/web/components/charts/contract/choice.tsx new file mode 100644 index 00000000..0811a2ed --- /dev/null +++ b/web/components/charts/contract/choice.tsx @@ -0,0 +1,206 @@ +import { useMemo, useRef } from 'react' +import { last, sum, sortBy, groupBy } from 'lodash' +import { scaleTime, scaleLinear } from 'd3-scale' + +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 { + Legend, + MARGIN_X, + MARGIN_Y, + getDateRange, + getRightmostVisibleDate, + formatPct, + formatDateInRange, +} from '../helpers' +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 = [ + '#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 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<Bet>[] = [] + for (const bet of sortedBets) { + const { outcome, shares } = bet + sharesByOutcome[outcome] += shares + + const sharesSquared = sum( + Object.values(sharesByOutcome).map((shares) => shares ** 2) + ) + points.push({ + x: new Date(bet.createdTime), + y: answers.map((a) => sharesByOutcome[a.id] ** 2 / sharesSquared), + datum: bet, + }) + } + return points +} + +export const ChoiceContractChart = (props: { + contract: FreeResponseContract | MultipleChoiceContract + bets: Bet[] + height?: number +}) => { + const { contract, bets } = props + const [start, end] = getDateRange(contract) + const answers = useMemo( + () => getTrackedAnswers(contract, CATEGORY_COLORS.length), + [contract] + ) + const betPoints = useMemo(() => getBetPoints(answers, bets), [answers, bets]) + const data = useMemo( + () => [ + { x: start, y: answers.map((_) => 0) }, + ...betPoints, + { + x: end ?? new Date(Date.now() + DAY_MS), + y: answers.map((a) => getOutcomeProbability(contract, a.id)), + }, + ], + [answers, contract, betPoints, start, end] + ) + const rightmostDate = getRightmostVisibleDate( + end, + last(betPoints)?.x, + new Date(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]) + + const ChoiceTooltip = useMemo( + () => (props: MultiValueHistoryTooltipProps<Bet>) => { + const { p, xScale } = props + const { x, y, datum } = p + 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 && ( + <MultiValueHistoryChart + w={width} + h={height} + xScale={xScale} + yScale={yScale} + data={data} + colors={CATEGORY_COLORS} + Tooltip={ChoiceTooltip} + 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..de1d1a0c --- /dev/null +++ b/web/components/charts/contract/numeric.tsx @@ -0,0 +1,66 @@ +import { useMemo, useRef } from 'react' +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, 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) => ({ + x: min + step * (i + 0.5), + y: bucketProbs[`${i}`], + })) +} + +const NumericChartTooltip = (props: SingleValueDistributionTooltipProps) => { + const { p } = props + const { x, y } = p + return ( + <span className="text-sm"> + <strong>{formatPct(y, 2)}</strong> {formatLargeNumber(x)} + </span> + ) +} + +export const NumericContractChart = (props: { + contract: NumericContract + 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 = 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 && ( + <SingleValueDistributionChart + w={width} + h={height} + xScale={xScale} + 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 new file mode 100644 index 00000000..f1b438dc --- /dev/null +++ b/web/components/charts/contract/pseudo-numeric.tsx @@ -0,0 +1,115 @@ +import { useMemo, useRef } from 'react' +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' +import { NUMERIC_GRAPH_COLOR } from 'common/numeric-constants' +import { useIsMobile } from 'web/hooks/use-is-mobile' +import { + MARGIN_X, + MARGIN_Y, + getDateRange, + getRightmostVisibleDate, + formatDateInRange, +} from '../helpers' +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. +// as a result, we have to do some weird-looking stuff in this code + +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 = (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, + })) +} + +const PseudoNumericChartTooltip = ( + props: SingleValueHistoryTooltipProps<Bet> +) => { + const { p, xScale } = props + const { x, y, datum } = p + 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> + ) +} + +export const PseudoNumericContractChart = (props: { + contract: PseudoNumericContract + bets: Bet[] + height?: number +}) => { + const { contract, bets } = props + 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( + () => [ + { x: startDate, y: startP }, + ...betPoints, + { x: endDate ?? new Date(Date.now() + DAY_MS), y: endP }, + ], + [betPoints, startDate, startP, endDate, endP] + ) + const rightmostDate = getRightmostVisibleDate( + endDate, + last(betPoints)?.x, + new Date(Date.now()) + ) + 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 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}> + {width > 0 && ( + <SingleValueHistoryChart + w={width} + h={height} + xScale={xScale} + yScale={yScale} + data={data} + Tooltip={PseudoNumericChartTooltip} + 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..161721f1 --- /dev/null +++ b/web/components/charts/generic-charts.tsx @@ -0,0 +1,280 @@ +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 { + curveLinear, + curveStepAfter, + stack, + stackOrderReverse, + SeriesPoint, +} from 'd3-shape' +import { range } from 'lodash' + +import { + SVGChart, + AreaPath, + AreaWithTopStroke, + TooltipContent, + 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 } + +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>[] + w: number + h: number + color: string + xScale: ScaleContinuousNumeric<number, number> + yScale: ScaleContinuousNumeric<number, number> + Tooltip?: TooltipContent<SingleValueDistributionTooltipProps<T>> +}) => { + const { color, data, yScale, w, h, Tooltip } = props + + const [viewXScale, setViewXScale] = + useState<ScaleContinuousNumeric<number, number>>() + const xScale = viewXScale ?? props.xScale + + const px = useCallback((p: DistributionPoint<T>) => 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 { xAxis, yAxis } = useMemo(() => { + const xAxis = axisBottom<number>(xScale).ticks(w / 100) + const yAxis = axisLeft<number>(yScale).tickFormat((n) => formatPct(n, 2)) + return { xAxis, yAxis } + }, [w, xScale, yScale]) + + const onSelect = useEvent((ev: D3BrushEvent<DistributionPoint<T>>) => { + if (ev.selection) { + const [mouseX0, mouseX1] = ev.selection as [number, number] + setViewXScale(() => + xScale.copy().domain([xScale.invert(mouseX0), xScale.invert(mouseX1)]) + ) + } else { + setViewXScale(undefined) + } + }) + + 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 { x: queryX, y: item.y, datum: item.datum } + }) + + return ( + <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> = { + p: DistributionPoint<T> + xScale: React.ComponentProps<typeof SingleValueDistributionChart<T>>['xScale'] +} + +export const MultiValueHistoryChart = <T,>(props: { + data: MultiPoint<T>[] + w: number + h: number + colors: readonly string[] + xScale: ScaleTime<number, number> + yScale: ScaleContinuousNumeric<number, number> + Tooltip?: TooltipContent<MultiValueHistoryTooltipProps<T>> + pct?: boolean +}) => { + const { colors, data, yScale, w, h, Tooltip, pct } = props + + const [viewXScale, setViewXScale] = useState<ScaleTime<number, number>>() + const xScale = viewXScale ?? props.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<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((n) => formatPct(n)) + : axisLeft<number>(yScale) + return { xAxis, yAxis } + }, [w, h, pct, xScale, yScale]) + + const series = useMemo(() => { + 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]) + + const onSelect = useEvent((ev: D3BrushEvent<MultiPoint<T>>) => { + if (ev.selection) { + const [mouseX0, mouseX1] = ev.selection as [number, number] + setViewXScale(() => + xScale.copy().domain([xScale.invert(mouseX0), xScale.invert(mouseX1)]) + ) + } else { + setViewXScale(undefined) + } + }) + + 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 { x: queryX, y: item.y, datum: item.datum } + }) + + return ( + <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> = { + p: 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, Tooltip } = props + + const [viewXScale, setViewXScale] = useState<ScaleTime<number, number>>() + const xScale = viewXScale ?? props.xScale + + const px = useCallback((p: HistoryPoint<T>) => 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 { 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((n) => formatPct(n)) + : axisLeft<number>(yScale) + return { xAxis, yAxis } + }, [w, h, pct, xScale, yScale]) + + const onSelect = useEvent((ev: D3BrushEvent<HistoryPoint<T>>) => { + if (ev.selection) { + const [mouseX0, mouseX1] = ev.selection as [number, number] + setViewXScale(() => + xScale.copy().domain([xScale.invert(mouseX0), xScale.invert(mouseX1)]) + ) + } else { + setViewXScale(undefined) + } + }) + + 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 { x: queryX, y: item.y, datum: item.datum } + }) + + return ( + <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> = { + p: HistoryPoint<T> + xScale: React.ComponentProps<typeof SingleValueHistoryChart<T>>['xScale'] +} diff --git a/web/components/charts/helpers.tsx b/web/components/charts/helpers.tsx new file mode 100644 index 00000000..236c6e1d --- /dev/null +++ b/web/components/charts/helpers.tsx @@ -0,0 +1,316 @@ +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' +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 +export const MARGIN_Y = MARGIN.top + MARGIN.bottom + +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, P, XS>(props: { + children: ReactNode + w: number + h: number + xAxis: Axis<X> + yAxis: Axis<Y> + onSelect?: (ev: D3BrushEvent<any>) => void + onMouseOver?: (mouseX: number, mouseY: number) => P | undefined + Tooltip?: TooltipContent<{ xScale: XS } & { p: P }> +}) => { + 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 + 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) + setMouseState(undefined) + if (overlayRef.current) { + select(overlayRef.current).call(brush.clear) + } + } else { + justSelected.current = false + } + }) + // 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]) + + 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 ( + <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> + ) +} + +export type TooltipContent<P> = React.ComponentType<P> +export type TooltipPosition = { top: number; left: number } +export const TooltipContainer = ( + 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 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 + 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 + } +} + +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) +} diff --git a/web/components/contract-search.tsx b/web/components/contract-search.tsx index 331dcb80..ba589d0e 100644 --- a/web/components/contract-search.tsx +++ b/web/components/contract-search.tsx @@ -103,6 +103,7 @@ export function ContractSearch(props: { loadMore: () => void ) => ReactNode autoFocus?: boolean + profile?: boolean | undefined }) { const { user, @@ -123,6 +124,7 @@ export function ContractSearch(props: { maxResults, renderContracts, autoFocus, + profile, } = props const [state, setState] = usePersistentState( @@ -239,6 +241,10 @@ export function ContractSearch(props: { /> {renderContracts ? ( renderContracts(renderedContracts, performQuery) + ) : renderedContracts && renderedContracts.length === 0 && profile ? ( + <p className="mx-2 text-gray-500"> + This creator does not yet have any markets. + </p> ) : ( <ContractsGrid contracts={renderedContracts} diff --git a/web/components/contract/contract-overview.tsx b/web/components/contract/contract-overview.tsx index bfb4829f..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' @@ -13,20 +18,17 @@ import { PseudoNumericResolutionOrExpectation, } from './contract-card' import { Bet } from 'common/bet' -import BetButton from '../bet-button' -import { AnswersGraph } from '../answers/answers-graph' +import BetButton, { BinaryMobileBetting } from '../bet-button' import { Contract, - BinaryContract, CPMMContract, - CPMMBinaryContract, FreeResponseContract, MultipleChoiceContract, NumericContract, PseudoNumericContract, + 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} /> @@ -64,7 +66,7 @@ const NumericOverview = (props: { contract: NumericContract }) => { contract={contract} /> </Col> - <NumericGraph contract={contract} /> + <NumericContractChart contract={contract} /> </Col> ) } @@ -78,19 +80,18 @@ const BinaryOverview = (props: { contract: BinaryContract; bets: Bet[] }) => { <Row className="justify-between gap-4"> <OverviewQuestion text={contract.question} /> <BinaryResolutionOrChance - className="hidden items-end xl:flex" + className="flex items-end" contract={contract} large /> </Row> - <Row className="items-center justify-between gap-4 xl:hidden"> - <BinaryResolutionOrChance contract={contract} /> - {tradingAllowed(contract) && ( - <BetWidget contract={contract as CPMMBinaryContract} /> - )} - </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} /> + )} + </Row> </Col> ) } @@ -111,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> ) @@ -138,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/contract-tabs.tsx b/web/components/contract/contract-tabs.tsx index 17471796..33a3c05a 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,68 +17,57 @@ 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' +import { ContractComment } from 'common/comment' -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[] + comments: ContractComment[] +}) { + const { contract, bets, userBets, comments } = props const yourTrades = ( <div> - <BetsSummary - className="px-2" - contract={contract} - bets={userBets ?? []} - isYourBets - /> <Spacer h={6} /> - <ContractBetsTable contract={contract} bets={userBets ?? []} isYourBets /> + <ContractBetsTable contract={contract} bets={userBets} isYourBets /> <Spacer h={12} /> </div> ) + const tabs = buildArray( + { + title: 'Comments', + content: <CommentsTabContent contract={contract} comments={comments} />, + }, + { + title: capitalize(PAST_BETS), + content: <BetsTabContent contract={contract} bets={bets} />, + }, + userBets.length > 0 && { + title: 'Your trades', + content: yourTrades, + } + ) + return ( - <Tabs - className="mb-4" - currentPageForAnalytics={'contract'} - tabs={[ - { - title: 'Comments', - content: <CommentsTabContent contract={contract} />, - }, - { - title: capitalize(PAST_BETS), - content: <BetsTabContent contract={contract} bets={bets} />, - }, - ...(!user || !userBets?.length - ? [] - : [ - { - title: isMobile ? `You` : `Your ${PAST_BETS}`, - content: yourTrades, - }, - ]), - ]} - /> + <Tabs className="mb-4" currentPageForAnalytics={'contract'} tabs={tabs} /> ) } 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/components/contract/contracts-grid.tsx b/web/components/contract/contracts-grid.tsx index 0b93148d..d6c9c5fa 100644 --- a/web/components/contract/contracts-grid.tsx +++ b/web/components/contract/contracts-grid.tsx @@ -128,6 +128,7 @@ export function CreatorContractsList(props: { creatorId: creator.id, }} persistPrefix={`user-${creator.id}`} + profile={true} /> ) } 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/components/contract/prob-change-table.tsx b/web/components/contract/prob-change-table.tsx index 07b7c659..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' @@ -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 @@ -16,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( @@ -59,7 +58,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( diff --git a/web/components/contract/quick-bet.tsx b/web/components/contract/quick-bet.tsx index 7b19306f..a71b6c7d 100644 --- a/web/components/contract/quick-bet.tsx +++ b/web/components/contract/quick-bet.tsx @@ -344,7 +344,7 @@ export function getColor(contract: Contract) { return ( OUTCOME_TO_COLOR[resolution as resolution] ?? // If resolved to a FR answer, use 'primary' - 'primary' + 'teal-500' ) } @@ -355,5 +355,5 @@ export function getColor(contract: Contract) { } // TODO: Not sure why eg green-400 doesn't work here; try upgrading Tailwind - return 'primary' + return 'teal-500' } diff --git a/web/components/following-button.tsx b/web/components/following-button.tsx index 135f43a8..c897c89b 100644 --- a/web/components/following-button.tsx +++ b/web/components/following-button.tsx @@ -13,16 +13,18 @@ import { useDiscoverUsers } from 'web/hooks/use-users' import { TextButton } from './text-button' import { track } from 'web/lib/service/analytics' -export function FollowingButton(props: { user: User }) { - const { user } = props +export function FollowingButton(props: { user: User; className?: string }) { + const { user, className } = props const [isOpen, setIsOpen] = useState(false) const followingIds = useFollows(user.id) const followerIds = useFollowers(user.id) return ( <> - <TextButton onClick={() => setIsOpen(true)}> - <span className="font-semibold">{followingIds?.length ?? ''}</span>{' '} + <TextButton onClick={() => setIsOpen(true)} className={className}> + <span className={clsx('font-semibold')}> + {followingIds?.length ?? ''} + </span>{' '} Following </TextButton> @@ -69,15 +71,15 @@ export function EditFollowingButton(props: { user: User; className?: string }) { ) } -export function FollowersButton(props: { user: User }) { - const { user } = props +export function FollowersButton(props: { user: User; className?: string }) { + const { user, className } = props const [isOpen, setIsOpen] = useState(false) const followingIds = useFollows(user.id) const followerIds = useFollowers(user.id) return ( <> - <TextButton onClick={() => setIsOpen(true)}> + <TextButton onClick={() => setIsOpen(true)} className={className}> <span className="font-semibold">{followerIds?.length ?? ''}</span>{' '} Followers </TextButton> diff --git a/web/components/groups/groups-button.tsx b/web/components/groups/groups-button.tsx index e6271466..5c9d2edd 100644 --- a/web/components/groups/groups-button.tsx +++ b/web/components/groups/groups-button.tsx @@ -14,14 +14,14 @@ import { firebaseLogin } from 'web/lib/firebase/users' import { GroupLinkItem } from 'web/pages/groups' import toast from 'react-hot-toast' -export function GroupsButton(props: { user: User }) { - const { user } = props +export function GroupsButton(props: { user: User; className?: string }) { + const { user, className } = props const [isOpen, setIsOpen] = useState(false) const groups = useMemberGroups(user.id) return ( <> - <TextButton onClick={() => setIsOpen(true)}> + <TextButton onClick={() => setIsOpen(true)} className={className}> <span className="font-semibold">{groups?.length ?? ''}</span> Groups </TextButton> diff --git a/web/components/layout/tabs.tsx b/web/components/layout/tabs.tsx index 980a3cfc..b82131ec 100644 --- a/web/components/layout/tabs.tsx +++ b/web/components/layout/tabs.tsx @@ -2,6 +2,7 @@ import clsx from 'clsx' import { useRouter, NextRouter } from 'next/router' import { ReactNode, useState } from 'react' import { track } from '@amplitude/analytics-browser' +import { Col } from './col' type Tab = { title: string @@ -55,11 +56,13 @@ export function ControlledTabs(props: TabProps & { activeIndex: number }) { )} aria-current={activeIndex === i ? 'page' : undefined} > - {tab.tabIcon && <span>{tab.tabIcon}</span>} {tab.badge ? ( <span className="px-0.5 font-bold">{tab.badge}</span> ) : null} - {tab.title} + <Col> + {tab.tabIcon && <div className="mx-auto">{tab.tabIcon}</div>} + {tab.title} + </Col> </a> ))} </nav> 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: () => ( <Avatar className="mx-auto my-1" diff --git a/web/components/nav/profile-menu.tsx b/web/components/nav/profile-menu.tsx index cf91ac66..c360d1ed 100644 --- a/web/components/nav/profile-menu.tsx +++ b/web/components/nav/profile-menu.tsx @@ -4,12 +4,11 @@ import { User } from 'web/lib/firebase/users' import { formatMoney } from 'common/util/format' import { Avatar } from '../avatar' import { trackCallback } from 'web/lib/service/analytics' -import { PAST_BETS } from 'common/user' export function ProfileSummary(props: { user: User }) { const { user } = props return ( - <Link href={`/${user.username}?tab=${PAST_BETS}`}> + <Link href={`/${user.username}?tab=portfolio`}> <a onClick={trackCallback('sidebar: profile')} className="group mb-3 flex flex-row items-center gap-4 truncate rounded-md py-3 text-gray-500 hover:bg-gray-100 hover:text-gray-700" 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/components/outcome-label.tsx b/web/components/outcome-label.tsx index 94b2b878..60b4403b 100644 --- a/web/components/outcome-label.tsx +++ b/web/components/outcome-label.tsx @@ -106,7 +106,7 @@ export const OUTCOME_TO_COLOR = { } export function YesLabel() { - return <span className="text-primary">YES</span> + return <span className="text-teal-500">YES</span> } export function HigherLabel() { diff --git a/web/components/portfolio/portfolio-value-graph.tsx b/web/components/portfolio/portfolio-value-graph.tsx index d8489b47..6ed5d195 100644 --- a/web/components/portfolio/portfolio-value-graph.tsx +++ b/web/components/portfolio/portfolio-value-graph.tsx @@ -1,72 +1,155 @@ import { ResponsiveLine } from '@nivo/line' import { PortfolioMetrics } from 'common/user' +import { filterDefined } from 'common/util/array' import { formatMoney } from 'common/util/format' +import dayjs from 'dayjs' import { last } from 'lodash' import { memo } from 'react' import { useWindowSize } from 'web/hooks/use-window-size' -import { formatTime } from 'web/lib/util/time' +import { Col } from '../layout/col' export const PortfolioValueGraph = memo(function PortfolioValueGraph(props: { portfolioHistory: PortfolioMetrics[] mode: 'value' | 'profit' + handleGraphDisplayChange: (arg0: string | number | null) => void height?: number - includeTime?: boolean }) { - const { portfolioHistory, height, includeTime, mode } = props + const { portfolioHistory, height, mode, handleGraphDisplayChange } = props const { width } = useWindowSize() - const points = portfolioHistory.map((p) => { - const { timestamp, balance, investmentValue, totalDeposits } = p - const value = balance + investmentValue - const profit = value - totalDeposits + const valuePoints = getPoints('value', portfolioHistory) + const posProfitPoints = getPoints('posProfit', portfolioHistory) + const negProfitPoints = getPoints('negProfit', portfolioHistory) + + const valuePointsY = valuePoints.map((p) => p.y) + const posProfitPointsY = posProfitPoints.map((p) => p.y) + const negProfitPointsY = negProfitPoints.map((p) => p.y) + + let data + + if (mode === 'value') { + data = [{ id: 'value', data: valuePoints, color: '#4f46e5' }] + } else { + data = [ + { + id: 'negProfit', + data: negProfitPoints, + color: '#dc2626', + }, + { + id: 'posProfit', + data: posProfitPoints, + color: '#14b8a6', + }, + ] + } + const numYTickValues = 2 + const endDate = last(data[0].data)?.x + + const yMin = + mode === 'value' + ? Math.min(...filterDefined(valuePointsY)) + : Math.min( + ...filterDefined(negProfitPointsY), + ...filterDefined(posProfitPointsY) + ) + + const yMax = + mode === 'value' + ? Math.max(...filterDefined(valuePointsY)) + : Math.max( + ...filterDefined(negProfitPointsY), + ...filterDefined(posProfitPointsY) + ) - return { - x: new Date(timestamp), - y: mode === 'value' ? value : profit, - } - }) - const data = [{ id: 'Value', data: points, color: '#11b981' }] - const numXTickValues = !width || width < 800 ? 2 : 5 - const numYTickValues = 4 - const endDate = last(points)?.x return ( <div className="w-full overflow-hidden" - style={{ height: height ?? (!width || width >= 800 ? 350 : 250) }} + style={{ height: height ?? (!width || width >= 800 ? 200 : 100) }} + onMouseLeave={() => handleGraphDisplayChange(null)} > <ResponsiveLine + margin={{ top: 10, right: 0, left: 40, bottom: 10 }} data={data} - margin={{ top: 20, right: 28, bottom: 22, left: 60 }} xScale={{ type: 'time', - min: points[0]?.x, + min: valuePoints[0]?.x, max: endDate, }} yScale={{ type: 'linear', stacked: false, - min: Math.min(...points.map((p) => p.y)), + min: yMin, + max: yMax, }} - gridYValues={numYTickValues} curve="stepAfter" enablePoints={false} colors={{ datum: 'color' }} axisBottom={{ - tickValues: numXTickValues, - format: (time) => formatTime(+time, !!includeTime), + tickValues: 0, }} pointBorderColor="#fff" - pointSize={points.length > 100 ? 0 : 6} + pointSize={valuePoints.length > 100 ? 0 : 6} axisLeft={{ tickValues: numYTickValues, - format: (value) => formatMoney(value), + format: '.3s', }} - enableGridX={!!width && width >= 800} + enableGridX={false} enableGridY={true} + gridYValues={numYTickValues} enableSlices="x" animate={false} yFormat={(value) => formatMoney(+value)} + enableArea={true} + areaOpacity={0.1} + sliceTooltip={({ slice }) => { + handleGraphDisplayChange(slice.points[0].data.yFormatted) + return ( + <div className="rounded bg-white px-4 py-2 opacity-80"> + <div + key={slice.points[0].id} + className="text-xs font-semibold sm:text-sm" + > + <Col> + <div> + {dayjs(slice.points[0].data.xFormatted).format('MMM/D/YY')} + </div> + <div className="text-greyscale-6 text-2xs font-normal sm:text-xs"> + {dayjs(slice.points[0].data.xFormatted).format('h:mm A')} + </div> + </Col> + </div> + {/* ))} */} + </div> + ) + }} ></ResponsiveLine> </div> ) }) + +export function getPoints( + line: 'value' | 'posProfit' | 'negProfit', + portfolioHistory: PortfolioMetrics[] +) { + const points = portfolioHistory.map((p) => { + const { timestamp, balance, investmentValue, totalDeposits } = p + const value = balance + investmentValue + + const profit = value - totalDeposits + let posProfit = null + let negProfit = null + if (profit < 0) { + negProfit = profit + } else { + posProfit = profit + } + + return { + x: new Date(timestamp), + y: + line === 'value' ? value : line === 'posProfit' ? posProfit : negProfit, + } + }) + return points +} diff --git a/web/components/portfolio/portfolio-value-section.tsx b/web/components/portfolio/portfolio-value-section.tsx index a0006c60..ec364c8d 100644 --- a/web/components/portfolio/portfolio-value-section.tsx +++ b/web/components/portfolio/portfolio-value-section.tsx @@ -1,11 +1,12 @@ +import clsx from 'clsx' import { formatMoney } from 'common/util/format' 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 { Spacer } from '../layout/spacer' import { PortfolioValueGraph } from './portfolio-value-graph' export const PortfolioValueSection = memo( @@ -14,6 +15,13 @@ export const PortfolioValueSection = memo( const [portfolioPeriod, setPortfolioPeriod] = useState<Period>('weekly') const portfolioHistory = usePortfolioHistory(userId, portfolioPeriod) + const [graphMode, setGraphMode] = useState<'profit' | 'value'>('value') + const [graphDisplayNumber, setGraphDisplayNumber] = useState< + number | string | null + >(null) + const handleGraphDisplayChange = (num: string | number | null) => { + setGraphDisplayNumber(num) + } // Remember the last defined portfolio history. const portfolioRef = useRef(portfolioHistory) @@ -28,43 +36,144 @@ export const PortfolioValueSection = memo( const { balance, investmentValue, totalDeposits } = lastPortfolioMetrics const totalValue = balance + investmentValue const totalProfit = totalValue - totalDeposits - return ( <> - <Row className="gap-8"> - <Col className="flex-1 justify-center"> - <div className="text-sm text-gray-500">Profit</div> - <div className="text-lg">{formatMoney(totalProfit)}</div> - </Col> - <select - className="select select-bordered self-start" - value={portfolioPeriod} - onChange={(e) => { - setPortfolioPeriod(e.target.value as Period) - }} - > - <option value="allTime">All time</option> - <option value="monthly">Last Month</option> - <option value="weekly">Last 7d</option> - <option value="daily">Last 24h</option> - </select> + <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', + graphMode != 'profit' + ? 'cursor-pointer opacity-40 hover:opacity-80' + : '' + )} + onClick={() => setGraphMode('profit')} + > + <div className="text-greyscale-6 text-xs sm:text-sm">Profit</div> + <div + className={clsx( + graphMode === 'profit' + ? graphDisplayNumber + ? graphDisplayNumber.toString().includes('-') + ? 'text-red-600' + : 'text-teal-500' + : totalProfit > 0 + ? 'text-teal-500' + : 'text-red-600' + : totalProfit > 0 + ? 'text-teal-500' + : 'text-red-600', + 'text-lg sm:text-xl' + )} + > + {graphMode === 'profit' + ? graphDisplayNumber + ? graphDisplayNumber + : formatMoney(totalProfit) + : formatMoney(totalProfit)} + </div> + </Col> + </Row> </Row> <PortfolioValueGraph portfolioHistory={currPortfolioHistory} - includeTime={portfolioPeriod == 'daily'} - mode="profit" + mode={graphMode} + handleGraphDisplayChange={handleGraphDisplayChange} /> - <Spacer h={8} /> - <Col className="flex-1 justify-center"> - <div className="text-sm text-gray-500">Portfolio value</div> - <div className="text-lg">{formatMoney(totalValue)}</div> - </Col> - <PortfolioValueGraph - portfolioHistory={currPortfolioHistory} - includeTime={portfolioPeriod == 'daily'} - mode="value" + <PortfolioPeriodSelection + portfolioPeriod={portfolioPeriod} + setPortfolioPeriod={setPortfolioPeriod} + className="border-greyscale-2 mt-2 gap-4 border-b" + selectClassName="text-indigo-600 text-bold border-b border-indigo-600" /> </> ) } ) + +export function PortfolioPeriodSelection(props: { + setPortfolioPeriod: (string: any) => void + portfolioPeriod: string + className?: string + selectClassName?: string +}) { + const { setPortfolioPeriod, portfolioPeriod, className, selectClassName } = + props + return ( + <Row className={clsx(className, 'text-greyscale-4')}> + <button + className={clsx(portfolioPeriod === 'daily' ? selectClassName : '')} + onClick={() => setPortfolioPeriod('daily' as Period)} + > + 1D + </button> + <button + className={clsx(portfolioPeriod === 'weekly' ? selectClassName : '')} + onClick={() => setPortfolioPeriod('weekly' as Period)} + > + 1W + </button> + <button + className={clsx(portfolioPeriod === 'monthly' ? selectClassName : '')} + onClick={() => setPortfolioPeriod('monthly' as Period)} + > + 1M + </button> + <button + className={clsx(portfolioPeriod === 'allTime' ? selectClassName : '')} + onClick={() => setPortfolioPeriod('allTime' as Period)} + > + ALL + </button> + </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> + ) +} diff --git a/web/components/profile/user-likes-button.tsx b/web/components/profile/user-likes-button.tsx index 3d4fa9ac..666036a8 100644 --- a/web/components/profile/user-likes-button.tsx +++ b/web/components/profile/user-likes-button.tsx @@ -10,15 +10,15 @@ import { XIcon } from '@heroicons/react/outline' import { unLikeContract } from 'web/lib/firebase/likes' import { contractPath } from 'web/lib/firebase/contracts' -export function UserLikesButton(props: { user: User }) { - const { user } = props +export function UserLikesButton(props: { user: User; className?: string }) { + const { user, className } = props const [isOpen, setIsOpen] = useState(false) const likedContracts = useUserLikedContracts(user.id) return ( <> - <TextButton onClick={() => setIsOpen(true)}> + <TextButton onClick={() => setIsOpen(true)} className={className}> <span className="font-semibold">{likedContracts?.length ?? ''}</span>{' '} Likes </TextButton> 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 ( + <span + className={clsx( + 'ml-1 inline-flex items-center rounded-full px-3 py-0.5 text-sm font-medium', + colors, + className + )} + > + {(profitPercent > 0 ? '+' : '') + + profitPercent.toFixed(round ? 0 : 1) + + '%'} + </span> + ) +} diff --git a/web/components/referrals-button.tsx b/web/components/referrals-button.tsx index b164e10c..9a548031 100644 --- a/web/components/referrals-button.tsx +++ b/web/components/referrals-button.tsx @@ -13,14 +13,18 @@ import { getUser, updateUser } from 'web/lib/firebase/users' import { TextButton } from 'web/components/text-button' import { UserLink } from 'web/components/user-link' -export function ReferralsButton(props: { user: User; currentUser?: User }) { - const { user, currentUser } = props +export function ReferralsButton(props: { + user: User + currentUser?: User + className?: string +}) { + const { user, currentUser, className } = props const [isOpen, setIsOpen] = useState(false) const referralIds = useReferrals(user.id) return ( <> - <TextButton onClick={() => setIsOpen(true)}> + <TextButton onClick={() => setIsOpen(true)} className={className}> <span className="font-semibold">{referralIds?.length ?? ''}</span>{' '} Referrals </TextButton> 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 ( <MaybeLink href={href}> <a 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" 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/components/user-page.tsx b/web/components/user-page.tsx index f9845fbe..623b4d35 100644 --- a/web/components/user-page.tsx +++ b/web/components/user-page.tsx @@ -1,8 +1,13 @@ import clsx from 'clsx' import { useEffect, useState } from 'react' -import { useRouter } from 'next/router' +import { NextRouter, useRouter } from 'next/router' import { LinkIcon } from '@heroicons/react/solid' -import { PencilIcon } from '@heroicons/react/outline' +import { + ChatIcon, + FolderIcon, + PencilIcon, + ScaleIcon, +} from '@heroicons/react/outline' import { User } from 'web/lib/firebase/users' import { useUser } from 'web/hooks/use-user' @@ -24,39 +29,23 @@ import { FollowersButton, FollowingButton } from './following-button' import { UserFollowButton } from './follow-button' import { GroupsButton } from 'web/components/groups/groups-button' import { PortfolioValueSection } from './portfolio/portfolio-value-section' -import { ReferralsButton } from 'web/components/referrals-button' import { formatMoney } from 'common/util/format' -import { ShareIconButton } from 'web/components/share-icon-button' -import { ENV_CONFIG } from 'common/envs/constants' import { BettingStreakModal, hasCompletedStreakToday, } from 'web/components/profile/betting-streak-modal' -import { REFERRAL_AMOUNT } from 'common/economy' import { LoansModal } from './profile/loans-modal' -import { UserLikesButton } from 'web/components/profile/user-likes-button' -import { PAST_BETS } from 'common/user' -import { capitalize } from 'lodash' export function UserPage(props: { user: User }) { const { user } = props const router = useRouter() const currentUser = useUser() const isCurrentUser = user.id === currentUser?.id - const bannerUrl = user.bannerUrl ?? defaultBannerUrl(user.id) const [showConfetti, setShowConfetti] = useState(false) - const [showBettingStreakModal, setShowBettingStreakModal] = useState(false) - const [showLoansModal, setShowLoansModal] = useState(false) useEffect(() => { const claimedMana = router.query['claimed-mana'] === 'yes' - const showBettingStreak = router.query['show'] === 'betting-streak' - setShowBettingStreakModal(showBettingStreak) - setShowConfetti(claimedMana || showBettingStreak) - - const showLoansModel = router.query['show'] === 'loans' - setShowLoansModal(showLoansModel) - + setShowConfetti(claimedMana) const query = { ...router.query } if (query.claimedMana || query.show) { delete query['claimed-mana'] @@ -85,218 +74,159 @@ export function UserPage(props: { user: User }) { {showConfetti && ( )} - - {showLoansModal && ( - - )} - {/* Banner image up top, with an circle avatar overlaid */} -
-
-
+ + -
- - {/* Top right buttons (e.g. edit, follow) */} -
- {!isCurrentUser && } {isCurrentUser && ( - - {' '} -
Edit
-
+
+ + {' '} + +
)} -
-
- {/* Profile details: name, username, bio, and link to twitter/discord */} - - - - - {user.name} - - @{user.username} - - - - - = 0 ? 'text-green-600' : 'text-red-400' - )} - > - {formatMoney(profit)} + +
+ + + {user.name} - profit - - setShowBettingStreakModal(true)} - > - 🔥 {user.currentBettingStreak ?? 0} - streak - - setShowLoansModal(true)} - > - - 🏦 {formatMoney(user.nextLoanCached ?? 0)} + + @{user.username} - next loan - + {isCurrentUser && ( + + )} + {!isCurrentUser && } +
+
- - {user.bio && ( - <> -
- -
- - - )} - {(user.website || user.twitterHandle || user.discordHandle) && ( - - {user.website && ( - - - - {user.website} - - - )} - - {user.twitterHandle && ( - - - Twitter - - {user.twitterHandle} - - - - )} - - {user.discordHandle && ( - - - Discord - - {user.discordHandle} - - - - )} - - )} - {currentUser?.id === user.id && REFERRAL_AMOUNT > 0 && ( - - - - Earn {formatMoney(REFERRAL_AMOUNT)} when you refer a friend! - {' '} - You've gotten{' '} - - - - - )} - - ), - }, - { - title: 'Comments', - content: ( - - - - ), - }, - { - title: capitalize(PAST_BETS), - content: ( - <> - - - ), - }, - { - title: 'Stats', - content: ( - - - - - - - + + + + + {user.bio && ( + <> +
+ +
+ + + )} + {(user.website || user.twitterHandle || user.discordHandle) && ( + + {user.website && ( + + + + + {user.website} + - - - ), - }, - ]} - /> + + )} + + {user.twitterHandle && ( + + + Twitter + + {user.twitterHandle} + + + + )} + + {user.discordHandle && ( + + + Discord + + {user.discordHandle} + + + + )} + + )} + , + content: ( + <> + + + + ), + }, + { + title: 'Portfolio', + tabIcon: , + content: ( + <> + + + + + + ), + }, + { + title: 'Comments', + tabIcon: , + content: ( + <> + + + + + + ), + }, + ]} + /> + ) @@ -314,3 +244,88 @@ export function defaultBannerUrl(userId: string) { ] return defaultBanner[genHash(userId)() % defaultBanner.length] } + +export function ProfilePrivateStats(props: { + currentUser: User | null | undefined + profit: number + user: User + router: NextRouter +}) { + const { currentUser, profit, user, router } = props + const [showBettingStreakModal, setShowBettingStreakModal] = useState(false) + const [showLoansModal, setShowLoansModal] = useState(false) + + useEffect(() => { + const showBettingStreak = router.query['show'] === 'betting-streak' + setShowBettingStreakModal(showBettingStreak) + + const showLoansModel = router.query['show'] === 'loans' + setShowLoansModal(showLoansModel) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + return ( + <> + + + = 0 ? 'text-green-600' : 'text-red-400')} + > + {formatMoney(profit)} + + profit + + setShowBettingStreakModal(true)} + > + + 🔥 {user.currentBettingStreak ?? 0} + + + streak + + + setShowLoansModal(true)} + > + + 🏦 {formatMoney(user.nextLoanCached ?? 0)} + + next loan + + + {BettingStreakModal && ( + + )} + {showLoansModal && ( + + )} + + ) +} + +export function ProfilePublicStats(props: { user: User; className?: string }) { + const { user, className } = props + return ( + + + + {/* */} + + {/* */} + + ) +} diff --git a/web/components/warning-confirmation-button.tsx b/web/components/warning-confirmation-button.tsx index 7b707098..7c546c3b 100644 --- a/web/components/warning-confirmation-button.tsx +++ b/web/components/warning-confirmation-button.tsx @@ -4,8 +4,12 @@ import React from 'react' import { Row } from './layout/row' import { ConfirmationButton } from './confirmation-button' import { ExclamationIcon } from '@heroicons/react/solid' +import { formatMoney } from 'common/util/format' export function WarningConfirmationButton(props: { + amount: number | undefined + outcome?: 'YES' | 'NO' | undefined + marketType: 'freeResponse' | 'binary' warning?: string onSubmit: () => void disabled?: boolean @@ -14,26 +18,36 @@ export function WarningConfirmationButton(props: { submitButtonClassName?: string }) { const { + amount, onSubmit, warning, disabled, isSubmitting, openModalButtonClass, submitButtonClassName, + outcome, + marketType, } = props - if (!warning) { return ( ) } @@ -45,7 +59,7 @@ export function WarningConfirmationButton(props: { openModalButtonClass, isSubmitting && 'btn-disabled loading' ), - label: 'Submit', + label: amount ? `Wager ${formatMoney(amount)}` : 'Wager', }} cancelBtn={{ label: 'Cancel', diff --git a/web/components/yes-no-selector.tsx b/web/components/yes-no-selector.tsx index 719308bf..f73cdef2 100644 --- a/web/components/yes-no-selector.tsx +++ b/web/components/yes-no-selector.tsx @@ -35,10 +35,11 @@ export function YesNoSelector(props: {