From dbbee90f08a84b6ca88d6688ec7a61bd57866f9c Mon Sep 17 00:00:00 2001 From: "Kalle (Sendou)" <38327916+Sendouc@users.noreply.github.com> Date: Thu, 18 Mar 2021 21:36:23 +0200 Subject: [PATCH] voting UI done --- app/plus/api.ts | 12 +- app/plus/components/PlusVotingButton.tsx | 23 +++ app/plus/components/PlusVotingPage.tsx | 101 ++++++++++++- app/plus/hooks/usePlusVoting.ts | 49 +++--- app/plus/service.ts | 183 ++++++++++++++++++++++- utils/plus.ts | 3 +- utils/shuffleArray.ts | 2 +- utils/validators/votes.ts | 8 + 8 files changed, 347 insertions(+), 34 deletions(-) create mode 100644 app/plus/components/PlusVotingButton.tsx create mode 100644 utils/validators/votes.ts diff --git a/app/plus/api.ts b/app/plus/api.ts index 713f42588..1b5ecdf1f 100644 --- a/app/plus/api.ts +++ b/app/plus/api.ts @@ -1,6 +1,7 @@ import { createRouter } from "pages/api/trpc/[trpc]"; import { throwIfNotLoggedIn } from "utils/api"; import { suggestionFullSchema } from "utils/validators/suggestion"; +import { votesSchema } from "utils/validators/votes"; import { vouchSchema } from "utils/validators/vouch"; import service from "./service"; @@ -15,10 +16,10 @@ const plusApi = createRouter() return service.getPlusStatuses(); }, }) - .query("ballots", { + .query("usersForVoting", { resolve({ ctx }) { const user = throwIfNotLoggedIn(ctx.user); - return service.getBallots(user.id); + return service.getUsersForVoting(user.id); }, }) .mutation("suggestion", { @@ -34,5 +35,12 @@ const plusApi = createRouter() const user = throwIfNotLoggedIn(ctx.user); return service.addVouch({ input, userId: user.id }); }, + }) + .mutation("vote", { + input: votesSchema, + resolve({ input, ctx }) { + const user = throwIfNotLoggedIn(ctx.user); + return service.addVotes({ input, userId: user.id }); + }, }); export default plusApi; diff --git a/app/plus/components/PlusVotingButton.tsx b/app/plus/components/PlusVotingButton.tsx new file mode 100644 index 000000000..8acbd71d0 --- /dev/null +++ b/app/plus/components/PlusVotingButton.tsx @@ -0,0 +1,23 @@ +import { Button } from "@chakra-ui/button"; + +export function PlusVotingButton({ + number, + onClick, +}: { + number: -2 | -1 | 1 | 2; + onClick: () => void; +}) { + return ( + + ); +} diff --git a/app/plus/components/PlusVotingPage.tsx b/app/plus/components/PlusVotingPage.tsx index 425c90a78..90cffc322 100644 --- a/app/plus/components/PlusVotingPage.tsx +++ b/app/plus/components/PlusVotingPage.tsx @@ -1,12 +1,103 @@ +import { Button } from "@chakra-ui/button"; +import { Box, HStack } from "@chakra-ui/layout"; +import { Progress } from "@chakra-ui/progress"; +import Markdown from "components/common/Markdown"; +import SubText from "components/common/SubText"; +import UserAvatar from "components/common/UserAvatar"; +import { useEffect } from "react"; +import { getFullUsername } from "utils/strings"; import usePlusVoting from "../hooks/usePlusVoting"; +import { PlusVotingButton } from "./PlusVotingButton"; export default function PlusVotingPage() { - const { isLoading, usersToVoteOn } = usePlusVoting(); + const { + isLoading, + shouldRedirect, + plusStatus, + currentUser, + handleVote, + progress, + previousUser, + goBack, + } = usePlusVoting(); + + useEffect(() => { + //redirect!! + }, [shouldRedirect]); + + if (isLoading || !plusStatus || !currentUser) return null; - if (isLoading) return null; return ( -

-
{JSON.stringify(usersToVoteOn, null, 2)}
-

+ + {previousUser && ( + + + + {getFullUsername(previousUser)} + + + + )} + + + + + {getFullUsername(currentUser)} + + + + {currentUser.region === plusStatus.region && ( + + handleVote({ userId: currentUser.userId, score: -2 }) + } + /> + )} + handleVote({ userId: currentUser.userId, score: -1 })} + /> + handleVote({ userId: currentUser.userId, score: 1 })} + /> + {currentUser.region === plusStatus.region && ( + handleVote({ userId: currentUser.userId, score: 2 })} + /> + )} + + {currentUser.suggestions && ( + + Suggestions + {currentUser.suggestions.map((suggestion) => { + return ( + + "{suggestion.description}" -{" "} + {getFullUsername(suggestion.suggesterUser)} + + ); + })} + + )} + {currentUser.bio && ( + + Bio + + + )} + ); } diff --git a/app/plus/hooks/usePlusVoting.ts b/app/plus/hooks/usePlusVoting.ts index f84d17d07..c5b7e4c98 100644 --- a/app/plus/hooks/usePlusVoting.ts +++ b/app/plus/hooks/usePlusVoting.ts @@ -1,39 +1,46 @@ import { useUser } from "hooks/common"; -import { getVotingRange } from "utils/plus"; +import { useState } from "react"; import { trpc } from "utils/trpc"; +import { Unpacked } from "utils/types"; +import { votesSchema } from "utils/validators/votes"; +import * as z from "zod"; export default function usePlusVoting() { + const [currentIndex, setCurrentIndex] = useState(0); + const [votes, setVotes] = useState>([]); + const [user] = useUser(); const { data: ballotsData, isLoading: isLoadingBallots } = trpc.useQuery([ - "plus.ballots", + "plus.usersForVoting", ]); const { data: statusesData, isLoading: isLoadingStatuses } = trpc.useQuery([ "plus.statuses", ]); - const { - data: suggestionsData, - isLoading: isLoadingSuggestions, - } = trpc.useQuery(["plus.suggestions"]); const ownPlusStatus = statusesData?.find( (status) => status.user.id === user?.id ); - const votingTier = ownPlusStatus?.membershipTier; - return { - ballotsData: ballotsData?.filter((ballot) => !ballot.isStale), - staleBallots: ballotsData?.filter((ballot) => ballot.isStale), - shouldRedirect: - (statusesData && !votingTier) || !getVotingRange().isHappening, - usersToVoteOn: statusesData?.filter( - (user) => - (user.membershipTier && user.membershipTier === votingTier) || - (user.vouchTier && user.vouchTier === votingTier) - ), - suggestedUsersToVoteOn: suggestionsData?.filter( - (suggestion) => suggestion.tier === votingTier - ), - isLoading: isLoadingBallots || isLoadingStatuses || isLoadingSuggestions, + isLoading: isLoadingBallots || isLoadingStatuses, + shouldRedirect: !isLoadingBallots && !ballotsData, + plusStatus: ownPlusStatus, + currentUser: ballotsData?.[currentIndex], + previousUser: + currentIndex > 0 && ballotsData + ? { ...ballotsData[currentIndex - 1], ...votes[votes.length - 1] } + : undefined, + progress: ballotsData + ? ((currentIndex + 1) / ballotsData.length) * 100 + : undefined, + handleVote: (vote: Unpacked>) => { + setVotes([...votes, vote]); + setCurrentIndex(currentIndex + 1); + (document.activeElement).blur(); + }, + goBack: () => { + setVotes(votes.slice(0, votes.length - 1)); + setCurrentIndex(currentIndex - 1); + }, }; } diff --git a/app/plus/service.ts b/app/plus/service.ts index a36768376..03236a221 100644 --- a/app/plus/service.ts +++ b/app/plus/service.ts @@ -1,9 +1,11 @@ -import { Prisma } from "@prisma/client"; +import { PlusRegion, Prisma } from "@prisma/client"; import { httpError } from "@trpc/server"; import prisma from "prisma/client"; import { getPercentageFromCounts, getVotingRange } from "utils/plus"; import { userBasicSelection } from "utils/prisma"; +import { shuffleArray } from "utils/shuffleArray"; import { suggestionFullSchema } from "utils/validators/suggestion"; +import { votesSchema } from "utils/validators/votes"; import { vouchSchema } from "utils/validators/vouch"; import * as z from "zod"; @@ -163,9 +165,100 @@ const getDistinctSummaryMonths = () => { }); }; -const getBallots = (userId: number) => { +const getUsersForVoting = async (userId: number) => { if (!getVotingRange().isHappening) return null; - return prisma.plusBallot.findMany({ where: { voterUser: { id: userId } } }); + const plusStatus = await prisma.plusStatus.findUnique({ where: { userId } }); + + if (!plusStatus?.membershipTier) return null; + + const [plusStatuses, suggestions] = await Promise.all([ + prisma.plusStatus.findMany({ + where: { + OR: [ + { membershipTier: plusStatus.membershipTier }, + { membershipTier: plusStatus.membershipTier }, + ], + }, + include: { user: { include: { profile: true } } }, + }), + prisma.plusSuggestion.findMany({ + where: { tier: plusStatus.membershipTier }, + include: { + suggestedUser: { include: { profile: true, plusStatus: true } }, + suggesterUser: true, + }, + orderBy: { createdAt: "asc" }, + }), + ]); + + const result: { + userId: number; + username: string; + discriminator: string; + discordAvatar: string | null; + discordId: string; + region: PlusRegion; + bio?: string | null; + suggestions?: { + description: string; + suggesterUser: { + id: number; + username: string; + discriminator: string; + }; + }[]; + }[] = []; + + for (const status of plusStatuses) { + result.push({ + userId: status.user.id, + username: status.user.username, + discriminator: status.user.discriminator, + discordAvatar: status.user.discordAvatar, + bio: status.user.profile?.bio, + discordId: status.user.discordId, + region: status.region, + }); + } + + for (const suggestion of suggestions) { + const user = result.find( + ({ userId }) => userId === suggestion.suggestedUser.id + ); + if (user) { + user.suggestions?.push({ + description: suggestion.description, + suggesterUser: { + id: suggestion.suggesterUser.id, + username: suggestion.suggesterUser.username, + discriminator: suggestion.suggestedUser.discriminator, + }, + }); + continue; + } + + result.push({ + userId: suggestion.suggestedUser.id, + username: suggestion.suggestedUser.username, + discriminator: suggestion.suggestedUser.discriminator, + discordAvatar: suggestion.suggestedUser.discordAvatar, + bio: suggestion.suggestedUser.profile?.bio, + discordId: suggestion.suggestedUser.discordId, + region: suggestion.suggestedUser.plusStatus?.region!, + suggestions: [ + { + description: suggestion.description, + suggesterUser: { + id: suggestion.suggesterUser.id, + username: suggestion.suggesterUser.username, + discriminator: suggestion.suggestedUser.discriminator, + }, + }, + ], + }); + } + + return shuffleArray(result).sort((a, b) => a.region.localeCompare(b.region)); }; const addSuggestion = async ({ @@ -295,13 +388,95 @@ const addVouch = async ({ } }; +const addVotes = async ({ + input, + userId, +}: { + input: z.infer; + userId: number; +}) => { + const [plusStatuses, suggestions] = await Promise.all([ + prisma.plusStatus.findMany({}), + prisma.plusSuggestion.findMany({ where: { isResuggestion: false } }), + ]); + + const usersPlusStatus = plusStatuses.find( + (status) => status.userId === userId + ); + + const usersMembership = usersPlusStatus?.membershipTier; + + if (!usersPlusStatus || !usersMembership) + throw httpError.badRequest("not a member"); + + const allowedUsers = new Map(); + + for (const status of plusStatuses) { + if ( + status.membershipTier !== usersMembership && + status.vouchTier !== usersMembership + ) { + continue; + } + + allowedUsers.set(status.userId, status.region); + } + + for (const suggestion of suggestions) { + if (suggestion.tier !== usersMembership) { + continue; + } + + const status = plusStatuses.find( + (status) => status.userId === suggestion.suggestedId + ); + if (!status) + throw httpError.badRequest("unexpected no status for suggested user"); + + allowedUsers.set(suggestion.suggestedId, status.region); + } + + if (input.length !== allowedUsers.size) { + throw httpError.badRequest("didn't vote on every user exactly once"); + } + + if ( + input.some((vote) => { + const region = allowedUsers.get(vote.userId); + if (!region) return false; + + if (region === usersPlusStatus.region) { + if (![-2, -1, 1, 2].includes(vote.score)) return false; + } else { + if (![-1, 1].includes(vote.score)) return false; + } + + return true; + }) + ) { + throw httpError.badRequest("invalid vote provided"); + } + + return prisma.plusBallot.createMany({ + data: input.map((vote) => { + return { + score: vote.score, + tier: usersMembership, + voterId: userId, + votedId: vote.userId, + }; + }), + }); +}; + export default { getPlusStatuses, getSuggestions, getVotingSummariesByMonthAndTier, getMostRecentVotingWithResultsMonth, getDistinctSummaryMonths, - getBallots, + getUsersForVoting, addSuggestion, addVouch, + addVotes, }; diff --git a/utils/plus.ts b/utils/plus.ts index d2c606d03..7d5ffedeb 100644 --- a/utils/plus.ts +++ b/utils/plus.ts @@ -59,5 +59,6 @@ export const getVotingRange = () => { ? startDate : getThirdFridayDate(true); - return { startDate, endDate, isHappening, nextVotingDate }; + //return { startDate, endDate, isHappening, nextVotingDate }; + return { startDate, endDate, isHappening: true, nextVotingDate }; }; diff --git a/utils/shuffleArray.ts b/utils/shuffleArray.ts index 93dda2a1d..fd984115f 100644 --- a/utils/shuffleArray.ts +++ b/utils/shuffleArray.ts @@ -1,4 +1,4 @@ -export const shuffleArray = (array: string[]) => { +export const shuffleArray = (array: T[]) => { return array .map((a) => ({ sort: Math.random(), value: a })) .sort((a, b) => a.sort - b.sort) diff --git a/utils/validators/votes.ts b/utils/validators/votes.ts new file mode 100644 index 000000000..547b3f34b --- /dev/null +++ b/utils/validators/votes.ts @@ -0,0 +1,8 @@ +import * as z from "zod"; + +export const votesSchema = z.array( + z.object({ + score: z.number().min(-2).max(2).int(), + userId: z.number(), + }) +);