From d3b0a7681c458f04ebbc7e43de0445581d363ada Mon Sep 17 00:00:00 2001 From: yu-zhen Date: Sat, 12 Oct 2024 02:17:48 +0900 Subject: [PATCH] feat: add result page --- .../scripts/uploadRoundMetadata.ts | 5 - packages/interface/public/bronze.svg | 5 + packages/interface/public/gold.svg | 5 + packages/interface/public/line-chart.svg | 4 + packages/interface/public/silver.svg | 5 + .../src/components/BallotOverview.tsx | 2 +- .../src/components/EligibilityDialog.tsx | 2 +- packages/interface/src/components/Header.tsx | 2 +- packages/interface/src/components/Info.tsx | 2 +- packages/interface/src/contexts/Round.tsx | 27 +++- packages/interface/src/contexts/types.ts | 1 + .../ballot/components/BallotConfirmation.tsx | 2 +- .../projects/components/ProjectAwarded.tsx | 15 +- .../projects/components/ProjectDetails.tsx | 2 +- .../projects/components/ProjectItem.tsx | 2 +- .../projects/components/ProjectsResults.tsx | 13 +- .../features/rounds/components/Projects.tsx | 8 +- .../features/rounds/components/RoundItem.tsx | 2 +- packages/interface/src/hooks/useProjects.ts | 15 ++ packages/interface/src/hooks/useResults.ts | 38 ++--- .../interface/src/layouts/DefaultLayout.tsx | 8 +- .../rounds/[pollId]/[projectId]/Project.tsx | 4 +- .../[pollId]/applications/confirmation.tsx | 2 +- .../rounds/[pollId]/applications/new.tsx | 2 +- .../pages/rounds/[pollId]/ballot/index.tsx | 6 +- .../pages/rounds/[pollId]/result/index.tsx | 95 +++++++++++++ .../src/pages/rounds/[pollId]/stats/index.tsx | 134 ------------------ .../interface/src/server/api/routers/maci.ts | 17 ++- .../src/server/api/routers/projects.ts | 4 + .../src/server/api/routers/results.ts | 48 ++++--- packages/interface/src/utils/fetchPoll.ts | 4 + packages/interface/src/utils/fetchTally.ts | 27 ++++ packages/interface/src/utils/state.ts | 22 +-- packages/interface/src/utils/types.ts | 27 +++- packages/subgraph/config/network.json | 8 +- packages/subgraph/package.json | 2 +- 36 files changed, 328 insertions(+), 239 deletions(-) create mode 100644 packages/interface/public/bronze.svg create mode 100644 packages/interface/public/gold.svg create mode 100644 packages/interface/public/line-chart.svg create mode 100644 packages/interface/public/silver.svg create mode 100644 packages/interface/src/hooks/useProjects.ts create mode 100644 packages/interface/src/pages/rounds/[pollId]/result/index.tsx delete mode 100644 packages/interface/src/pages/rounds/[pollId]/stats/index.tsx diff --git a/packages/coordinator/scripts/uploadRoundMetadata.ts b/packages/coordinator/scripts/uploadRoundMetadata.ts index 102763b9..6964961d 100644 --- a/packages/coordinator/scripts/uploadRoundMetadata.ts +++ b/packages/coordinator/scripts/uploadRoundMetadata.ts @@ -14,7 +14,6 @@ export interface RoundMetadata { registrationEndsAt: Date; votingStartsAt: Date; votingEndsAt: Date; - tallyFile: string; } interface IUploadMetadataProps { @@ -137,9 +136,6 @@ export async function collectMetadata(): Promise { rl.close(); - // NOTICE! this is when you use vercel blob storage, if you're using another tool, please change this part. - const vercelStoragePrefix = `https://${process.env.BLOB_READ_WRITE_TOKEN?.split("_")[3]}.public.blob.vercel-storage.com`; - return { roundId, description, @@ -147,7 +143,6 @@ export async function collectMetadata(): Promise { registrationEndsAt: new Date(startsAt.getTime() + registrationEndsIn * 1000), votingStartsAt: new Date(startsAt.getTime() + registrationEndsIn * 1000), votingEndsAt: new Date(startsAt.getTime() + registrationEndsIn * 1000 + votingEndsIn * 1000), - tallyFile: `${vercelStoragePrefix}/tally-${roundId}.json`, }; } diff --git a/packages/interface/public/bronze.svg b/packages/interface/public/bronze.svg new file mode 100644 index 00000000..8d9657ed --- /dev/null +++ b/packages/interface/public/bronze.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/interface/public/gold.svg b/packages/interface/public/gold.svg new file mode 100644 index 00000000..6b7dd843 --- /dev/null +++ b/packages/interface/public/gold.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/interface/public/line-chart.svg b/packages/interface/public/line-chart.svg new file mode 100644 index 00000000..480cf2ae --- /dev/null +++ b/packages/interface/public/line-chart.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/interface/public/silver.svg b/packages/interface/public/silver.svg new file mode 100644 index 00000000..e9f6480c --- /dev/null +++ b/packages/interface/public/silver.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/interface/src/components/BallotOverview.tsx b/packages/interface/src/components/BallotOverview.tsx index a0d9aa9d..23f2a459 100644 --- a/packages/interface/src/components/BallotOverview.tsx +++ b/packages/interface/src/components/BallotOverview.tsx @@ -19,7 +19,7 @@ export const BallotOverview = ({ title = undefined, pollId }: IBallotOverviewPro const ballot = useMemo(() => getBallot(pollId), [pollId, getBallot]); - const roundState = useRoundState(pollId); + const roundState = useRoundState({ pollId }); return ( { const round = getRoundByPollId(pollId); diff --git a/packages/interface/src/components/Header.tsx b/packages/interface/src/components/Header.tsx index 42b141f2..0a75ac80 100644 --- a/packages/interface/src/components/Header.tsx +++ b/packages/interface/src/components/Header.tsx @@ -63,7 +63,7 @@ const Header = ({ navLinks, pollId = "" }: IHeaderProps) => { const { asPath } = useRouter(); const [isOpen, setOpen] = useState(false); const { getBallot } = useBallot(); - const roundState = useRoundState(pollId); + const roundState = useRoundState({ pollId }); const { theme, setTheme } = useTheme(); const ballot = useMemo(() => getBallot(pollId), [pollId, getBallot]); diff --git a/packages/interface/src/components/Info.tsx b/packages/interface/src/components/Info.tsx index 6b5b9be2..5bb7f622 100644 --- a/packages/interface/src/components/Info.tsx +++ b/packages/interface/src/components/Info.tsx @@ -39,7 +39,7 @@ export const Info = ({ showAppState = false, showBallot = false, }: IInfoProps): JSX.Element => { - const roundState = useRoundState(pollId); + const roundState = useRoundState({ pollId }); const { getRoundByPollId } = useRound(); const round = getRoundByPollId(pollId); diff --git a/packages/interface/src/contexts/Round.tsx b/packages/interface/src/contexts/Round.tsx index 686a5e6e..29b0296c 100644 --- a/packages/interface/src/contexts/Round.tsx +++ b/packages/interface/src/contexts/Round.tsx @@ -4,15 +4,16 @@ import { config } from "~/config"; import { api } from "~/utils/api"; import type { RoundContextType, RoundProviderProps } from "./types"; -import type { IRoundData } from "~/utils/types"; +import type { IRoundData, Tally } from "~/utils/types"; export const RoundContext = createContext(undefined); export const RoundProvider: React.FC = ({ children }: RoundProviderProps) => { const [isLoading, setIsLoading] = useState(false); - const polls = api.maci.poll.useQuery(undefined, { enabled: Boolean(config.maciSubgraphUrl) }); - const rounds = api.maci.round.useQuery({ polls: polls.data ?? [] }, { enabled: Boolean(polls.data) }); + const polls = api.maci.polls.useQuery(undefined, { enabled: Boolean(config.maciSubgraphUrl) }); + const rounds = api.maci.rounds.useQuery({ polls: polls.data ?? [] }, { enabled: Boolean(polls.data) }); + const tallies = api.maci.tallies.useQuery(undefined, { enabled: Boolean(config.maciSubgraphUrl) }); // on load we fetch the data from the poll useEffect(() => { @@ -28,6 +29,15 @@ export const RoundProvider: React.FC = ({ children }: RoundP rounds.refetch().catch(console.error); }, [polls, rounds]); + useEffect(() => { + if (tallies.data) { + return; + } + + // eslint-disable-next-line no-console + tallies.refetch().catch(console.error); + }, [tallies]); + const getRoundByRoundId = useCallback( (roundId: string): IRoundData | undefined => rounds.data?.find((round) => round.roundId === roundId), [rounds], @@ -38,14 +48,23 @@ export const RoundProvider: React.FC = ({ children }: RoundP [rounds], ); + const isRoundTallied = useCallback( + (tallyAddress: string): boolean => { + const t = tallies.data?.find((tally: Tally) => tally.id === tallyAddress); + return !!t && t.results.length > 0; + }, + [tallies], + ); + const value = useMemo( () => ({ rounds: rounds.data, getRoundByRoundId, getRoundByPollId, isLoading, + isRoundTallied, }), - [rounds, getRoundByRoundId, getRoundByPollId, isLoading], + [rounds, getRoundByRoundId, getRoundByPollId, isLoading, isRoundTallied], ); return {children}; diff --git a/packages/interface/src/contexts/types.ts b/packages/interface/src/contexts/types.ts index c4edf5bb..98f7b86f 100644 --- a/packages/interface/src/contexts/types.ts +++ b/packages/interface/src/contexts/types.ts @@ -56,6 +56,7 @@ export interface RoundContextType { getRoundByRoundId: (roundId: string) => IRoundData | undefined; getRoundByPollId: (pollId: string) => IRoundData | undefined; isLoading: boolean; + isRoundTallied: (tallyAddress: string) => boolean; } export interface RoundProviderProps { diff --git a/packages/interface/src/features/ballot/components/BallotConfirmation.tsx b/packages/interface/src/features/ballot/components/BallotConfirmation.tsx index 329d2779..43e1b7b0 100644 --- a/packages/interface/src/features/ballot/components/BallotConfirmation.tsx +++ b/packages/interface/src/features/ballot/components/BallotConfirmation.tsx @@ -41,7 +41,7 @@ export const getServerSideProps: GetServerSideProps = async ({ query: { pollId } export const BallotConfirmation = ({ pollId }: IBallotConfirmationProps): JSX.Element => { const { getBallot, sumBallot } = useBallot(); - const roundState = useRoundState(pollId); + const roundState = useRoundState({ pollId }); const { getRoundByPollId } = useRound(); const round = useMemo(() => getRoundByPollId(pollId), [pollId, getRoundByPollId]); diff --git a/packages/interface/src/features/projects/components/ProjectAwarded.tsx b/packages/interface/src/features/projects/components/ProjectAwarded.tsx index 827353a6..4331ec8a 100644 --- a/packages/interface/src/features/projects/components/ProjectAwarded.tsx +++ b/packages/interface/src/features/projects/components/ProjectAwarded.tsx @@ -1,24 +1,23 @@ +import { ZeroAddress } from "ethers"; +import { useMemo } from "react"; import { Hex } from "viem"; import { Button } from "~/components/ui/Button"; import { config } from "~/config"; +import { useRound } from "~/contexts/Round"; import { useProjectResults } from "~/hooks/useResults"; import { formatNumber } from "~/utils/formatNumber"; export interface IProjectAwardedProps { pollId: string; - tallyFile?: string; registryAddress: string; id?: string; } -export const ProjectAwarded = ({ - pollId, - tallyFile = undefined, - registryAddress, - id = "", -}: IProjectAwardedProps): JSX.Element | null => { - const amount = useProjectResults(id, registryAddress as Hex, pollId, tallyFile); +export const ProjectAwarded = ({ pollId, registryAddress, id = "" }: IProjectAwardedProps): JSX.Element | null => { + const { getRoundByPollId } = useRound(); + const round = useMemo(() => getRoundByPollId(pollId), [pollId, getRoundByPollId]); + const amount = useProjectResults(id, registryAddress as Hex, pollId, (round?.tallyAddress ?? ZeroAddress) as Hex); if (amount.isLoading) { return null; diff --git a/packages/interface/src/features/projects/components/ProjectDetails.tsx b/packages/interface/src/features/projects/components/ProjectDetails.tsx index d11b7b47..a8f769c4 100644 --- a/packages/interface/src/features/projects/components/ProjectDetails.tsx +++ b/packages/interface/src/features/projects/components/ProjectDetails.tsx @@ -27,7 +27,7 @@ const ProjectDetails = ({ pollId, project, action = undefined }: IProjectDetails const { bio, websiteUrl, payoutAddress, github, twitter, fundingSources, profileImageUrl, bannerImageUrl } = metadata.data ?? {}; - const roundState = useRoundState(pollId); + const roundState = useRoundState({ pollId }); return (
diff --git a/packages/interface/src/features/projects/components/ProjectItem.tsx b/packages/interface/src/features/projects/components/ProjectItem.tsx index 0bf048e9..db4142ca 100644 --- a/packages/interface/src/features/projects/components/ProjectItem.tsx +++ b/packages/interface/src/features/projects/components/ProjectItem.tsx @@ -33,7 +33,7 @@ export const ProjectItem = ({ action = undefined, }: IProjectItemProps): JSX.Element => { const metadata = useProjectMetadata(recipient.metadataUrl); - const roundState = useRoundState(pollId); + const roundState = useRoundState({ pollId }); return (
getRoundByPollId(pollId), [pollId, getRoundByPollId]); - const projects = useProjectsResults((round?.registryAddress ?? zeroAddress) as Hex); - const results = useResults(pollId, (round?.registryAddress ?? zeroAddress) as Hex, round?.tallyFile); - const roundState = useRoundState(pollId); + const projects = useProjects((round?.registryAddress ?? zeroAddress) as Hex); + const results = useResults( + pollId, + (round?.registryAddress ?? zeroAddress) as Hex, + (round?.tallyAddress ?? zeroAddress) as Hex, + ); + const roundState = useRoundState({ pollId }); const handleAction = useCallback( (projectId: string) => (e: Event) => { diff --git a/packages/interface/src/features/rounds/components/Projects.tsx b/packages/interface/src/features/rounds/components/Projects.tsx index f2f6359f..606e902f 100644 --- a/packages/interface/src/features/rounds/components/Projects.tsx +++ b/packages/interface/src/features/rounds/components/Projects.tsx @@ -25,7 +25,7 @@ export interface IProjectsProps { } export const Projects = ({ pollId = "" }: IProjectsProps): JSX.Element => { - const roundState = useRoundState(pollId); + const roundState = useRoundState({ pollId }); const { getRoundByPollId } = useRound(); const round = useMemo(() => getRoundByPollId(pollId), [pollId, getRoundByPollId]); @@ -35,7 +35,11 @@ export const Projects = ({ pollId = "" }: IProjectsProps): JSX.Element => { const { isRegistered } = useMaci(); const { addToBallot, removeFromBallot, ballotContains, getBallot } = useBallot(); - const results = useResults(pollId, (round?.registryAddress ?? zeroAddress) as Hex, round?.tallyFile); + const results = useResults( + pollId, + (round?.registryAddress ?? zeroAddress) as Hex, + (round?.tallyAddress ?? zeroAddress) as Hex, + ); const ballot = useMemo(() => getBallot(pollId), [pollId, getBallot]); diff --git a/packages/interface/src/features/rounds/components/RoundItem.tsx b/packages/interface/src/features/rounds/components/RoundItem.tsx index 3fd7d096..2add1831 100644 --- a/packages/interface/src/features/rounds/components/RoundItem.tsx +++ b/packages/interface/src/features/rounds/components/RoundItem.tsx @@ -61,7 +61,7 @@ const RoundTag = ({ state }: IRoundTagProps): JSX.Element => { }; export const RoundItem = ({ round }: IRoundItemProps): JSX.Element => { - const roundState = useRoundState(round.pollId); + const roundState = useRoundState({ pollId: round.pollId }); return ( diff --git a/packages/interface/src/hooks/useProjects.ts b/packages/interface/src/hooks/useProjects.ts new file mode 100644 index 00000000..960f6ea6 --- /dev/null +++ b/packages/interface/src/hooks/useProjects.ts @@ -0,0 +1,15 @@ +import { config } from "~/config"; +import { api } from "~/utils/api"; + +import type { UseTRPCInfiniteQueryResult } from "@trpc/react-query/shared"; +import type { IRecipient } from "~/utils/types"; + +const seed = 0; +export function useProjects(registryAddress: string): UseTRPCInfiniteQueryResult { + return api.projects.projects.useInfiniteQuery( + { registryAddress, limit: config.pageSize, seed }, + { + getNextPageParam: (_, pages) => pages.length, + }, + ); +} diff --git a/packages/interface/src/hooks/useResults.ts b/packages/interface/src/hooks/useResults.ts index 19691eaf..e64c6348 100644 --- a/packages/interface/src/hooks/useResults.ts +++ b/packages/interface/src/hooks/useResults.ts @@ -1,33 +1,27 @@ import { Chain } from "viem"; -import { config } from "~/config"; import { api } from "~/utils/api"; import { useRoundState } from "~/utils/state"; import { ERoundState } from "~/utils/types"; -import type { UseTRPCInfiniteQueryResult, UseTRPCQueryResult } from "@trpc/react-query/shared"; -import type { IRecipient } from "~/utils/types"; +import type { UseTRPCQueryResult } from "@trpc/react-query/shared"; +import type { IRecipientWithVotes, Tally } from "~/utils/types"; export function useResults( pollId: string, registryAddress: string, - tallyFile?: string, + tallyAddress: string, ): UseTRPCQueryResult<{ averageVotes: number; projects: Record }, unknown> { - const roundState = useRoundState(pollId); + const roundState = useRoundState({ pollId }); - return api.results.votes.useQuery({ registryAddress, tallyFile }, { enabled: roundState === ERoundState.RESULTS }); + return api.results.votes.useQuery({ registryAddress, tallyAddress }, { enabled: roundState === ERoundState.RESULTS }); } -const seed = 0; export function useProjectsResults( registryAddress: string, -): UseTRPCInfiniteQueryResult { - return api.results.projects.useInfiniteQuery( - { registryAddress, limit: config.pageSize, seed }, - { - getNextPageParam: (_, pages) => pages.length, - }, - ); + tallyAddress: string, +): UseTRPCQueryResult { + return api.results.projects.useQuery({ registryAddress, tallyAddress }); } export function useProjectCount(registryAddress: string, chain: Chain): UseTRPCQueryResult<{ count: number }, unknown> { @@ -38,12 +32,20 @@ export function useProjectResults( id: string, registryAddress: string, pollId: string, - tallyFile?: string, + tallyAddress: string, ): UseTRPCQueryResult<{ amount: number }, unknown> { - const appState = useRoundState(pollId); + const roundState = useRoundState({ pollId }); return api.results.project.useQuery( - { id, registryAddress, tallyFile }, - { enabled: appState === ERoundState.RESULTS }, + { id, registryAddress, tallyAddress }, + { enabled: roundState === ERoundState.RESULTS }, ); } + +export function useIsTallied(tallyAddress: string): UseTRPCQueryResult<{ isTallied: boolean }, unknown> { + return api.maci.isTallied.useQuery({ tallyAddress }); +} + +export function useFetchTallies(): UseTRPCQueryResult<{ tallies: Tally[] }, unknown> { + return api.maci.tallies.useQuery(); +} diff --git a/packages/interface/src/layouts/DefaultLayout.tsx b/packages/interface/src/layouts/DefaultLayout.tsx index edfce635..93b0a8d2 100644 --- a/packages/interface/src/layouts/DefaultLayout.tsx +++ b/packages/interface/src/layouts/DefaultLayout.tsx @@ -18,7 +18,7 @@ import { BaseLayout } from "./BaseLayout"; export const Layout = ({ children = null, ...props }: ILayoutProps): JSX.Element => { const { address } = useAccount(); - const roundState = useRoundState(props.pollId ?? ""); + const roundState = useRoundState({ pollId: props.pollId ?? "" }); const { getBallot } = useBallot(); const { isRegistered, gatekeeperTrait } = useMaci(); @@ -54,8 +54,8 @@ export const Layout = ({ children = null, ...props }: ILayoutProps): JSX.Element if (roundState === ERoundState.RESULTS) { links.push({ - href: `/rounds/${props.pollId}/stats`, - children: "Stats", + href: `/rounds/${props.pollId}/result`, + children: "Result", }); } @@ -96,7 +96,7 @@ export const LayoutWithSidebar = ({ ...props }: ILayoutProps): JSX.Element => { const { address } = useAccount(); const { getBallot } = useBallot(); - const roundState = useRoundState(props.pollId ?? ""); + const roundState = useRoundState({ pollId: props.pollId ?? "" }); const ballot = useMemo(() => getBallot(props.pollId!), [props.pollId, getBallot]); diff --git a/packages/interface/src/pages/rounds/[pollId]/[projectId]/Project.tsx b/packages/interface/src/pages/rounds/[pollId]/[projectId]/Project.tsx index 1cd62948..067e8192 100644 --- a/packages/interface/src/pages/rounds/[pollId]/[projectId]/Project.tsx +++ b/packages/interface/src/pages/rounds/[pollId]/[projectId]/Project.tsx @@ -23,11 +23,11 @@ const ProjectDetailsPage = ({ projectId = "", pollId }: IProjectDetailsProps): J const projects = useProjectById(projectId, round?.registryAddress ?? zeroAddress); - const appState = useRoundState(pollId); + const roundState = useRoundState({ pollId }); return ( - {appState === ERoundState.APPLICATION && } + {roundState === ERoundState.APPLICATION && } {projects.data && } diff --git a/packages/interface/src/pages/rounds/[pollId]/applications/confirmation.tsx b/packages/interface/src/pages/rounds/[pollId]/applications/confirmation.tsx index f694817d..2bba6372 100644 --- a/packages/interface/src/pages/rounds/[pollId]/applications/confirmation.tsx +++ b/packages/interface/src/pages/rounds/[pollId]/applications/confirmation.tsx @@ -22,7 +22,7 @@ export const getServerSideProps: GetServerSideProps = async ({ query: { pollId } }); const ConfirmProjectPage = ({ pollId }: { pollId: string }): JSX.Element => { - const state = useRoundState(pollId); + const state = useRoundState({ pollId }); const { getRoundByPollId } = useRound(); diff --git a/packages/interface/src/pages/rounds/[pollId]/applications/new.tsx b/packages/interface/src/pages/rounds/[pollId]/applications/new.tsx index 59f9d2b8..9d68eb84 100644 --- a/packages/interface/src/pages/rounds/[pollId]/applications/new.tsx +++ b/packages/interface/src/pages/rounds/[pollId]/applications/new.tsx @@ -15,7 +15,7 @@ export const getServerSideProps: GetServerSideProps = async ({ query: { pollId } }); const NewProjectPage = ({ pollId }: { pollId: string }): JSX.Element => { - const state = useRoundState(pollId); + const state = useRoundState({ pollId }); return ( diff --git a/packages/interface/src/pages/rounds/[pollId]/ballot/index.tsx b/packages/interface/src/pages/rounds/[pollId]/ballot/index.tsx index d79b7d93..aa1fcf2d 100644 --- a/packages/interface/src/pages/rounds/[pollId]/ballot/index.tsx +++ b/packages/interface/src/pages/rounds/[pollId]/ballot/index.tsx @@ -35,7 +35,7 @@ const ClearBallot = ({ pollId }: IClearBallotProps): JSX.Element | null => { setOpen(true); }, [setOpen]); - if ([ERoundState.TALLYING, ERoundState.RESULTS].includes(useRoundState(pollId))) { + if ([ERoundState.TALLYING, ERoundState.RESULTS].includes(useRoundState({ pollId }))) { return null; } @@ -99,7 +99,7 @@ interface IBallotAllocationFormProps { } const BallotAllocationForm = ({ pollId, mode }: IBallotAllocationFormProps): JSX.Element => { - const roundState = useRoundState(pollId); + const roundState = useRoundState({ pollId }); const { getBallot, sumBallot } = useBallot(); const { initialVoiceCredits } = useMaci(); @@ -158,7 +158,7 @@ const BallotPage = ({ pollId }: IBallotPageProps): JSX.Element => { const { getBallot, sumBallot } = useBallot(); const { getRoundByPollId } = useRound(); const router = useRouter(); - const roundState = useRoundState(pollId); + const roundState = useRoundState({ pollId }); const round = useMemo(() => getRoundByPollId(pollId), [pollId, getRoundByPollId]); const ballot = useMemo(() => getBallot(pollId), [round?.pollId, getBallot]); diff --git a/packages/interface/src/pages/rounds/[pollId]/result/index.tsx b/packages/interface/src/pages/rounds/[pollId]/result/index.tsx new file mode 100644 index 00000000..395b56a6 --- /dev/null +++ b/packages/interface/src/pages/rounds/[pollId]/result/index.tsx @@ -0,0 +1,95 @@ +import { format } from "date-fns"; +import Image from "next/image"; +import { useMemo, useEffect } from "react"; +import { Hex, zeroAddress } from "viem"; + +import { Heading } from "~/components/ui/Heading"; +import { useRound } from "~/contexts/Round"; +import { useProjectsResults } from "~/hooks/useResults"; +import { LayoutWithSidebar } from "~/layouts/DefaultLayout"; +import { useRoundState } from "~/utils/state"; +import { ERoundState } from "~/utils/types"; + +import type { GetServerSideProps } from "next"; + +interface IResultPageProps { + pollId: string; +} + +const ResultPage = ({ pollId }: IResultPageProps): JSX.Element => { + const { getRoundByPollId } = useRound(); + const round = useMemo(() => getRoundByPollId(pollId), [pollId, getRoundByPollId]); + + const projectsResults = useProjectsResults( + (round?.registryAddress ?? zeroAddress) as Hex, + (round?.tallyAddress ?? zeroAddress) as Hex, + ); + + const roundState = useRoundState({ pollId }); + + useEffect(() => { + if (projectsResults.data) { + return; + } + + // eslint-disable-next-line no-console + projectsResults.refetch().catch(console.error); + }, [projectsResults, round]); + + return ( + + {roundState === ERoundState.RESULTS && ( +
+ + Leaderboard + + +

+ {round?.startsAt ? format(round.startsAt, "d MMM yyyy") : "undefined"} + + - + + {round?.votingEndsAt ? format(round.votingEndsAt, "d MMM yyyy") : "undefined"} +

+ +
+ {projectsResults.data?.map((item, i) => ( +
+
+ {i === 0 && gold} + + {i === 1 && silver} + + {i === 2 && bronze} +
+ +
{i + 1}
+ +
{item.payout}
+ +
{item.votes}
+
+ ))} +
+
+ )} + + {roundState !== ERoundState.RESULTS && ( +
+ line-chart + + Results will be available after tallying. + +

{round?.votingEndsAt && format(round.votingEndsAt, "d MMM yyyy")}

+
+ )} +
+ ); +}; + +export default ResultPage; + +export const getServerSideProps: GetServerSideProps = async ({ query: { pollId } }) => + Promise.resolve({ + props: { pollId }, + }); diff --git a/packages/interface/src/pages/rounds/[pollId]/stats/index.tsx b/packages/interface/src/pages/rounds/[pollId]/stats/index.tsx deleted file mode 100644 index ac209262..00000000 --- a/packages/interface/src/pages/rounds/[pollId]/stats/index.tsx +++ /dev/null @@ -1,134 +0,0 @@ -import { differenceInDays } from "date-fns"; -import dynamic from "next/dynamic"; -import { useMemo, type PropsWithChildren } from "react"; -import { Hex, zeroAddress } from "viem"; -import { useAccount } from "wagmi"; - -import { ConnectButton } from "~/components/ConnectButton"; -import { Alert } from "~/components/ui/Alert"; -import { Heading } from "~/components/ui/Heading"; -import { useMaci } from "~/contexts/Maci"; -import { useRound } from "~/contexts/Round"; -import { useProjectCount } from "~/features/projects/hooks/useProjects"; -import { useProjectsResults, useResults } from "~/hooks/useResults"; -import { Layout } from "~/layouts/DefaultLayout"; -import { formatNumber } from "~/utils/formatNumber"; -import { useRoundState } from "~/utils/state"; -import { ERoundState } from "~/utils/types"; - -import type { GetServerSideProps } from "next"; - -const ResultsChart = dynamic(async () => import("~/features/results/components/Chart"), { ssr: false }); - -const Stat = ({ title, children = null }: PropsWithChildren<{ title: string }>) => ( -
- - {title} - - -
{children}
-
-); - -interface IStatsProps { - pollId: string; -} - -const Stats = ({ pollId }: IStatsProps) => { - const { isLoading } = useMaci(); - const { chain, isConnected } = useAccount(); - const { getRoundByPollId } = useRound(); - - const round = useMemo(() => getRoundByPollId(pollId), [pollId, getRoundByPollId]); - const results = useResults(pollId, (round?.registryAddress ?? zeroAddress) as Hex, round?.tallyFile); - const count = useProjectCount({ - chain: chain!, - registryAddress: (round?.registryAddress ?? zeroAddress) as Hex, - }); - - const { data: projectsResults } = useProjectsResults((round?.registryAddress ?? zeroAddress) as Hex); - - const { averageVotes, projects = {} } = results.data ?? {}; - - const chartData = useMemo(() => { - const data = (projectsResults?.pages[0] ?? []) - .map((project) => ({ - x: project.index, - y: projects[project.id]?.votes, - })) - .slice(0, 15); - - return [{ id: "awarded", data }]; - }, [projects, projectsResults]); - - if (isLoading) { - return
Loading...
; - } - - if (!isConnected) { - return ( - - Connect your wallet to see results - -
- -
-
- ); - } - - return ( -
- Top Projects - -
- -
- -
- {count.data?.count.toString()} - - {Object.keys(projects).length} - - {round?.numSignups ? Number(round.numSignups) - 1 : 0} - - {formatNumber(averageVotes)} -
-
- ); -}; - -interface IStatsPageProps { - pollId: string; -} - -const StatsPage = ({ pollId }: IStatsPageProps): JSX.Element => { - const roundState = useRoundState(pollId); - const { getRoundByPollId } = useRound(); - const round = useMemo(() => getRoundByPollId(pollId), [pollId, getRoundByPollId]); - const duration = round?.votingEndsAt && differenceInDays(round.votingEndsAt, new Date()); - - return ( - - - Stats - - - {roundState === ERoundState.RESULTS ? ( - - ) : ( - - The results will be revealed in
{duration && duration > 0 ? duration : 0}
- days -
- )} -
- ); -}; - -export default StatsPage; - -export const getServerSideProps: GetServerSideProps = async ({ query: { pollId } }) => - Promise.resolve({ - props: { pollId }, - }); diff --git a/packages/interface/src/server/api/routers/maci.ts b/packages/interface/src/server/api/routers/maci.ts index b692c833..8bbbd088 100644 --- a/packages/interface/src/server/api/routers/maci.ts +++ b/packages/interface/src/server/api/routers/maci.ts @@ -4,7 +4,7 @@ import { z, ZodType } from "zod"; import { createTRPCRouter, publicProcedure } from "~/server/api/trpc"; import { fetchMetadata } from "~/utils/fetchMetadata"; import { fetchPolls } from "~/utils/fetchPoll"; -import { fetchTally } from "~/utils/fetchTally"; +import { fetchTally, fetchTallies } from "~/utils/fetchTally"; import { fetchUser } from "~/utils/fetchUser"; import type { IPollData, IRoundMetadata, IRoundData } from "~/utils/types"; @@ -20,15 +20,23 @@ const PollSchema = z.object({ initTime: z.union([z.string(), z.number(), z.bigint()]).nullable(), registryAddress: z.string(), metadataUrl: z.string(), + tallyAddress: z.string(), }) satisfies ZodType; export const maciRouter = createTRPCRouter({ user: publicProcedure .input(z.object({ publicKey: z.string() })) .query(async ({ input }) => fetchUser(PubKey.deserialize(input.publicKey).rawPubKey)), - poll: publicProcedure.query(async () => fetchPolls()), - tally: publicProcedure.input(z.object({ id: z.string() })).query(async ({ input }) => fetchTally(input.id)), - round: publicProcedure.input(z.object({ polls: z.array(PollSchema) })).query(async ({ input }) => + polls: publicProcedure.query(async () => fetchPolls()), + tally: publicProcedure + .input(z.object({ tallyAddress: z.string() })) + .query(async ({ input }) => fetchTally(input.tallyAddress)), + tallies: publicProcedure.query(async () => fetchTallies()), + isTallied: publicProcedure.input(z.object({ tallyAddress: z.string() })).query(async ({ input }) => { + const tallyData = await fetchTally(input.tallyAddress); + return !!tallyData; + }), + rounds: publicProcedure.input(z.object({ polls: z.array(PollSchema) })).query(async ({ input }) => Promise.all( input.polls.map((poll) => fetchMetadata(poll.metadataUrl).then((metadata) => { @@ -54,6 +62,7 @@ export const maciRouter = createTRPCRouter({ registrationEndsAt: new Date(data.registrationEndsAt), votingStartsAt, votingEndsAt, + tallyAddress: poll.tallyAddress, } as IRoundData; }), ), diff --git a/packages/interface/src/server/api/routers/projects.ts b/packages/interface/src/server/api/routers/projects.ts index f6bd58c5..a1dfad3f 100644 --- a/packages/interface/src/server/api/routers/projects.ts +++ b/packages/interface/src/server/api/routers/projects.ts @@ -35,6 +35,10 @@ export const projectsRouter = createTRPCRouter({ fetchApprovedProjects(input.registryAddress), ), + projects: publicProcedure + .input(FilterSchema.extend({ registryAddress: z.string() })) + .query(async ({ input }) => fetchProjects(input.registryAddress)), + // Used for distribution to get the projects' payoutAddress // To get this data we need to fetch all projects and their metadata // payoutAddresses: publicProcedure.input(z.object({ ids: z.array(z.string()) })).query(async ({ input }) => diff --git a/packages/interface/src/server/api/routers/results.ts b/packages/interface/src/server/api/routers/results.ts index 373201d9..81ac8ee4 100644 --- a/packages/interface/src/server/api/routers/results.ts +++ b/packages/interface/src/server/api/routers/results.ts @@ -1,20 +1,21 @@ import { TRPCError } from "@trpc/server"; -import { type TallyData } from "maci-cli/sdk"; import { z } from "zod"; -import { FilterSchema } from "~/features/filter/types"; import { createTRPCRouter, publicProcedure } from "~/server/api/trpc"; import { fetchApprovedProjects, fetchProjects } from "~/utils/fetchProjects"; +import { fetchTally } from "~/utils/fetchTally"; + +import type { IRecipient } from "~/utils/types"; export const resultsRouter = createTRPCRouter({ votes: publicProcedure - .input(z.object({ registryAddress: z.string(), tallyFile: z.string().optional() })) - .query(async ({ input }) => calculateMaciResults(input.registryAddress, input.tallyFile)), + .input(z.object({ registryAddress: z.string(), tallyAddress: z.string() })) + .query(async ({ input }) => calculateMaciResults(input.registryAddress, input.tallyAddress)), project: publicProcedure - .input(z.object({ id: z.string(), registryAddress: z.string(), tallyFile: z.string().optional() })) + .input(z.object({ id: z.string(), registryAddress: z.string(), tallyAddress: z.string() })) .query(async ({ input }) => { - const { projects } = await calculateMaciResults(input.registryAddress, input.tallyFile); + const { projects } = await calculateMaciResults(input.registryAddress, input.tallyAddress); return { amount: projects[input.id]?.votes ?? 0, @@ -22,46 +23,45 @@ export const resultsRouter = createTRPCRouter({ }), projects: publicProcedure - .input(FilterSchema.extend({ registryAddress: z.string() })) - .query(async ({ input }) => fetchProjects(input.registryAddress)), + .input(z.object({ registryAddress: z.string(), tallyAddress: z.string() })) + .query(async ({ input }) => { + const { projects: results } = await calculateMaciResults(input.registryAddress, input.tallyAddress); + const projects = await fetchProjects(input.registryAddress); + + return mappedProjectsResults(results, projects); + }), }); /** * Calculate the results of the MACI tally * * @param registryAddress - The registry address - * @param tallyFile - The tally file URL + * @param tallyAddress - The poll address * @returns The results of the tally */ export async function calculateMaciResults( registryAddress: string, - tallyFile?: string, + tallyAddress: string, ): Promise<{ averageVotes: number; projects: Record; }> { - if (!tallyFile) { - throw new Error("No tallyFile URL provided."); - } - const [tallyData, projects] = await Promise.all([ - fetch(tallyFile) - .then((res) => res.json() as Promise) - .catch(() => undefined), + fetchTally(tallyAddress).catch(() => undefined), fetchApprovedProjects(registryAddress), ]); if (!tallyData) { throw new TRPCError({ code: "BAD_REQUEST", - message: "Voting has not ended yet", + message: "The data is not tallied yet", }); } - const results = tallyData.results.tally.reduce((acc, tally, index) => { + const results = tallyData.results.reduce((acc, tally, index) => { const project = projects[index]; if (project) { - acc.set(project.id, { votes: Number(tally), voters: 0 }); + acc.set(project.id, { votes: Number(tally.result), voters: 0 }); } return acc; @@ -75,6 +75,14 @@ export async function calculateMaciResults( }; } +function mappedProjectsResults(results: Record, projects: IRecipient[]) { + const projectWithVotes = projects.map((project) => ({ + ...project, + votes: results[project.id]?.votes ?? 0, + })); + return projectWithVotes.sort((a, b) => (a.votes > b.votes ? -1 : 1)); +} + /** * Calculate the average of an array of numbers * diff --git a/packages/interface/src/utils/fetchPoll.ts b/packages/interface/src/utils/fetchPoll.ts index 8c7b3c20..8fe80efd 100644 --- a/packages/interface/src/utils/fetchPoll.ts +++ b/packages/interface/src/utils/fetchPoll.ts @@ -28,6 +28,9 @@ const PollQuery = ` id metadataUrl } + tally { + id + } } } `; @@ -50,6 +53,7 @@ function mappedPollData(polls: Poll[]): IPollData[] { registryAddress: poll.registry.id, metadataUrl: poll.registry.metadataUrl, initTime: poll.initTime, + tallyAddress: poll.tally.id, })); } diff --git a/packages/interface/src/utils/fetchTally.ts b/packages/interface/src/utils/fetchTally.ts index cdc79696..644519cb 100644 --- a/packages/interface/src/utils/fetchTally.ts +++ b/packages/interface/src/utils/fetchTally.ts @@ -24,9 +24,22 @@ const tallyQuery = ` } `; +const talliesQuery = ` + query Tally { + tallies { + id + results { + id + result + } + } + } +`; + /** * Fetches the tally data from the subgraph * + * @param id the address of the tally contract * @returns The tally data */ export async function fetchTally(id: string): Promise { @@ -37,3 +50,17 @@ export async function fetchTally(id: string): Promise { }), }).then((response: GraphQLResponse) => response.data?.tally); } + +/** + * Fetches all the tallies from the subgraph + * + * @returns The on-chain tallies + */ +export async function fetchTallies(): Promise { + return cachedFetch<{ tallies: Tally[] }>(config.maciSubgraphUrl, { + method: "POST", + body: JSON.stringify({ + query: talliesQuery, + }), + }).then((r) => r.data.tallies); +} diff --git a/packages/interface/src/utils/state.ts b/packages/interface/src/utils/state.ts index 8da9378c..8b11602f 100644 --- a/packages/interface/src/utils/state.ts +++ b/packages/interface/src/utils/state.ts @@ -1,13 +1,19 @@ import { isAfter } from "date-fns"; +import { ZeroAddress } from "ethers"; import { useRound } from "~/contexts/Round"; import { ERoundState } from "./types"; -export const useRoundState = (pollId: string): ERoundState => { +interface IUseRoundState { + pollId: string; +} + +export const useRoundState = ({ pollId }: IUseRoundState): ERoundState => { const now = new Date(); - const { getRoundByPollId } = useRound(); + const { getRoundByPollId, isRoundTallied } = useRound(); const round = getRoundByPollId(pollId); + const isTallied = isRoundTallied(round?.tallyAddress ?? ZeroAddress); if (!round) { return ERoundState.DEFAULT; @@ -21,15 +27,13 @@ export const useRoundState = (pollId: string): ERoundState => { return ERoundState.VOTING; } - // TODO commented out for testing results - // if (isAfter(now, round.votingEndsAt)) { - // return ERoundState.TALLYING; - // } - - // TODO: how to collect tally.json url - if (isAfter(now, round.votingEndsAt)) { + if (isAfter(now, round.votingEndsAt) && isTallied) { return ERoundState.RESULTS; } + if (isAfter(now, round.votingEndsAt) && !isTallied) { + return ERoundState.TALLYING; + } + return ERoundState.DEFAULT; }; diff --git a/packages/interface/src/utils/types.ts b/packages/interface/src/utils/types.ts index 0791d1d4..801f0d91 100644 --- a/packages/interface/src/utils/types.ts +++ b/packages/interface/src/utils/types.ts @@ -137,7 +137,7 @@ export interface Poll { */ initTime: string; /** - * The poll registry address + * The poll registry data */ registry: { /** @@ -149,6 +149,15 @@ export interface Poll { */ metadataUrl: string; }; + /** + * The poll tally data + */ + tally: { + /** + * The poll tally address + */ + id: string; + }; } /** @@ -317,6 +326,10 @@ export interface IPollData extends IGetPollData { * The poll init time */ initTime: bigint | number | string | null; + /** + * The tally address + */ + tallyAddress: string; } /** @@ -347,10 +360,6 @@ export interface IRoundMetadata { * The time the voting ends */ votingEndsAt: string; - /** - * The round tally file - */ - tallyFile: string; } /** @@ -406,7 +415,11 @@ export interface IRoundData { */ votingEndsAt: Date; /** - * The round tally file + * The round tally address */ - tallyFile: string; + tallyAddress: string; +} + +export interface IRecipientWithVotes extends IRecipient { + votes: number; } diff --git a/packages/subgraph/config/network.json b/packages/subgraph/config/network.json index 208e4642..735f9d2e 100644 --- a/packages/subgraph/config/network.json +++ b/packages/subgraph/config/network.json @@ -1,7 +1,7 @@ { "network": "optimism-sepolia", - "maciContractAddress": "0xEf02daBaE8B2FFF1062Dc8F127EF8D67C475534e", - "maciContractStartBlock": 18881128, - "registryManagerContractAddress": "0xb515a4b36989Fd785e04F760FD489EdcDb89b6d9", - "registryManagerContractStartBlock": 18881137 + "maciContractAddress": "0xca941B064D28276D7f125324ad2a41ec4c7F784e", + "maciContractStartBlock": 19755066, + "registryManagerContractAddress": "0xBc014791f00621AD08733043F788AB4ecAe3BAAd", + "registryManagerContractStartBlock": 19755080 } diff --git a/packages/subgraph/package.json b/packages/subgraph/package.json index 5a6d8a50..22fce076 100644 --- a/packages/subgraph/package.json +++ b/packages/subgraph/package.json @@ -18,7 +18,7 @@ "generate:schema": "cp ./schemas/schema.${VERSION:-v1}.graphql schema.graphql", "prebuild": "pnpm codegen", "build": "graph build", - "deploy": "graph deploy --node https://api.studio.thegraph.com/deploy/ maci-subgraph", + "deploy": "graph deploy --node https://api.studio.thegraph.com/deploy/ maci", "create-local": "graph create --node http://localhost:8020/ maci-subgraph", "remove-local": "graph remove --node http://localhost:8020/ maci-subgraph", "deploy-local": "graph deploy --node http://localhost:8020/ --ipfs http://localhost:5001 maci-subgraph --network localhost",