diff --git a/backend/api/src/get-max-min-profit-2024.ts b/backend/api/src/get-max-min-profit-2024.ts new file mode 100644 index 0000000000..66b3b11371 --- /dev/null +++ b/backend/api/src/get-max-min-profit-2024.ts @@ -0,0 +1,52 @@ +import { APIHandler } from 'api/helpers/endpoint' +import { createSupabaseDirectClient } from 'shared/supabase/init' + +export const getmaxminprofit2024: APIHandler< + 'get-max-min-profit-2024' +> = async (props) => { + const { userId } = props + const pg = createSupabaseDirectClient() + + const data = await pg.manyOrNone( + ` +with filtered_data as ( + select + ucm.profit, + ucm.has_yes_shares, + ucm.has_no_shares, + ucm.answer_id, + c.data + from + user_contract_metrics ucm + join + contracts c on ucm.contract_id = c.id + where + ucm.user_id = $1 + and c.token = 'MANA' + and c.resolution_time >= '2024-01-01'::timestamp + and c.resolution_time <= '2024-12-31 23:59:59'::timestamp +), +min_max_profits as ( + select + max(profit) as max_profit, + min(profit) as min_profit + from + filtered_data +) +select + fd.profit, + fd.has_yes_shares, + fd.has_no_shares, + fd.answer_id, + fd.data +from + filtered_data fd +join + min_max_profits mmp on fd.profit = mmp.max_profit or fd.profit = mmp.min_profit + order by fd.profit desc; + `, + [userId] + ) + + return data +} diff --git a/backend/api/src/get-monthly-bets-2024.ts b/backend/api/src/get-monthly-bets-2024.ts new file mode 100644 index 0000000000..3377ee786b --- /dev/null +++ b/backend/api/src/get-monthly-bets-2024.ts @@ -0,0 +1,46 @@ +import { APIHandler } from 'api/helpers/endpoint' +import { createSupabaseDirectClient } from 'shared/supabase/init' + +export const getmonthlybets2024: APIHandler<'get-monthly-bets-2024'> = async ( + props +) => { + const { userId } = props + const pg = createSupabaseDirectClient() + + const data = await pg.manyOrNone( + ` + with months as ( + select + to_char(generate_series( + '2024-01-01'::timestamp with time zone, + '2024-12-01'::timestamp with time zone, + '1 month'::interval + ), 'YYYY-MM') as month + ), + user_bets as ( + select + to_char(date_trunc('month', created_time), 'YYYY-MM') as month, + count(*) as bet_count, + coalesce(sum(abs(amount)), 0) as total_amount + from contract_bets + where + user_id = $1 + and created_time >= '2024-01-01'::timestamp with time zone + and created_time < '2025-01-01'::timestamp with time zone + and not coalesce(is_cancelled, false) + and not coalesce(is_redemption, false) + group by date_trunc('month', created_time) + ) + select + months.month, + coalesce(user_bets.bet_count, 0) as bet_count, + coalesce(user_bets.total_amount, 0) as total_amount + from months + left join user_bets on months.month = user_bets.month + order by months.month + `, + [userId] + ) + + return data +} diff --git a/backend/api/src/routes.ts b/backend/api/src/routes.ts index a4aa975d53..bf5b08e244 100644 --- a/backend/api/src/routes.ts +++ b/backend/api/src/routes.ts @@ -136,6 +136,8 @@ import { generateAIMarketSuggestions } from './generate-ai-market-suggestions' import { generateAIMarketSuggestions2 } from './generate-ai-market-suggestions-2' import { generateAIDescription } from './generate-ai-description' import { generateAIAnswers } from './generate-ai-answers' +import { getmonthlybets2024 } from './get-monthly-bets-2024' +import { getmaxminprofit2024 } from './get-max-min-profit-2024' import { getNextLoanAmount } from './get-next-loan-amount' // we define the handlers in this object in order to typecheck that every API has a handler @@ -292,5 +294,7 @@ export const handlers: { [k in APIPath]: APIHandler } = { 'generate-ai-market-suggestions-2': generateAIMarketSuggestions2, 'generate-ai-description': generateAIDescription, 'generate-ai-answers': generateAIAnswers, + 'get-monthly-bets-2024': getmonthlybets2024, + 'get-max-min-profit-2024': getmaxminprofit2024, 'get-next-loan-amount': getNextLoanAmount, } diff --git a/common/src/api/schema.ts b/common/src/api/schema.ts index 85ac8d42e1..7a59cbb602 100644 --- a/common/src/api/schema.ts +++ b/common/src/api/schema.ts @@ -1883,6 +1883,26 @@ export const API = (_apiTypeCheck = { }) .strict(), }, + 'get-monthly-bets-2024': { + method: 'GET', + visibility: 'public', + authed: true, + props: z.object({ userId: z.string() }), + returns: [] as { month: string; bet_count: number; total_amount: number }[], + }, + 'get-max-min-profit-2024': { + method: 'GET', + visibility: 'public', + authed: true, + props: z.object({ userId: z.string() }), + returns: [] as { + profit: number + data: Contract + answer_id: string | null + has_no_shares: boolean + has_yes_shares: boolean + }[], + }, 'get-next-loan-amount': { method: 'GET', visibility: 'undocumented', diff --git a/web/components/nav/profile-summary.tsx b/web/components/nav/profile-summary.tsx index 372d31e750..15385fbd06 100644 --- a/web/components/nav/profile-summary.tsx +++ b/web/components/nav/profile-summary.tsx @@ -34,8 +34,9 @@ export function ProfileSummary(props: { user: User; className?: string }) { + 🎁 void + goToNextPage: () => void + user: User +}) { + const { goToPrevPage, goToNextPage, monthlyBets } = props + const animateTotalSpentIn = true + const [animateMostSpentIn, setAnimateMostSpentIn] = useState(false) + const [animateGraphicIn, setAnimateGraphicIn] = useState(false) + const [animateOut, setAnimateOut] = useState(false) + + //triggers for animation in + useEffect(() => { + if (!animateTotalSpentIn) return + const timeout1 = setTimeout(() => { + setAnimateMostSpentIn(true) + }, 1500) + const timeout2 = setTimeout(() => { + setAnimateGraphicIn(true) + }, 3000) + const timeout3 = setTimeout(() => { + onGoToNext() + }, 6000) + return () => { + clearTimeout(timeout1) + clearTimeout(timeout2) + clearTimeout(timeout3) + } + }, [animateTotalSpentIn]) + + const onGoToNext = () => { + setAnimateOut(true) + setTimeout(() => { + goToNextPage() + }, 1000) + } + + if (monthlyBets == undefined) { + return ( +
+ +
+ ) + } + const amountBetThisYear = monthlyBets.reduce((accumulator, current) => { + return accumulator + current.total_amount + }, 0) + + if (monthlyBets == null) { + return <>An error occured + } + + const monthWithMostBet = monthlyBets.reduce((max, current) => { + return current.total_amount > max.total_amount ? current : max + }) + // Create a date object using the UTC constructor to prevent timezone offsets from affecting the month + const dateOfMostBet = new Date(monthWithMostBet.month) + dateOfMostBet.setDate(dateOfMostBet.getDate() + 1) + + // Now you have the month with the highest number of bets + const monthName = dateOfMostBet.toLocaleString('default', { + month: 'long', + timeZone: 'UTC', + }) + + return ( + <> +
+
+ This year you spent{' '} + + {formatMoney(amountBetThisYear)} + {' '} + trading on things you believed in! +
+ +
+ You traded the most in{' '} + + {monthName} + + , spending{' '} + + {formatMoney(monthWithMostBet.total_amount)} + {' '} + mana! +
+
+ +
+
+ + + ) +} + +const CoinBarChart = (props: { data: MonthlyBetsType[] }) => { + const { data } = props + const svgWidth = 280 + const svgHeight = 350 + const maxCoins = 20 // Maximum number of coins in a stack + const coinWidth = 9 // Width of the oval (coin) + const coinHeight = 3 // Height of the oval (coin) + const spacing = 35 // Horizontal spacing between stacks + const rowSpacing = svgHeight / 3 // Vertical spacing between rows + + const maxManaBet = Math.max(...data.map((item) => item.total_amount)) + const scaleFactor = maxManaBet > 0 ? maxCoins / maxManaBet : 1 + + return ( +
+ + {data.map((item, index) => { + const coinsInStack = Math.round(item.total_amount * scaleFactor) + const isTopRow = index < 6 // First 6 months (Jan-Jun) are in the top row + const rowIndex = isTopRow ? index : index - 6 // Adjust index for each row + const xPosition = (svgWidth / 6) * rowIndex + spacing // X position of each stack + const yBasePosition = isTopRow ? rowSpacing : rowSpacing * 2 // Y base position for each row + + return ( + + {/* Stack of coins */} + {Array.from({ length: coinsInStack }).map((_, coinIndex) => { + const yPosition = yBasePosition - (coinIndex * coinHeight + 30) + return ( + + ) + })} + {/* Month label */} + + {MONTHS[index]} + + + ) + })} + +
+ ) +} + +export const MONTHS = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'June', + 'July', + 'Aug', + 'Sept', + 'Oct', + 'Nov', + 'Dec', +] diff --git a/web/components/wrapped/MaxMinProfit.tsx b/web/components/wrapped/MaxMinProfit.tsx new file mode 100644 index 0000000000..5f71c6386c --- /dev/null +++ b/web/components/wrapped/MaxMinProfit.tsx @@ -0,0 +1,150 @@ +import clsx from 'clsx' +import { User } from 'common/user' +import { formatMoney } from 'common/util/format' +import { useEffect, useState } from 'react' +import { ProfitType, useMaxAndMinProfit } from 'web/hooks/use-wrapped-2024' +import { Col } from '../layout/col' +import { Row } from '../layout/row' +import { NavButtons } from './NavButtons' +import { LoadingIndicator } from '../widgets/loading-indicator' +import { useLiveContract } from 'web/hooks/use-contract' +import Link from 'next/link' +import { contractPath } from 'common/contract' + +export function MaxMinProfit(props: { + goToPrevPage: () => void + goToNextPage: () => void + user: User +}) { + const { goToPrevPage, goToNextPage, user } = props + const animateIn = true + const [animateIn2, setAnimateIn2] = useState(false) + const [animateOut, setAnimateOut] = useState(false) + + const { maxProfit, minProfit } = useMaxAndMinProfit(user.id) + //triggers for animation in + useEffect(() => { + if (!animateIn) return + const timeout1 = setTimeout(() => { + setAnimateIn2(true) + }, 2000) + const timeout2 = setTimeout(() => { + onGoToNext() + }, 5000) + return () => { + clearTimeout(timeout1) + clearTimeout(timeout2) + } + }, [animateIn]) + + const onGoToNext = () => { + setAnimateOut(true) + setTimeout(() => { + goToNextPage() + }, 1000) + } + + if (maxProfit == undefined || minProfit == undefined) { + return ( +
+ +
+ ) + } + + if (maxProfit == null || minProfit == null) { + return <>An error occured + } + + function getBetOnThing(profit: ProfitType) { + const contract = profit.contract + const betOnAnswer = + profit.answerId && 'answers' in contract + ? contract.answers.find((a) => a.id === profit.answerId) + : undefined + return [contract, betOnAnswer] + } + + const [maxContract, maxBetOnAnswer] = getBetOnThing(maxProfit) + const [minContract, minBetOnAnswer] = getBetOnThing(minProfit) + + return ( + <> +
+ +
+ +
+ {formatMoney(maxProfit?.profit ?? 0)} +
+
+ {formatMoney(minProfit?.profit ?? 0)} +
+ + +
+ You made the most trading + on{' '} + {maxBetOnAnswer ?? maxProfit.contract.question}{' '} + {maxBetOnAnswer && <>on {maxProfit.contract.question}} +
+
+ You lost the most trading + + on {minBetOnAnswer ?? minProfit.contract.question}{' '} + {maxBetOnAnswer && <>on {maxProfit.contract.question}} +
+ + +
+ + + ) +} + +function BettingDirection(props: { profit: ProfitType | null | undefined }) { + if (!props.profit) { + return <> + } + const { hasYesShares, hasNoShares } = props.profit + return ( + <> + {hasYesShares ? ( + YES + ) : hasNoShares ? ( + NO + ) : ( + <> + )} + + ) +} diff --git a/web/components/wrapped/MonthlyBets.tsx b/web/components/wrapped/MonthlyBets.tsx new file mode 100644 index 0000000000..517c973da5 --- /dev/null +++ b/web/components/wrapped/MonthlyBets.tsx @@ -0,0 +1,219 @@ +import clsx from 'clsx' +import { useEffect, useState } from 'react' +import { MonthlyBetsType } from 'web/hooks/use-wrapped-2024' +import { LoadingIndicator } from '../widgets/loading-indicator' +import { numberWithCommas } from 'web/lib/util/formatNumber' +import { Spacer } from '../layout/spacer' +import { MONTHS } from './GeneralStats' +import { NavButtons } from './NavButtons' + +export function MonthlyBets(props: { + goToPrevPage: () => void + goToNextPage: () => void + monthlyBets: MonthlyBetsType[] | undefined | null +}) { + const { goToPrevPage, goToNextPage, monthlyBets } = props + const animateCircleIn = true + const [animateTotalBetIn, setAnimateTotalBetIn] = useState(false) + const [animateMostMonthBetIn, setAnimateMostMonthBetIn] = useState(false) + const [animateOut, setAnimateOut] = useState(false) + + //triggers for animation in + useEffect(() => { + if (!animateCircleIn) return + const timeout1 = setTimeout(() => { + setAnimateTotalBetIn(true) + }, 1000) + const timeout2 = setTimeout(() => { + setAnimateMostMonthBetIn(true) + }, 3000) + const timeout3 = setTimeout(() => { + onGoToNext() + }, 6000) + return () => { + clearTimeout(timeout1) + clearTimeout(timeout2) + clearTimeout(timeout3) + } + }, [animateCircleIn]) + + const onGoToNext = () => { + setAnimateOut(true) + setTimeout(() => { + goToNextPage() + }, 1000) + } + + if (monthlyBets == undefined) { + return ( +
+ +
+ ) + } + const totalBetsThisYear = monthlyBets.reduce((accumulator, current) => { + return accumulator + current.bet_count + }, 0) + if (monthlyBets == null) { + return <>An error occured + } + + const monthWithMaxBets = monthlyBets.reduce((max, current) => { + return current.bet_count > max.bet_count ? current : max + }) + // Create a date object using the UTC constructor to prevent timezone offsets from affecting the month + const dateOfMaxBets = new Date(monthWithMaxBets.month) + dateOfMaxBets.setDate(dateOfMaxBets.getDate() + 1) + + // Now you have the month with the highest number of bets + const monthName = dateOfMaxBets.toLocaleString('default', { + month: 'long', + timeZone: 'UTC', + }) + + return ( + <> +
+
+ +
+
+ You've traded a total of{' '} + + {numberWithCommas(totalBetsThisYear)} + {' '} + times this year! +
+ +
+ You traded the most in{' '} + + {monthName} + + , with{' '} + + {numberWithCommas(monthWithMaxBets.bet_count)} + {' '} + trades! +
+
+ + + ) +} + +export const CircleGraph = (props: { + monthlyBets: MonthlyBetsType[] + maxBets: number +}) => { + const { monthlyBets, maxBets } = props + const radius = 100 // Radius of the circle + const maxLength = 50 // Maximum length of the spikes + const svgPadding = 20 + const svgSize = (radius + maxLength + svgPadding) * 2 + const svgCenter = svgSize / 4 + + // Calculate the scale factor + const scaleFactor = maxBets > 0 ? maxLength / maxBets : 0 + const numMonths = monthlyBets.length + return ( + + + + + + + + + {/* Draw the lines for each month */} + {monthlyBets.map((data, index) => { + const angle = (index / numMonths) * Math.PI * 2 - Math.PI / 2 // -90 degrees to start from top + const lineLength = data.bet_count * scaleFactor + const x1 = svgCenter + radius + radius * Math.cos(angle) + const y1 = svgCenter + radius + radius * Math.sin(angle) + const x2 = x1 + lineLength * Math.cos(angle) + const y2 = y1 + lineLength * Math.sin(angle) + + return ( + + ) + })} + + {/* Label the months */} + {monthlyBets.map((_data, index) => { + const angle = (index / numMonths) * Math.PI * 2 - Math.PI / 2 + const textRadius = radius - 10 // Place text inside the circle + const x = svgCenter + radius + textRadius * Math.cos(angle) + const y = svgCenter + radius + textRadius * Math.sin(angle) + + return ( + + {MONTHS[index]} + + ) + })} + + ) +} + +function getScaleColor(scaledNum: number) { + if (scaledNum < 20) { + return '#22d3ee' + } + if (scaledNum < 40) { + return '#67e8f9' + } + if (scaledNum < 60) { + return '#a5f3fc' + } + if (scaledNum < 80) { + return '#cffafe' + } else { + return '#ecfeff' + } +} diff --git a/web/components/wrapped/NavButtons.tsx b/web/components/wrapped/NavButtons.tsx new file mode 100644 index 0000000000..8cd64bcbe4 --- /dev/null +++ b/web/components/wrapped/NavButtons.tsx @@ -0,0 +1,29 @@ +import { ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/solid' + +export function NavButtons(props: { + goToPrevPage?: () => void + goToNextPage?: () => void +}) { + const { goToPrevPage, goToNextPage } = props + return ( + <> + {goToPrevPage && ( + + )} + {goToNextPage && ( + + )} + + ) +} diff --git a/web/components/wrapped/TheEnd.tsx b/web/components/wrapped/TheEnd.tsx new file mode 100644 index 0000000000..b6a0f773ab --- /dev/null +++ b/web/components/wrapped/TheEnd.tsx @@ -0,0 +1,82 @@ +import clsx from 'clsx' +import { Row } from '../layout/row' +import { Spacer } from '../layout/spacer' +import { NavButtons } from './NavButtons' +import Link from 'next/link' +import { ENV_CONFIG } from 'common/envs/constants' +import { Button } from '../buttons/button' +import { copyToClipboard } from 'web/lib/util/copy' +import { HomeIcon, LinkIcon } from '@heroicons/react/solid' +import { Col } from '../layout/col' +import { VscDebugRestart } from 'react-icons/vsc' +import toast, { Toaster } from 'react-hot-toast' + +export function TheEnd(props: { + goToPrevPage: () => void + username: string + restart: () => void + isCurrentUser: boolean +}) { + const { goToPrevPage, username, restart, isCurrentUser } = props + const url = getWrappedUrl(username) + return ( + <> + + +
The end!
+ + + + { + // e.preventDefault() + e.stopPropagation() + }} + href={isCurrentUser ? `/${username}` : '/home'} + className="font-md z-50 flex flex-row items-center justify-center gap-1 rounded-md px-4 py-2 text-center text-sm ring-inset transition-colors hover:text-pink-400 hover:underline disabled:cursor-not-allowed" + > +