️ Optimize participant query

This commit is contained in:
Luke Vella 2023-08-26 11:02:54 +01:00
parent bc521f7ecb
commit d5fc45c506
8 changed files with 33 additions and 271 deletions

View file

@ -1,9 +1,10 @@
import { trpc } from "@rallly/backend"; import { trpc } from "@rallly/backend";
import { Participant, Vote, VoteType } from "@rallly/database"; import { Participant, VoteType } from "@rallly/database";
import * as React from "react"; import * as React from "react";
import { useVisibility } from "@/components/visibility"; import { useVisibility } from "@/components/visibility";
import { usePermissions } from "@/contexts/permissions"; import { usePermissions } from "@/contexts/permissions";
import { Vote } from "@/utils/trpc/types";
import { useRequiredContext } from "./use-required-context"; import { useRequiredContext } from "./use-required-context";

View file

@ -1,4 +1,4 @@
import { Participant, Vote, VoteType } from "@rallly/database"; import { Participant, VoteType } from "@rallly/database";
import { TrashIcon } from "@rallly/icons"; import { TrashIcon } from "@rallly/icons";
import { keyBy } from "lodash"; import { keyBy } from "lodash";
import { useTranslation } from "next-i18next"; import { useTranslation } from "next-i18next";
@ -10,7 +10,7 @@ import {
ParsedTimeSlotOption, ParsedTimeSlotOption,
} from "@/utils/date-time-utils"; } from "@/utils/date-time-utils";
import { useDayjs } from "@/utils/dayjs"; import { useDayjs } from "@/utils/dayjs";
import { GetPollApiResponse } from "@/utils/trpc/types"; import { GetPollApiResponse, Vote } from "@/utils/trpc/types";
import ErrorPage from "./error-page"; import ErrorPage from "./error-page";
import { useParticipants } from "./participants-provider"; import { useParticipants } from "./participants-provider";

View file

@ -1,4 +1,4 @@
import { Participant, Vote, VoteType } from "@rallly/database"; import { Participant, VoteType } from "@rallly/database";
import { MoreHorizontalIcon } from "@rallly/icons"; import { MoreHorizontalIcon } from "@rallly/icons";
import { Button } from "@rallly/ui/button"; import { Button } from "@rallly/ui/button";
import clsx from "clsx"; import clsx from "clsx";
@ -8,6 +8,7 @@ import { ParticipantDropdown } from "@/components/participant-dropdown";
import { usePoll } from "@/components/poll-context"; import { usePoll } from "@/components/poll-context";
import { useUser } from "@/components/user-provider"; import { useUser } from "@/components/user-provider";
import { usePermissions } from "@/contexts/permissions"; import { usePermissions } from "@/contexts/permissions";
import { Vote } from "@/utils/trpc/types";
import UserAvatar from "../user-avatar"; import UserAvatar from "../user-avatar";
import VoteIcon from "../vote-icon"; import VoteIcon from "../vote-icon";

View file

