diff --git a/src/components/features/evaluator/accept-dialog.tsx b/src/components/features/evaluator/accept-dialog.tsx index ef6a391..5258dce 100644 --- a/src/components/features/evaluator/accept-dialog.tsx +++ b/src/components/features/evaluator/accept-dialog.tsx @@ -1,3 +1,4 @@ +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; import { Confirm } from "@/components/ui/confirm"; import { @@ -16,28 +17,78 @@ import { FormMessage, } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; import { MultiSelect } from "@/components/ui/multi-select"; +import { type AcceptancePlan, planAcceptance } from "@/lib/acceptance"; import { useEvaluator } from "@/providers/evaluator-provider"; import { acceptApplicants, getApplicantsToAccept } from "@/services/evaluator"; import { zodResolver } from "@hookform/resolvers/zod"; -import { useState } from "react"; -import { useForm } from "react-hook-form"; +import { useId, useState } from "react"; +import { useForm, useWatch } from "react-hook-form"; import { toast } from "sonner"; import { z } from "zod"; import { CONTRIBUTION_ROLE_OPTIONS, YEAR_LEVEL_OPTIONS } from "./constants"; -const formSchema = z.object({ - minScore: z.coerce.number().optional(), - minZScore: z.coerce.number().optional(), - minPrevHacks: z.coerce.number().optional(), - maxPrevHacks: z.coerce.number().optional(), - contributionRoles: z.array(z.string()).optional(), - yearLevels: z.array(z.string()).optional(), - minExperiencesScore: z.coerce.number().optional(), - maxExperiencesScore: z.coerce.number().optional(), -}); +const blankToUndefined = (value?: string) => + value === undefined || value.trim() === "" ? undefined : Number(value); -const BASE_VALUES: z.infer = { +const optionalNumber = z + .string() + .optional() + .transform(blankToUndefined) + .pipe(z.number({ invalid_type_error: "Enter a number" }).optional()); + +const optionalCount = z + .string() + .optional() + .transform(blankToUndefined) + .pipe( + z + .number({ invalid_type_error: "Enter a number" }) + .int("Enter a whole number") + .positive("Enter a number greater than 0") + .optional(), + ); + +const optionalPercentage = z + .string() + .optional() + .transform(blankToUndefined) + .pipe( + z + .number({ invalid_type_error: "Enter a number" }) + .min(0, "Enter a number between 0 and 100") + .max(100, "Enter a number between 0 and 100") + .optional(), + ); + +const formSchema = z + .object({ + minScore: optionalNumber, + minZScore: optionalNumber, + minPrevHacks: optionalNumber, + maxPrevHacks: optionalNumber, + contributionRoles: z.array(z.string()).optional(), + yearLevels: z.array(z.string()).optional(), + minExperiencesScore: optionalNumber, + maxExperiencesScore: optionalNumber, + totalToAccept: optionalCount, + beginnerPercentage: optionalPercentage, + }) + .superRefine((values, ctx) => { + if (values.beginnerPercentage !== undefined && values.totalToAccept === undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["beginnerPercentage"], + message: "Set a total number of hackers to use a ratio", + }); + } + }); + +type FormInput = z.input; +type FormValues = z.output; + +const BASE_VALUES: FormInput = { minScore: undefined, minZScore: undefined, minPrevHacks: undefined, @@ -46,58 +97,85 @@ const BASE_VALUES: z.infer = { yearLevels: undefined, minExperiencesScore: undefined, maxExperiencesScore: undefined, + totalToAccept: undefined, + beginnerPercentage: undefined, }; +interface Preview { + plan: AcceptancePlan; + total?: number; + signature: string; +} + export function AcceptDialog() { const { hackathon } = useEvaluator(); + const experiencedPercentageId = useId(); const [isCalculating, setIsCalculating] = useState(false); const [loading, setLoading] = useState(false); const [open, setOpen] = useState(false); - const [affectedApplicantIds, setAffectedApplicantsId] = useState(null); + const [preview, setPreview] = useState(null); - const form = useForm>({ + const form = useForm({ resolver: zodResolver(formSchema), values: BASE_VALUES, }); + const filterSignature = JSON.stringify(useWatch({ control: form.control })); + + const beginnerPercentage = useWatch({ control: form.control, name: "beginnerPercentage" }); + const experiencedPercentage = calculateExperiencedPercentage(beginnerPercentage); + const close = () => { form.reset(); - setAffectedApplicantsId(null); + setPreview(null); setOpen(false); }; - const onCalculate = async () => { + const onCalculate = async (values: FormValues) => { if (isCalculating) return; - const formData = form.getValues(); setIsCalculating(true); - const applicants = await getApplicantsToAccept( - hackathon, - formData.minScore, - formData.minZScore, - formData.minPrevHacks, - formData.maxPrevHacks, - formData.yearLevels, - formData.contributionRoles, - formData.minExperiencesScore, - formData.maxExperiencesScore, - ); + try { + const applicants = await getApplicantsToAccept( + hackathon, + values.minScore, + values.minZScore, + values.minPrevHacks, + values.maxPrevHacks, + values.yearLevels, + values.contributionRoles, + values.minExperiencesScore, + values.maxExperiencesScore, + ); - setAffectedApplicantsId(applicants?.map((a) => a._id)); - setIsCalculating(false); + setPreview({ + plan: planAcceptance(applicants ?? [], { + total: values.totalToAccept, + beginnerPercentage: values.beginnerPercentage, + }), + total: values.totalToAccept, + signature: filterSignature, + }); + } catch (error) { + console.error(error); + toast("Error calculating acceptances"); + } finally { + setIsCalculating(false); + } }; const onAccept = async () => { if (loading) return; - if (!affectedApplicantIds || affectedApplicantIds?.length < 1) { + const acceptIds = preview?.plan.ids; + if (!acceptIds || acceptIds.length < 1) { toast("No applications to accept"); return; } setLoading(true); try { - await acceptApplicants(hackathon, affectedApplicantIds); - toast(`${affectedApplicantIds?.length} hackers successfully accepted`); + await acceptApplicants(hackathon, acceptIds); + toast(`${acceptIds.length} hackers successfully accepted`); } catch (error) { console.error(error); toast("Error accepting applicants"); @@ -107,6 +185,11 @@ export function AcceptDialog() { close(); }; + // only surface the preview while it still matches the current filters + const activePreview = preview?.signature === filterSignature ? preview : null; + const plan = activePreview?.plan; + const canAccept = !!plan && plan.ids.length > 0; + return ( - + Accept applicants
- +
Minimum score - + @@ -148,7 +236,12 @@ export function AcceptDialog() { Minimum z-score - + @@ -163,7 +256,12 @@ export function AcceptDialog() { Minimum hackathons - + @@ -176,7 +274,12 @@ export function AcceptDialog() { Maximum hackathons - + @@ -231,7 +334,12 @@ export function AcceptDialog() { Number of experiences (Min) - + @@ -244,35 +352,95 @@ export function AcceptDialog() { Number of experiences (Max) - + )} />
- {/* {isCalculating ? ( - - ) : ( - affectedApplicantIds && ( - - Affected applicants - - You'll be accepting {affectedApplicantIds?.length ?? 0} hackers. - - - ) - )} */} +
+
Proportional acceptance
+
+ ( + + Total hackers + + + + + + )} + /> + ( + + Beginners (%) + + + + + + )} + /> +
+ + +
+
+

+ Beginners have attended 0–1 hackathons, everyone else counts as experienced. Leave + these blank to accept every applicant matching the filters. +

+
+ {plan && }
- - Calculate acceptances - + + {plan && + (canAccept ? ( + + Accept {plan.ids.length} hackers + + ) : ( + + ))}
@@ -280,3 +448,64 @@ export function AcceptDialog() {
); } + +/** + * mirrors the beginner percentage input to show the experienced percentage, or blank if the input is invalid + */ +const calculateExperiencedPercentage = (beginnerPercentage?: string) => { + const entered = (beginnerPercentage ?? "").trim(); + const parsed = entered === "" ? Number.NaN : Number(entered); + if (Number.isNaN(parsed) || parsed < 0 || parsed > 100) return ""; + return String(100 - parsed); +}; + +function AcceptancePreview({ plan, total }: { plan: AcceptancePlan; total?: number }) { + const { selected, target, available, finalBeginnerPercentage } = plan; + const shortGroup = selected.beginner < target.beginner ? "beginner" : "experienced"; + const overflowGroup = shortGroup === "beginner" ? "experienced" : "beginner"; + const movedSpots = selected[overflowGroup] - target[overflowGroup]; + + return ( + + You'll be accepting {selected.total} hackers + +
+ {total !== undefined && ( + <> +
Requested
+
+ {total} + {plan.mode === "proportional" && + ` (${target.beginner} beginner, ${target.experienced} experienced)`} +
+ + )} +
Selected
+
+ {selected.total} ({selected.beginner} beginner, {selected.experienced} experienced) + {finalBeginnerPercentage !== null && + ` — ${finalBeginnerPercentage}% / ${round(100 - finalBeginnerPercentage)}%`} +
+
Available
+
+ {available.beginner + available.experienced} ({available.beginner} beginner,{" "} + {available.experienced} experienced) +
+
+ {plan.isUnderfilled && total !== undefined && ( +

+ Only {selected.total} of the {total} requested hackers match these filters. +

+ )} + {plan.hasOverflow && ( +

+ Only {available[shortGroup]} {shortGroup} applicants are available, so {movedSpots} spot + {movedSpots === 1 ? "" : "s"} moved to {overflowGroup} hackers. +

+ )} +
+
+ ); +} + +const round = (value: number) => Math.round(value * 10) / 10; diff --git a/src/components/ui/form.tsx b/src/components/ui/form.tsx index 3addcd9..7ad6344 100644 --- a/src/components/ui/form.tsx +++ b/src/components/ui/form.tsx @@ -28,9 +28,10 @@ const FormFieldContext = React.createContext({} as FormFi const FormField = < TFieldValues extends FieldValues = FieldValues, TName extends FieldPath = FieldPath, + TTransformedValues = TFieldValues, >({ ...props -}: ControllerProps) => { +}: ControllerProps) => { return ( diff --git a/src/lib/acceptance.ts b/src/lib/acceptance.ts new file mode 100644 index 0000000..2ffab68 --- /dev/null +++ b/src/lib/acceptance.ts @@ -0,0 +1,153 @@ +import type { Applicant } from "@/lib/firebase/types"; + +export const BEGINNER_MAX_PREV_HACKATHONS = 1; + +export type ExperienceGroup = "beginner" | "experienced"; + +export interface AcceptanceRatio { + total?: number; + beginnerPercentage?: number; +} + +export interface AcceptancePlan { + ids: string[]; + /** + * `all` accepts everyone, + * `top` takes the highest ranked applicants, + * `proportional` fills a quota per group + */ + mode: "all" | "top" | "proportional"; + selected: { beginner: number; experienced: number; total: number }; + target: { beginner: number; experienced: number }; + available: { beginner: number; experienced: number }; + finalBeginnerPercentage: number | null; + hasOverflow: boolean; + isUnderfilled: boolean; +} + +const percentageOf = (count: number, total: number) => + total === 0 ? null : Math.round((count / total) * 1000) / 10; + +const getZScore = (applicant: Applicant) => { + const zScore = applicant.score?.totalZScore; + return typeof zScore === "number" && !Number.isNaN(zScore) ? zScore : Number.NEGATIVE_INFINITY; +}; + +/** + * reads number of prev hackathons from applicant + */ +export const parsePrevHackathons = (numHackathonsAttended?: string) => { + const parsed = Number.parseInt(String(numHackathonsAttended ?? "").trim(), 10); + return Number.isNaN(parsed) || parsed < 0 ? 0 : parsed; +}; + +export const getExperienceGroup = (applicant: Applicant): ExperienceGroup => + parsePrevHackathons(applicant.skills?.numHackathonsAttended) <= BEGINNER_MAX_PREV_HACKATHONS + ? "beginner" + : "experienced"; + +export const groupByExperience = ( + applicants: Applicant[], +): Record => { + const groups: Record = { beginner: [], experienced: [] }; + for (const applicant of applicants) { + groups[getExperienceGroup(applicant)].push(applicant); + } + return groups; +}; + +/** + * sorts applicants by z-score, highest first + */ +export const sortByZScore = (applicants: Applicant[]): Applicant[] => + applicants.slice().sort((a, b) => { + const aZScore = getZScore(a); + const bZScore = getZScore(b); + if (aZScore !== bZScore) return bZScore - aZScore; + return a._id.localeCompare(b._id); + }); + +/** + * splits a total into per group quotas + */ +export const calculateQuotas = (total: number, beginnerPercentage: number) => { + const beginner = Math.min(total, Math.max(0, Math.round((total * beginnerPercentage) / 100))); + return { beginner, experienced: total - beginner }; +}; + +/** + * Works out which of the (already filtered) applicants should be accepted + * @param applicants applicants that passed the acceptance filters + * @param ratio optional total and beginner share to select proportionally by + */ +export const planAcceptance = ( + applicants: Applicant[], + { total, beginnerPercentage }: AcceptanceRatio = {}, +): AcceptancePlan => { + const groups = groupByExperience(applicants); + const available = { beginner: groups.beginner.length, experienced: groups.experienced.length }; + + if (total === undefined) { + return { + ids: applicants.map((applicant) => applicant._id), + mode: "all", + selected: { ...available, total: applicants.length }, + target: available, + available, + finalBeginnerPercentage: percentageOf(available.beginner, applicants.length), + hasOverflow: false, + isUnderfilled: false, + }; + } + + // takes highest ranked applicants if no ratio is specified + if (beginnerPercentage === undefined) { + const selection = sortByZScore(applicants).slice(0, total); + const selected = groupByExperience(selection); + const counts = { + beginner: selected.beginner.length, + experienced: selected.experienced.length, + }; + return { + ids: selection.map((applicant) => applicant._id), + mode: "top", + selected: { ...counts, total: selection.length }, + target: counts, + available, + finalBeginnerPercentage: percentageOf(counts.beginner, selection.length), + hasOverflow: false, + isUnderfilled: selection.length < total, + }; + } + + const target = calculateQuotas(total, beginnerPercentage); + let takeBeginner = Math.min(target.beginner, available.beginner); + let takeExperienced = Math.min(target.experienced, available.experienced); + + // spill over unfilled seats + let unfilled = total - takeBeginner - takeExperienced; + if (unfilled > 0) { + const spareBeginner = Math.min(unfilled, available.beginner - takeBeginner); + takeBeginner += spareBeginner; + unfilled -= spareBeginner; + takeExperienced += Math.min(unfilled, available.experienced - takeExperienced); + } + + const selectedTotal = takeBeginner + takeExperienced; + const finalBeginnerPercentage = percentageOf(takeBeginner, selectedTotal); + const hasOverflow = takeBeginner > target.beginner || takeExperienced > target.experienced; + + return { + ids: [ + ...sortByZScore(groups.beginner).slice(0, takeBeginner), + ...sortByZScore(groups.experienced).slice(0, takeExperienced), + ].map((applicant) => applicant._id), + mode: "proportional", + selected: { beginner: takeBeginner, experienced: takeExperienced, total: selectedTotal }, + target, + available, + finalBeginnerPercentage, + hasOverflow, + isUnderfilled: selectedTotal < total, + }; +};