From e409c940d247bedbe2a87b196d7228c76af12f3c Mon Sep 17 00:00:00 2001 From: Mackenzie Date: Wed, 22 Jul 2026 17:24:21 -0700 Subject: [PATCH 1/8] tsc --- src/components/ui/form.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 ( From 655cc710e58821e00c9d03595f6f02da577bb86b Mon Sep 17 00:00:00 2001 From: Mackenzie Date: Wed, 22 Jul 2026 17:36:58 -0700 Subject: [PATCH 2/8] file for acceptance helpers --- src/lib/acceptance.ts | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 src/lib/acceptance.ts diff --git a/src/lib/acceptance.ts b/src/lib/acceptance.ts new file mode 100644 index 0000000..e69de29 From 320ace750b6e0eff065600ccffd310b7b568268f Mon Sep 17 00:00:00 2001 From: Mackenzie Date: Wed, 22 Jul 2026 17:44:47 -0700 Subject: [PATCH 3/8] add beginner and experienced grouping helpers --- src/lib/acceptance.ts | 54 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/src/lib/acceptance.ts b/src/lib/acceptance.ts index e69de29..64ac00e 100644 --- a/src/lib/acceptance.ts +++ b/src/lib/acceptance.ts @@ -0,0 +1,54 @@ +import type { Applicant } from "@/lib/firebase/types"; + +export const BEGINNER_MAX_PREV_HACKATHONS = 1; + +export type ExperienceGroup = "beginner" | "experienced"; + + +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 }; +}; + From 2a14f35537163c2bc34a74ecc080166aa2ae650c Mon Sep 17 00:00:00 2001 From: Mackenzie Date: Thu, 23 Jul 2026 13:32:27 -0700 Subject: [PATCH 4/8] select acceptances proportionally by z-score --- src/lib/acceptance.ts | 107 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 107 insertions(+) diff --git a/src/lib/acceptance.ts b/src/lib/acceptance.ts index 64ac00e..cd40ae7 100644 --- a/src/lib/acceptance.ts +++ b/src/lib/acceptance.ts @@ -1,9 +1,34 @@ import type { Applicant } from "@/lib/firebase/types"; export const BEGINNER_MAX_PREV_HACKATHONS = 1; +export const MAX_RATIO_DEVIATION_POINTS = 5; 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; + exceedsRatioLimit: 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; @@ -52,3 +77,85 @@ export const calculateQuotas = (total: number, beginnerPercentage: number) => { 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, + exceedsRatioLimit: 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, + exceedsRatioLimit: false, + }; + } + + 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, + exceedsRatioLimit: + hasOverflow && + finalBeginnerPercentage !== null && + Math.abs(finalBeginnerPercentage - beginnerPercentage) > MAX_RATIO_DEVIATION_POINTS, + }; +}; From 23d7c198d50070718f970796ce0a65046825cc30 Mon Sep 17 00:00:00 2001 From: Mackenzie Date: Thu, 23 Jul 2026 13:48:32 -0700 Subject: [PATCH 5/8] validate accept dialog filters on submit --- .../features/evaluator/accept-dialog.tsx | 164 +++++++++++++----- 1 file changed, 124 insertions(+), 40 deletions(-) diff --git a/src/components/features/evaluator/accept-dialog.tsx b/src/components/features/evaluator/accept-dialog.tsx index ef6a391..6bd1050 100644 --- a/src/components/features/evaluator/accept-dialog.tsx +++ b/src/components/features/evaluator/accept-dialog.tsx @@ -26,18 +26,46 @@ 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 optionalNumber = z + .string() + .optional() + .transform(blankToUndefined) + .pipe(z.number({ invalid_type_error: "Enter a number" }).optional()); + }); const BASE_VALUES: z.infer = { + +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,8 +74,11 @@ const BASE_VALUES: z.infer = { yearLevels: undefined, minExperiencesScore: undefined, maxExperiencesScore: undefined, + totalToAccept: undefined, + beginnerPercentage: undefined, }; + export function AcceptDialog() { const { hackathon } = useEvaluator(); const [isCalculating, setIsCalculating] = useState(false); @@ -55,7 +86,7 @@ export function AcceptDialog() { const [open, setOpen] = useState(false); const [affectedApplicantIds, setAffectedApplicantsId] = useState(null); - const form = useForm>({ + const form = useForm({ resolver: zodResolver(formSchema), values: BASE_VALUES, }); @@ -66,25 +97,38 @@ export function AcceptDialog() { 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, + beginnerPercentage: values.beginnerPercentage, + }); + } catch (error) { + console.error(error); + toast("Error calculating acceptances"); + } finally { + setIsCalculating(false); + } }; const onAccept = async () => { @@ -126,7 +170,7 @@ export function AcceptDialog() { Accept applicants
- +
Minimum score - + @@ -148,7 +197,12 @@ export function AcceptDialog() { Minimum z-score - + @@ -163,7 +217,12 @@ export function AcceptDialog() { Minimum hackathons - + @@ -176,7 +235,12 @@ export function AcceptDialog() { Maximum hackathons - + @@ -231,7 +295,12 @@ export function AcceptDialog() { Number of experiences (Min) - + @@ -244,7 +313,12 @@ export function AcceptDialog() { Number of experiences (Max) - + @@ -264,15 +338,25 @@ export function AcceptDialog() { ) )} */}
- - Calculate acceptances - + + {plan && + (canAccept ? ( + + Accept {plan.ids.length} hackers + + ) : ( + + ))}
From 677a88b38705bd830bb1912ef26e60f9f167bced Mon Sep 17 00:00:00 2001 From: Mackenzie Date: Thu, 23 Jul 2026 17:04:31 -0700 Subject: [PATCH 6/8] add total and beginner ratio fields + show split before confirming --- .../features/evaluator/accept-dialog.tsx | 200 ++++++++++++++++-- 1 file changed, 177 insertions(+), 23 deletions(-) diff --git a/src/components/features/evaluator/accept-dialog.tsx b/src/components/features/evaluator/accept-dialog.tsx index 6bd1050..2f371ca 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,12 +17,14 @@ 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, MAX_RATIO_DEVIATION_POINTS, 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 { useEffect, 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"; @@ -35,9 +38,29 @@ const optionalNumber = z .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 BASE_VALUES: z.infer = { +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({ @@ -78,28 +101,43 @@ const BASE_VALUES: FormInput = { beginnerPercentage: undefined, }; +interface Preview { + plan: AcceptancePlan; + total?: number; + beginnerPercentage?: number; +} 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({ resolver: zodResolver(formSchema), values: BASE_VALUES, }); + // a preview only describes the filters it was calculated with, so drop it as soon as they change + // this is a bit of a hack, but react-hook-form doesn't provide a way to watch all fields at once + useEffect(() => { + const subscription = form.watch(() => setPreview(null)); + return () => subscription.unsubscribe(); + }, [form]); + + 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 (values: FormValues) => { if (isCalculating) return; - const formData = form.getValues(); setIsCalculating(true); try { @@ -133,15 +171,16 @@ export function AcceptDialog() { 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"); @@ -151,6 +190,9 @@ export function AcceptDialog() { close(); }; + const plan = preview?.plan; + const canAccept = !!plan && plan.ids.length > 0 && !plan.exceedsRatioLimit; + return ( - + Accept applicants @@ -325,18 +367,69 @@ export function AcceptDialog() { )} />
- {/* {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 && ( + + )}
))}
@@ -467,33 +459,39 @@ const calculateExperiencedPercentage = (beginnerPercentage?: string) => { return String(100 - parsed); }; -function AcceptancePreview({ - plan, - total, - beginnerPercentage, -}: { - plan: AcceptancePlan; - total?: number; - beginnerPercentage?: number; -}) { +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 ( - - - {plan.exceedsRatioLimit - ? "Ratio cannot be met" - : `You'll be accepting ${selected.total} hackers`} - + + You'll be accepting {selected.total} hackers -

- {selected.beginner} beginner, {selected.experienced} experienced - {finalBeginnerPercentage !== null && - ` (${finalBeginnerPercentage}% / ${round(100 - finalBeginnerPercentage)}%)`} -

+
+ {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. @@ -505,12 +503,6 @@ function AcceptancePreview({ {movedSpots === 1 ? "" : "s"} moved to {overflowGroup} hackers.

)} - {plan.exceedsRatioLimit && beginnerPercentage !== undefined && ( -

- That is more than {MAX_RATIO_DEVIATION_POINTS} points off the {beginnerPercentage}% - beginner split you asked for. Lower the total or change the percentage to continue. -

- )}
); diff --git a/src/lib/acceptance.ts b/src/lib/acceptance.ts index cd40ae7..2ffab68 100644 --- a/src/lib/acceptance.ts +++ b/src/lib/acceptance.ts @@ -1,7 +1,6 @@ import type { Applicant } from "@/lib/firebase/types"; export const BEGINNER_MAX_PREV_HACKATHONS = 1; -export const MAX_RATIO_DEVIATION_POINTS = 5; export type ExperienceGroup = "beginner" | "experienced"; @@ -24,7 +23,6 @@ export interface AcceptancePlan { finalBeginnerPercentage: number | null; hasOverflow: boolean; isUnderfilled: boolean; - exceedsRatioLimit: boolean; } const percentageOf = (count: number, total: number) => @@ -99,7 +97,6 @@ export const planAcceptance = ( finalBeginnerPercentage: percentageOf(available.beginner, applicants.length), hasOverflow: false, isUnderfilled: false, - exceedsRatioLimit: false, }; } @@ -120,7 +117,6 @@ export const planAcceptance = ( finalBeginnerPercentage: percentageOf(counts.beginner, selection.length), hasOverflow: false, isUnderfilled: selection.length < total, - exceedsRatioLimit: false, }; } @@ -153,9 +149,5 @@ export const planAcceptance = ( finalBeginnerPercentage, hasOverflow, isUnderfilled: selectedTotal < total, - exceedsRatioLimit: - hasOverflow && - finalBeginnerPercentage !== null && - Math.abs(finalBeginnerPercentage - beginnerPercentage) > MAX_RATIO_DEVIATION_POINTS, }; };