diff --git a/common/calculate-metrics.ts b/common/calculate-metrics.ts index 3aad1a9c..b27ac977 100644 --- a/common/calculate-metrics.ts +++ b/common/calculate-metrics.ts @@ -116,12 +116,12 @@ const calculateProfitForPeriod = ( return currentProfit } - const startingProfit = calculateTotalProfit(startingPortfolio) + const startingProfit = calculatePortfolioProfit(startingPortfolio) return currentProfit - startingProfit } -const calculateTotalProfit = (portfolio: PortfolioMetrics) => { +export const calculatePortfolioProfit = (portfolio: PortfolioMetrics) => { return portfolio.investmentValue + portfolio.balance - portfolio.totalDeposits } @@ -129,7 +129,7 @@ export const calculateNewProfit = ( portfolioHistory: PortfolioMetrics[], newPortfolio: PortfolioMetrics ) => { - const allTimeProfit = calculateTotalProfit(newPortfolio) + const allTimeProfit = calculatePortfolioProfit(newPortfolio) const descendingPortfolio = sortBy( portfolioHistory, (p) => p.timestamp diff --git a/web/components/arrange-home.tsx b/web/components/arrange-home.tsx index 2f49d144..ae02e3ea 100644 --- a/web/components/arrange-home.tsx +++ b/web/components/arrange-home.tsx @@ -12,7 +12,7 @@ import { User } from 'common/user' import { Group } from 'common/group' export function ArrangeHome(props: { - user: User | null + user: User | null | undefined homeSections: { visible: string[]; hidden: string[] } setHomeSections: (homeSections: { visible: string[] @@ -30,7 +30,6 @@ export function ArrangeHome(props: { return ( { - console.log('drag end', e) const { destination, source, draggableId } = e if (!destination) return diff --git a/web/components/carousel.tsx b/web/components/carousel.tsx index 79baa451..030c256c 100644 --- a/web/components/carousel.tsx +++ b/web/components/carousel.tsx @@ -38,7 +38,7 @@ export function Carousel(props: { return (
diff --git a/web/components/contract-search.tsx b/web/components/contract-search.tsx index 8e3b18e0..e4b7f9cf 100644 --- a/web/components/contract-search.tsx +++ b/web/components/contract-search.tsx @@ -69,6 +69,7 @@ type AdditionalFilter = { excludeContractIds?: string[] groupSlug?: string yourBets?: boolean + followed?: boolean } export function ContractSearch(props: { @@ -88,6 +89,7 @@ export function ContractSearch(props: { useQueryUrlParam?: boolean isWholePage?: boolean noControls?: boolean + maxResults?: number renderContracts?: ( contracts: Contract[] | undefined, loadMore: () => void @@ -107,6 +109,7 @@ export function ContractSearch(props: { useQueryUrlParam, isWholePage, noControls, + maxResults, renderContracts, } = props @@ -189,7 +192,8 @@ export function ContractSearch(props: { const contracts = state.pages .flat() .filter((c) => !additionalFilter?.excludeContractIds?.includes(c.id)) - const renderedContracts = state.pages.length === 0 ? undefined : contracts + const renderedContracts = + state.pages.length === 0 ? undefined : contracts.slice(0, maxResults) if (IS_PRIVATE_MANIFOLD || process.env.NEXT_PUBLIC_FIREBASE_EMULATE) { return @@ -292,6 +296,19 @@ function ContractSearchControls(props: { const pillGroups: { name: string; slug: string }[] = memberPillGroups.length > 0 ? memberPillGroups : DEFAULT_CATEGORY_GROUPS + const personalFilters = user + ? [ + // Show contracts in groups that the user is a member of. + memberGroupSlugs + .map((slug) => `groupLinks.slug:${slug}`) + // Or, show contracts created by users the user follows + .concat(follows?.map((followId) => `creatorId:${followId}`) ?? []), + + // Subtract contracts you bet on, to show new ones. + `uniqueBettorIds:-${user.id}`, + ] + : [] + const additionalFilters = [ additionalFilter?.creatorId ? `creatorId:${additionalFilter.creatorId}` @@ -304,6 +321,7 @@ function ContractSearchControls(props: { ? // Show contracts bet on by the user `uniqueBettorIds:${user.id}` : '', + ...(additionalFilter?.followed ? personalFilters : []), ] const facetFilters = query ? additionalFilters @@ -320,17 +338,7 @@ function ContractSearchControls(props: { state.pillFilter !== 'your-bets' ? `groupLinks.slug:${state.pillFilter}` : '', - state.pillFilter === 'personal' - ? // Show contracts in groups that the user is a member of - memberGroupSlugs - .map((slug) => `groupLinks.slug:${slug}`) - // Show contracts created by users the user follows - .concat(follows?.map((followId) => `creatorId:${followId}`) ?? []) - : '', - // Subtract contracts you bet on from For you. - state.pillFilter === 'personal' && user - ? `uniqueBettorIds:-${user.id}` - : '', + ...(state.pillFilter === 'personal' ? personalFilters : []), state.pillFilter === 'your-bets' && user ? // Show contracts bet on by the user `uniqueBettorIds:${user.id}` diff --git a/web/components/contract/prob-change-table.tsx b/web/components/contract/prob-change-table.tsx index f6e5d892..f973d260 100644 --- a/web/components/contract/prob-change-table.tsx +++ b/web/components/contract/prob-change-table.tsx @@ -3,52 +3,74 @@ import { contractPath } from 'web/lib/firebase/contracts' import { CPMMContract } from 'common/contract' import { formatPercent } from 'common/util/format' import { useProbChanges } from 'web/hooks/use-prob-changes' -import { SiteLink } from '../site-link' +import { linkClass, SiteLink } from '../site-link' import { Col } from '../layout/col' import { Row } from '../layout/row' +import { useState } from 'react' export function ProbChangeTable(props: { userId: string | undefined }) { const { userId } = props const changes = useProbChanges(userId ?? '') + const [expanded, setExpanded] = useState(false) if (!changes) { return null } - const { positiveChanges, negativeChanges } = changes + const count = expanded ? 16 : 4 - const count = 3 + const { positiveChanges, negativeChanges } = changes + const filteredPositiveChanges = positiveChanges.slice(0, count / 2) + const filteredNegativeChanges = negativeChanges.slice(0, count / 2) + const filteredChanges = [ + ...filteredPositiveChanges, + ...filteredNegativeChanges, + ] return ( - - - {positiveChanges.slice(0, count).map((contract) => ( - - - - {contract.question} - - - ))} + + + + {filteredChanges.slice(0, count / 2).map((contract) => ( + + + + {contract.question} + + + ))} + + + {filteredChanges.slice(count / 2).map((contract) => ( + + + + {contract.question} + + + ))} + - - {negativeChanges.slice(0, count).map((contract) => ( - - - - {contract.question} - - - ))} - - +
setExpanded(!expanded)} + > + {expanded ? 'Show less' : 'Show more'} +
+ ) } @@ -63,9 +85,9 @@ export function ProbChange(props: { const color = change > 0 - ? 'text-green-600' + ? 'text-green-500' : change < 0 - ? 'text-red-600' + ? 'text-red-500' : 'text-gray-600' const str = diff --git a/web/components/double-carousel.tsx b/web/components/double-carousel.tsx index da01eb5a..12538cf7 100644 --- a/web/components/double-carousel.tsx +++ b/web/components/double-carousel.tsx @@ -7,7 +7,6 @@ import { Col } from 'web/components/layout/col' export function DoubleCarousel(props: { contracts: Contract[] - seeMoreUrl?: string showTime?: ShowTime loadMore?: () => void }) { @@ -19,7 +18,7 @@ export function DoubleCarousel(props: { ? range(0, Math.floor(contracts.length / 2)).map((col) => { const i = col * 2 return ( - + { const [group, setGroup] = useState() @@ -49,12 +50,10 @@ export const useOpenGroups = () => { } export const useMemberGroups = (userId: string | null | undefined) => { - const [memberGroups, setMemberGroups] = useState() - useEffect(() => { - if (userId) - return listenForMemberGroups(userId, (groups) => setMemberGroups(groups)) - }, [userId]) - return memberGroups + const result = useQuery(['member-groups', userId ?? ''], () => + getMemberGroups(userId ?? '') + ) + return result.data } // Note: We cache member group ids in localstorage to speed up the initial load diff --git a/web/lib/firebase/groups.ts b/web/lib/firebase/groups.ts index 0366fe0b..7a372d9a 100644 --- a/web/lib/firebase/groups.ts +++ b/web/lib/firebase/groups.ts @@ -32,7 +32,7 @@ export const groupMembers = (groupId: string) => export const groupContracts = (groupId: string) => collection(groups, groupId, 'groupContracts') const openGroupsQuery = query(groups, where('anyoneCanJoin', '==', true)) -const memberGroupsQuery = (userId: string) => +export const memberGroupsQuery = (userId: string) => query(collectionGroup(db, 'groupMembers'), where('userId', '==', userId)) export function groupPath( @@ -113,6 +113,15 @@ export function listenForGroup( return listenForValue(doc(groups, groupId), setGroup) } +export async function getMemberGroups(userId: string) { + const snapshot = await getDocs(memberGroupsQuery(userId)) + const groupIds = filterDefined( + snapshot.docs.map((doc) => doc.ref.parent.parent?.id) + ) + const groups = await Promise.all(groupIds.map(getGroup)) + return filterDefined(groups) +} + export function listenForMemberGroupIds( userId: string, setGroupIds: (groupIds: string[]) => void diff --git a/web/pages/experimental/home/edit.tsx b/web/pages/experimental/home/edit.tsx new file mode 100644 index 00000000..2cba3f19 --- /dev/null +++ b/web/pages/experimental/home/edit.tsx @@ -0,0 +1,60 @@ +import clsx from 'clsx' +import { useState } from 'react' +import { ArrangeHome } from 'web/components/arrange-home' +import { Button } from 'web/components/button' +import { Col } from 'web/components/layout/col' +import { Row } from 'web/components/layout/row' +import { Page } from 'web/components/page' +import { SiteLink } from 'web/components/site-link' +import { Title } from 'web/components/title' +import { useTracking } from 'web/hooks/use-tracking' +import { useUser } from 'web/hooks/use-user' +import { updateUser } from 'web/lib/firebase/users' + +export default function Home() { + const user = useUser() + + useTracking('edit home') + + const [homeSections, setHomeSections] = useState( + user?.homeSections ?? { visible: [], hidden: [] } + ) + + const updateHomeSections = (newHomeSections: { + visible: string[] + hidden: string[] + }) => { + if (!user) return + updateUser(user.id, { homeSections: newHomeSections }) + setHomeSections(newHomeSections) + } + + return ( + + + + + <DoneButton /> + </Row> + + <ArrangeHome + user={user} + homeSections={homeSections} + setHomeSections={updateHomeSections} + /> + </Col> + </Page> + ) +} + +function DoneButton(props: { className?: string }) { + const { className } = props + + return ( + <SiteLink href="/experimental/home"> + <Button size="lg" color="blue" className={clsx(className, 'flex')}> + Done + </Button> + </SiteLink> + ) +} diff --git a/web/pages/experimental/home/index.tsx b/web/pages/experimental/home/index.tsx index fb0b488d..90b4f888 100644 --- a/web/pages/experimental/home/index.tsx +++ b/web/pages/experimental/home/index.tsx @@ -1,40 +1,36 @@ import React, { useState } from 'react' import Router from 'next/router' -import { PencilIcon, PlusSmIcon } from '@heroicons/react/solid' +import { + PencilIcon, + PlusSmIcon, + ArrowSmRightIcon, +} from '@heroicons/react/solid' +import clsx from 'clsx' import { Page } from 'web/components/page' import { Col } from 'web/components/layout/col' import { ContractSearch, SORTS } from 'web/components/contract-search' import { User } from 'common/user' -import { getUserAndPrivateUser, updateUser } from 'web/lib/firebase/users' import { useTracking } from 'web/hooks/use-tracking' import { track } from 'web/lib/service/analytics' -import { authenticateOnServer } from 'web/lib/firebase/server-auth' import { useSaveReferral } from 'web/hooks/use-save-referral' -import { GetServerSideProps } from 'next' import { Sort } from 'web/components/contract-search' import { Group } from 'common/group' -import { LoadingIndicator } from 'web/components/loading-indicator' -import { GroupLinkItem } from '../../groups' import { SiteLink } from 'web/components/site-link' import { useUser } from 'web/hooks/use-user' import { useMemberGroups } from 'web/hooks/use-group' -import { DoubleCarousel } from '../../../components/double-carousel' -import clsx from 'clsx' import { Button } from 'web/components/button' -import { ArrangeHome, getHomeItems } from '../../../components/arrange-home' +import { getHomeItems } from '../../../components/arrange-home' import { Title } from 'web/components/title' import { Row } from 'web/components/layout/row' import { ProbChangeTable } from 'web/components/contract/prob-change-table' +import { groupPath } from 'web/lib/firebase/groups' +import { usePortfolioHistory } from 'web/hooks/use-portfolio-history' +import { calculatePortfolioProfit } from 'common/calculate-metrics' +import { formatMoney } from 'common/util/format' -export const getServerSideProps: GetServerSideProps = async (ctx) => { - const creds = await authenticateOnServer(ctx) - const auth = creds ? await getUserAndPrivateUser(creds.uid) : null - return { props: { auth } } -} - -const Home = (props: { auth: { user: User } | null }) => { - const user = useUser() ?? props.auth?.user ?? null +const Home = () => { + const user = useUser() useTracking('view home') @@ -42,76 +38,54 @@ const Home = (props: { auth: { user: User } | null }) => { const groups = useMemberGroups(user?.id) ?? [] - const [homeSections, setHomeSections] = useState( + const [homeSections] = useState( user?.homeSections ?? { visible: [], hidden: [] } ) const { visibleItems } = getHomeItems(groups, homeSections) - const updateHomeSections = (newHomeSections: { - visible: string[] - hidden: string[] - }) => { - if (!user) return - updateUser(user.id, { homeSections: newHomeSections }) - setHomeSections(newHomeSections) - } - - const [isEditing, setIsEditing] = useState(false) - return ( <Page> - <Col className="pm:mx-10 gap-4 px-4 pb-12 xl:w-[125%]"> + <Col className="pm:mx-10 gap-4 px-4 pb-12"> <Row className={'w-full items-center justify-between'}> - <Title text={isEditing ? 'Edit your home page' : 'Home'} /> + <Title className="!mb-0" text="Home" /> - <EditDoneButton isEditing={isEditing} setIsEditing={setIsEditing} /> + <EditButton /> </Row> - {isEditing ? ( - <> - <ArrangeHome - user={user} - homeSections={homeSections} - setHomeSections={updateHomeSections} - /> - </> - ) : ( - <> - <div className="text-xl text-gray-800">Daily movers</div> - <ProbChangeTable userId={user?.id} /> + <DailyProfitAndBalance userId={user?.id} /> - {visibleItems.map((item) => { - const { id } = item - if (id === 'your-bets') { - return ( - <SearchSection - key={id} - label={'Your trades'} - sort={'prob-change-day'} - user={user} - yourBets - /> - ) - } - const sort = SORTS.find((sort) => sort.value === id) - if (sort) - return ( - <SearchSection - key={id} - label={sort.label} - sort={sort.value} - user={user} - /> - ) + <div className="text-xl text-gray-800">Daily movers</div> + <ProbChangeTable userId={user?.id} /> - const group = groups.find((g) => g.id === id) - if (group) - return <GroupSection key={id} group={group} user={user} /> + {visibleItems.map((item) => { + const { id } = item + if (id === 'your-bets') { + return ( + <SearchSection + key={id} + label={'Your trades'} + sort={'newest'} + user={user} + yourBets + /> + ) + } + const sort = SORTS.find((sort) => sort.value === id) + if (sort) + return ( + <SearchSection + key={id} + label={sort.label} + sort={sort.value} + user={user} + /> + ) - return null - })} - </> - )} + const group = groups.find((g) => g.id === id) + if (group) return <GroupSection key={id} group={group} user={user} /> + + return null + })} </Col> <button type="button" @@ -129,7 +103,7 @@ const Home = (props: { auth: { user: User } | null }) => { function SearchSection(props: { label: string - user: User | null + user: User | null | undefined sort: Sort yourBets?: boolean }) { @@ -139,88 +113,91 @@ function SearchSection(props: { return ( <Col> <SiteLink className="mb-2 text-xl" href={href}> - {label} + {label}{' '} + <ArrowSmRightIcon + className="mb-0.5 inline h-6 w-6 text-gray-500" + aria-hidden="true" + /> </SiteLink> <ContractSearch user={user} defaultSort={sort} - additionalFilter={yourBets ? { yourBets: true } : undefined} + additionalFilter={yourBets ? { yourBets: true } : { followed: true }} noControls - // persistPrefix={`experimental-home-${sort}`} - renderContracts={(contracts, loadMore) => - contracts ? ( - <DoubleCarousel - contracts={contracts} - seeMoreUrl={href} - showTime={ - sort === 'close-date' || sort === 'resolve-date' - ? sort - : undefined - } - loadMore={loadMore} - /> - ) : ( - <LoadingIndicator /> - ) - } + maxResults={6} + persistPrefix={`experimental-home-${sort}`} /> </Col> ) } -function GroupSection(props: { group: Group; user: User | null }) { +function GroupSection(props: { group: Group; user: User | null | undefined }) { const { group, user } = props return ( <Col> - <GroupLinkItem className="mb-2 text-xl" group={group} /> + <SiteLink className="mb-2 text-xl" href={groupPath(group.slug)}> + {group.name}{' '} + <ArrowSmRightIcon + className="mb-0.5 inline h-6 w-6 text-gray-500" + aria-hidden="true" + /> + </SiteLink> <ContractSearch user={user} defaultSort={'score'} additionalFilter={{ groupSlug: group.slug }} noControls - // persistPrefix={`experimental-home-${group.slug}`} - renderContracts={(contracts, loadMore) => - contracts ? ( - contracts.length == 0 ? ( - <div className="m-2 text-gray-500">No open markets</div> - ) : ( - <DoubleCarousel - contracts={contracts} - seeMoreUrl={`/group/${group.slug}`} - loadMore={loadMore} - /> - ) - ) : ( - <LoadingIndicator /> - ) - } + maxResults={6} + persistPrefix={`experimental-home-${group.slug}`} /> </Col> ) } -function EditDoneButton(props: { - isEditing: boolean - setIsEditing: (isEditing: boolean) => void - className?: string -}) { - const { isEditing, setIsEditing, className } = props +function EditButton(props: { className?: string }) { + const { className } = props return ( - <Button - size="lg" - color={isEditing ? 'blue' : 'gray-white'} - className={clsx(className, 'flex')} - onClick={() => { - setIsEditing(!isEditing) - }} - > - {!isEditing && ( - <PencilIcon className={clsx('mr-2 h-[24px] w-5')} aria-hidden="true" /> - )} - {isEditing ? 'Done' : 'Edit'} - </Button> + <SiteLink href="/experimental/home/edit"> + <Button size="lg" color="gray-white" className={clsx(className, 'flex')}> + <PencilIcon className={clsx('mr-2 h-[24px] w-5')} aria-hidden="true" />{' '} + Edit + </Button> + </SiteLink> + ) +} + +function DailyProfitAndBalance(props: { + userId: string | null | undefined + className?: string +}) { + const { userId, className } = props + const metrics = usePortfolioHistory(userId ?? '', 'daily') ?? [] + const [first, last] = [metrics[0], metrics[metrics.length - 1]] + + if (first === undefined || last === undefined) return null + + const profit = + calculatePortfolioProfit(last) - calculatePortfolioProfit(first) + + const balanceChange = last.balance - first.balance + + return ( + <div className={clsx(className, 'text-lg')}> + <span className={clsx(profit >= 0 ? 'text-green-500' : 'text-red-500')}> + {profit >= 0 ? '+' : '-'} + {formatMoney(profit)} + </span>{' '} + profit and{' '} + <span + className={clsx(balanceChange >= 0 ? 'text-green-500' : 'text-red-500')} + > + {balanceChange >= 0 ? '+' : '-'} + {formatMoney(balanceChange)} + </span>{' '} + balance today + </div> ) } diff --git a/web/pages/tournaments/index.tsx b/web/pages/tournaments/index.tsx index 4b573e3f..b308ee7f 100644 --- a/web/pages/tournaments/index.tsx +++ b/web/pages/tournaments/index.tsx @@ -237,7 +237,7 @@ const MarketCarousel = (props: { slug: string }) => { key={m.id} contract={m} hideGroupLink - className="mb-2 max-h-[200px] w-96 shrink-0" + className="mb-2 max-h-[200px] w-96 shrink-0 snap-start scroll-m-4 md:snap-align-none" questionClass="line-clamp-3" trackingPostfix=" tournament" />