voting UI done

This commit is contained in:
Kalle (Sendou)
2021-03-18 21:36:23 +02:00
parent ed3c9ddda1
commit dbbee90f08
8 changed files with 347 additions and 34 deletions

View File

@@ -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;

View File

@@ -0,0 +1,23 @@
import { Button } from "@chakra-ui/button";
export function PlusVotingButton({
number,
onClick,
}: {
number: -2 | -1 | 1 | 2;
onClick: () => void;
}) {
return (
<Button
borderRadius="50%"
height={12}
width={12}
variant="outline"
colorScheme={number < 0 ? "red" : "theme"}
onClick={onClick}
>
{number > 0 ? "+" : ""}
{number}
</Button>
);
}

View File

@@ -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 (
<h1>
<pre>{JSON.stringify(usersToVoteOn, null, 2)}</pre>
</h1>
<Box>
{previousUser && (
<Box textAlign="center" mb={6}>
<UserAvatar user={previousUser} isSmall />
<Box my={2} fontSize="sm">
{getFullUsername(previousUser)}
</Box>
<Button
borderRadius="50%"
height={10}
width={10}
variant="outline"
colorScheme={previousUser.score < 0 ? "red" : "theme"}
onClick={goBack}
>
{previousUser.score > 0 ? "+" : ""}
{previousUser.score}
</Button>
</Box>
)}
<Progress value={progress} size="xs" colorScheme="pink" />
<Box mt={6} textAlign="center">
<UserAvatar user={currentUser} size="2xl" mx="auto" />
<Box fontSize="2rem" fontWeight="bold" mt={2}>
{getFullUsername(currentUser)}
</Box>
</Box>
<HStack justify="center" spacing={4} mt={2}>
{currentUser.region === plusStatus.region && (
<PlusVotingButton
number={-2}
onClick={() =>
handleVote({ userId: currentUser.userId, score: -2 })
}
/>
)}
<PlusVotingButton
number={-1}
onClick={() => handleVote({ userId: currentUser.userId, score: -1 })}
/>
<PlusVotingButton
number={1}
onClick={() => handleVote({ userId: currentUser.userId, score: 1 })}
/>
{currentUser.region === plusStatus.region && (
<PlusVotingButton
number={2}
onClick={() => handleVote({ userId: currentUser.userId, score: 2 })}
/>
)}
</HStack>
{currentUser.suggestions && (
<Box mt={5}>
<SubText>Suggestions</SubText>
{currentUser.suggestions.map((suggestion) => {
return (
<Box key={suggestion.suggesterUser.id} mt={4} fontSize="sm">
"{suggestion.description}" -{" "}
{getFullUsername(suggestion.suggesterUser)}
</Box>
);
})}
</Box>
)}
{currentUser.bio && (
<Box mt={4}>
<SubText mb={4}>Bio</SubText>
<Markdown value={currentUser.bio} smallHeaders />
</Box>
)}
</Box>
);
}

View File

@@ -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<z.infer<typeof votesSchema>>([]);
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<z.infer<typeof votesSchema>>) => {
setVotes([...votes, vote]);
setCurrentIndex(currentIndex + 1);
(<HTMLElement>document.activeElement).blur();
},
goBack: () => {
setVotes(votes.slice(0, votes.length - 1));
setCurrentIndex(currentIndex - 1);
},
};
}

View File

@@ -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<typeof votesSchema>;
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<number, "EU" | "NA">();
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,
};

View File

@@ -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 };
};

View File

@@ -1,4 +1,4 @@
export const shuffleArray = (array: string[]) => {
export const shuffleArray = <T>(array: T[]) => {
return array
.map((a) => ({ sort: Math.random(), value: a }))
.sort((a, b) => a.sort - b.sort)

View File

@@ -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(),
})
);