From 5750cd9e21a86fefdcb85bd86c6ecb463a1c79b6 Mon Sep 17 00:00:00 2001 From: aamoghS Date: Tue, 25 Aug 2026 21:45:52 +0000 Subject: [PATCH 1/8] Update the public team page to the 2026-2027 executive board Replace last year's officers with the Fall 2026 kickoff roster. Aamogh Sawant is President with a personal-site link; Diya Kaimal is Vice President and Co-Director of Hacklytics. Keep IDEaS and the club advisor. Co-authored-by: Aamogh --- sites/mainweb/app/team/page.tsx | 136 ++++++++------------ sites/mainweb/components/TeamCard/index.tsx | 56 ++++++-- 2 files changed, 96 insertions(+), 96 deletions(-) diff --git a/sites/mainweb/app/team/page.tsx b/sites/mainweb/app/team/page.tsx index d81beb16..7e9c9c2e 100644 --- a/sites/mainweb/app/team/page.tsx +++ b/sites/mainweb/app/team/page.tsx @@ -6,19 +6,9 @@ import Navbar from "@/components/Navbar"; import Section from "@/components/Section"; import TeamCard from "@/components/TeamCard"; -// Asset imports -import President from "@/assets/images/2025/aditi.jpg"; -import ViceP from "@/assets/images/2025/nitika.jpg"; -import Logistics1 from "@/assets/images/2025/alysha.jpg"; -import Logistics2 from "@/assets/images/2025/diya.jpeg"; -import Events from "@/assets/images/2025/aryan.jpeg"; -import Marketing from "@/assets/images/2025/smera.png"; -import Tech from "@/assets/images/2025/aamogh.png"; -import Content1 from "@/assets/images/2025/anushka.jpg"; -import Content2 from "@/assets/images/2025/glenne.png"; - -import External2 from "@/assets/images/2025/vidhi.jpeg"; -import Project from "@/assets/images/2025/anika.jpg"; +// Asset imports — reuse in-repo photos only +import Aamogh from "@/assets/images/2025/aamogh.png"; +import Diya from "@/assets/images/2025/diya.jpeg"; import Advisor from "@/assets/images/2025/jake.png"; import IDEaS from "@/assets/images/2025/ideas.png"; @@ -50,7 +40,7 @@ const Team = () => { {/* Simplified Header Section */}
- Executive Board 2025-2026 + Executive Board 2026-2027

