From c7bbb8871f24111979fe0e9750c6f34d028968d9 Mon Sep 17 00:00:00 2001 From: naijwu Date: Sat, 14 Feb 2026 19:32:09 -0800 Subject: [PATCH 1/2] added evaluator settings + change evaluator active hackathon + change scoring criteria --- .../features/evaluator/applicant-scoring.tsx | 11 +- .../features/evaluator/constants.ts | 87 ++----- .../features/evaluator/settings-dialog.tsx | 224 ++++++++++++++++++ src/lib/firebase/types.ts | 12 + src/providers/evaluator-provider.tsx | 18 +- src/routes/_auth/evaluator.tsx | 26 +- src/services/evaluator.ts | 12 + 7 files changed, 314 insertions(+), 76 deletions(-) create mode 100644 src/components/features/evaluator/settings-dialog.tsx diff --git a/src/components/features/evaluator/applicant-scoring.tsx b/src/components/features/evaluator/applicant-scoring.tsx index c685cb8..880d432 100644 --- a/src/components/features/evaluator/applicant-scoring.tsx +++ b/src/components/features/evaluator/applicant-scoring.tsx @@ -3,17 +3,16 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { useDebouncedSave } from "@/hooks/use-debounce-save"; -import type { ApplicantScoreItem } from "@/lib/firebase/types"; +import type { ApplicantScoreItem, ScoringCriteria } from "@/lib/firebase/types"; import { useAuth } from "@/providers/auth-provider"; import { useEvaluator } from "@/providers/evaluator-provider"; import { updateApplicant } from "@/services/evaluator"; import { Timestamp } from "firebase/firestore"; import { useCallback, useEffect, useState } from "react"; -import { SCORING_CRITERIA, type ScoringCriteria } from "./constants"; export function ApplicantScoring() { const { user } = useAuth(); - const { hackathon, focusedApplicant } = useEvaluator(); + const { hackathon, focusedApplicant, scoringCriteria } = useEvaluator(); const [scores, setScores] = useState>({}); const [comment, setComment] = useState(""); @@ -65,7 +64,7 @@ export function ApplicantScoring() { [field]: newScore, }, ...newMetadata, - totalScore: SCORING_CRITERIA.reduce((sum, criteria) => { + totalScore: scoringCriteria.reduce((sum, criteria) => { const scoreItem = updatedScores[criteria.field]; const scoreValue = typeof scoreItem?.score === "number" ? scoreItem.score : 0; return sum + scoreValue * criteria.weight; @@ -78,7 +77,7 @@ export function ApplicantScoring() { const areAllCategoriesScored = () => { if (!scores || Object.keys(scores).length === 0) return false; - return SCORING_CRITERIA.every((criteria) => { + return scoringCriteria.every((criteria) => { const score = scores[criteria.field]; return score && typeof score.score === "number"; }); @@ -142,7 +141,7 @@ export function ApplicantScoring() { Scoring - {SCORING_CRITERIA?.map((criteria) => ( + {scoringCriteria?.map((criteria) => ( = [ +export const YEAR_LEVEL_OPTIONS: Array<{ + label: ApplicantEducationLevel; + value: ApplicantEducationLevel; +}> = [ { label: "Less than Secondary / High School", value: "Less than Secondary / High School" }, { label: "Secondary / High School", value: "Secondary / High School" }, { @@ -124,4 +81,4 @@ export const YEAR_LEVEL_OPTIONS: Array<{ label: ApplicantEducationLevel; value: { label: "Post-Doctorate", value: "Post-Doctorate" }, { label: "I'm not currently a student", value: "I'm not currently a student" }, { label: "Prefer not to answer", value: "Prefer not to answer" }, -]; \ No newline at end of file +]; diff --git a/src/components/features/evaluator/settings-dialog.tsx b/src/components/features/evaluator/settings-dialog.tsx new file mode 100644 index 0000000..1734274 --- /dev/null +++ b/src/components/features/evaluator/settings-dialog.tsx @@ -0,0 +1,224 @@ +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { useEvaluator } from "@/providers/evaluator-provider"; +import { setAdminFlags } from "@/services/evaluator"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useState } from "react"; +import { useFieldArray, useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { z } from "zod"; + +const scoreNumber = z.coerce + .number() + .int("Score must be an integer") + .min(-10, "Score cannot be less than -10") + .max(10, "Score cannot be greater than 10"); + +const scoringCriteriaSchema = z + .object({ + label: z.string(), + field: z.string(), + minScore: scoreNumber, + maxScore: scoreNumber, + increments: z.coerce.number().positive(), + weight: z.coerce.number().nonnegative(), + }) + .refine((data) => data.minScore < data.maxScore, { + message: "minScore must be less than maxScore", + path: ["minScore"], + }); + +const formSchema = z.object({ + activeHackathon: z.string().min(2).max(10000), + scoringCriteria: z.array(scoringCriteriaSchema), +}); + +interface FAQDialogProps { + open: boolean; + hackathonIds: string[]; + onClose: () => void; +} + +export function SettingsDialog({ open, onClose, hackathonIds }: FAQDialogProps) { + const { hackathon, scoringCriteria, setScoringCriteria, setHackathon } = useEvaluator(); + + const [loading, setLoading] = useState(false); + + const form = useForm>({ + resolver: zodResolver(formSchema), + values: { + activeHackathon: hackathon, + scoringCriteria: scoringCriteria ?? [], + }, + mode: "onChange", + shouldUnregister: false, + }); + + const { fields } = useFieldArray({ + control: form.control, + name: "scoringCriteria", + }); + + const handleSave = async (values: z.infer) => { + if (loading) return; + setLoading(true); + try { + await setAdminFlags({ + activeHackathon: values.activeHackathon, + evaluator: { + criteria: values.scoringCriteria, + }, + }); + setHackathon(values.activeHackathon); + setScoringCriteria(values.scoringCriteria); + toast("Evaluator settings saved"); + onClose(); + } catch (error) { + toast.error("Failed to save evaluator settings"); + console.error(error); + } finally { + setLoading(false); + } + }; + + return ( + onClose()}> + + + Evaluator settings + + +
+ + ( + + Active hackathon + + + + )} + /> +
+ Scoring criteria + {fields.map((criteria, index) => ( +
+
{criteria.label}
+ + + +
+ ( + + Min score + + + field.onChange(e.target.value === "" ? "" : Number(e.target.value)) + } + /> + + + + )} + /> + ( + + Max score + + + field.onChange(e.target.value === "" ? "" : Number(e.target.value)) + } + /> + + + + )} + /> + ( + + Increment + + + + + + )} + /> +
+
+ ))} +
+
+
+ +
+
+ + +
+
+ ); +} diff --git a/src/lib/firebase/types.ts b/src/lib/firebase/types.ts index df99c7a..17ccae4 100644 --- a/src/lib/firebase/types.ts +++ b/src/lib/firebase/types.ts @@ -294,10 +294,22 @@ export interface ApplicantScoreItem { score?: number; } +export interface ScoringCriteria { + label: string; + field: string; + minScore: number; + maxScore: number; + increments: number; + weight: number; +} + export interface InternalWebsitesCMS { activeHackathon?: string; offUntilDate?: boolean; // not sure what this is for targetSite?: string; + evaluator: { + criteria: ScoringCriteria[]; + }; } /** diff --git a/src/providers/evaluator-provider.tsx b/src/providers/evaluator-provider.tsx index b7bc424..f0cbf77 100644 --- a/src/providers/evaluator-provider.tsx +++ b/src/providers/evaluator-provider.tsx @@ -1,5 +1,5 @@ import { Loading } from "@/components/ui/loading"; -import type { Applicant } from "@/lib/firebase/types"; +import type { Applicant, ScoringCriteria } from "@/lib/firebase/types"; import { getAdminFlags, subscribeLongAnswerQuestions, @@ -11,7 +11,10 @@ export interface EvaluatorContextType { hackathon: string; applicants: Applicant[]; focusedApplicant: Applicant | null; + setHackathon: React.Dispatch>; + setScoringCriteria: React.Dispatch>; setFocusedApplicant: React.Dispatch>; + scoringCriteria: ScoringCriteria[]; questionLabels: Record; } @@ -22,6 +25,7 @@ const EvaluatorProvider = ({ children }: { children: ReactNode }) => { const [hackathon, setHackathon] = useState(""); const [applicants, setApplicants] = useState([]); const [focusedApplicant, setFocusedApplicant] = useState(null); + const [scoringCriteria, setScoringCriteria] = useState([]); const [questionLabels, setQuestionLabels] = useState>({}); useEffect(() => { @@ -29,7 +33,10 @@ const EvaluatorProvider = ({ children }: { children: ReactNode }) => { const fetchAndSubscribe = async () => { try { const adminConfig = await getAdminFlags(); - setHackathon(adminConfig?.activeHackathon ?? ""); + if (hackathon === "") { + setHackathon(adminConfig?.activeHackathon ?? ""); + } + setScoringCriteria(adminConfig?.evaluator?.criteria ?? []); if (!adminConfig?.activeHackathon) throw new Error("No activeHackathon flag set in CMS"); unsubApplicants = subscribeToApplicants( adminConfig?.activeHackathon, @@ -45,11 +52,13 @@ const EvaluatorProvider = ({ children }: { children: ReactNode }) => { }; fetchAndSubscribe(); return () => unsubApplicants?.(); - }, []); + }, [hackathon]); useEffect(() => { if (!hackathon) return; + setFocusedApplicant(null); + const unsubLongAnswerQuestions = subscribeLongAnswerQuestions(hackathon, (questions) => { const labelMap = questions.reduce( (acc, q, index) => { @@ -70,6 +79,9 @@ const EvaluatorProvider = ({ children }: { children: ReactNode }) => { hackathon, applicants, focusedApplicant, + scoringCriteria, + setHackathon, + setScoringCriteria, setFocusedApplicant, questionLabels, }; diff --git a/src/routes/_auth/evaluator.tsx b/src/routes/_auth/evaluator.tsx index 5fa8de3..9feb2c8 100644 --- a/src/routes/_auth/evaluator.tsx +++ b/src/routes/_auth/evaluator.tsx @@ -1,18 +1,33 @@ import { ApplicantList } from "@/components/features/evaluator/applicant-list"; import { ApplicantResponse } from "@/components/features/evaluator/applicant-response"; import { ApplicantScoring } from "@/components/features/evaluator/applicant-scoring"; +import { SettingsDialog } from "@/components/features/evaluator/settings-dialog"; import { PageHeader } from "@/components/graphy/typo"; import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { subscribeToHackathons } from "@/lib/firebase/firestore"; +import type { Hackathon } from "@/lib/firebase/types"; import EvaluatorProvider, { useEvaluator } from "@/providers/evaluator-provider"; import { createFileRoute } from "@tanstack/react-router"; -import { Info } from "lucide-react"; +import { Info, Settings } from "lucide-react"; +import { useEffect, useState } from "react"; export const Route = createFileRoute("/_auth/evaluator")({ component: Evaluator, }); function Evaluator() { + const [open, setOpen] = useState(false); + const [hackathonIds, setHackathonIds] = useState([]); + + useEffect(() => { + const unsubHackathons = subscribeToHackathons((hackathons: Hackathon[]) => { + setHackathonIds(hackathons?.map((h) => h._id)); + }); + return () => unsubHackathons(); + }, []); + return (
@@ -22,6 +37,12 @@ function Evaluator() { Evaluator +
+ +
@@ -29,6 +50,7 @@ function Evaluator() {
+ setOpen(false)} hackathonIds={hackathonIds} />
); } @@ -45,7 +67,7 @@ const ActiveHackathonBadge = () => { - Active hackathon is from the InternalWebsites collection + Changeable in settings ) ); diff --git a/src/services/evaluator.ts b/src/services/evaluator.ts index 62db871..cf0765e 100644 --- a/src/services/evaluator.ts +++ b/src/services/evaluator.ts @@ -29,6 +29,18 @@ export const getAdminFlags = async () => { } }; +export const setAdminFlags = async (flags: Partial) => { + try { + const adminRef = doc(db, "InternalWebsites", "CMS"); + await setDoc(adminRef, flags, { merge: true }); + const adminSnap = await getDoc(adminRef); + if (!adminSnap.exists()) throw new Error("CMS in InternalWebsites doesn't exist"); + return adminSnap.data() as unknown as InternalWebsitesCMS; + } catch (error) { + console.log(error); + } +} + /** * Utility function that returns Applicants collection group realtime data * @param hackathon - The hackathon collection of the applicants to query From 21972a464327cd605ceb760b9a90307cd6d4c866 Mon Sep 17 00:00:00 2001 From: naijwu Date: Mon, 16 Feb 2026 15:47:01 -0800 Subject: [PATCH 2/2] add ability to disable scoring criteria, enable weight to be changed --- .../features/evaluator/applicant-scoring.tsx | 19 +-- .../features/evaluator/settings-dialog.tsx | 110 ++++++++++++++++-- src/lib/firebase/types.ts | 1 + src/services/evaluator.ts | 96 ++++++++++++--- 4 files changed, 195 insertions(+), 31 deletions(-) diff --git a/src/components/features/evaluator/applicant-scoring.tsx b/src/components/features/evaluator/applicant-scoring.tsx index 880d432..42604f3 100644 --- a/src/components/features/evaluator/applicant-scoring.tsx +++ b/src/components/features/evaluator/applicant-scoring.tsx @@ -141,14 +141,17 @@ export function ApplicantScoring() { Scoring - {scoringCriteria?.map((criteria) => ( - - ))} + {scoringCriteria?.map( + (criteria) => + !criteria.isDisabled && ( + + ), + )}