diff --git a/common/calculate-metrics.ts b/common/calculate-metrics.ts index 2c544217..6c1d9a5f 100644 --- a/common/calculate-metrics.ts +++ b/common/calculate-metrics.ts @@ -1,4 +1,4 @@ -import { Dictionary, groupBy, last, sum, sumBy, uniq } from 'lodash' +import { Dictionary, groupBy, last, partition, sum, sumBy, uniq } from 'lodash' import { calculatePayout, getContractBetMetrics } from './calculate' import { Bet, LimitBet } from './bet' import { @@ -266,7 +266,9 @@ export const calculateMetricsByContract = ( }) } -export type ContractMetrics = ReturnType[number] +export type ContractMetrics = ReturnType< + typeof calculateMetricsByContract +>[number] const calculatePeriodProfit = ( contract: CPMMBinaryContract, @@ -275,7 +277,10 @@ const calculatePeriodProfit = ( ) => { const days = period === 'day' ? 1 : period === 'week' ? 7 : 30 const fromTime = Date.now() - days * DAY_MS - const previousBets = bets.filter((b) => b.createdTime < fromTime) + const [previousBets, recentBets] = partition( + bets, + (b) => b.createdTime < fromTime + ) const prevProb = contract.prob - contract.probChanges[period] const prob = contract.resolutionProbability @@ -292,13 +297,18 @@ const calculatePeriodProfit = ( contract, prob ) - const profit = currentBetsValue - previousBetsValue - const profitPercent = - previousBetsValue === 0 ? 0 : 100 * (profit / previousBetsValue) + + const { profit: recentProfit, invested: recentInvested } = + getContractBetMetrics(contract, recentBets) + + const profit = currentBetsValue - previousBetsValue + recentProfit + const invested = previousBetsValue + recentInvested + const profitPercent = invested === 0 ? 0 : 100 * (profit / invested) return { profit, profitPercent, + invested, prevValue: previousBetsValue, value: currentBetsValue, } diff --git a/functions/README.md b/functions/README.md index 02477588..59423c6e 100644 --- a/functions/README.md +++ b/functions/README.md @@ -20,7 +20,7 @@ Adapted from https://firebase.google.com/docs/functions/get-started 3. `$ firebase login` to authenticate the CLI tools to Firebase 4. `$ firebase use dev` to choose the dev project -### For local development +#### (Installing) For local development 0. [Install](https://cloud.google.com/sdk/docs/install) gcloud CLI 1. If you don't have java (or see the error `Error: Process java -version has exited with code 1. Please make sure Java is installed and on your system PATH.`): @@ -35,10 +35,10 @@ Adapted from https://firebase.google.com/docs/functions/get-started ## Developing locally -0. `$ firebase use dev` if you haven't already -1. `$ yarn serve` to spin up the emulators 0. The Emulator UI is at http://localhost:4000; the functions are hosted on :5001. - Note: You have to kill and restart emulators when you change code; no hot reload =( -2. `$ yarn dev:emulate` in `/web` to connect to emulators with the frontend 0. Note: emulated database is cleared after every shutdown +0. `$ ./dev.sh localdb` to start the local emulator and front end +1. If you change db trigger code, you have to start (doesn't have to complete) the deploy of it to dev to cause a hard emulator code refresh `$ firebase deploy --only functions:dbTriggerNameHere` + - There's surely a better way to cause/react to a db trigger update but just adding this here for now as it works +2. If you want to test a scheduled function replace your function in `test-scheduled-function.ts` and send a GET to `http://localhost:8088/testscheduledfunction` (Best user experience is via [Postman](https://www.postman.com/downloads/)!) ## Firestore Commands diff --git a/functions/src/scripts/backfill-badges.ts b/functions/src/scripts/backfill-badges.ts index a3776eb0..8205735c 100644 --- a/functions/src/scripts/backfill-badges.ts +++ b/functions/src/scripts/backfill-badges.ts @@ -32,6 +32,9 @@ async function main() { user.achievements = await awardBettingStreakBadges(user) console.log('Added achievements to user', user.id) // going to ignore backfilling the proven correct badges for now + } else { + // Make corrections to existing achievements + await awardMarketCreatorBadges(user) } }) ) @@ -67,12 +70,11 @@ async function removeErrorBadges(user: User) { async function awardMarketCreatorBadges(user: User) { // Award market maker badges - const contracts = await getValues( - firestore - .collection(`contracts`) - .where('creatorId', '==', user.id) - .where('resolution', '!=', 'CANCEL') - ) + const contracts = ( + await getValues( + firestore.collection(`contracts`).where('creatorId', '==', user.id) + ) + ).filter((c) => !c.resolution || c.resolution != 'CANCEL') const achievements = { ...user.achievements, @@ -81,7 +83,12 @@ async function awardMarketCreatorBadges(user: User) { }, } for (const threshold of marketCreatorBadgeRarityThresholds) { + const alreadyHasBadge = user.achievements.marketCreator?.badges.some( + (b) => b.data.totalContractsCreated === threshold + ) + if (alreadyHasBadge) continue if (contracts.length >= threshold) { + console.log(`User ${user.id} has at least ${threshold} contracts`) const badge = { type: 'MARKET_CREATOR', name: 'Market Creator', diff --git a/web/components/challenges/create-challenge-modal.tsx b/web/components/challenges/create-challenge-modal.tsx index f8d91a7b..367a8ed0 100644 --- a/web/components/challenges/create-challenge-modal.tsx +++ b/web/components/challenges/create-challenge-modal.tsx @@ -20,7 +20,6 @@ import { getProbability } from 'common/calculate' import { createMarket } from 'web/lib/firebase/api' import { removeUndefinedProps } from 'common/util/object' import { FIXED_ANTE } from 'common/economy' -import { useTextEditor } from 'web/components/editor' import { LoadingIndicator } from 'web/components/loading-indicator' import { track } from 'web/lib/service/analytics' import { CopyLinkButton } from '../copy-link-button' @@ -43,7 +42,6 @@ export function CreateChallengeModal(props: { const { user, contract, isOpen, setOpen } = props const [challengeSlug, setChallengeSlug] = useState('') const [loading, setLoading] = useState(false) - const { editor } = useTextEditor({ placeholder: '' }) return ( @@ -64,7 +62,6 @@ export function CreateChallengeModal(props: { question: newChallenge.question, outcomeType: 'BINARY', initialProb: 50, - description: editor?.getJSON(), ante: FIXED_ANTE, closeTime: dayjs().add(30, 'day').valueOf(), }) diff --git a/web/components/comment-input.tsx b/web/components/comment-input.tsx index 65a697fe..460fa438 100644 --- a/web/components/comment-input.tsx +++ b/web/components/comment-input.tsx @@ -17,13 +17,21 @@ export function CommentInput(props: { // Reply to another comment parentCommentId?: string onSubmitComment?: (editor: Editor) => void + // unique id for autosave + pageId: string className?: string }) { - const { parentAnswerOutcome, parentCommentId, replyTo, onSubmitComment } = - props + const { + parentAnswerOutcome, + parentCommentId, + replyTo, + onSubmitComment, + pageId, + } = props const user = useUser() const { editor, upload } = useTextEditor({ + key: `comment ${pageId} ${parentCommentId ?? parentAnswerOutcome ?? ''}`, simple: true, max: MAX_COMMENT_LENGTH, placeholder: @@ -80,7 +88,7 @@ export function CommentInputTextArea(props: { const submit = () => { submitComment() - editor?.commands?.clearContent() + editor?.commands?.clearContent(true) } useEffect(() => { @@ -107,7 +115,7 @@ export function CommentInputTextArea(props: { }, }) // insert at mention and focus - if (replyTo) { + if (replyTo && editor.isEmpty) { editor .chain() .setContent({ diff --git a/web/components/contract/add-liquidity-button.tsx b/web/components/contract/add-liquidity-button.tsx new file mode 100644 index 00000000..1288206f --- /dev/null +++ b/web/components/contract/add-liquidity-button.tsx @@ -0,0 +1,36 @@ +import { useState } from 'react' +import clsx from 'clsx' + +import { buttonClass } from 'web/components/button' +import { CPMMContract } from 'common/contract' +import { LiquidityModal } from './liquidity-modal' + +export function AddLiquidityButton(props: { + contract: CPMMContract + className?: string +}) { + const { contract, className } = props + + const [open, setOpen] = useState(false) + + const disabled = + contract.isResolved || (contract.closeTime ?? Infinity) < Date.now() + + if (disabled) return <> + + return ( + setOpen(true)} + target="_blank" + > +
💧 Add liquidity
+ +
+ ) +} diff --git a/web/components/contract/contract-card.tsx b/web/components/contract/contract-card.tsx index d0672f27..80c90b93 100644 --- a/web/components/contract/contract-card.tsx +++ b/web/components/contract/contract-card.tsx @@ -21,7 +21,6 @@ import { import { AnswerLabel, BinaryContractOutcomeLabel, - BinaryOutcomeLabel, CancelLabel, FreeResponseOutcomeLabel, } from '../outcome-label' @@ -430,17 +429,16 @@ export function ContractCardProbChange(props: { 'items-center justify-between gap-4 pl-6 pr-4 pb-2 text-sm' )} > - +
Position
- {formatMoney(metrics.payout)} - + {formatMoney(metrics.payout)} {outcome}
{dayMetrics && ( <>
Daily profit
- +
)} diff --git a/web/components/contract/contract-description.tsx b/web/components/contract/contract-description.tsx index 40532c21..855bc750 100644 --- a/web/components/contract/contract-description.tsx +++ b/web/components/contract/contract-description.tsx @@ -48,6 +48,7 @@ function RichEditContract(props: { contract: Contract; isAdmin?: boolean }) { const [isSubmitting, setIsSubmitting] = useState(false) const { editor, upload } = useTextEditor({ + // key: `description ${contract.id}`, max: MAX_DESCRIPTION_LENGTH, defaultValue: contract.description, disabled: isSubmitting, diff --git a/web/components/contract/contract-info-dialog.tsx b/web/components/contract/contract-info-dialog.tsx index e1c36901..a41be451 100644 --- a/web/components/contract/contract-info-dialog.tsx +++ b/web/components/contract/contract-info-dialog.tsx @@ -20,6 +20,7 @@ import { DuplicateContractButton } from '../duplicate-contract-button' import { Row } from '../layout/row' import { BETTORS, User } from 'common/user' import { Button } from '../button' +import { AddLiquidityButton } from './add-liquidity-button' export const contractDetailsButtonClassName = 'group flex items-center rounded-md px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-100 text-gray-400 hover:text-gray-500' @@ -241,6 +242,9 @@ export function ContractInfoDialog(props: { + {mechanism === 'cpmm-1' && ( + + )} diff --git a/web/components/contract/extra-contract-actions-row.tsx b/web/components/contract/extra-contract-actions-row.tsx index d81132b9..0c77c666 100644 --- a/web/components/contract/extra-contract-actions-row.tsx +++ b/web/components/contract/extra-contract-actions-row.tsx @@ -9,7 +9,6 @@ import { FollowMarketButton } from 'web/components/follow-market-button' import { LikeMarketButton } from 'web/components/contract/like-market-button' import { ContractInfoDialog } from 'web/components/contract/contract-info-dialog' import { Tooltip } from '../tooltip' -import { LiquidityButton } from './liquidity-button' export function ExtraContractActionsRow(props: { contract: Contract }) { const { contract } = props @@ -19,10 +18,9 @@ export function ExtraContractActionsRow(props: { contract: Contract }) { return ( - {contract.mechanism === 'cpmm-1' && ( - - )} + + + ) diff --git a/web/components/contract/liquidity-modal.tsx b/web/components/contract/liquidity-modal.tsx index 1cc337cd..068377b2 100644 --- a/web/components/contract/liquidity-modal.tsx +++ b/web/components/contract/liquidity-modal.tsx @@ -23,7 +23,7 @@ export function LiquidityModal(props: { return ( - + <Title className="!mt-0 !mb-2" text="💧 Add liquidity" /> <div>Total liquidity subsidies: {formatMoney(totalLiquidity)}</div> <AddLiquidityPanel contract={contract as CPMMContract} /> @@ -91,7 +91,7 @@ function AddLiquidityPanel(props: { contract: CPMMContract }) { label="M$" error={error} disabled={isLoading} - inputClassName="w-16 mr-4" + inputClassName="w-28 mr-4" /> <Button size="md" color="blue" onClick={submit} disabled={isLoading}> Add diff --git a/web/components/create-post.tsx b/web/components/create-post.tsx index f7d9b8bd..a4b65661 100644 --- a/web/components/create-post.tsx +++ b/web/components/create-post.tsx @@ -21,6 +21,7 @@ export function CreatePost(props: { group?: Group }) { const { group } = props const { editor, upload } = useTextEditor({ + key: `post ${group?.id || ''}`, disabled: isSubmitting, }) @@ -45,6 +46,7 @@ export function CreatePost(props: { group?: Group }) { return e }) if (result.post) { + editor.commands.clearContent(true) await Router.push(postPath(result.post.slug)) } } diff --git a/web/components/editor.tsx b/web/components/editor.tsx index 17cabc38..f0b6f4bb 100644 --- a/web/components/editor.tsx +++ b/web/components/editor.tsx @@ -14,7 +14,7 @@ import StarterKit from '@tiptap/starter-kit' import { Image } from '@tiptap/extension-image' import { Link } from '@tiptap/extension-link' import clsx from 'clsx' -import { useEffect, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' import { Linkify } from './linkify' import { uploadImage } from 'web/lib/firebase/storage' import { useMutation } from 'react-query' @@ -41,6 +41,12 @@ import ItalicIcon from 'web/lib/icons/italic-icon' import LinkIcon from 'web/lib/icons/link-icon' import { getUrl } from 'common/util/parse' import { TiptapSpoiler } from 'common/util/tiptap-spoiler' +import { + storageStore, + usePersistentState, +} from 'web/hooks/use-persistent-state' +import { safeLocalStorage } from 'web/lib/util/local' +import { debounce } from 'lodash' const DisplayImage = Image.configure({ HTMLAttributes: { @@ -90,19 +96,34 @@ export function useTextEditor(props: { defaultValue?: Content disabled?: boolean simple?: boolean + key?: string // unique key for autosave. If set, plz call `clearContent(true)` on submit to clear autosave }) { - const { placeholder, max, defaultValue = '', disabled, simple } = props + const { placeholder, max, defaultValue, disabled, simple, key } = props + + const [content, saveContent] = usePersistentState<JSONContent | undefined>( + undefined, + { + key: `text ${key}`, + store: storageStore(safeLocalStorage()), + } + ) + + // eslint-disable-next-line react-hooks/exhaustive-deps + const save = useCallback(debounce(saveContent, 500), []) const editorClass = clsx( proseClass, !simple && 'min-h-[6em]', 'outline-none pt-2 px-4', 'prose-img:select-auto', - '[&_.ProseMirror-selectednode]:outline-dotted [&_*]:outline-indigo-300' // selected img, emebeds + '[&_.ProseMirror-selectednode]:outline-dotted [&_*]:outline-indigo-300' // selected img, embeds ) const editor = useEditor({ - editorProps: { attributes: { class: editorClass } }, + editorProps: { + attributes: { class: editorClass, spellcheck: simple ? 'true' : 'false' }, + }, + onUpdate: key ? ({ editor }) => save(editor.getJSON()) : undefined, extensions: [ ...editorExtensions(simple), Placeholder.configure({ @@ -112,7 +133,7 @@ export function useTextEditor(props: { }), CharacterCount.configure({ limit: max }), ], - content: defaultValue, + content: defaultValue ?? (key && content ? content : ''), }) const upload = useUploadMutation(editor) diff --git a/web/components/feed/feed-comments.tsx b/web/components/feed/feed-comments.tsx index 0cc7012d..552bfe7c 100644 --- a/web/components/feed/feed-comments.tsx +++ b/web/components/feed/feed-comments.tsx @@ -268,6 +268,7 @@ export function ContractCommentInput(props: { parentAnswerOutcome={parentAnswerOutcome} parentCommentId={parentCommentId} onSubmitComment={onSubmitComment} + pageId={contract.id} className={className} /> ) diff --git a/web/components/profile/badges-modal.tsx b/web/components/profile/badges-modal.tsx index 96e0fa9d..3065136c 100644 --- a/web/components/profile/badges-modal.tsx +++ b/web/components/profile/badges-modal.tsx @@ -20,6 +20,7 @@ import { goldClassName, silverClassName, } from 'web/components/badge-display' +import { formatMoney } from 'common/util/format' export function BadgesModal(props: { isOpen: boolean @@ -132,7 +133,9 @@ function ProvenCorrectBadgeItem(props: { <Col className={'text-center'}> <Medal rarity={rarity} /> <Tooltip - text={`Make a comment attached to a winning bet worth ${betAmount}`} + text={`Make a comment attached to a winning bet worth ${formatMoney( + betAmount + )}`} > <span className={ diff --git a/web/components/profit-badge.tsx b/web/components/profit-badge.tsx index ff7d4dc0..88d958d4 100644 --- a/web/components/profit-badge.tsx +++ b/web/components/profit-badge.tsx @@ -28,10 +28,17 @@ export function ProfitBadge(props: { ) } -export function ProfitBadgeMana(props: { amount: number; className?: string }) { - const { amount, className } = props - const colors = - amount > 0 ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800' +export function ProfitBadgeMana(props: { + amount: number + gray?: boolean + className?: string +}) { + const { amount, gray, className } = props + const colors = gray + ? 'bg-gray-100 text-gray-700' + : amount > 0 + ? 'bg-gray-100 text-green-800' + : 'bg-gray-100 text-red-800' const formatted = ENV_CONFIG.moneyMoniker + (amount > 0 ? '+' : '') + amount.toFixed(0) diff --git a/web/pages/create.tsx b/web/pages/create.tsx index 7ba99a39..ca6c5ffe 100644 --- a/web/pages/create.tsx +++ b/web/pages/create.tsx @@ -22,7 +22,6 @@ import { ChoicesToggleGroup } from 'web/components/choices-toggle-group' import { getGroup, groupPath } from 'web/lib/firebase/groups' import { Group } from 'common/group' import { useTracking } from 'web/hooks/use-tracking' -import { useWarnUnsavedChanges } from 'web/hooks/use-warn-unsaved-changes' import { track } from 'web/lib/service/analytics' import { GroupSelector } from 'web/components/groups/group-selector' import { User } from 'common/user' @@ -228,6 +227,7 @@ export function NewContract(props: { : `e.g. I will choose the answer according to...` const { editor, upload } = useTextEditor({ + key: 'create market', max: MAX_DESCRIPTION_LENGTH, placeholder: descriptionPlaceholder, disabled: isSubmitting, @@ -236,9 +236,6 @@ export function NewContract(props: { : undefined, }) - const isEditorFilled = editor != null && !editor.isEmpty - useWarnUnsavedChanges(!isSubmitting && (Boolean(question) || isEditorFilled)) - function setCloseDateInDays(days: number) { const newCloseDate = dayjs().add(days, 'day').format('YYYY-MM-DD') setCloseDate(newCloseDate) @@ -272,6 +269,7 @@ export function NewContract(props: { selectedGroup: selectedGroup?.id, isFree: false, }) + editor?.commands.clearContent(true) await router.push(contractPath(result as Contract)) } catch (e) { console.error('error creating contract', e, (e as any).details) diff --git a/web/pages/group/[...slugs]/index.tsx b/web/pages/group/[...slugs]/index.tsx index 88c05f78..918b8225 100644 --- a/web/pages/group/[...slugs]/index.tsx +++ b/web/pages/group/[...slugs]/index.tsx @@ -99,10 +99,10 @@ export async function getStaticPaths() { const groupSubpages = [ undefined, GROUP_CHAT_SLUG, + 'overview', 'markets', 'leaderboards', 'about', - 'posts', ] as const export default function GroupPage(props: { @@ -131,8 +131,8 @@ export default function GroupPage(props: { const router = useRouter() const { slugs } = router.query as { slugs: string[] } const page = slugs?.[1] as typeof groupSubpages[number] - const tabIndex = ['markets', 'leaderboard', 'about', 'posts'].indexOf( - page ?? 'markets' + const tabIndex = ['overview', 'markets', 'leaderboards'].indexOf( + page === 'about' ? 'overview' : page ?? 'markets' ) const group = useGroup(props.group?.id) ?? props.group diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index 7388986a..cd5a7192 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -1220,10 +1220,9 @@ function getSourceUrl(notification: Notification) { sourceType )}` else if (sourceSlug) - return `/${sourceSlug}#${getSourceIdForLinkComponent( - sourceId ?? '', - sourceType - )}` + return `${ + sourceSlug.startsWith('/') ? sourceSlug : '/' + sourceSlug + }#${getSourceIdForLinkComponent(sourceId ?? '', sourceType)}` } function getSourceIdForLinkComponent( diff --git a/web/posts/post-comments.tsx b/web/posts/post-comments.tsx index 62f69074..722c16c6 100644 --- a/web/posts/post-comments.tsx +++ b/web/posts/post-comments.tsx @@ -94,6 +94,7 @@ export function PostCommentInput(props: { replyTo={replyToUser} parentCommentId={parentCommentId} onSubmitComment={onSubmitComment} + pageId={post.id} /> ) }