Meet the{" "} @@ -79,109 +69,87 @@ const Team = () => { - Aditi oversees all operations of DSGT, leading the executive board - and coordinating with faculty and industry partners to shape the - future of data science at GT. + Aamogh oversees DSGT operations, leading the executive board and + coordinating with faculty and industry partners to shape data + science at Georgia Tech. - Nikita leads Hacklytics, DSGT's flagship - datathon. She manages corporate sponsorships, event organization, - and networking initiatives. + Diya serves as Vice President and Co-Director of{" "} + Hacklytics, DSGT's flagship datathon, managing + event organization, corporate sponsorships, and member-facing + programming. - - Alysha coordinates logistics for club events and Hacklytics, - managing smooth operations and collaborating with external - sponsors. + + Nitya directs social media, graphic design, and outreach + strategies to increase engagement with DSGT both on and off + campus. - - Diya focuses on event management, Hacklytics logistic - coordination, and logistics operations, ensuring a seamless - experience for members and corporate partners. + + Samantha oversees project logistics, managing the project portal + and setting up research opportunities with professors and + industry pros. - Aamogh leads the Tech Team, managing frontend and backend systems - for DSGT's digital infrastructure, including the Membership - Portal. + Aishi develops strategic partnerships and manages sponsor + communications to support club initiatives and industry + collaboration. - Smera directs social media, graphic design, and outreach - strategies to increase engagement with DSGT both on and off - campus. + Vishal manages sponsor communications and builds industry + partnerships that support DSGT initiatives. - - Aryan manages room bookings, catering, and volunteer coordination, - ensuring technical workshops and socials execute perfectly. + + Minjee coordinates logistics for club events and Hacklytics, + managing smooth operations and collaborating with external + sponsors. - Vidhi develops strategic partnerships and manages sponsor - communications to support club initiatives and industry - collaboration. + Francisco manages event logistics so club programs and Hacklytics + run on schedule. - - Anushka leads the Content team in managing Bootcamp and Udemy + + Yashika manages room bookings, catering, and volunteer + coordination, ensuring technical workshops and socials execute + perfectly. + + + + Sahith leads the Content team in managing Bootcamp and Udemy courses, helping members build professional ML projects. - - Glenne oversees Bootcamp workshops, ensuring members learn core + + Victor oversees Bootcamp workshops, ensuring members learn core data science skills and complete polished, industry-ready projects. - - Anika oversees project logistics, managing the project portal and - setting up research opportunities with professors and industry - pros. - - Jacob is an Assistant Professor in the School of Computer Science and serves as a faculty advisor to DS@GT. His research focuses on diff --git a/sites/mainweb/components/TeamCard/index.tsx b/sites/mainweb/components/TeamCard/index.tsx index 268d560e..c6756bcd 100644 --- a/sites/mainweb/components/TeamCard/index.tsx +++ b/sites/mainweb/components/TeamCard/index.tsx @@ -5,17 +5,29 @@ import type { StaticImageData } from "next/image"; import Image from "next/image"; interface TeamCardProps extends HTMLAttributes { - img: string | StaticImageData; + img?: string | StaticImageData; name: string; title: string; + href?: string; zoom?: boolean; children?: ReactNode; } +function initialsFor(name: string) { + return name + .split(/\s+/) + .filter((part) => /^[A-Za-z]/.test(part)) + .slice(0, 2) + .map((part) => part[0]) + .join("") + .toUpperCase(); +} + export default function TeamCard({ img, name, title, + href, zoom, children, ...rest @@ -36,14 +48,23 @@ export default function TeamCard({ height: 140, }} > - {name} + {img ? ( + {name} + ) : ( + + )} {/* Updated overlay glow to #00A8A8 */}
@@ -51,15 +72,26 @@ export default function TeamCard({ {/* Content */}
{/* Title Tag - Updated to #00A8A8 */} -
-

+
+

{title}

{/* Name - High contrast white */}

- {name} + {href ? ( + + {name} + + ) : ( + name + )}

{/* Description */} From 4f26501d030077fa2417f7555e1be317f322557b Mon Sep 17 00:00:00 2001 From: aamoghS Date: Thu, 27 Aug 2026 18:50:27 +0000 Subject: [PATCH 2/8] hotfix Co-authored-by: Aamogh --- docs/sites/mainweb.md | 6 +- .../.internal-tests/hackathon-flow.test.ts | 16 + packages/api/src/routers/hackathon/crud.ts | 53 ++- .../(portal)/admin/hackathons/[id]/page.tsx | 6 +- .../mainweb/app/(portal)/admin/setup/page.tsx | 377 +----------------- .../(portal)/hackathons/[id]/judge/page.tsx | 6 +- .../app/(portal)/hackathons/[id]/page.tsx | 6 +- .../admin/hackathons/HackathonCard.tsx | 9 +- .../admin/setup/CreateHackathonStep.tsx | 190 --------- .../components/admin/setup/SetupWizard.tsx | 47 --- .../components/portal/PortalSidebar.tsx | 259 ++---------- sites/mainweb/lib/hackathon-slug.test.ts | 58 ++- sites/mainweb/lib/hackathon-slug.ts | 33 ++ sites/mainweb/lib/portal-nav.test.ts | 145 +++++++ sites/mainweb/lib/portal-nav.ts | 150 +++++++ 15 files changed, 508 insertions(+), 853 deletions(-) delete mode 100644 sites/mainweb/components/admin/setup/CreateHackathonStep.tsx delete mode 100644 sites/mainweb/components/admin/setup/SetupWizard.tsx create mode 100644 sites/mainweb/lib/portal-nav.test.ts create mode 100644 sites/mainweb/lib/portal-nav.ts diff --git a/docs/sites/mainweb.md b/docs/sites/mainweb.md index 636ca0ee..55575b3d 100644 --- a/docs/sites/mainweb.md +++ b/docs/sites/mainweb.md @@ -45,17 +45,17 @@ Unauthenticated and authenticated product UI. `proxy.ts` marks these prefixes `p | `/judge`, `/judge/register` | Judge home / apply | | `/scan` | QR scanning | | `/admin` | Staff home | -| `/admin/hackathons`, `/admin/hackathons/[id]` | Edition admin (attendees, waves, announcements, analytics, events) | +| `/admin/hackathons`, `/admin/hackathons/[id]` | Edition admin (attendees, waves, announcements, analytics, events). `[id]` is the name slug (`hacklytics-digital-bloom`), not a percent-encoded name. | | `/admin/members` | Membership admin | | `/admin/attendees` | Attendee tools | -| `/admin/judging` | Judging admin | +| `/admin/judging` | Judging admin (sync submissions + assign judges live here) | | `/admin/initiatives` | Initiative / proposal review | | `/admin/bootcamp` | Bootcamp attendance | | `/admin/staff` | Admin users | | `/admin/analytics` | Overview | | `/admin/audit` | Audit log | | `/admin/projects` | Project admin | -| `/admin/setup` | First-run wizard | +| `/admin/setup` | Redirects to `/admin/judging`. Do not create a second hackathon from judging — create it under Hackathons. | ## API routes diff --git a/packages/api/src/.internal-tests/hackathon-flow.test.ts b/packages/api/src/.internal-tests/hackathon-flow.test.ts index 7c5b51d8..a20a5991 100644 --- a/packages/api/src/.internal-tests/hackathon-flow.test.ts +++ b/packages/api/src/.internal-tests/hackathon-flow.test.ts @@ -1061,6 +1061,22 @@ describe("Hackathon end-to-end flow", () => { ); }); + it("opens an edition from a percent-encoded name (old admin dashboard links)", async () => { + // `/admin/hackathons/${encodeURIComponent(name)}` used to put + // "Hacklytics%3A%20Digital%20Bloom" in the path. That string is not the + // stored name, and slugging it produces "hacklytics-3a-20digital-20bloom". + mockFindFirst.mockImplementation(() => undefined); + mockFindMany.mockImplementation((table) => + table === "hackathons" ? [bloom()] : [], + ); + const caller = appRouter.createCaller(createMockCtx("user_a")); + + const res = await caller.hackathon.getById({ + id: encodeURIComponent("Hacklytics: Digital Bloom"), + }); + expect(res.id).toBe(HACK_A); + }); + it("404s on a slug matching no edition", async () => { mockFindFirst.mockImplementation(() => undefined); mockFindMany.mockImplementation((table) => diff --git a/packages/api/src/routers/hackathon/crud.ts b/packages/api/src/routers/hackathon/crud.ts index cde5283d..b4c9ac48 100644 --- a/packages/api/src/routers/hackathon/crud.ts +++ b/packages/api/src/routers/hackathon/crud.ts @@ -30,19 +30,50 @@ const toSlug = (value: string) => .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, ""); -// Exact name first, then the slug the portal links with. The slug pass reads -// every edition — there are a handful, and no index covers the normalisation. +/** Must match `decodeHackathonParam` in sites/mainweb/lib/hackathon-slug.ts. */ +function decodeHackathonParam(value: string) { + let current = value; + for (let i = 0; i < 2; i++) { + try { + const next = decodeURIComponent(current); + if (next === current) break; + current = next; + } catch { + break; + } + } + return current; +} + +function uniqueStrings(values: string[]) { + const seen = new Set(); + const out: string[] = []; + for (const value of values) { + if (!value || seen.has(value)) continue; + seen.add(value); + out.push(value); + } + return out; +} + +// Exact name first (including a percent-encoded name from old admin links), +// then the slug the portal links with. The slug pass reads every edition — +// there are a handful, and no index covers the normalisation. async function findByNameOrSlug(db: DrizzleDB, value: string) { - const exact = await db.query.hackathons.findFirst({ - where: eq(hackathons.name, value), - }); - if (exact) return exact; + const candidates = uniqueStrings([value, decodeHackathonParam(value)]); + + for (const candidate of candidates) { + const exact = await db.query.hackathons.findFirst({ + where: eq(hackathons.name, candidate), + }); + if (exact) return exact; + } - const slug = toSlug(value); - if (!slug) return undefined; + const slugs = uniqueStrings(candidates.map(toSlug)); + if (slugs.length === 0) return undefined; const all = await db.query.hackathons.findMany(); - return all.find((row) => toSlug(row.name) === slug); + return all.find((row) => slugs.includes(toSlug(row.name))); } export const hackathonCrudRouter = createTRPCRouter({ @@ -99,8 +130,8 @@ export const hackathonCrudRouter = createTRPCRouter({ ).query.hackathons.findMany({ where: and( // Hidden means hidden from the public funnel, not from the people running the - // event. Filtering it for staff too emptied Judging Setup and the judging - // dashboard, which both pick an edition off here. + // event. Filtering it for staff too emptied the judging dashboard, which + // picks an edition off here. adminViewer ? undefined : eq(hackathons.isPublic, true), input.status ? eq(hackathons.status, input.status) : undefined, adminViewer diff --git a/sites/mainweb/app/(portal)/admin/hackathons/[id]/page.tsx b/sites/mainweb/app/(portal)/admin/hackathons/[id]/page.tsx index 4d7bdc77..a0ddfa8c 100644 --- a/sites/mainweb/app/(portal)/admin/hackathons/[id]/page.tsx +++ b/sites/mainweb/app/(portal)/admin/hackathons/[id]/page.tsx @@ -6,6 +6,7 @@ import { usePortalContext } from "@/lib/use-portal-context"; import { useParams } from "next/navigation"; import { useState } from "react"; import Link from "next/link"; +import { decodeHackathonParam } from "@/lib/hackathon-slug"; import { LoadingScreen } from "@/components/portal/LoadingScreen"; import { ScannerTab } from "@/components/admin/hackathons/ScannerTab"; import { AttendeesTab } from "@/components/admin/hackathons/AttendeesTab"; @@ -28,7 +29,10 @@ type Tab = export default function AdminHackathonDashboard() { const { status } = useSession(); const params = useParams(); - const hackathonId = params?.id as string; + const rawId = params?.id; + const hackathonId = decodeHackathonParam( + Array.isArray(rawId) ? (rawId[0] ?? "") : ((rawId as string | undefined) ?? ""), + ); const [activeTab, setActiveTab] = useState("attendees"); const { data: portalContext, isLoading: portalLoading } = usePortalContext(); diff --git a/sites/mainweb/app/(portal)/admin/setup/page.tsx b/sites/mainweb/app/(portal)/admin/setup/page.tsx index 2697713b..4d25216f 100644 --- a/sites/mainweb/app/(portal)/admin/setup/page.tsx +++ b/sites/mainweb/app/(portal)/admin/setup/page.tsx @@ -1,370 +1,9 @@ -"use client"; - -import React, { useState, useEffect } from "react"; -import { Zap } from "lucide-react"; -import { useSession } from "next-auth/react"; -import { trpc } from "@/lib/trpc"; -import { trpcErrorMessage } from "@/lib/trpc-error"; -import { usePortalContext } from "@/lib/use-portal-context"; -import { useRouter } from "next/navigation"; -import { LiquidGlass } from "@/components/portal/LiquidGlass"; -import { SetupWizard } from "@/components/admin/setup/SetupWizard"; -import { CreateHackathonStep } from "@/components/admin/setup/CreateHackathonStep"; - -export default function AdminSetupPage() { - const { data: session } = useSession(); - const router = useRouter(); - const [mounted, setMounted] = useState(false); - - // Step state - const [activeStep, setActiveStep] = useState(1); - const [hackathonName, setHackathonName] = useState(""); - const [hackathonTracks, setHackathonTracks] = useState(""); - const [hackathonChallenges, setHackathonChallenges] = useState(""); - // Real dates. These used to be `now` and `now + 24h`, and the submission - // window is derived from the hacking start time — so every deadline the - // product enforces was anchored to whenever the button happened to be pressed. - const [startDate, setStartDate] = useState(""); - const [endDate, setEndDate] = useState(""); - const [hackingStartTime, setHackingStartTime] = useState(""); - const [selectedHackathonId, setSelectedHackathonId] = useState( - null, - ); - - // Status tracking - const [projectsSynced, setProjectsSynced] = useState(false); - const [judgesAssigned, setJudgesAssigned] = useState(false); - - // Admin check - const { data: portalContext } = usePortalContext(); - - const { data: hackathons, refetch: refetchHackathons } = - trpc.hackathon.list.useQuery( - {}, - { - enabled: !!session && !!portalContext?.isAdmin, - }, - ); - - // Mutations - const createHackathon = trpc.hackathon.create.useMutation({ - onSuccess: (data) => { - setSelectedHackathonId(data?.id ?? null); - setActiveStep(2); - refetchHackathons(); - }, - }); - - const promoteSubmissions = trpc.judge.promoteSubmissions.useMutation({ - onSuccess: (data) => { - // Nothing to judge means the step is not done, however cleanly the - // request succeeded — moving on would hand the assigner an empty list. - if (data.total === 0) return; - setProjectsSynced(true); - setActiveStep(3); - }, - }); - - const assignJudges = trpc.judge.assignJudgesToProjects.useMutation({ - onSuccess: () => { - setJudgesAssigned(true); - }, - }); - - useEffect(() => setMounted(true), []); - - if (!mounted) return null; - - const steps = [ - { num: 1, label: "Create Hackathon", done: !!selectedHackathonId }, - { num: 2, label: "Sync Submissions", done: projectsSynced }, - { num: 3, label: "Assign Judges", done: judgesAssigned }, - ]; - - return ( - <> -
-
-

- Hackathon Hub -

-

- Judging Setup -

-

- Everything comes from the portal — nothing to upload -

-
- - {/* Progress Steps */} - - - {/* Step 1: Create Hackathon */} - {activeStep === 1 && ( - { - if (!hackathonName.trim()) return; - - // Parse tracks and challenges - const parsedTracks = hackathonTracks - .split(",") - .map((s) => s.trim()) - .filter(Boolean); - const parsedChallenges = hackathonChallenges - .split(",") - .map((s) => s.trim()) - .filter(Boolean); - - if (!startDate || !endDate) return; - - createHackathon.mutate({ - name: hackathonName.trim(), - startDate: new Date(startDate), - endDate: new Date(endDate), - hackingStartTime: hackingStartTime - ? new Date(hackingStartTime) - : undefined, - tracks: parsedTracks.length > 0 ? parsedTracks : undefined, - challenges: - parsedChallenges.length > 0 ? parsedChallenges : undefined, - }); - }} - /> - )} - - {/* Step 2: Sync submitted projects into judging */} - {activeStep === 2 && ( - -

- Sync Submitted Projects -

-

- Every submitted project becomes a judgeable entry with its own - table number, carrying the tracks and challenges the team picked. - Safe to run again as late submissions land — projects already - synced are left alone. -

- - - - {promoteSubmissions.error && ( -
-

- {trpcErrorMessage( - promoteSubmissions.error, - "Could not sync submissions.", - )} -

-
- )} - - {promoteSubmissions.data && - (promoteSubmissions.data.total === 0 ? ( -
-

- No submitted projects yet. Teams submit from /submit — come - back once the deadline has passed. -

-
- ) : ( -
-
-

- {promoteSubmissions.data.created} newly synced |{" "} - {promoteSubmissions.data.alreadyPresent} already in - judging | {promoteSubmissions.data.total} total -

-
- {/* Queues are a snapshot, so promotion adds late projects to - the queues that already exist — appending, because a - rebuild reorders every queue mid-judging. */} - {promoteSubmissions.data.queueRowsAdded > 0 && ( -
-

- Added to existing judge queues in{" "} - {promoteSubmissions.data.queueRowsAdded} slot(s). No - re-assign needed — nobody's current queue was reordered. -

-
- )} - {promoteSubmissions.data.queuesNeedRebuild && ( -
-

- The newly synced projects reached no judge — their - track is one no active judge covers. Set a judge to that - track, or re-run Assign Judges. -

-
- )} -
- ))} -
- )} - - {/* Step 3: Auto-Assign Judges */} - {activeStep === 3 && ( - -

- Auto-Assign Judges to Projects -

-

- Main track judges get 3-9 projects. Special label judges get all - matching projects (randomized). -

- -
-

- Judges sign themselves up at{" "} - /judge/register. Approve - their applications under the Judges tab of this hackathon before - assigning — only approved judges get a queue. -

-
- - - - {assignJudges.error && ( -
-

- {trpcErrorMessage( - assignJudges.error, - "Could not assign judges.", - )} -

- {/* The server refuses a rebuild that would disturb live or - completed judging, and reports how much is at stake. Only - once the admin has read that number is overriding offered — - they cannot know the count without the round trip. */} - {assignJudges.error.data?.code === "CONFLICT" && ( - - )} -
- )} - - {assignJudges.data && ( -
-
-

- Assigned {assignJudges.data.totalJudges} judges -

-
-
- - - - - - - - - - {assignJudges.data.assignments.map((a, i) => ( - - - - - - ))} - -
JudgeTrack - Projects -
- {a.judgeName || "Unknown"} - - {a.track || "General"} - - {a.assignedCount} -
-
-
- )} - - {judgesAssigned && ( -
-

- Setup complete - all judges assigned. -

- -
- )} -
- )} -
- - ); +import { redirect } from "next/navigation"; + +/** + * Judging Setup used to create a second hackathon record. Editions already + * exist on the hackathon side of the portal; queue prep lives on /admin/judging. + */ +export default function AdminSetupRedirect() { + redirect("/admin/judging"); } diff --git a/sites/mainweb/app/(portal)/hackathons/[id]/judge/page.tsx b/sites/mainweb/app/(portal)/hackathons/[id]/judge/page.tsx index d6243041..73617ba5 100644 --- a/sites/mainweb/app/(portal)/hackathons/[id]/judge/page.tsx +++ b/sites/mainweb/app/(portal)/hackathons/[id]/judge/page.tsx @@ -8,6 +8,7 @@ import { trpc } from "@/lib/trpc"; import { LiquidGlass } from "@/components/portal/LiquidGlass"; import { LoadingScreen } from "@/components/portal/LoadingScreen"; import { QRScannerModal } from "@/components/portal/QRScannerModal"; +import { decodeHackathonParam } from "@/lib/hackathon-slug"; type Project = { id: string; @@ -49,7 +50,10 @@ export default function JudgeHackathonPage() { * procedures take a uuid, so a by-name URL would fail input validation. * Resolve it first when it is not already an id. */ - const routeParam = params.id as string; + const rawId = params.id; + const routeParam = decodeHackathonParam( + Array.isArray(rawId) ? (rawId[0] ?? "") : ((rawId as string | undefined) ?? ""), + ); const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( routeParam, diff --git a/sites/mainweb/app/(portal)/hackathons/[id]/page.tsx b/sites/mainweb/app/(portal)/hackathons/[id]/page.tsx index 439e9fff..b8f2d6a0 100644 --- a/sites/mainweb/app/(portal)/hackathons/[id]/page.tsx +++ b/sites/mainweb/app/(portal)/hackathons/[id]/page.tsx @@ -15,6 +15,7 @@ import { ProjectsTab } from "@/components/hackathon/ProjectsTab"; import { ResultsTab } from "@/components/hackathon/ResultsTab"; import { TeamsTab } from "@/components/hackathon/TeamsTab"; import { HackathonUnavailable } from "@/components/hackathon/HackathonUnavailable"; +import { decodeHackathonParam } from "@/lib/hackathon-slug"; function formatDate(d: Date | string) { return new Date(d).toLocaleDateString("en-US", { @@ -124,7 +125,10 @@ export default function HackathonDetailPage() { const router = useRouter(); const params = useParams(); const searchParams = useSearchParams(); - const hackathonId = params.id as string; + const rawId = params.id; + const hackathonId = decodeHackathonParam( + Array.isArray(rawId) ? (rawId[0] ?? "") : ((rawId as string | undefined) ?? ""), + ); const tabParam = searchParams.get("tab") as TabType | null; const [tab, setTab] = useState( diff --git a/sites/mainweb/components/admin/hackathons/HackathonCard.tsx b/sites/mainweb/components/admin/hackathons/HackathonCard.tsx index 8c74e81d..32e784a6 100644 --- a/sites/mainweb/components/admin/hackathons/HackathonCard.tsx +++ b/sites/mainweb/components/admin/hackathons/HackathonCard.tsx @@ -4,6 +4,7 @@ import React from "react"; import Link from "next/link"; import { trpc } from "@/lib/trpc"; import { LiquidGlass } from "@/components/portal/LiquidGlass"; +import { adminHackathonPath } from "@/lib/hackathon-slug"; import type { HackathonStatus } from "@/components/admin/hackathons/constants"; export function HackathonCard({ @@ -54,7 +55,7 @@ export function HackathonCard({

{hackathon.name} @@ -134,7 +135,7 @@ export function HackathonCard({

{events && events.length > 0 && ( Manage Events ({events.length}) → @@ -263,7 +264,7 @@ export function HackathonCard({ {events.length > 4 && (
+ {events.length - 4} more scheduled events (Click to view @@ -343,7 +344,7 @@ export function HackathonCard({ Dashboard diff --git a/sites/mainweb/components/admin/setup/CreateHackathonStep.tsx b/sites/mainweb/components/admin/setup/CreateHackathonStep.tsx deleted file mode 100644 index 35892634..00000000 --- a/sites/mainweb/components/admin/setup/CreateHackathonStep.tsx +++ /dev/null @@ -1,190 +0,0 @@ -"use client"; - -import React from "react"; -import { LiquidGlass } from "@/components/portal/LiquidGlass"; - -type Hackathon = { - id: string; - name: string; -}; - -type CreateHackathonStepProps = { - hackathons: Hackathon[]; - selectedHackathonId: string | null; - setSelectedHackathonId: (id: string | null) => void; - setActiveStep: (step: number) => void; - hackathonName: string; - setHackathonName: (name: string) => void; - hackathonTracks: string; - setHackathonTracks: (tracks: string) => void; - hackathonChallenges: string; - setHackathonChallenges: (challenges: string) => void; - /** `datetime-local` strings. The submission window is derived from the - * hacking start time, so a wrong value here shifts every deadline. */ - startDate: string; - setStartDate: (value: string) => void; - endDate: string; - setEndDate: (value: string) => void; - hackingStartTime: string; - setHackingStartTime: (value: string) => void; - createHackathonPending: boolean; - /** Server-side refusal, e.g. the CONFLICT raised when a hackathon with this - * name already exists. Without somewhere to render it the button simply - * does nothing. */ - createHackathonError?: string | null; - onCreateHackathon: () => void; -}; - -export function CreateHackathonStep({ - hackathons, - selectedHackathonId, - setSelectedHackathonId, - setActiveStep, - hackathonName, - setHackathonName, - hackathonTracks, - setHackathonTracks, - hackathonChallenges, - setHackathonChallenges, - startDate, - setStartDate, - endDate, - setEndDate, - hackingStartTime, - setHackingStartTime, - createHackathonPending, - createHackathonError, - onCreateHackathon, -}: CreateHackathonStepProps) { - return ( - -

- Create Hackathon -

- - {/* Existing hackathons */} - {hackathons && hackathons.length > 0 && ( -
-

- Or select an existing event: -

-
- {hackathons.map((h) => ( - - ))} -
-
-
- - or create new - -
-
-
- )} - -
- setHackathonName(e.target.value)} - className="w-full px-5 py-4 bg-[var(--bg-primary)]/40 border border-[var(--border-subtle)] rounded-none text-[var(--text-primary)] font-mono placeholder:text-gray-600 focus:outline-none focus:border-accent/40 transition-colors" - /> - setHackathonTracks(e.target.value)} - className="w-full px-5 py-4 bg-[var(--bg-primary)]/40 border border-[var(--border-subtle)] rounded-none text-[var(--text-primary)] font-mono placeholder:text-gray-600 focus:outline-none focus:border-accent/40 transition-colors" - /> - setHackathonChallenges(e.target.value)} - className="w-full px-5 py-4 bg-[var(--bg-primary)]/40 border border-[var(--border-subtle)] rounded-none text-[var(--text-primary)] font-mono placeholder:text-gray-600 focus:outline-none focus:border-accent/40 transition-colors" - /> - {/* Real dates, not "now" and "now + 24h". - createHackathon used to stamp both from the moment the button was - pressed, and the whole submission window is derived from the hacking - start time — so every deadline the product enforces was anchored to - an accident. */} -
- - - -
- - {createHackathonError && ( -

- {createHackathonError} -

- )} -
- - ); -} diff --git a/sites/mainweb/components/admin/setup/SetupWizard.tsx b/sites/mainweb/components/admin/setup/SetupWizard.tsx deleted file mode 100644 index 07eb5dc4..00000000 --- a/sites/mainweb/components/admin/setup/SetupWizard.tsx +++ /dev/null @@ -1,47 +0,0 @@ -"use client"; - -import React from "react"; - -type StepProps = { - activeStep: number; - setActiveStep: (step: number) => void; - steps: { num: number; label: string; done: boolean }[]; -}; - -export function SetupWizard({ activeStep, setActiveStep, steps }: StepProps) { - return ( -
- {steps.map((s, i) => ( - - - {i < steps.length - 1 && ( -
- )} - - ))} -
- ); -} diff --git a/sites/mainweb/components/portal/PortalSidebar.tsx b/sites/mainweb/components/portal/PortalSidebar.tsx index d9355497..ee1ddd78 100644 --- a/sites/mainweb/components/portal/PortalSidebar.tsx +++ b/sites/mainweb/components/portal/PortalSidebar.tsx @@ -6,32 +6,17 @@ import Image from "next/image"; import { usePathname } from "next/navigation"; import { useSession, signOut } from "next-auth/react"; import { - LayoutDashboard, - Code, - ClipboardList, - Users, - BarChart3, LogOut, Menu, - QrCode, - Zap, X, Sun, Moon, Home, - ShieldAlert, - UserCircle, - Rocket, - Upload, - FolderGit2, - CreditCard, - ShieldCheck, - ScrollText, - BookOpen, - GraduationCap, + Zap, } from "lucide-react"; import { useTheme } from "next-themes"; import { usePortalContext } from "@/lib/use-portal-context"; +import { isPortalNavActive, portalNavSections } from "@/lib/portal-nav"; import logo from "../../assets/images/dsgt/apple-touch-icon.png"; interface PortalSidebarProps { @@ -50,6 +35,7 @@ export default function PortalSidebar({ const [mounted, setMounted] = useState(false); const { data: portalContext } = usePortalContext(); + const sections = portalNavSections(portalContext); useEffect(() => { setMounted(true); @@ -66,106 +52,6 @@ export default function PortalSidebar({ if (pathname === "/login" || pathname === "/verify") return null; - const mainRoutes = [ - { - name: "Dashboard", - href: "/dashboard", - icon: Home, - show: !portalContext?.isAdmin, - }, - { - name: "Hackathons", - href: "/hackathons", - icon: Zap, - show: !portalContext?.isAdmin, - }, - { - name: "Check-In Desk", - href: "/scan", - icon: QrCode, - // Volunteers hold an admins row but isAdmin rejects them, so the admin - // nav below is invisible to them — this is their only entry point. - show: portalContext?.isScanner && !portalContext?.isAdmin, - }, - { - name: "Submit Project", - href: "/submit", - icon: Upload, - // The only route to team.submitProject, and nothing else in the product - // linked to it — so no project could be submitted, and with submissions - // now feeding judging, nothing could be judged either. - show: !portalContext?.isAdmin, - }, - { - name: "Club Portal", - href: "/club", - icon: QrCode, - show: portalContext?.member.isMember && !portalContext?.isAdmin, - }, - { - name: "Bootcamp", - // Not /bootcamp — the public curriculum page owns that path. Shown to - // everyone, like Initiatives, so the add-on can be seen before paying. - href: "/club/bootcamp", - icon: GraduationCap, - show: !portalContext?.isAdmin, - }, - { - name: "Judge Portal", - href: "/judge", - icon: ClipboardList, - // Admins get this in the admin nav instead, so their sidebar stays - // admin-only. - show: portalContext?.isJudge && !portalContext?.isAdmin, - }, - { - name: "Initiatives", - href: "/initiatives", - icon: Rocket, - // Somebody deciding whether to pay should be able to see what membership - // gets them. Applying is where the membership check bites, not browsing. - show: !portalContext?.isAdmin, - }, - { - name: "My Initiatives", - href: "/lead", - icon: Rocket, - show: portalContext?.isProjectLeader && !portalContext?.isAdmin, - }, - { - name: "Settings", - href: "/settings", - icon: UserCircle, - show: true, - }, - ].filter((r) => r.show); - - const adminRoutes = [ - { name: "Club Hub", href: "/admin", icon: LayoutDashboard }, - { name: "Hackathons", href: "/admin/hackathons", icon: Code }, - { name: "Judging", href: "/admin/judging", icon: ClipboardList }, - // Judge import and queue assignment live only here. Without this entry the - // page is reachable by typed URL alone, which means judging never starts. - { name: "Judging Setup", href: "/admin/setup", icon: Upload }, - { name: "Projects", href: "/admin/projects", icon: FolderGit2 }, - { name: "Initiatives", href: "/admin/initiatives", icon: Rocket }, - // /lead is the only screen that decides an initiative application, and - // isProjectLeader admits admins so an absent leader cannot strand theirs. - { name: "Initiative Applications", href: "/lead", icon: Rocket }, - { name: "Bootcamp", href: "/admin/bootcamp", icon: GraduationCap }, - { name: "Attendees", href: "/admin/attendees", icon: Users }, - { name: "Memberships", href: "/admin/members", icon: CreditCard }, - // Granting the volunteer tier is otherwise an INSERT against production, - // and /scan's rejection screen tells people to ask an organiser for it. - { name: "Staff & Roles", href: "/admin/staff", icon: ShieldCheck }, - { name: "Analytics", href: "/admin/analytics", icon: BarChart3 }, - // Retention prunes routine entries at 90 days, so an unreadable log is an - // expiring one. - { name: "Audit Log", href: "/admin/audit", icon: ScrollText }, - // Shipped in every build and previously reachable only by typing the URL. - { name: "Docs", href: "/docs", icon: BookOpen }, - ]; - return ( <> {/* Mobile Top Bar */} @@ -205,52 +91,20 @@ export default function PortalSidebar({
- {mainRoutes.length > 0 && ( -
-

- - Main Navigation -

-
- {mainRoutes.map((route) => { - const isActive = - pathname === route.href || - (route.href !== "/dashboard" && - pathname.startsWith(route.href + "/")); - return ( - setIsMobileOpen(false)} - className={`flex items-center justify-center gap-3 py-4 rounded-none transition-ui ${ - isActive - ? "bg-accent/10 text-accent border border-accent/20 font-bold" - : "text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-secondary)]" - }`} - > - - {route.name} - - ); - })} -
-
- )} - - {portalContext?.isAdmin && ( - <> -
-
-

- - Admin Area + {sections.map((section, index) => { + const SectionIcon = section.id === "hackathon" ? Zap : Home; + return ( +
+ {index > 0 && ( +
+ )} +

+ + {section.label}

- {adminRoutes.map((route) => { - const isActive = - pathname === route.href || - (route.href !== "/admin" && - pathname.startsWith(route.href + "/")); + {section.items.map((route) => { + const isActive = isPortalNavActive(pathname, route.href); return (
- - )} + ); + })} {/* Mobile User Section */}
@@ -371,74 +225,29 @@ export default function PortalSidebar({ {/* Navigation */} {/* User section */} diff --git a/sites/mainweb/lib/hackathon-slug.test.ts b/sites/mainweb/lib/hackathon-slug.test.ts index a099326b..e001e19d 100644 --- a/sites/mainweb/lib/hackathon-slug.test.ts +++ b/sites/mainweb/lib/hackathon-slug.test.ts @@ -1,5 +1,10 @@ import { describe, it, expect } from "vitest"; -import { hackathonSlug } from "./hackathon-slug"; +import { + hackathonSlug, + decodeHackathonParam, + hackathonPathSegment, + adminHackathonPath, +} from "./hackathon-slug"; describe("hackathonSlug", () => { it("turns an edition name into a URL segment", () => { @@ -30,3 +35,54 @@ describe("hackathonSlug", () => { expect(hackathonSlug("")).toBe(""); }); }); + +describe("decodeHackathonParam", () => { + it("undoes the percent-encoding admin dashboard links used to put in the path", () => { + expect( + decodeHackathonParam(encodeURIComponent("Hacklytics: Digital Bloom")), + ).toBe("Hacklytics: Digital Bloom"); + }); + + it("undoes a double-encoded name from Next.js Link + encodeURIComponent", () => { + const name = "Hacklytics: Digital Bloom"; + expect(decodeHackathonParam(encodeURIComponent(encodeURIComponent(name)))).toBe( + name, + ); + }); + + it("leaves a slug and a raw name alone", () => { + expect(decodeHackathonParam("hacklytics-digital-bloom")).toBe( + "hacklytics-digital-bloom", + ); + expect(decodeHackathonParam("Hacklytics: Digital Bloom")).toBe( + "Hacklytics: Digital Bloom", + ); + }); + + it("returns the original string when it is not valid percent-encoding", () => { + expect(decodeHackathonParam("%")).toBe("%"); + }); +}); + +describe("hackathonPathSegment", () => { + it("uses the slug for a named edition", () => { + expect( + hackathonPathSegment("Hacklytics: Digital Bloom", "uuid-bloom"), + ).toBe("hacklytics-digital-bloom"); + }); + + it("falls back to the id when the name has nothing sluggable", () => { + expect(hackathonPathSegment("!!!", "uuid-bloom")).toBe("uuid-bloom"); + }); +}); + +describe("adminHackathonPath", () => { + it("builds a slug URL, not an encoded name", () => { + expect(adminHackathonPath("Hacklytics: Digital Bloom", "uuid-bloom")).toBe( + "/admin/hackathons/hacklytics-digital-bloom", + ); + expect(adminHackathonPath("Hacklytics: Digital Bloom", "uuid-bloom")).not.toContain( + "%", + ); + }); +}); diff --git a/sites/mainweb/lib/hackathon-slug.ts b/sites/mainweb/lib/hackathon-slug.ts index 18a8f975..89a91576 100644 --- a/sites/mainweb/lib/hackathon-slug.ts +++ b/sites/mainweb/lib/hackathon-slug.ts @@ -9,3 +9,36 @@ export function hackathonSlug(name: string): string { .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, ""); } + +/** + * Admin dashboard used to put `encodeURIComponent(name)` in the path. A name + * like "Hacklytics: Digital Bloom" became `Hacklytics%3A%20Digital%20Bloom`, + * which matches neither the stored name nor the slug — and that is the + * "Hackathon not found" screen. Undo one or two encode passes so old links + * still resolve. + */ +export function decodeHackathonParam(value: string): string { + let current = value; + for (let i = 0; i < 2; i++) { + try { + const next = decodeURIComponent(current); + if (next === current) break; + current = next; + } catch { + break; + } + } + return current; +} + +/** + * Path segment for a hackathon URL. Prefer the slug; fall back to the id when + * the name has nothing sluggable (`!!!`), so the link still opens something. + */ +export function hackathonPathSegment(name: string, id: string): string { + return hackathonSlug(name) || id; +} + +export function adminHackathonPath(name: string, id: string): string { + return `/admin/hackathons/${hackathonPathSegment(name, id)}`; +} diff --git a/sites/mainweb/lib/portal-nav.test.ts b/sites/mainweb/lib/portal-nav.test.ts new file mode 100644 index 00000000..306b115c --- /dev/null +++ b/sites/mainweb/lib/portal-nav.test.ts @@ -0,0 +1,145 @@ +import { describe, it, expect } from "vitest"; +import type { PortalContext } from "@query/api"; +import { + isPortalNavActive, + portalNavSections, +} from "./portal-nav"; + +const emptyMember = { + isMember: false, + isActive: false, + hasLapsed: false, + expiresAt: null, + daysRemaining: null, + memberType: null, + renewalCount: 0, +} satisfies PortalContext["member"]; + +function ctx(overrides: Partial = {}): PortalContext { + return { + isAdmin: false, + isScanner: false, + role: null, + permissions: [], + isJudge: false, + judgeId: null, + judgeName: null, + isProjectLeader: false, + member: emptyMember, + ...overrides, + }; +} + +function names( + sections: ReturnType, + id: "hackathon" | "portal", +): string[] { + return sections.find((s) => s.id === id)?.items.map((i) => i.name) ?? []; +} + +function hrefs(sections: ReturnType): string[] { + return sections.flatMap((s) => s.items.map((i) => i.href)); +} + +describe("portalNavSections", () => { + it("splits hackathon and portal for a guest, with Dashboard on the hackathon side", () => { + const sections = portalNavSections(ctx()); + expect(sections.map((s) => s.id)).toEqual(["hackathon", "portal"]); + expect(names(sections, "hackathon")).toEqual([ + "Dashboard", + "Hackathons", + "Submit Project", + ]); + expect(names(sections, "portal")).toEqual([ + "Bootcamp", + "Initiatives", + "Settings", + ]); + expect(names(sections, "portal")).not.toContain("Club Portal"); + }); + + it("moves Dashboard and Club Portal onto the portal side for a member", () => { + const sections = portalNavSections( + ctx({ member: { ...emptyMember, isMember: true, isActive: true } }), + ); + expect(names(sections, "hackathon")).toEqual([ + "Hackathons", + "Submit Project", + ]); + expect(names(sections, "hackathon")).not.toContain("Dashboard"); + expect(names(sections, "portal")).toEqual([ + "Dashboard", + "Club Portal", + "Bootcamp", + "Initiatives", + "Settings", + ]); + }); + + it("adds judge and scanner links only on the hackathon side", () => { + const sections = portalNavSections( + ctx({ isJudge: true, isScanner: true }), + ); + expect(names(sections, "hackathon")).toEqual([ + "Dashboard", + "Hackathons", + "Submit Project", + "Judge Portal", + "Check-In Desk", + ]); + expect(names(sections, "portal")).not.toContain("Judge Portal"); + }); + + it("adds My Initiatives on the portal side for a project leader", () => { + const sections = portalNavSections(ctx({ isProjectLeader: true })); + expect(names(sections, "portal")).toContain("My Initiatives"); + expect(names(sections, "hackathon")).not.toContain("My Initiatives"); + }); + + it("splits admin tools the same way and omits Judging Setup", () => { + const sections = portalNavSections(ctx({ isAdmin: true, role: "admin" })); + expect(names(sections, "hackathon")).toEqual([ + "Hackathons", + "Judging", + "Projects", + "Attendees", + ]); + expect(names(sections, "portal")).toEqual([ + "Club Hub", + "Initiatives", + "Initiative Applications", + "Bootcamp", + "Memberships", + "Staff & Roles", + "Analytics", + "Audit Log", + "Docs", + "Settings", + ]); + expect(hrefs(sections)).not.toContain("/admin/setup"); + expect(sections.flatMap((s) => s.items.map((i) => i.name))).not.toContain( + "Judging Setup", + ); + }); +}); + +describe("isPortalNavActive", () => { + it("does not treat /admin as a prefix of every admin page", () => { + expect(isPortalNavActive("/admin", "/admin")).toBe(true); + expect(isPortalNavActive("/admin/hackathons", "/admin")).toBe(false); + expect( + isPortalNavActive("/admin/hackathons/bloom", "/admin/hackathons"), + ).toBe(true); + }); + + it("does not treat /dashboard as a prefix of other routes", () => { + expect(isPortalNavActive("/dashboard", "/dashboard")).toBe(true); + expect(isPortalNavActive("/hackathons", "/dashboard")).toBe(false); + }); + + it("does not treat /club as a prefix of /club/bootcamp", () => { + expect(isPortalNavActive("/club", "/club")).toBe(true); + expect(isPortalNavActive("/club/bootcamp", "/club")).toBe(false); + expect(isPortalNavActive("/club/bootcamp", "/club/bootcamp")).toBe(true); + }); +}); diff --git a/sites/mainweb/lib/portal-nav.ts b/sites/mainweb/lib/portal-nav.ts new file mode 100644 index 00000000..953aee7f --- /dev/null +++ b/sites/mainweb/lib/portal-nav.ts @@ -0,0 +1,150 @@ +import type { LucideIcon } from "lucide-react"; +import { + LayoutDashboard, + Code, + ClipboardList, + Users, + BarChart3, + QrCode, + Zap, + Home, + Rocket, + Upload, + FolderGit2, + CreditCard, + ShieldCheck, + ScrollText, + BookOpen, + GraduationCap, + UserCircle, +} from "lucide-react"; +import type { PortalContext } from "@query/api"; + +export type PortalNavItem = { + name: string; + href: string; + icon: LucideIcon; +}; + +export type PortalNavSection = { + id: "hackathon" | "portal"; + label: string; + items: PortalNavItem[]; +}; + +type NavFlags = { + isAdmin: boolean; + isScanner: boolean; + isJudge: boolean; + isMember: boolean; + isProjectLeader: boolean; +}; + +function flags(ctx: PortalContext | undefined | null): NavFlags { + return { + isAdmin: !!ctx?.isAdmin, + isScanner: !!ctx?.isScanner, + isJudge: !!ctx?.isJudge, + isMember: !!ctx?.member.isMember, + isProjectLeader: !!ctx?.isProjectLeader, + }; +} + +/** + * Sidebar is two columns of the org, not one dump: hackathon (open to anyone + * with an account) and portal (club). Membership decides which portal links + * appear; it does not mix the two back together. + * + * Staff see the same split on the admin tools. Judging Setup is gone — an + * edition created from /admin/hackathons is the judging edition, and queue + * prep lives on /admin/judging. + */ +export function portalNavSections( + ctx: PortalContext | undefined | null, +): PortalNavSection[] { + const f = flags(ctx); + + if (f.isAdmin) { + return [ + { + id: "hackathon", + label: "Hackathon", + items: [ + { name: "Hackathons", href: "/admin/hackathons", icon: Code }, + { name: "Judging", href: "/admin/judging", icon: ClipboardList }, + { name: "Projects", href: "/admin/projects", icon: FolderGit2 }, + { name: "Attendees", href: "/admin/attendees", icon: Users }, + ], + }, + { + id: "portal", + label: "Portal", + items: [ + { name: "Club Hub", href: "/admin", icon: LayoutDashboard }, + { name: "Initiatives", href: "/admin/initiatives", icon: Rocket }, + { + name: "Initiative Applications", + href: "/lead", + icon: Rocket, + }, + { name: "Bootcamp", href: "/admin/bootcamp", icon: GraduationCap }, + { name: "Memberships", href: "/admin/members", icon: CreditCard }, + { name: "Staff & Roles", href: "/admin/staff", icon: ShieldCheck }, + { name: "Analytics", href: "/admin/analytics", icon: BarChart3 }, + { name: "Audit Log", href: "/admin/audit", icon: ScrollText }, + { name: "Docs", href: "/docs", icon: BookOpen }, + { name: "Settings", href: "/settings", icon: UserCircle }, + ], + }, + ]; + } + + const hackathon: PortalNavItem[] = [ + // Non-members land on the hackathon half of /dashboard; members get the + // same home link under Portal so the default section matches the default view. + ...(!f.isMember + ? [{ name: "Dashboard", href: "/dashboard", icon: Home }] + : []), + { name: "Hackathons", href: "/hackathons", icon: Zap }, + { + name: "Submit Project", + href: "/submit", + icon: Upload, + }, + ...(f.isJudge + ? [{ name: "Judge Portal", href: "/judge", icon: ClipboardList }] + : []), + ...(f.isScanner + ? [{ name: "Check-In Desk", href: "/scan", icon: QrCode }] + : []), + ]; + + const portal: PortalNavItem[] = [ + ...(f.isMember + ? [ + { name: "Dashboard", href: "/dashboard", icon: Home }, + { name: "Club Portal", href: "/club", icon: QrCode }, + ] + : []), + { name: "Bootcamp", href: "/club/bootcamp", icon: GraduationCap }, + { name: "Initiatives", href: "/initiatives", icon: Rocket }, + ...(f.isProjectLeader + ? [{ name: "My Initiatives", href: "/lead", icon: Rocket }] + : []), + { name: "Settings", href: "/settings", icon: UserCircle }, + ]; + + return [ + { id: "hackathon", label: "Hackathon", items: hackathon }, + { id: "portal", label: "Portal", items: portal }, + ]; +} + +export function isPortalNavActive(pathname: string, href: string): boolean { + if (pathname === href) return true; + // Prefixes that own a child route with its own nav item. + if (href === "/dashboard" || href === "/admin" || href === "/club") { + return false; + } + return pathname.startsWith(`${href}/`); +} From c0cf69e9b7e26773ca7e32e984af192e15936ae2 Mon Sep 17 00:00:00 2001 From: aamoghS Date: Thu, 27 Aug 2026 19:06:29 +0000 Subject: [PATCH 3/8] Keep club events off the hackathon Club meetings were showing up as a mode of the hackathon check-in desk and as Attendees under the Hackathon sidebar. Move club attendees and pass scanning onto Portal, leave /scan as the edition badge desk, and stop describing the public events page as if it listed Hacklytics. Co-authored-by: Aamogh --- .../app/(portal)/admin/analytics/page.tsx | 2 +- .../app/(portal)/admin/attendees/page.tsx | 3 +- sites/mainweb/app/(portal)/admin/page.tsx | 26 ++- .../mainweb/app/(portal)/admin/staff/page.tsx | 2 +- .../app/(portal)/hackathons/[id]/page.tsx | 2 +- sites/mainweb/app/(portal)/scan/club/page.tsx | 74 +++++++ sites/mainweb/app/(portal)/scan/page.tsx | 200 +++++------------- sites/mainweb/app/events/page.tsx | 20 +- .../components/admin/hackathons/EventsTab.tsx | 17 +- .../admin/hackathons/HackathonCard.tsx | 6 +- .../mainweb/components/portal/ScanAccess.tsx | 52 +++++ sites/mainweb/lib/portal-nav.test.ts | 13 +- sites/mainweb/lib/portal-nav.ts | 17 +- sites/mainweb/proxy.ts | 1 + 14 files changed, 258 insertions(+), 177 deletions(-) create mode 100644 sites/mainweb/app/(portal)/scan/club/page.tsx create mode 100644 sites/mainweb/components/portal/ScanAccess.tsx diff --git a/sites/mainweb/app/(portal)/admin/analytics/page.tsx b/sites/mainweb/app/(portal)/admin/analytics/page.tsx index 6e279cfb..1e547d97 100644 --- a/sites/mainweb/app/(portal)/admin/analytics/page.tsx +++ b/sites/mainweb/app/(portal)/admin/analytics/page.tsx @@ -101,7 +101,7 @@ export default function AnalyticsPage() {

- Club Events + Operations

Analytics Dashboard diff --git a/sites/mainweb/app/(portal)/admin/attendees/page.tsx b/sites/mainweb/app/(portal)/admin/attendees/page.tsx index d2791aeb..881bfaa5 100644 --- a/sites/mainweb/app/(portal)/admin/attendees/page.tsx +++ b/sites/mainweb/app/(portal)/admin/attendees/page.tsx @@ -89,7 +89,8 @@ export default function AttendeesPage() { Registry

- View and manage attendee registrations for club events. + View and manage attendee check-ins for club meetings. Hackathon + applications live on each edition's dashboard.

{/* Decorative Corner Accent */}
diff --git a/sites/mainweb/app/(portal)/admin/page.tsx b/sites/mainweb/app/(portal)/admin/page.tsx index 3a3f5ed0..a2b53518 100644 --- a/sites/mainweb/app/(portal)/admin/page.tsx +++ b/sites/mainweb/app/(portal)/admin/page.tsx @@ -4,6 +4,7 @@ import { useSession } from "next-auth/react"; import { trpc } from "@/lib/trpc"; import { usePortalContext } from "@/lib/use-portal-context"; import { useState } from "react"; +import Link from "next/link"; import { QRCodeModal } from "@/components/portal/QRCodeModal"; import { EventFormModal } from "@/components/portal/EventFormModal"; import { EventAttendanceModal } from "@/components/portal/EventAttendanceModal"; @@ -252,8 +253,9 @@ export default function AdminPage() { Manager

- Create events, generate QR codes, and track attendance for general - club gatherings. + Club meetings, workshops, and bootcamp sessions. These are not + part of a Hacklytics weekend — that itinerary lives on the + hackathon dashboard.

@@ -283,12 +285,20 @@ export default function AdminPage() { ))}
- +
+ + Scan Passes + + +
{eventsLoading ? ( diff --git a/sites/mainweb/app/(portal)/admin/staff/page.tsx b/sites/mainweb/app/(portal)/admin/staff/page.tsx index b7f2f3eb..861fe487 100644 --- a/sites/mainweb/app/(portal)/admin/staff/page.tsx +++ b/sites/mainweb/app/(portal)/admin/staff/page.tsx @@ -20,7 +20,7 @@ const ROLES = [ { id: "volunteer" as const, label: "Volunteer", - hint: "Badge scanning at /scan. Nothing else — the admin pages refuse them.", + hint: "Hackathon badge scanning at /scan and club pass scanning at /scan/club. Nothing else — the admin pages refuse them.", }, { id: "moderator" as const, diff --git a/sites/mainweb/app/(portal)/hackathons/[id]/page.tsx b/sites/mainweb/app/(portal)/hackathons/[id]/page.tsx index b8f2d6a0..d2076a26 100644 --- a/sites/mainweb/app/(portal)/hackathons/[id]/page.tsx +++ b/sites/mainweb/app/(portal)/hackathons/[id]/page.tsx @@ -196,7 +196,7 @@ export default function HackathonDetailPage() { />
- All Events + All Hackathons {/* Header Card */} diff --git a/sites/mainweb/app/(portal)/scan/club/page.tsx b/sites/mainweb/app/(portal)/scan/club/page.tsx new file mode 100644 index 00000000..a4728ee2 --- /dev/null +++ b/sites/mainweb/app/(portal)/scan/club/page.tsx @@ -0,0 +1,74 @@ +"use client"; + +import { useState } from "react"; +import { trpc } from "@/lib/trpc"; +import { usePortalContext } from "@/lib/use-portal-context"; +import { LiquidGlass } from "@/components/portal/LiquidGlass"; +import { ScanAccess } from "@/components/portal/ScanAccess"; +import { ClubScannerTab } from "@/components/portal/ClubScannerTab"; + +/** + * Club meeting check-in. + * + * Scans a member pass, not a hackathon badge. Kept off /scan so club + * gatherings are not a mode of the hackathon desk. + */ +export default function ClubScanPage() { + const { data: portalContext } = usePortalContext(); + const [clubEventId, setClubEventId] = useState(""); + + const { data: clubEvents } = trpc.events.list.useQuery(undefined, { + enabled: !!portalContext?.isScanner, + }); + + return ( + +
+
+

+ Club +

+

+ Club Check-In +

+

+ Scan member passes into a club meeting. Not part of a hackathon + weekend. +

+
+ +
+ + +
+ + {clubEventId ? ( + + ) : ( + +

+ Choose a club event above to start scanning. +

+
+ )} +
+
+ ); +} diff --git a/sites/mainweb/app/(portal)/scan/page.tsx b/sites/mainweb/app/(portal)/scan/page.tsx index ac4cce48..e6d035cf 100644 --- a/sites/mainweb/app/(portal)/scan/page.tsx +++ b/sites/mainweb/app/(portal)/scan/page.tsx @@ -1,176 +1,76 @@ "use client"; import { useState } from "react"; -import { useSession } from "next-auth/react"; -import { useRouter } from "next/navigation"; -import Link from "next/link"; import { trpc } from "@/lib/trpc"; import { usePortalContext } from "@/lib/use-portal-context"; -import { LoadingScreen } from "@/components/portal/LoadingScreen"; import { LiquidGlass } from "@/components/portal/LiquidGlass"; +import { ScanAccess } from "@/components/portal/ScanAccess"; import { ScannerTab } from "@/components/admin/hackathons/ScannerTab"; -import { ClubScannerTab } from "@/components/portal/ClubScannerTab"; /** - * The check-in desk. + * Hackathon badge check-in. * - * Deliberately outside the /admin segment. AdminLayout gates on - * `portalContext.isAdmin`, which rejects volunteers by design — so the only - * scanning UI in the product lived behind a door the scanner tier could not - * open. The alternative was relaxing that gate and then re-scoping every admin - * tab behind it, which is a much wider blast radius for the same outcome. - * - * Full staff can use this too: isScanner accepts any active admins row, so a - * volunteer and an organiser get the same screen. + * Club meetings are a different desk (/scan/club). Volunteers are not admins, + * so this stays outside /admin; mixing both modes here is what put club + * events inside the hackathon. */ export default function ScanPage() { - const { status } = useSession(); - const router = useRouter(); - const { data: portalContext, isLoading } = usePortalContext(); - const [mode, setMode] = useState<"hackathon" | "club">("hackathon"); + const { data: portalContext } = usePortalContext(); const [hackathonId, setHackathonId] = useState(""); - const [clubEventId, setClubEventId] = useState(""); - // Public procedures, so a volunteer can read them. The listAll variants are - // isAdmin and would reject exactly the people this page exists for. const { data: hackathons } = trpc.hackathon.list.useQuery( {}, { enabled: !!portalContext?.isScanner }, ); - const { data: clubEvents } = trpc.events.list.useQuery(undefined, { - enabled: !!portalContext?.isScanner, - }); - - if (status === "loading" || isLoading) { - return ; - } - if (status === "unauthenticated") { - router.push("/login"); - return null; - } - - if (!portalContext?.isScanner) { - return ( -
- -

- Not a Scanner + return ( + +
+
+

+ Hackathon +

+

+ Check-In Desk

-

- This is the event check-in desk. Ask an organiser to add you as - event staff. +

+ Scan participant badges into this edition's workshops, meals, + and ceremonies.

- - Back to Dashboard - - -
- ); - } +
- return ( -
-
-

- Check-In Desk -

-

- Scan attendee badges into an event. -

-
- - {/* The two halves scan opposite things: a hackathon badge encodes the - participant, a club pass encodes the member. */} -
- {(["hackathon", "club"] as const).map((m) => ( - - ))} -
- - {mode === "hackathon" ? ( - <> -
- - -
- - {hackathonId ? ( - - ) : ( - -

- Choose a hackathon above to start scanning. -

-
- )} - - ) : ( - <> -
- - -
+ Hackathon + + +
- {clubEventId ? ( - - ) : ( - -

- Choose a club event above to start scanning. -

-
- )} - - )} -

+ {hackathonId ? ( + + ) : ( + +

+ Choose a hackathon above to start scanning. +

+
+ )} +
+ ); } diff --git a/sites/mainweb/app/events/page.tsx b/sites/mainweb/app/events/page.tsx index d1f2e5ad..459a333e 100644 --- a/sites/mainweb/app/events/page.tsx +++ b/sites/mainweb/app/events/page.tsx @@ -3,6 +3,7 @@ import Footer from "@/components/Footer"; import Section from "@/components/Section"; import { db, events } from "@query/db"; import { gte } from "drizzle-orm"; +import Link from "next/link"; /** * The club's upcoming events. @@ -64,18 +65,19 @@ export default async function EventsPage() {

- Events + Club Events

- Track upcoming hackathons, workshops, and community gatherings. + Upcoming DSGT meetings, workshops, and community gatherings. + Hacklytics has its own page.

- Upcoming Events + Upcoming Club Events

{upcoming.length === 0 ? (

- No upcoming events scheduled. Check back soon! + No upcoming club events scheduled. Check back soon!

) : (
    @@ -109,6 +111,16 @@ export default async function EventsPage() {
)}
+

+ Looking for Hacklytics?{" "} + + Go to the hackathon page + + . +

diff --git a/sites/mainweb/components/admin/hackathons/EventsTab.tsx b/sites/mainweb/components/admin/hackathons/EventsTab.tsx index 134bfd1d..e009818a 100644 --- a/sites/mainweb/components/admin/hackathons/EventsTab.tsx +++ b/sites/mainweb/components/admin/hackathons/EventsTab.tsx @@ -1,6 +1,7 @@ "use client"; import React, { useState } from "react"; +import Link from "next/link"; import { trpc } from "@/lib/trpc"; import { trpcErrorMessage } from "@/lib/trpc-error"; import { LiquidGlass } from "@/components/portal/LiquidGlass"; @@ -184,11 +185,16 @@ export function EventsTab({ hackathonId }: { hackathonId: string }) {

- Events + Weekend Itinerary

- {events?.length || 0} event{events?.length !== 1 ? "s" : ""}{" "} - scheduled + {events?.length || 0} session + {events?.length !== 1 ? "s" : ""} this edition — workshops, meals, + ceremonies.{" "} + + Club meetings are on Club Hub + + .

- No events yet + No itinerary yet

- Create workshops, meals, ceremonies and more. + Add workshops, meals, and ceremonies for this weekend. Club + meetings belong on Club Hub, not here.

) : ( diff --git a/sites/mainweb/components/admin/hackathons/HackathonCard.tsx b/sites/mainweb/components/admin/hackathons/HackathonCard.tsx index 32e784a6..e804ffb2 100644 --- a/sites/mainweb/components/admin/hackathons/HackathonCard.tsx +++ b/sites/mainweb/components/admin/hackathons/HackathonCard.tsx @@ -138,7 +138,7 @@ export function HackathonCard({ href={adminHackathonPath(hackathon.name, hackathon.id)} className="text-xs font-mono text-accent hover:underline flex items-center gap-1" > - Manage Events ({events.length}) → + Manage Itinerary ({events.length}) → )}
@@ -149,8 +149,8 @@ export function HackathonCard({
) : !events || events.length === 0 ? (
- No events scheduled for this hackathon yet. Click Dashboard to - add some! + No weekend itinerary yet. Open the dashboard to add workshops + and meals for this edition — club meetings stay on Club Hub.
) : (
diff --git a/sites/mainweb/components/portal/ScanAccess.tsx b/sites/mainweb/components/portal/ScanAccess.tsx new file mode 100644 index 00000000..d0ccc819 --- /dev/null +++ b/sites/mainweb/components/portal/ScanAccess.tsx @@ -0,0 +1,52 @@ +"use client"; + +import { useSession } from "next-auth/react"; +import { useRouter } from "next/navigation"; +import Link from "next/link"; +import { usePortalContext } from "@/lib/use-portal-context"; +import { LoadingScreen } from "@/components/portal/LoadingScreen"; +import { LiquidGlass } from "@/components/portal/LiquidGlass"; + +/** + * Volunteers are not admins, so both check-in desks live outside /admin. + * Club scanning and hackathon scanning are separate pages — mixing them on + * one screen put club meetings inside the hackathon desk. + */ +export function ScanAccess({ children }: { children: React.ReactNode }) { + const { status } = useSession(); + const router = useRouter(); + const { data: portalContext, isLoading } = usePortalContext(); + + if (status === "loading" || isLoading) { + return ; + } + + if (status === "unauthenticated") { + router.push("/login"); + return null; + } + + if (!portalContext?.isScanner) { + return ( +
+ +

+ Not a Scanner +

+

+ This is the event check-in desk. Ask an organiser to add you as + event staff. +

+ + Back to Dashboard + +
+
+ ); + } + + return <>{children}; +} diff --git a/sites/mainweb/lib/portal-nav.test.ts b/sites/mainweb/lib/portal-nav.test.ts index 306b115c..edef059b 100644 --- a/sites/mainweb/lib/portal-nav.test.ts +++ b/sites/mainweb/lib/portal-nav.test.ts @@ -87,6 +87,8 @@ describe("portalNavSections", () => { "Judge Portal", "Check-In Desk", ]); + expect(names(sections, "portal")).toContain("Club Check-In"); + expect(names(sections, "hackathon")).not.toContain("Club Check-In"); expect(names(sections, "portal")).not.toContain("Judge Portal"); }); @@ -102,10 +104,13 @@ describe("portalNavSections", () => { "Hackathons", "Judging", "Projects", - "Attendees", ]); + expect(names(sections, "hackathon")).not.toContain("Attendees"); + expect(names(sections, "hackathon")).not.toContain("Club Attendees"); expect(names(sections, "portal")).toEqual([ "Club Hub", + "Club Attendees", + "Club Check-In", "Initiatives", "Initiative Applications", "Bootcamp", @@ -142,4 +147,10 @@ describe("isPortalNavActive", () => { expect(isPortalNavActive("/club/bootcamp", "/club")).toBe(false); expect(isPortalNavActive("/club/bootcamp", "/club/bootcamp")).toBe(true); }); + + it("does not treat /scan as a prefix of /scan/club", () => { + expect(isPortalNavActive("/scan", "/scan")).toBe(true); + expect(isPortalNavActive("/scan/club", "/scan")).toBe(false); + expect(isPortalNavActive("/scan/club", "/scan/club")).toBe(true); + }); }); diff --git a/sites/mainweb/lib/portal-nav.ts b/sites/mainweb/lib/portal-nav.ts index 953aee7f..5037a0a7 100644 --- a/sites/mainweb/lib/portal-nav.ts +++ b/sites/mainweb/lib/portal-nav.ts @@ -17,6 +17,7 @@ import { BookOpen, GraduationCap, UserCircle, + Calendar, } from "lucide-react"; import type { PortalContext } from "@query/api"; @@ -58,6 +59,9 @@ function flags(ctx: PortalContext | undefined | null): NavFlags { * Staff see the same split on the admin tools. Judging Setup is gone — an * edition created from /admin/hackathons is the judging edition, and queue * prep lives on /admin/judging. + * + * Club meetings (Hub, attendees, pass scan) stay on Portal. They are not a + * tab or mode of a hackathon edition. */ export function portalNavSections( ctx: PortalContext | undefined | null, @@ -73,7 +77,6 @@ export function portalNavSections( { name: "Hackathons", href: "/admin/hackathons", icon: Code }, { name: "Judging", href: "/admin/judging", icon: ClipboardList }, { name: "Projects", href: "/admin/projects", icon: FolderGit2 }, - { name: "Attendees", href: "/admin/attendees", icon: Users }, ], }, { @@ -81,6 +84,8 @@ export function portalNavSections( label: "Portal", items: [ { name: "Club Hub", href: "/admin", icon: LayoutDashboard }, + { name: "Club Attendees", href: "/admin/attendees", icon: Users }, + { name: "Club Check-In", href: "/scan/club", icon: Calendar }, { name: "Initiatives", href: "/admin/initiatives", icon: Rocket }, { name: "Initiative Applications", @@ -126,6 +131,9 @@ export function portalNavSections( { name: "Club Portal", href: "/club", icon: QrCode }, ] : []), + ...(f.isScanner + ? [{ name: "Club Check-In", href: "/scan/club", icon: Calendar }] + : []), { name: "Bootcamp", href: "/club/bootcamp", icon: GraduationCap }, { name: "Initiatives", href: "/initiatives", icon: Rocket }, ...(f.isProjectLeader @@ -143,7 +151,12 @@ export function portalNavSections( export function isPortalNavActive(pathname: string, href: string): boolean { if (pathname === href) return true; // Prefixes that own a child route with its own nav item. - if (href === "/dashboard" || href === "/admin" || href === "/club") { + if ( + href === "/dashboard" || + href === "/admin" || + href === "/club" || + href === "/scan" + ) { return false; } return pathname.startsWith(`${href}/`); diff --git a/sites/mainweb/proxy.ts b/sites/mainweb/proxy.ts index dec3ec62..7e509435 100644 --- a/sites/mainweb/proxy.ts +++ b/sites/mainweb/proxy.ts @@ -37,6 +37,7 @@ const PRIVATE_PREFIXES = [ "/submit", "/verify", "/login", + "/scan", ]; function getCacheControl(pathname: string): string { From 7b46c2be6c81d65182350238ee1f535bcf950cbf Mon Sep 17 00:00:00 2001 From: aamoghS Date: Thu, 27 Aug 2026 19:14:36 +0000 Subject: [PATCH 4/8] Link interest to the edition it belongs to The form already stored rows on Digital Bloom's id, but staff saw 0 registered and the edition page bounced to a singleton /hacklytics funnel. Join against this hackathon id from Info, and show the interest count on the admin card while the event is announced. Co-authored-by: Aamogh --- .../hackathon-interest.test.ts | 17 ++ .../api/src/routers/hackathon/interest.ts | 13 + .../mainweb/app/(portal)/hacklytics/page.tsx | 266 +---------------- .../admin/hackathons/HackathonCard.tsx | 24 +- .../admin/hackathons/RegistrationControls.tsx | 24 +- .../mainweb/components/hackathon/InfoTab.tsx | 26 +- .../components/hackathon/InterestForm.tsx | 282 ++++++++++++++++++ 7 files changed, 370 insertions(+), 282 deletions(-) create mode 100644 sites/mainweb/components/hackathon/InterestForm.tsx diff --git a/packages/api/src/.internal-tests/hackathon-interest.test.ts b/packages/api/src/.internal-tests/hackathon-interest.test.ts index 45137d31..4a2b56cb 100644 --- a/packages/api/src/.internal-tests/hackathon-interest.test.ts +++ b/packages/api/src/.internal-tests/hackathon-interest.test.ts @@ -480,5 +480,22 @@ describe("Hackathon interest list", () => { // changes their address stays reachable. expect(rows[0]!.email).toBe("ada@example.com"); }); + + it("counts this edition's interest rows for staff", async () => { + lookups({ hackathon: announced(), isAdmin: true }); + mockSelectRows.mockReturnValue([{ count: 34 }]); + + await expect( + callerFor(ADMIN).hackathon.interestCount({ hackathonId: HACK }), + ).resolves.toBe(34); + }); + + it("hides the count from a caller who is not staff", async () => { + lookups({ hackathon: announced(), isAdmin: false }); + + await expect( + callerFor(VISITOR).hackathon.interestCount({ hackathonId: HACK }), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + }); }); }); diff --git a/packages/api/src/routers/hackathon/interest.ts b/packages/api/src/routers/hackathon/interest.ts index e90da693..931ffe66 100644 --- a/packages/api/src/routers/hackathon/interest.ts +++ b/packages/api/src/routers/hackathon/interest.ts @@ -424,4 +424,17 @@ export const hackathonInterestRouter = createTRPCRouter({ .orderBy(desc(hackathonInterest.createdAt)) .limit(5000); }), + + // How many people asked to be told about THIS edition. The admin card used + // to show currentParticipants (registrations), which is zero while the + // event is announced — so a live interest list looked unlinked. + interestCount: isAdmin + .input(z.object({ hackathonId: z.string().uuid() })) + .query(async ({ ctx, input }) => { + const [row] = await (ctx.db as DrizzleDB) + .select({ count: sql`count(*)::int` }) + .from(hackathonInterest) + .where(eq(hackathonInterest.hackathonId, input.hackathonId)); + return row?.count ?? 0; + }), }); diff --git a/sites/mainweb/app/(portal)/hacklytics/page.tsx b/sites/mainweb/app/(portal)/hacklytics/page.tsx index 573e56cc..065f07f3 100644 --- a/sites/mainweb/app/(portal)/hacklytics/page.tsx +++ b/sites/mainweb/app/(portal)/hacklytics/page.tsx @@ -1,11 +1,10 @@ "use client"; -import { useEffect, useState } from "react"; import Link from "next/link"; -import { useSession } from "next-auth/react"; import { trpc } from "@/lib/trpc"; import { hackathonSlug } from "@/lib/hackathon-slug"; import { LoadingScreen } from "@/components/portal/LoadingScreen"; +import { InterestForm } from "@/components/hackathon/InterestForm"; /** * The public landing page for an edition that has been announced but is not yet @@ -16,16 +15,12 @@ import { LoadingScreen } from "@/components/portal/LoadingScreen"; * page. A signed-out stranger is the entire audience, so everything above the * form renders without a session and the sidebar is suppressed for it in * PortalWrapper. + * + * The form writes against this edition's id (from getUpcoming), not a + * singleton. The same component is mounted on the edition page so joining + * from either place lands on the same row. */ -const EXPERIENCE_OPTIONS = [ - { value: "first", label: "This would be my first" }, - { value: "one_or_two", label: "I've done one or two" }, - { value: "three_plus", label: "I've done three or more" }, -] as const; - -type Experience = (typeof EXPERIENCE_OPTIONS)[number]["value"]; - /** * Dates are rendered from a fixed locale and an explicit time zone rather than * the viewer's. The event happens in Atlanta; showing somebody in Singapore @@ -58,85 +53,8 @@ const formatDeadline = (deadline: Date) => timeZone: "America/New_York", }); -function Field({ - label, - hint, - children, -}: { - label: string; - hint?: string; - children: React.ReactNode; -}) { - return ( - - ); -} - -const inputClass = - "w-full px-4 py-3 bg-[var(--bg-primary)]/60 border border-[var(--border-subtle)] text-[var(--text-primary)] text-sm rounded-sm focus:border-accent/50 focus:outline-none focus:ring-2 focus:ring-accent/20 placeholder:text-[var(--text-muted)]/50 transition-ui"; - export default function HacklyticsPage() { - const { data: session, status: sessionStatus } = useSession(); - const utils = trpc.useUtils(); - const upcoming = trpc.hackathon.getUpcoming.useQuery(); - const hackathonId = upcoming.data?.id; - - const mine = trpc.hackathon.myInterest.useQuery( - { hackathonId: hackathonId ?? "" }, - { enabled: !!hackathonId && !!session }, - ); - - const [school, setSchool] = useState(""); - const [country, setCountry] = useState(""); - const [graduationYear, setGraduationYear] = useState(""); - const [experience, setExperience] = useState(""); - const [editing, setEditing] = useState(false); - const [error, setError] = useState(""); - - // Prefill from an existing entry so "edit" starts from what they told us, - // rather than making them retype it to change one field. - useEffect(() => { - if (!mine.data) return; - setSchool(mine.data.school ?? ""); - setCountry(mine.data.country ?? ""); - setGraduationYear( - mine.data.graduationYear ? String(mine.data.graduationYear) : "", - ); - setExperience((mine.data.experience as Experience) ?? ""); - }, [mine.data]); - - const refresh = async () => { - if (hackathonId) await utils.hackathon.myInterest.invalidate({ hackathonId }); - }; - - const join = trpc.hackathon.registerInterest.useMutation({ - onSuccess: async () => { - setError(""); - setEditing(false); - await refresh(); - }, - onError: (e) => setError(e.message), - }); - - const leave = trpc.hackathon.withdrawInterest.useMutation({ - onSuccess: async () => { - setError(""); - setEditing(false); - await refresh(); - }, - onError: (e) => setError(e.message), - }); if (upcoming.isPending) return ; @@ -182,9 +100,6 @@ export default function HacklyticsPage() { // working past the moment registration opens — before, it collected the // interest list; after, it points at the registration itself. const registrationOpen = event.registrationOpen; - const onList = !!mine.data; - const showForm = !onList || editing; - const busy = join.isPending || leave.isPending; return (
@@ -259,174 +174,11 @@ export default function HacklyticsPage() { Register now
- ) : sessionStatus === "loading" ? ( -

Checking sign-in…

- ) : !session ? ( -
-

- Get told the moment it opens -

-

- Sign in so we have a verified address to reach you at. Google, - GitHub, or a code sent to any email — no account needed - beforehand, and it works wherever you are in the world. -

- - Sign in to join the list - -
) : ( -
-
-

- {onList ? "You're on the list" : "Join the interest list"} -

-

- {onList - ? `We'll email ${session.user?.email} the moment registration opens.` - : "Four optional questions. They only shape how we plan the event — none of them affect whether you get in."} -

-
- - {showForm ? ( -
{ - e.preventDefault(); - if (!hackathonId) return; - const parsedYear = graduationYear.trim() - ? Number(graduationYear) - : null; - if ( - parsedYear !== null && - (!Number.isInteger(parsedYear) || - parsedYear < 1900 || - parsedYear > 2100) - ) { - setError("That graduation year does not look right."); - return; - } - join.mutate({ - hackathonId, - school: school.trim() || undefined, - country: country.trim() || undefined, - graduationYear: parsedYear, - experience: experience || undefined, - }); - }} - > - - setSchool(e.target.value)} - placeholder="Georgia Institute of Technology" - maxLength={200} - /> - - - - setCountry(e.target.value)} - placeholder="United States" - maxLength={100} - /> - - - - setGraduationYear(e.target.value)} - placeholder="2029" - inputMode="numeric" - /> - - - - - - - {error ? ( -

- {error} -

- ) : null} - -
- - {onList ? ( - - ) : null} -
-
- ) : ( -
- - -
- )} - - {error && !showForm ? ( -

{error}

- ) : null} -
+ )}
diff --git a/sites/mainweb/components/admin/hackathons/HackathonCard.tsx b/sites/mainweb/components/admin/hackathons/HackathonCard.tsx index e804ffb2..1ef9ef01 100644 --- a/sites/mainweb/components/admin/hackathons/HackathonCard.tsx +++ b/sites/mainweb/components/admin/hackathons/HackathonCard.tsx @@ -31,6 +31,10 @@ export function HackathonCard({ const { data: events, isLoading: eventsLoading } = trpc.hackathon.getEvents.useQuery({ hackathonId: hackathon.id }); + const { data: interestCount } = trpc.hackathon.interestCount.useQuery( + { hackathonId: hackathon.id }, + { enabled: hackathon.status === "announced" }, + ); const updateMutation = trpc.hackathon.update.useMutation({ onSuccess: () => { @@ -90,9 +94,12 @@ export function HackathonCard({

- Participants + {hackathon.status === "announced" + ? "Interest list" + : "Participants"}

- {hackathon.maxParticipants && ( + {hackathon.status !== "announced" && + hackathon.maxParticipants && ( {Math.round( (hackathon.currentParticipants / @@ -104,16 +111,19 @@ export function HackathonCard({ )}

- {hackathon.currentParticipants} - {hackathon.maxParticipants - ? ` / ${hackathon.maxParticipants}` - : " registered"} + {hackathon.status === "announced" + ? `${interestCount ?? "…"} interested` + : `${hackathon.currentParticipants}${ + hackathon.maxParticipants + ? ` / ${hackathon.maxParticipants}` + : " registered" + }`}

{/* Registration Progress Bar */} - {hackathon.maxParticipants && ( + {hackathon.status !== "announced" && hackathon.maxParticipants && (
(null); @@ -149,6 +158,19 @@ export function RegistrationControls({ hackathonId }: { hackathonId: string }) {
+ {hackathon?.status === "announced" && ( +
+

+ Interest list for this edition +

+

+ {(interestCount ?? 0) === 0 + ? "Nobody has joined yet. The public form writes to this hackathon." + : `${interestCount} ${interestCount === 1 ? "person is" : "people are"} on this edition's list. Applications stay empty until you open registration. The Email tab has the roster.`} +

+
+ )} + {registrationOpen && (interestStatus.data?.total ?? 0) > 0 && (
diff --git a/sites/mainweb/components/hackathon/InfoTab.tsx b/sites/mainweb/components/hackathon/InfoTab.tsx index 889b96d8..e52a7cb7 100644 --- a/sites/mainweb/components/hackathon/InfoTab.tsx +++ b/sites/mainweb/components/hackathon/InfoTab.tsx @@ -1,7 +1,6 @@ "use client"; import React, { useEffect, useState } from "react"; -import Link from "next/link"; import { trpc } from "@/lib/trpc"; import { formatPhoneAsTyped, @@ -30,6 +29,8 @@ import { MAJORS, } from "@/components/hackathon/constants"; import type { ShirtSize, LevelOfStudy } from "@/components/hackathon/constants"; +import { InterestForm } from "@/components/hackathon/InterestForm"; +import { hackathonSlug } from "@/lib/hackathon-slug"; type RegistrationStep = 0 | 1 | 2 | 3; @@ -464,22 +465,13 @@ export function InfoTab({
) : hackathon.status === "announced" ? ( - /* Announced is not closed — the lock below reads as "you missed it". */ -
-

- Registration Opens Soon -

-

- Applications aren't open yet. Join the interest list and - we'll email you the moment they are. -

- - Join the Interest List - -
+ /* Announced is not closed — the lock below reads as "you missed it". + Join against THIS edition's id, not a bounce to /hacklytics which + follows whichever event getUpcoming picks. */ + ) : isFull ? (
+ + {label} + + {children} + {hint ? ( + + {hint} + + ) : null} + + ); +} + +const inputClass = + "w-full px-4 py-3 bg-[var(--bg-primary)]/60 border border-[var(--border-subtle)] text-[var(--text-primary)] text-sm rounded-sm focus:border-accent/50 focus:outline-none focus:ring-2 focus:ring-accent/20 placeholder:text-[var(--text-muted)]/50 transition-ui"; + +/** + * Join / leave the interest list for one edition. + * + * The row is keyed by hackathon id, not by a singleton funnel. /hacklytics + * and this edition's Info tab both have to write against the same UUID or + * staff looking at Digital Bloom see an empty list next to a form that + * actually landed on a different event. + */ +export function InterestForm({ + hackathonId, + callbackPath, +}: { + hackathonId: string; + callbackPath: string; +}) { + const { data: session, status: sessionStatus } = useSession(); + const utils = trpc.useUtils(); + + const mine = trpc.hackathon.myInterest.useQuery( + { hackathonId }, + { enabled: !!hackathonId && !!session }, + ); + + const [school, setSchool] = useState(""); + const [country, setCountry] = useState(""); + const [graduationYear, setGraduationYear] = useState(""); + const [experience, setExperience] = useState(""); + const [editing, setEditing] = useState(false); + const [error, setError] = useState(""); + + useEffect(() => { + if (!mine.data) return; + setSchool(mine.data.school ?? ""); + setCountry(mine.data.country ?? ""); + setGraduationYear( + mine.data.graduationYear ? String(mine.data.graduationYear) : "", + ); + setExperience((mine.data.experience as Experience) ?? ""); + }, [mine.data]); + + const refresh = async () => { + await Promise.all([ + utils.hackathon.myInterest.invalidate({ hackathonId }), + utils.hackathon.interestCount.invalidate({ hackathonId }), + ]); + }; + + const join = trpc.hackathon.registerInterest.useMutation({ + onSuccess: async () => { + setError(""); + setEditing(false); + await refresh(); + }, + onError: (e) => setError(e.message), + }); + + const leave = trpc.hackathon.withdrawInterest.useMutation({ + onSuccess: async () => { + setError(""); + setEditing(false); + await refresh(); + }, + onError: (e) => setError(e.message), + }); + + const onList = !!mine.data; + const showForm = !onList || editing; + const busy = join.isPending || leave.isPending; + + if (sessionStatus === "loading") { + return

Checking sign-in…

; + } + + if (!session) { + return ( +
+

+ Get told the moment it opens +

+

+ Sign in so we have a verified address to reach you at. Google, + GitHub, or a code sent to any email — no account needed beforehand, + and it works wherever you are in the world. +

+ + Sign in to join the list + +
+ ); + } + + return ( +
+
+

+ {onList ? "You're on the list" : "Join the interest list"} +

+

+ {onList + ? `We'll email ${session.user?.email} the moment registration opens.` + : "Four optional questions. They only shape how we plan the event — none of them affect whether you get in."} +

+
+ + {showForm ? ( +
{ + e.preventDefault(); + const parsedYear = graduationYear.trim() + ? Number(graduationYear) + : null; + if ( + parsedYear !== null && + (!Number.isInteger(parsedYear) || + parsedYear < 1900 || + parsedYear > 2100) + ) { + setError("That graduation year does not look right."); + return; + } + join.mutate({ + hackathonId, + school: school.trim() || undefined, + country: country.trim() || undefined, + graduationYear: parsedYear, + experience: experience || undefined, + }); + }} + > + + setSchool(e.target.value)} + placeholder="Georgia Institute of Technology" + maxLength={200} + /> + + + + setCountry(e.target.value)} + placeholder="United States" + maxLength={100} + /> + + + + setGraduationYear(e.target.value)} + placeholder="2029" + inputMode="numeric" + /> + + + + + + + {error ? ( +

{error}

+ ) : null} + +
+ + {onList ? ( + + ) : null} +
+
+ ) : ( +
+ + +
+ )} + + {error && !showForm ? ( +

{error}

+ ) : null} +
+ ); +} From cb7f503640ee15e41fd4e0c6929605e8f4258fe5 Mon Sep 17 00:00:00 2001 From: aamoghS Date: Thu, 27 Aug 2026 19:17:07 +0000 Subject: [PATCH 5/8] Show this edition's interest list on Applications Digital Bloom opens on Applications, which listed zero registrations while 34 people were already on the interest list. While the event is announced, that tab is the interest roster for this hackathon. Co-authored-by: Aamogh --- .../admin/hackathons/AnnouncementsTab.tsx | 2 +- .../admin/hackathons/AttendeesTab.tsx | 68 +++++++++++++++++++ .../components/hackathon/InterestForm.tsx | 1 + 3 files changed, 70 insertions(+), 1 deletion(-) diff --git a/sites/mainweb/components/admin/hackathons/AnnouncementsTab.tsx b/sites/mainweb/components/admin/hackathons/AnnouncementsTab.tsx index 5d56c426..a8143327 100644 --- a/sites/mainweb/components/admin/hackathons/AnnouncementsTab.tsx +++ b/sites/mainweb/components/admin/hackathons/AnnouncementsTab.tsx @@ -61,7 +61,7 @@ export function AnnouncementsTab({ hackathonId }: { hackathonId: string }) { const [sending, setSending] = useState(false); const [progress, setProgress] = useState(null); const [error, setError] = useState(null); - const [showInterest, setShowInterest] = useState(false); + const [showInterest, setShowInterest] = useState(true); // The four questions the public form collects were shown to no organiser at // all — the data was gathered and then only ever read as a recipient count. diff --git a/sites/mainweb/components/admin/hackathons/AttendeesTab.tsx b/sites/mainweb/components/admin/hackathons/AttendeesTab.tsx index 197cb171..aad45985 100644 --- a/sites/mainweb/components/admin/hackathons/AttendeesTab.tsx +++ b/sites/mainweb/components/admin/hackathons/AttendeesTab.tsx @@ -79,6 +79,15 @@ export function AttendeesTab({ // Stat tiles come from the aggregate, not from counting the rows on screen. // Counting a page would report "12 pending" when 400 are. const { data: analytics } = trpc.hackathon.analytics.useQuery({ hackathonId }); + const { data: hackathon } = trpc.hackathon.getById.useQuery({ + id: hackathonId, + }); + const announced = hackathon?.status === "announced"; + const { data: interestRows, isLoading: interestLoading } = + trpc.hackathon.listInterest.useQuery( + { hackathonId }, + { enabled: announced }, + ); const attendees = data?.attendees; @@ -393,6 +402,63 @@ export function AttendeesTab({
+ {announced ? ( + +

+ Interest list +

+

+ People who asked to be told when{" "} + {hackathonName}{" "} + opens. This is not an application queue — opening registration lets + them apply. +

+ {interestLoading ? ( +

+ Loading this edition's list… +

+ ) : (interestRows?.length ?? 0) === 0 ? ( +

+ Nobody has joined this edition yet. The public form writes here. +

+ ) : ( +
+ + + + + + + + + + + + + {interestRows?.map((row) => ( + + + + + + + + + ))} + +
NameEmailSchoolCountryGradExperience
+ {row.name ?? "—"} + {row.email}{row.school ?? "—"}{row.country ?? "—"} + {row.graduationYear ?? "—"} + {row.experience ?? "—"}
+
+ )} +
+ ) : ( + <> )} + + )}
); } diff --git a/sites/mainweb/components/hackathon/InterestForm.tsx b/sites/mainweb/components/hackathon/InterestForm.tsx index e61a9b66..e46f029d 100644 --- a/sites/mainweb/components/hackathon/InterestForm.tsx +++ b/sites/mainweb/components/hackathon/InterestForm.tsx @@ -84,6 +84,7 @@ export function InterestForm({ await Promise.all([ utils.hackathon.myInterest.invalidate({ hackathonId }), utils.hackathon.interestCount.invalidate({ hackathonId }), + utils.hackathon.listInterest.invalidate({ hackathonId }), ]); }; From 6039bf6fcf7f685e2f3669f907331f3ef5d2ca69 Mon Sep 17 00:00:00 2001 From: aamoghS Date: Thu, 27 Aug 2026 22:24:09 +0000 Subject: [PATCH 6/8] Restore queue rebuild confirm and announced interest first paint Prepare judging now offers Rebuild anyway after assign CONFLICT (force: true), matching the old /admin/setup control. Applications uses the parent edition status so the interest roster does not wait on a UUID getById cache miss, and the announced count no longer reads as nobody while the query is in flight. Co-authored-by: Aamogh --- .../(portal)/admin/hackathons/[id]/page.tsx | 1 + .../app/(portal)/admin/judging/page.tsx | 80 ++++++++++++++++--- .../admin/hackathons/AttendeesTab.tsx | 10 ++- .../admin/hackathons/RegistrationControls.tsx | 42 ++++++---- 4 files changed, 105 insertions(+), 28 deletions(-) diff --git a/sites/mainweb/app/(portal)/admin/hackathons/[id]/page.tsx b/sites/mainweb/app/(portal)/admin/hackathons/[id]/page.tsx index a0ddfa8c..a2e76496 100644 --- a/sites/mainweb/app/(portal)/admin/hackathons/[id]/page.tsx +++ b/sites/mainweb/app/(portal)/admin/hackathons/[id]/page.tsx @@ -223,6 +223,7 @@ export default function AdminHackathonDashboard() { )} {activeTab === "analytics" && ( diff --git a/sites/mainweb/app/(portal)/admin/judging/page.tsx b/sites/mainweb/app/(portal)/admin/judging/page.tsx index 7ecc0e06..48287cbd 100644 --- a/sites/mainweb/app/(portal)/admin/judging/page.tsx +++ b/sites/mainweb/app/(portal)/admin/judging/page.tsx @@ -68,12 +68,32 @@ export default function AdminResultsPage() { message: string | null; error: string | null; }>({ busy: false, message: null, error: null }); + // Set only when assignJudgesToProjects itself refused. A failed promote + // must not relabel into a button that sends force: true. + const [assignConflict, setAssignConflict] = useState(false); const promoteSubmissions = trpc.judge.promoteSubmissions.useMutation(); const assignJudges = trpc.judge.assignJudgesToProjects.useMutation(); + const isAssignConflict = (error: unknown) => + typeof error === "object" && + error !== null && + "data" in error && + (error as { data?: { code?: string } }).data?.code === "CONFLICT"; + + const finishPrepare = async (message: string) => { + if (!selectedHackathon) return; + await utils.judge.getRankings.invalidate({ + hackathonId: selectedHackathon, + }); + await refetchJudgingStatus(); + setAssignConflict(false); + setPrepState({ busy: false, error: null, message }); + }; + const prepareJudging = async () => { if (!selectedHackathon) return; + setAssignConflict(false); setPrepState({ busy: true, message: null, error: null }); try { const promoted = await promoteSubmissions.mutateAsync({ @@ -82,23 +102,42 @@ export default function AdminResultsPage() { const assigned = await assignJudges.mutateAsync({ hackathonId: selectedHackathon, }); - await utils.judge.getRankings.invalidate({ - hackathonId: selectedHackathon, - }); - await refetchJudgingStatus(); const warning = promoted.queuesNeedRebuild ? " One or more new projects carry a track no active judge covers — fix the track, then run this again." : ""; + await finishPrepare( + `Synced ${promoted.created} new submission(s) of ${promoted.total}, and built queues for ${assigned.totalJudges} judge(s) covering ${assigned.coverage.min}-${assigned.coverage.max} projects each. Print the table cards next.${warning}`, + ); + } catch (e) { + setAssignConflict(isAssignConflict(e)); setPrepState({ busy: false, - error: null, - message: `Synced ${promoted.created} new submission(s) of ${promoted.total}, and built queues for ${assigned.totalJudges} judge(s) covering ${assigned.coverage.min}-${assigned.coverage.max} projects each. Print the table cards next.${warning}`, + message: null, + error: e instanceof Error ? e.message : "Could not prepare judging.", + }); + } + }; + + // The server names how many completed slots (or that judging is live) and + // asks for confirmation. This is the only control that sends force: true — + // /admin/setup used to, and now redirects here. + const rebuildQueuesAnyway = async () => { + if (!selectedHackathon) return; + setPrepState({ busy: true, message: null, error: null }); + try { + const assigned = await assignJudges.mutateAsync({ + hackathonId: selectedHackathon, + force: true, }); + await finishPrepare( + `Rebuilt queues for ${assigned.totalJudges} judge(s) covering ${assigned.coverage.min}-${assigned.coverage.max} projects each. Completed slots were kept.`, + ); } catch (e) { + setAssignConflict(isAssignConflict(e)); setPrepState({ busy: false, message: null, - error: e instanceof Error ? e.message : "Could not prepare judging.", + error: e instanceof Error ? e.message : "Could not rebuild queues.", }); } }; @@ -120,6 +159,11 @@ export default function AdminResultsPage() { } }, [hackathons, selectedHackathon]); + useEffect(() => { + setAssignConflict(false); + setPrepState({ busy: false, message: null, error: null }); + }, [selectedHackathon]); + const categories = useMemo(() => { if (!rankings?.rankings) return ["ALL"]; const cats = new Set( @@ -338,12 +382,26 @@ export default function AdminResultsPage() {

)} {prepState.error && ( -

- {prepState.error} -

+

{prepState.error}

+ {assignConflict && ( + + )} +
)}
diff --git a/sites/mainweb/components/admin/hackathons/AttendeesTab.tsx b/sites/mainweb/components/admin/hackathons/AttendeesTab.tsx index aad45985..ec25f2aa 100644 --- a/sites/mainweb/components/admin/hackathons/AttendeesTab.tsx +++ b/sites/mainweb/components/admin/hackathons/AttendeesTab.tsx @@ -40,13 +40,16 @@ import { AcceptanceWaves } from "./AcceptanceWaves"; import { AttendeeStats } from "./AttendeeStats"; import { statusColors, statusIcon } from "./attendee-status"; import type { RegistrationStatus } from "./attendee-status"; +import type { HackathonStatus } from "./constants"; export function AttendeesTab({ hackathonId, hackathonName, + status, }: { hackathonId: string; hackathonName: string; + status: HackathonStatus; }) { const utils = trpc.useUtils(); @@ -79,10 +82,13 @@ export function AttendeesTab({ // Stat tiles come from the aggregate, not from counting the rows on screen. // Counting a page would report "12 pending" when 400 are. const { data: analytics } = trpc.hackathon.analytics.useQuery({ hackathonId }); + // Parent already fetched getById with the route slug; this UUID query + // misses that cache. Until it returns, fall back to the status the + // dashboard already has so announced editions don't flash the empty queue. const { data: hackathon } = trpc.hackathon.getById.useQuery({ id: hackathonId, }); - const announced = hackathon?.status === "announced"; + const announced = (hackathon?.status ?? status) === "announced"; const { data: interestRows, isLoading: interestLoading } = trpc.hackathon.listInterest.useQuery( { hackathonId }, @@ -400,7 +406,7 @@ export function AttendeesTab({ return (
- + {announced ? ( diff --git a/sites/mainweb/components/admin/hackathons/RegistrationControls.tsx b/sites/mainweb/components/admin/hackathons/RegistrationControls.tsx index 07143d2a..5eaaf3f6 100644 --- a/sites/mainweb/components/admin/hackathons/RegistrationControls.tsx +++ b/sites/mainweb/components/admin/hackathons/RegistrationControls.tsx @@ -3,18 +3,28 @@ import React from "react"; import { trpc } from "@/lib/trpc"; import { LiquidGlass } from "@/components/portal/LiquidGlass"; -import { toInputDate } from "@/components/admin/hackathons/constants"; +import { + toInputDate, + type HackathonStatus, +} from "@/components/admin/hackathons/constants"; import { Clock } from "lucide-react"; /** * Registration status + deadline controls for one hackathon. * Extracted from AttendeesTab; owns its own mutation so the parent stays lean. */ -export function RegistrationControls({ hackathonId }: { hackathonId: string }) { +export function RegistrationControls({ + hackathonId, + status, +}: { + hackathonId: string; + status: HackathonStatus; +}) { const utils = trpc.useUtils(); const { data: hackathon } = trpc.hackathon.getById.useQuery({ id: hackathonId, }); + const editionStatus = hackathon?.status ?? status; const updateHackathon = trpc.hackathon.update.useMutation({ onSuccess: () => { @@ -29,7 +39,7 @@ export function RegistrationControls({ hackathonId }: { hackathonId: string }) { const deadlinePassed = !!regDeadline && regDeadline < new Date(); const registrationOpen = - hackathon?.status === "open" || hackathon?.status === "in_progress"; + editionStatus === "open" || editionStatus === "in_progress"; /** * The interest list exists for one moment — this one — and nothing used to @@ -42,14 +52,14 @@ export function RegistrationControls({ hackathonId }: { hackathonId: string }) { { hackathonId }, { enabled: - hackathon?.status === "announced" || - hackathon?.status === "open" || - hackathon?.status === "in_progress", + editionStatus === "announced" || + editionStatus === "open" || + editionStatus === "in_progress", }, ); const { data: interestCount } = trpc.hackathon.interestCount.useQuery( { hackathonId }, - { enabled: hackathon?.status === "announced" }, + { enabled: editionStatus === "announced" }, ); const [notifyError, setNotifyError] = React.useState(null); @@ -81,9 +91,9 @@ export function RegistrationControls({ hackathonId }: { hackathonId: string }) { Status: - {hackathon?.status || "unknown"} + {editionStatus || "unknown"}
@@ -100,7 +110,7 @@ export function RegistrationControls({ hackathonId }: { hackathonId: string }) {
- {hackathon?.status !== "open" && ( + {editionStatus !== "open" && (
- {hackathon?.status === "announced" && ( + {editionStatus === "announced" && (

Interest list for this edition

- {(interestCount ?? 0) === 0 - ? "Nobody has joined yet. The public form writes to this hackathon." - : `${interestCount} ${interestCount === 1 ? "person is" : "people are"} on this edition's list. Applications stay empty until you open registration. The Email tab has the roster.`} + {interestCount === undefined + ? "Checking this edition's list…" + : interestCount === 0 + ? "Nobody has joined yet. The public form writes to this hackathon." + : `${interestCount} ${interestCount === 1 ? "person is" : "people are"} on this edition's list. Applications stay empty until you open registration. The Email tab has the roster.`}

)} From 44111cf84c1732e423c9ce8ba78e7e70cac12273 Mon Sep 17 00:00:00 2001 From: aamoghS Date: Thu, 27 Aug 2026 22:25:05 +0000 Subject: [PATCH 7/8] Keep the CONFLICT warning visible while queues rebuild Co-authored-by: Aamogh --- sites/mainweb/app/(portal)/admin/judging/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sites/mainweb/app/(portal)/admin/judging/page.tsx b/sites/mainweb/app/(portal)/admin/judging/page.tsx index 48287cbd..aff606a7 100644 --- a/sites/mainweb/app/(portal)/admin/judging/page.tsx +++ b/sites/mainweb/app/(portal)/admin/judging/page.tsx @@ -123,7 +123,7 @@ export default function AdminResultsPage() { // /admin/setup used to, and now redirects here. const rebuildQueuesAnyway = async () => { if (!selectedHackathon) return; - setPrepState({ busy: true, message: null, error: null }); + setPrepState((s) => ({ ...s, busy: true })); try { const assigned = await assignJudges.mutateAsync({ hackathonId: selectedHackathon, From 2c3697346f2ee007153acb59d9397e1af06925a2 Mon Sep 17 00:00:00 2001 From: aamoghS Date: Fri, 28 Aug 2026 22:03:21 +0000 Subject: [PATCH 8/8] Ignore stale judging prep after switching editions A CONFLICT from edition A that resolved after the selector moved to B used to paint A's refusal on B, and Rebuild anyway sent force: true for B. Prep results now apply only to the run and edition that started them, and force rebuilds that refused id. Co-authored-by: Aamogh --- .../app/(portal)/admin/judging/page.tsx | 73 +++++++++++++------ sites/mainweb/lib/judging-prep.test.ts | 20 +++++ sites/mainweb/lib/judging-prep.ts | 16 ++++ 3 files changed, 88 insertions(+), 21 deletions(-) create mode 100644 sites/mainweb/lib/judging-prep.test.ts create mode 100644 sites/mainweb/lib/judging-prep.ts diff --git a/sites/mainweb/app/(portal)/admin/judging/page.tsx b/sites/mainweb/app/(portal)/admin/judging/page.tsx index aff606a7..bd3fc79b 100644 --- a/sites/mainweb/app/(portal)/admin/judging/page.tsx +++ b/sites/mainweb/app/(portal)/admin/judging/page.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useEffect, useMemo } from "react"; +import React, { useState, useEffect, useMemo, useRef } from "react"; import { Zap } from "lucide-react"; import { useSession } from "next-auth/react"; import { trpc } from "@/lib/trpc"; @@ -13,6 +13,7 @@ import { JudgeMatrixView } from "@/components/admin/judging/JudgeMatrixView"; import { RankingsView } from "@/components/admin/judging/RankingsView"; import { LoadingScreen } from "@/components/portal/LoadingScreen"; import { JudgeLiveBoard } from "@/components/admin/hackathons/JudgeLiveBoard"; +import { judgingPrepIsCurrent } from "@/lib/judging-prep"; export default function AdminResultsPage() { const { data: session, status } = useSession(); @@ -68,9 +69,15 @@ export default function AdminResultsPage() { message: string | null; error: string | null; }>({ busy: false, message: null, error: null }); - // Set only when assignJudgesToProjects itself refused. A failed promote - // must not relabel into a button that sends force: true. - const [assignConflict, setAssignConflict] = useState(false); + // The edition whose assign call was refused. Rebuild anyway sends force + // for this id, not whatever is selected now — a late CONFLICT from A must + // not rebuild B. + const [assignConflictId, setAssignConflictId] = useState( + null, + ); + const selectedHackathonRef = useRef(selectedHackathon); + selectedHackathonRef.current = selectedHackathon; + const prepGen = useRef(0); const promoteSubmissions = trpc.judge.promoteSubmissions.useMutation(); const assignJudges = trpc.judge.assignJudgesToProjects.useMutation(); @@ -81,35 +88,52 @@ export default function AdminResultsPage() { "data" in error && (error as { data?: { code?: string } }).data?.code === "CONFLICT"; - const finishPrepare = async (message: string) => { - if (!selectedHackathon) return; - await utils.judge.getRankings.invalidate({ - hackathonId: selectedHackathon, - }); + const stillThisRun = (hackathonId: string, gen: number) => + judgingPrepIsCurrent( + hackathonId, + gen, + selectedHackathonRef.current, + prepGen.current, + ); + + const finishPrepare = async ( + hackathonId: string, + gen: number, + message: string, + ) => { + await utils.judge.getRankings.invalidate({ hackathonId }); + if (!stillThisRun(hackathonId, gen)) return; await refetchJudgingStatus(); - setAssignConflict(false); + if (!stillThisRun(hackathonId, gen)) return; + setAssignConflictId(null); setPrepState({ busy: false, error: null, message }); }; const prepareJudging = async () => { - if (!selectedHackathon) return; - setAssignConflict(false); + const hackathonId = selectedHackathon; + if (!hackathonId) return; + const gen = ++prepGen.current; + setAssignConflictId(null); setPrepState({ busy: true, message: null, error: null }); try { const promoted = await promoteSubmissions.mutateAsync({ - hackathonId: selectedHackathon, + hackathonId, }); const assigned = await assignJudges.mutateAsync({ - hackathonId: selectedHackathon, + hackathonId, }); + if (!stillThisRun(hackathonId, gen)) return; const warning = promoted.queuesNeedRebuild ? " One or more new projects carry a track no active judge covers — fix the track, then run this again." : ""; await finishPrepare( + hackathonId, + gen, `Synced ${promoted.created} new submission(s) of ${promoted.total}, and built queues for ${assigned.totalJudges} judge(s) covering ${assigned.coverage.min}-${assigned.coverage.max} projects each. Print the table cards next.${warning}`, ); } catch (e) { - setAssignConflict(isAssignConflict(e)); + if (!stillThisRun(hackathonId, gen)) return; + setAssignConflictId(isAssignConflict(e) ? hackathonId : null); setPrepState({ busy: false, message: null, @@ -122,18 +146,24 @@ export default function AdminResultsPage() { // asks for confirmation. This is the only control that sends force: true — // /admin/setup used to, and now redirects here. const rebuildQueuesAnyway = async () => { - if (!selectedHackathon) return; + const hackathonId = assignConflictId; + if (!hackathonId || hackathonId !== selectedHackathonRef.current) return; + const gen = ++prepGen.current; setPrepState((s) => ({ ...s, busy: true })); try { const assigned = await assignJudges.mutateAsync({ - hackathonId: selectedHackathon, + hackathonId, force: true, }); + if (!stillThisRun(hackathonId, gen)) return; await finishPrepare( + hackathonId, + gen, `Rebuilt queues for ${assigned.totalJudges} judge(s) covering ${assigned.coverage.min}-${assigned.coverage.max} projects each. Completed slots were kept.`, ); } catch (e) { - setAssignConflict(isAssignConflict(e)); + if (!stillThisRun(hackathonId, gen)) return; + setAssignConflictId(isAssignConflict(e) ? hackathonId : null); setPrepState({ busy: false, message: null, @@ -160,7 +190,8 @@ export default function AdminResultsPage() { }, [hackathons, selectedHackathon]); useEffect(() => { - setAssignConflict(false); + prepGen.current += 1; + setAssignConflictId(null); setPrepState({ busy: false, message: null, error: null }); }, [selectedHackathon]); @@ -385,13 +416,13 @@ export default function AdminResultsPage() {

{prepState.error}

- {assignConflict && ( + {assignConflictId === selectedHackathon && (