@ -38,31 +38,22 @@ const useScoreByOptionId = () => {
const { options } = usePoll(); const { options } = usePoll();
return React.useMemo(() => { return React.useMemo(() => {
const res = options.reduce<Record<string, OptionScore>>((acc, option) => { const scoreByOptionId: Record<string, OptionScore> = {};
acc[option.id] = { yes: [], ifNeedBe: [], no: [] }; options.forEach((option) => {
return acc; scoreByOptionId[option.id] = {
}, {}); yes: [],
ifNeedBe: [],
no: [],
};
});
const votes = responses.flatMap((response) => response.votes); responses?.forEach((response) => {
response.votes.forEach((vote) => {
scoreByOptionId[vote.optionId][vote.type].push(response.id);
});
});
for (const vote of votes) { return scoreByOptionId;
if (!res[vote.optionId]) {
res[vote.optionId] = { yes: [], ifNeedBe: [], no: [] };
}
switch (vote.type) {
case "yes":
res[vote.optionId].yes.push(vote.participantId);
break;
case "ifNeedBe":
res[vote.optionId].ifNeedBe.push(vote.participantId);
break;
case "no":
res[vote.optionId].no.push(vote.participantId);
break;
}
}
return res;
}, [responses, options]); }, [responses, options]);
}; };
@ -195,7 +186,7 @@ export const FinalizePollForm = ({
</RadioGroup> </RadioGroup>
</FormControl> </FormControl>
{max < options.length ? ( {max < options.length ? (
<div className="absolute bottom-0 mt-2 w-full bg-gradient-to-t from-white via-white to-white/10 py-8 px-3"> <div className="absolute bottom-0 mt-2 w-full bg-gradient-to-t from-white via-white to-white/10 px-3 py-8">
<Button <Button
variant="ghost" variant="ghost"
className="w-full" className="w-full"

View file

@ -1,209 +0,0 @@
import { cn } from "@rallly/ui";
import { Button } from "@rallly/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
} from "@rallly/ui/form";
import { RadioGroup, RadioGroupItem } from "@rallly/ui/radio-group";
import dayjs from "dayjs";
import { Trans } from "next-i18next";
import React from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { DateIcon } from "@/components/date-icon";
import { useParticipants } from "@/components/participants-provider";
import { ConnectedScoreSummary } from "@/components/poll/score-summary";
import { VoteSummaryProgressBar } from "@/components/vote-summary-progress-bar";
import { useDateFormatter, usePoll } from "@/contexts/poll";
const formSchema = z.object({
selectedOptionId: z.string(),
notify: z.enum(["everyone", "available", "noone"]),
});
type FinalizeFormData = z.infer<typeof formSchema>;
type OptionScore = {
yes: string[];
ifNeedBe: string[];
no: string[];
};
const useScoreByOptionId = () => {
const { participants: responses } = useParticipants();
const { options } = usePoll();
return React.useMemo(() => {
const res = options.reduce<Record<string, OptionScore>>((acc, option) => {
acc[option.id] = { yes: [], ifNeedBe: [], no: [] };
return acc;
}, {});
const votes = responses.flatMap((response) => response.votes);
for (const vote of votes) {
if (!res[vote.optionId]) {
res[vote.optionId] = { yes: [], ifNeedBe: [], no: [] };
}
switch (vote.type) {
case "yes":
res[vote.optionId].yes.push(vote.participantId);
break;
case "ifNeedBe":
res[vote.optionId].ifNeedBe.push(vote.participantId);
break;
case "no":
res[vote.optionId].no.push(vote.participantId);
break;
}
}
return res;
}, [responses, options]);
};
export const FinalizePollForm = ({
name,
onSubmit,
}: {
name: string;
onSubmit: (data: FinalizeFormData) => void;
}) => {
const poll = usePoll();
const [max, setMax] = React.useState(5);
const scoreByOptionId = useScoreByOptionId();
const { participants } = useParticipants();
const options = [...poll.options]
.sort((a, b) => {
const aYes = scoreByOptionId[a.id].yes.length;
const bYes = scoreByOptionId[b.id].yes.length;
if (aYes !== bYes) {
return bYes - aYes;
}
const aIfNeedBe = scoreByOptionId[a.id].ifNeedBe.length;
const bIfNeedBe = scoreByOptionId[b.id].ifNeedBe.length;
return bIfNeedBe - aIfNeedBe;
})
.map((option) => {
return { ...option, votes: scoreByOptionId[option.id] };
});
const dateFormatter = useDateFormatter();
const form = useForm<FinalizeFormData>({
defaultValues: {
selectedOptionId: options[0].id,
notify: "everyone",
},
});
return (
<Form {...form}>
<form
id={name}
className="space-y-4"
onSubmit={form.handleSubmit(onSubmit)}
>
<FormField
control={form.control}
name="selectedOptionId"
render={({ field }) => {
return (
<FormItem className="relative">
<FormLabel htmlFor={field.name}>
<Trans i18nKey="dates" />
</FormLabel>
<FormControl>
<RadioGroup
onValueChange={field.onChange}
value={field.value}
className="grid gap-2"
>
{options.slice(0, max).map((option) => {
const start = dateFormatter(option.start);
const end = dateFormatter(
dayjs(option.start).add(option.duration, "minute"),
);
return (
<label
key={option.id}
htmlFor={option.id}
className={cn(
"group flex select-none items-center gap-4 rounded-md border bg-white p-3 text-base",
field.value === option.id
? "bg-primary-50 border-primary ring-primary ring-1"
: "hover:bg-gray-50",
)}
>
<div className="hidden">
<RadioGroupItem id={option.id} value={option.id} />
</div>
<div>
<DateIcon date={start} />
</div>
<div className="grow">
<div className="flex">
<div className="grow whitespace-nowrap">
<div className="text-sm font-semibold">
{option.duration > 0
? start.format("LL")
: start.format("LL")}
</div>
<div className="text-muted-foreground text-sm">
{option.duration > 0 ? (
`${start.format("LT")} - ${end.format(
"LT",
)}`
) : (
<Trans
i18nKey="allDay"
defaults="All day"
/>
)}
</div>
</div>
<div>
<ConnectedScoreSummary optionId={option.id} />
</div>
</div>
<div className="mt-2">
<VoteSummaryProgressBar
{...scoreByOptionId[option.id]}
total={participants.length}
/>
</div>
</div>
</label>
);
})}
</RadioGroup>
</FormControl>
{max < options.length ? (
<div className="absolute bottom-0 mt-2 w-full bg-gradient-to-t from-white via-white to-white/10 py-8 px-3">
<Button
variant="ghost"
className="w-full"
onClick={() => {
setMax((oldMax) => oldMax + 5);
}}
>
<Trans i18nKey="showMore" />
</Button>
</div>
) : null}
</FormItem>
);
}}
/>
</form>
</Form>
);
};

View file

@ -25,38 +25,6 @@ export type OptionScore = {
no: string[]; no: string[];
}; };
export const useScoreByOptionId = () => {
const { data: responses = [] } = useCurrentPollResponses();
const { data: options = [] } = useCurrentPollOptions();
return React.useMemo(() => {
const res = options.reduce<Record<string, OptionScore>>((acc, option) => {
acc[option.id] = { yes: [], ifNeedBe: [], no: [] };
return acc;
}, {});
const votes = responses.flatMap((response) => response.votes);
for (const vote of votes) {
if (!res[vote.optionId]) {
res[vote.optionId] = { yes: [], ifNeedBe: [], no: [] };
}
switch (vote.type) {
case "yes":
res[vote.optionId].yes.push(vote.participantId);
break;
case "ifNeedBe":
res[vote.optionId].ifNeedBe.push(vote.participantId);
break;
case "no":
res[vote.optionId].no.push(vote.participantId);
break;
}
}
return res;
}, [responses, options]);
};
export const useCurrentPollOptions = () => { export const useCurrentPollOptions = () => {
const pollId = useCurrentEventId(); const pollId = useCurrentEventId();
return trpc.polls.options.list.useQuery({ pollId }); return trpc.polls.options.list.useQuery({ pollId });

View file

@ -1,4 +1,4 @@
import { User } from "@rallly/database"; import { User, VoteType } from "@rallly/database";
export type GetPollApiResponse = { export type GetPollApiResponse = {
id: string; id: string;
@ -16,3 +16,8 @@ export type GetPollApiResponse = {
createdAt: Date; createdAt: Date;
deleted: boolean; deleted: boolean;
}; };
export type Vote = {
optionId: string;
type: VoteType;
};

View file

@ -21,7 +21,12 @@ export const participants = router({
pollId, pollId,
}, },
include: { include: {
votes: true, votes: {
select: {
optionId: true,
type: true,
},
},
}, },
orderBy: [ orderBy: [
{ {