From aa8d69cd51535e87498a7855459fdf57bdc17ef3 Mon Sep 17 00:00:00 2001 From: Aly <156541350+alysibak@users.noreply.github.com> Date: Sat, 4 Jul 2026 17:47:51 -0400 Subject: [PATCH 01/24] Add in-person judge portal at /judging with critique-driven UX fixes. Departure-board wayfinding, derived slot status, localStorage persistence, auto-advance after judging, and production-safe mock handling so judges can walk a venue without placeholder noise. Co-authored-by: Cursor --- client/app/judging/README.md | 542 ++++++++++++++++++ client/app/judging/judging-portal.tsx | 315 ++++++++++ client/app/judging/judging.css | 457 +++++++++++++++ client/app/judging/layout.tsx | 17 + client/app/judging/page.tsx | 19 +- client/app/layout.tsx | 2 +- client/components/judging/all-done.tsx | 17 + .../components/judging/judge-notes-panel.tsx | 36 ++ client/components/judging/judging-header.tsx | 66 +++ client/components/judging/live-ribbon.tsx | 47 ++ client/components/judging/location-board.tsx | 61 ++ .../components/judging/project-spotlight.tsx | 156 +++++ client/components/judging/session-rail.tsx | 86 +++ client/components/judging/session-timer.tsx | 78 +++ client/lib/fonts.ts | 5 +- client/lib/judging/constants.ts | 1 + client/lib/judging/format.ts | 167 ++++++ client/lib/judging/get-data.ts | 80 +++ client/lib/judging/mock-data.ts | 90 +++ client/lib/judging/slots.ts | 29 + client/lib/judging/storage.ts | 30 + client/lib/judging/types.ts | 35 ++ 22 files changed, 2331 insertions(+), 5 deletions(-) create mode 100644 client/app/judging/README.md create mode 100644 client/app/judging/judging-portal.tsx create mode 100644 client/app/judging/judging.css create mode 100644 client/app/judging/layout.tsx create mode 100644 client/components/judging/all-done.tsx create mode 100644 client/components/judging/judge-notes-panel.tsx create mode 100644 client/components/judging/judging-header.tsx create mode 100644 client/components/judging/live-ribbon.tsx create mode 100644 client/components/judging/location-board.tsx create mode 100644 client/components/judging/project-spotlight.tsx create mode 100644 client/components/judging/session-rail.tsx create mode 100644 client/components/judging/session-timer.tsx create mode 100644 client/lib/judging/constants.ts create mode 100644 client/lib/judging/format.ts create mode 100644 client/lib/judging/get-data.ts create mode 100644 client/lib/judging/mock-data.ts create mode 100644 client/lib/judging/slots.ts create mode 100644 client/lib/judging/storage.ts create mode 100644 client/lib/judging/types.ts diff --git a/client/app/judging/README.md b/client/app/judging/README.md new file mode 100644 index 0000000..aee6d08 --- /dev/null +++ b/client/app/judging/README.md @@ -0,0 +1,542 @@ +# Judge Portal (`/judging`) — Full Work Log + +**Author:** Aly +**Branch:** `aly/judging` (off `development`) +**Route:** `/judging` +**App location:** `client/` (not `frontend/` — the old onboarding app is deprecated for this work) + +This document records **everything** done to build the HackCanada judge desk: setup, code, design iterations, files created, files removed, configuration, and what is still outstanding. + +--- + +## Table of contents + +1. [Assignment context](#assignment-context) +2. [Git & branch setup](#git--branch-setup) +3. [How to run locally](#how-to-run-locally) +4. [Environment & database](#environment--database) +5. [Architecture overview](#architecture-overview) +6. [Every file created or modified](#every-file-created-or-modified) +7. [Design evolution (all iterations)](#design-evolution-all-iterations) +8. [Current UI — what judges see](#current-ui--what-judges-see) +9. [Component reference](#component-reference) +10. [Data layer](#data-layer) +11. [Types & constants](#types--constants) +12. [CSS design system](#css-design-system) +13. [Shared app changes](#shared-app-changes) +14. [Frontend design skill](#frontend-design-skill) +15. [Files removed during development](#files-removed-during-development) +16. [What is NOT done yet](#what-is-not-done-yet) +17. [Team integration notes](#team-integration-notes) +18. [Git status (as of last session)](#git-status-as-of-last-session) + +--- + +## Assignment context + +From the team brief: + +| Item | Value | +|------|-------| +| **Owner** | Aly | +| **Portal** | Judge (`/judging`) | +| **Branch name** | `aly/judging` | +| **Base branch** | `development` | +| **Stack** | Next.js App Router, TypeScript, Tailwind CSS v4, shadcn/ui, Neon PostgreSQL | +| **Due** | Rough draft — Sunday July 5 (end of day) | + +The judge portal is for **in-person** hackathon judging: judges walk between tables/rooms, view their schedule, see project details, take notes, and mark projects as judged. It is **not** a virtual-investment or money-allocation UI (that was briefly built based on the root README, then removed per feedback). + +--- + +## Git & branch setup + +1. Fetched `origin/development` — the new monorepo app lives in `client/`. +2. Checked out `development` and pulled latest. +3. Created branch `aly/judging`. +4. Renamed the old local `aly` branch to `aly-onboarding-old` (Git cannot have both `aly` and `aly/judging` as branch names). +5. Stashed an unrelated change on the old `frontend/app/layout.tsx` (`suppressHydrationWarning` for Grammarly hydration). + +**Note:** Work on `aly/judging` was **not committed or pushed** during the build sessions. See [Git status](#git-status-as-of-last-session). + +--- + +## How to run locally + +```bash +cd client +npm install +``` + +Create `client/.env.local`: + +```env +DATABASE_URL=postgresql://...your Neon connection string... +``` + +```bash +npm run dev +``` + +Open **http://localhost:3000/judging** + +Or from the home page: **http://localhost:3000** → click **Judging**. + +Production build verified with: + +```bash +npm run build +``` + +--- + +## Environment & database + +| File | Purpose | +|------|---------| +| `client/.env.local` | Neon `DATABASE_URL` (gitignored) | +| `client/.env.example` | Template from `development` branch | + +**Connection behavior:** + +- If `DATABASE_URL` is missing → uses **mock data** (`lib/judging/mock-data.ts`). UI shows a **preview** label in the header. +- If `DATABASE_URL` is set → queries `public.projects` table. +- If query fails or table is empty → falls back to mock data. +- `/api/db-check` verifies the Neon connection (existing route from `development`). + +**Database table expected (from Linus’s branch pattern on `development`):** + +```sql +-- public.projects +id, project_name, tracks, members, devpost_link, submitter_name, ... +``` + +**Not yet in DB:** judged status, judge notes, real judging slots/rooms (slots are generated client-side when using DB projects). + +--- + +## Architecture overview + +``` +app/judging/ + page.tsx Server Component — fetches data, renders portal + layout.tsx Judging shell wrapper + Toaster + judging.css Scoped design tokens & layout classes + judging-portal.tsx Client Component — all interactive state + +components/judging/ + judging-header.tsx Top bar — event name, progress counter + location-board.tsx Signature hero — giant table number + project-spotlight.tsx ProjectHero + ProjectDetails + session-timer.tsx Live countdown (updates every second) + session-rail.tsx Schedule list (sidebar / mobile collapsible) + judge-notes-panel.tsx Private notes textarea + +lib/judging/ + types.ts JudgingProject, JudgingSlot, JudgeNotes + constants.ts EVENT_NAME + format.ts Time formatting, location parsing, slot progress + mock-data.ts 5 demo projects + 4 demo slots + get-data.ts Server-side fetch: DB or mock +``` + +**Rendering flow:** + +1. `page.tsx` calls `getJudgingProjects()` and `getJudgingSlots()`. +2. Passes `projects`, `slots`, `dataSource` to ``. +3. Portal holds client state: `activeProjectId`, `judgedIds`, `notes`, `showSchedule`. +4. Selecting a schedule row scrolls to top and switches the active project. + +--- + +## Every file created or modified + +### Created — judging route + +| File | Description | +|------|-------------| +| `client/app/judging/page.tsx` | Server page; `dynamic = "force-dynamic"`; wires data to portal | +| `client/app/judging/layout.tsx` | Wraps children in `.judging-shell`; Sonner toaster | +| `client/app/judging/judging.css` | Full scoped CSS design system | +| `client/app/judging/judging-portal.tsx` | Main client UI orchestrator | +| `client/app/judging/README.md` | This file | + +### Created — components + +| File | Description | +|------|-------------| +| `client/components/judging/judging-header.tsx` | White header bar with judged count (e.g. `2/4`, `50% complete`) | +| `client/components/judging/location-board.tsx` | Parses room string; renders giant table number in hero | +| `client/components/judging/project-spotlight.tsx` | `ProjectHero` (full-bleed black band) + `ProjectDetails` (description, meta) | +| `client/components/judging/session-timer.tsx` | Countdown for live slots; `hero` and `inline` variants | +| `client/components/judging/session-rail.tsx` | Schedule list with times, rooms, Now/Next/Done/Judged states | +| `client/components/judging/judge-notes-panel.tsx` | Large notes textarea per project | + +### Created — lib + +| File | Description | +|------|-------------| +| `client/lib/judging/types.ts` | TypeScript types | +| `client/lib/judging/constants.ts` | `EVENT_NAME = "HackCanada"` | +| `client/lib/judging/format.ts` | `formatSlotTime`, `formatSlotRange`, `formatSlotTimeShort`, `getInitials`, `getTimeRemaining`, `getSlotProgress`, `parseLocation` | +| `client/lib/judging/mock-data.ts` | 5 projects (Aurora Transit, Harvest Ledger, etc.) + 4 slots with relative times | +| `client/lib/judging/get-data.ts` | `getJudgingProjects()`, `getJudgingSlots()` | + +### Created — tooling / docs + +| File | Description | +|------|-------------| +| `.cursor/skills/frontend-design/SKILL.md` | Rewritten design skill (see below) | + +### Modified — shared client files + +| File | Change | +|------|--------| +| `client/app/layout.tsx` | Added `fredoka.variable` to `` for `--font-fredoka` CSS variable | +| `client/lib/fonts.ts` | Fredoka now exports `variable: "--font-fredoka"` (was class-only) | + +### Created then deleted + +| File | Why removed | +|------|-------------| +| `client/components/judging/capital-bar.tsx` | Virtual money UI — wrong for in-person judging | +| `client/components/judging/allocation-panel.tsx` | Slider / quick dollar amounts — removed | +| `client/components/judging/portfolio-ledger.tsx` | Capital distribution sidebar — removed | +| `client/components/judging/submit-dialog.tsx` | Confirm save allocations dialog — removed | +| `client/components/judging/round-progress.tsx` | Replaced by schedule judged states in session rail | +| `client/scripts/check-db.ts` | Temporary DB probe script — deleted after use | + +--- + +## Design evolution (all iterations) + +### Iteration 1 — Initial portal (virtual capital) + +Built from the root `readme.md` description (“judges allocate virtual investment funds”): + +- $10,000 budget, capital bar, allocation slider +- Quick amounts ($500, $1k, $2.5k, $5k) +- Portfolio ledger with proportion bars +- Save allocations + confirm dialog +- Dark navy dashboard aesthetic with blue glows and grid background + +**Feedback:** Wrong concept — in-person event, no money UI. + +### Iteration 2 — In-person pivot + +Removed all money/allocation. Replaced with: + +- Schedule queue with Live / Next / Done +- “Mark as judged” (client-side `Set`) +- Judge notes (client-side `Record`) +- Room/location emphasis +- Warm paper editorial aesthetic (light mode) to avoid generic dark AI dashboard + +### Iteration 3 — Anti-AI polish + +User asked for less “AI looking”: + +- Removed dark mode, grid backgrounds, glass blur, gradient panels +- Removed uppercase `TRACKING-WIDEST` eyebrow labels +- Signature element: **departure-board location block** (black card, table number) +- Teal actions (`#115E59`), red live state (`#B91C1C`) +- Sentence case copy throughout + +### Iteration 4 — Bigger & more outstanding (current) + +User asked for larger, more dramatic UI: + +- **Full-bleed black hero** spanning viewport width +- Table number up to **~11rem** (`clamp(5.5rem, 20vw, 11rem)`) +- Project title up to **~4.5rem** in hero +- Red **live bar** across top of hero when slot is active +- Pulsing **LIVE** pill +- Large tabular countdown timer in hero +- Wider layout (`max-width: 80rem`) +- Larger description text, schedule panel, notes field +- Fixed footer with prominent **Mark as judged** CTA (`j-cta` class) +- Selecting a schedule item scrolls to top + +--- + +## Current UI — what judges see + +### Header (`judging-header.tsx`) + +- HackCanada · Judge desk +- Judged counter: `2/4` with `% complete` +- Link back to portals (desktop) +- “preview” label when using mock data + +### Hero (`ProjectHero` + `LocationBoard`) + +Full-width black band: + +- Left: **GO HERE NOW** + giant table number (e.g. `4`) + venue (e.g. Maple Hall) +- Right: LIVE pill (if active) + project name + team + large countdown + +Room strings like `Maple Hall · Table 4` are parsed by `parseLocation()` into venue + table number. + +### Content area + +- **Project description** (large body text) +- **Meta grid:** team members, track pills, Devpost link +- **Notes:** large textarea, per-project, device-local only +- **Schedule sidebar** (desktop, sticky) or collapsible (mobile) + +### Footer (fixed) + +- Context message (desktop) +- **Mark as judged** button (large, teal) or **Undo** if already judged + +### Empty state + +If no projects: centered message to contact an organizer. + +--- + +## Component reference + +### `JudgingPortal` state + +| State | Type | Purpose | +|-------|------|---------| +| `activeProjectId` | `string` | Currently viewed project | +| `judgedIds` | `Set` | Projects marked judged (not persisted) | +| `notes` | `JudgeNotes` | Per-project notes (not persisted) | +| `showSchedule` | `boolean` | Mobile schedule toggle | + +### `SessionTimer` + +- Ticks every 1s when status is `live` +- `variant="hero"` — large text on black background +- `variant="inline"` — smaller (available, used in older layouts) + +### `SessionRail` + +- Grid rows: time | project name + room | status icon +- `aria-current="true"` on active row +- Live active row: red left inset border (`j-schedule-row--live`) +- Judged projects: strikethrough name + green check + +--- + +## Data layer + +### `getJudgingProjects()` + +1. No `DATABASE_URL` → mock +2. Query `projects` table +3. Empty or error → mock +4. Maps DB rows to `JudgingProject` (description is auto-generated placeholder for DB imports) + +### `getJudgingSlots(projects, source)` + +- **Mock source:** returns `MOCK_SLOTS` (4 slots with dynamic relative times) +- **Database source:** generates up to 4 synthetic slots from first 4 projects (15 min each, 25 min apart); first = live, second = upcoming, rest = done. Rooms use `project.room ?? "TBD"`. + +**Important:** Real judging slots from admin (Tenzin’s work) are **not** wired yet. + +### Mock projects (5) + +1. Aurora Transit — Maple Hall · Table 4 (live slot) +2. Harvest Ledger — Cedar Room · Table 2 +3. Signal Garden — Maple Hall · Table 7 +4. Forge Cartographer — Pine Atrium · Table 1 (done) +5. Tide Relay — Cedar Room · Table 9 + +--- + +## Types & constants + +```ts +// types.ts +JudgingProject { id, name, team, tracks, members, description, devpostUrl, room } +JudgingSlot { id, projectId, startTime, endTime, room, status } +JudgeNotes = Record +// status: "upcoming" | "live" | "done" + +// constants.ts +EVENT_NAME = "HackCanada" +``` + +### `format.ts` utilities + +| Function | Purpose | +|----------|---------| +| `formatSlotTime` | e.g. `8:45 PM` | +| `formatSlotTimeShort` | 24h e.g. `20:45` for schedule column | +| `formatSlotRange` | `8:45 PM – 9:00 PM` | +| `getInitials` | Avatar initials from name | +| `getTimeRemaining` | `{ label: "4:32", minutes, seconds }` | +| `getSlotProgress` | 0–100 for slot elapsed % | +| `parseLocation` | Splits `Maple Hall · Table 4` → venue + table number | + +--- + +## CSS design system + +All classes live in `client/app/judging/judging.css`, scoped under `.judging-shell`. + +### Color tokens + +| Token | Value | Use | +|-------|-------|-----| +| `--j-paper` | `#f5f3ef` | Page background | +| `--j-ink` | `#141210` | Primary text / hero background | +| `--j-muted` | `#6b6560` | Secondary text | +| `--j-faint` | `#9c9690` | Tertiary text | +| `--j-border` | `#e0ddd6` | Borders | +| `--j-white` | `#ffffff` | Cards, header | +| `--j-live` | `#e11d2e` | Live indicator, countdown | +| `--j-action` | `#115e59` | Primary button (teal) | +| `--j-done` | `#166534` | Judged state | + +### Key CSS classes + +| Class | Purpose | +|-------|---------| +| `.j-hero` | Full-bleed black hero section | +| `.j-hero-live-bar` | 4px red bar at top when live | +| `.j-hero-grid` | 2-column hero layout (location + project) | +| `.j-location-table-num` | Giant Fredoka table number | +| `.j-hero-title` | Giant project name | +| `.j-live-pill` | Red LIVE badge with pulse dot | +| `.j-content` | Main content area padding | +| `.j-content-grid` | Content + schedule sidebar grid | +| `.j-schedule-panel` | White schedule card | +| `.j-schedule-row` | Individual schedule row | +| `.j-notes-input` | Large notes textarea | +| `.j-footer` / `.j-cta` | Fixed footer and primary button | + +`prefers-reduced-motion` disables animations and transitions. + +--- + +## Shared app changes + +### `client/app/layout.tsx` + +- Added `${fredoka.variable}` to `` so judging CSS can use `var(--font-fredoka)` for the table number only. +- Root body still uses Fredoka + blue `bg-primary` for other portals (`/`, `/admin`, etc.). +- Judging portal overrides appearance entirely via `.judging-shell` (warm paper, not blue). + +### `client/lib/fonts.ts` + +- Fredoka exports CSS variable `--font-fredoka` in addition to `.className`. + +--- + +## Frontend design skill + +Created/rewrote `.cursor/skills/frontend-design/SKILL.md` based on a critique of the original skill doc. + +**Additions include:** + +- When to apply / when not to apply +- Greenfield vs in-system rules (extend shared app, don’t full re-skin) +- Fidelity levels (rough draft → ship) +- Operational uniqueness checklist before coding +- Motion rules (functional vs atmospheric vs decorative) +- Required design plan template before UI code +- Example of weak vs strong hero copy +- Removed vague “work through a similar prompt” instruction + +--- + +## Files removed during development + +| Phase | Removed | +|-------|---------| +| Money UI pivot | `capital-bar.tsx`, `allocation-panel.tsx`, `portfolio-ledger.tsx`, `submit-dialog.tsx` | +| Layout simplification | `round-progress.tsx` (merged into session rail) | +| Temp tooling | `scripts/check-db.ts` | +| Constants | `JUDGING_BUDGET`, `QUICK_ALLOCATIONS` removed from `constants.ts` | + +--- + +## What is NOT done yet + +| Item | Status | +|------|--------| +| Persist “marked as judged” to database | Client-only `Set` — lost on refresh | +| Persist judge notes to database | Client-only — lost on refresh | +| Real judging slots from admin | Synthetic slots when using DB; mock slots otherwise | +| Room data from DB projects | `room` is `null` for DB imports; slots use `"TBD"` | +| Auth / judge identity | No login; no judge name | +| API routes for judging actions | No `POST` endpoints | +| Scoring / rubric | Not implemented | +| Integration with Tenzin’s `/admin` | Not wired | +| Git commit / push to `origin/aly/judging` | Work is local, uncommitted | +| Pull request | Not created | +| Tests | None added | +| `turbopack.root` warning | Harmless warning about lockfile path on Windows | + +--- + +## Team integration notes + +| Teammate | Portal | Branch | Relevance to judging | +|----------|--------|--------|----------------------| +| Amy | `/volunteer`, `/sponsor` | `amy/volunteer-sponsor` | — | +| Tenzin | `/admin` | `tenzin/admin` | Will own schedules, rooms, project assignment | +| Aly | `/judging` | `aly/judging` | This portal | +| Linus | `/hacker` | `linus/hacker` | Added `projects` table + API on his branch | + +**Next integration steps:** + +1. Align on DB schema for `judging_slots`, `judge_assignments`, `judgments` / notes. +2. Replace `getJudgingSlots()` synthetic data with real admin-created slots. +3. Add server actions or API routes to save judged status and notes. +4. Pull room info from admin schedule into project/slot records. + +--- + +## Git status (as of last session) + +``` +Branch: aly/judging (from development) + +Modified (not committed): + client/app/judging/page.tsx + client/app/layout.tsx + client/lib/fonts.ts + +Untracked (not committed): + .cursor/skills/frontend-design/SKILL.md + client/app/judging/judging-portal.tsx + client/app/judging/judging.css + client/app/judging/layout.tsx + client/app/judging/README.md + client/components/judging/ (entire folder) + client/lib/judging/ (entire folder) + +Also local (gitignored): + client/.env.local (DATABASE_URL configured) + client/node_modules/ +``` + +To commit when ready: + +```bash +git checkout aly/judging +git add client/app/judging client/components/judging client/lib/judging +git add client/app/layout.tsx client/lib/fonts.ts +git add .cursor/skills/frontend-design/SKILL.md +git commit -m "Add in-person judge desk portal at /judging" +git push -u origin aly/judging +``` + +--- + +## Quick reference — URLs + +| URL | Purpose | +|-----|---------| +| http://localhost:3000 | Portal picker | +| http://localhost:3000/judging | Judge desk | +| http://localhost:3000/api/db-check | DB connection test | + +--- + +*Last updated: July 4, 2026 — Aly, HackCanada judging platform.* diff --git a/client/app/judging/judging-portal.tsx b/client/app/judging/judging-portal.tsx new file mode 100644 index 0000000..f20aaa9 --- /dev/null +++ b/client/app/judging/judging-portal.tsx @@ -0,0 +1,315 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { toast } from "sonner"; +import { AllDone } from "@/components/judging/all-done"; +import { JudgeNotesPanel } from "@/components/judging/judge-notes-panel"; +import { JudgingHeader } from "@/components/judging/judging-header"; +import { LiveRibbon } from "@/components/judging/live-ribbon"; +import { ProjectDetails, ProjectHero } from "@/components/judging/project-spotlight"; +import { SessionRail } from "@/components/judging/session-rail"; +import { findLiveSlot, getNextUnjudgedProjectId, withDerivedStatus } from "@/lib/judging/slots"; +import { loadJudgingStorage, saveJudgingStorage } from "@/lib/judging/storage"; +import type { + DataSource, + JudgeNotes, + JudgingProject, + JudgingSlot, + MockReason, +} from "@/lib/judging/types"; + +type JudgingPortalProps = { + projects: JudgingProject[]; + slots: JudgingSlot[]; + dataSource: DataSource; + mockReason?: MockReason; +}; + +function scrollToTop() { + window.scrollTo({ top: 0, behavior: "smooth" }); +} + +export function JudgingPortal({ + projects, + slots, + dataSource, + mockReason, +}: JudgingPortalProps) { + const [now, setNow] = useState(() => Date.now()); + const [hydrated, setHydrated] = useState(false); + const [activeProjectId, setActiveProjectId] = useState(""); + const [judgedIds, setJudgedIds] = useState>(new Set()); + const [skippedIds, setSkippedIds] = useState>(new Set()); + const [earlyMarkedIds, setEarlyMarkedIds] = useState>(new Set()); + const [notes, setNotes] = useState({}); + const [showSchedule, setShowSchedule] = useState(false); + + const derivedSlots = useMemo(() => withDerivedStatus(slots, now), [slots, now]); + + useEffect(() => { + const id = window.setInterval(() => setNow(Date.now()), 1000); + return () => window.clearInterval(id); + }, []); + + useEffect(() => { + const stored = loadJudgingStorage(); + setJudgedIds(new Set(stored.judgedIds)); + setSkippedIds(new Set(stored.skippedIds)); + setNotes(stored.notes); + setHydrated(true); + }, []); + + useEffect(() => { + if (!hydrated || slots.length === 0) return; + const live = findLiveSlot(withDerivedStatus(slots, Date.now()), new Set()); + const first = live?.projectId ?? slots[0]?.projectId ?? ""; + setActiveProjectId((prev) => prev || first); + }, [hydrated, slots]); + + useEffect(() => { + if (!hydrated) return; + saveJudgingStorage({ + judgedIds: [...judgedIds], + skippedIds: [...skippedIds], + notes, + }); + }, [hydrated, judgedIds, skippedIds, notes]); + + const activeProject = projects.find((p) => p.id === activeProjectId) ?? projects[0]; + const activeSlot = derivedSlots.find((s) => s.projectId === activeProjectId); + const liveSlot = findLiveSlot(derivedSlots, judgedIds); + const liveProject = liveSlot + ? projects.find((p) => p.id === liveSlot.projectId) + : undefined; + + const isJudged = judgedIds.has(activeProject?.id ?? ""); + const judgedCount = judgedIds.size; + const allDone = slots.length > 0 && judgedCount >= slots.length; + const judgedEarly = earlyMarkedIds.has(activeProject?.id ?? ""); + + const showLiveRibbon = + liveSlot && + liveProject && + activeProjectId !== liveSlot.projectId && + !isJudged; + + const completeJudging = useCallback( + (projectId: string, mode: "judged" | "skipped") => { + const project = projects.find((p) => p.id === projectId); + if (!project || judgedIds.has(projectId)) return; + + const slot = derivedSlots.find((s) => s.projectId === projectId); + const wasUpcoming = slot?.status === "upcoming"; + + setJudgedIds((prev) => new Set(prev).add(projectId)); + if (mode === "skipped") { + setSkippedIds((prev) => new Set(prev).add(projectId)); + } + if (wasUpcoming) { + setEarlyMarkedIds((prev) => new Set(prev).add(projectId)); + } + + const message = + mode === "skipped" ? "Skipped — team absent" : "Marked as judged"; + + toast.success(message, { + description: project.name, + action: { + label: "Undo", + onClick: () => { + setJudgedIds((prev) => { + const next = new Set(prev); + next.delete(projectId); + return next; + }); + setSkippedIds((prev) => { + const next = new Set(prev); + next.delete(projectId); + return next; + }); + setEarlyMarkedIds((prev) => { + const next = new Set(prev); + next.delete(projectId); + return next; + }); + setActiveProjectId(projectId); + scrollToTop(); + }, + }, + }); + + const nextId = getNextUnjudgedProjectId( + slots, + new Set([...judgedIds, projectId]), + projectId + ); + if (nextId) { + setActiveProjectId(nextId); + scrollToTop(); + } + }, + [projects, derivedSlots, judgedIds, slots] + ); + + function updateNotes(value: string) { + if (!activeProject) return; + setNotes((prev) => ({ ...prev, [activeProject.id]: value })); + } + + function selectProject(id: string) { + setActiveProjectId(id); + scrollToTop(); + } + + if (!hydrated) { + return ( +
+ Loading… +
+ ); + } + + if (!activeProject || slots.length === 0) { + return ( +
+ +
+

+ Nothing scheduled +

+

+ Your judging block will show up here once projects are assigned. +

+
+
+ ); + } + + if (allDone) { + return ( +
+ +
+ +
+
+ ); + } + + return ( +
+ + + {showLiveRibbon && liveSlot && liveProject && ( + selectProject(liveSlot.projectId)} + /> + )} + + + +
+
+
+ + + +
+ + {showSchedule && ( +
+ { + selectProject(id); + setShowSchedule(false); + }} + /> +
+ )} +
+
+ + +
+
+ +
+
+

+ {isJudged + ? "Marked as judged." + : activeSlot?.status === "live" + ? "Visit the table, then mark as judged." + : "Review details before your slot."} +

+ {!isJudged && ( +
+ + +
+ )} +
+
+
+ ); +} diff --git a/client/app/judging/judging.css b/client/app/judging/judging.css new file mode 100644 index 0000000..57e2ac8 --- /dev/null +++ b/client/app/judging/judging.css @@ -0,0 +1,457 @@ +/* Judging — warm editorial, in-person wayfinding. Scoped to .judging-shell only. */ +.judging-shell { + --j-paper: #f5f3ef; + --j-ink: #141210; + --j-muted: #6b6560; + --j-faint: #9c9690; + --j-border: #e0ddd6; + --j-white: #ffffff; + --j-live: #e11d2e; + --j-live-bg: #fef2f2; + --j-action: #115e59; + --j-action-hover: #0d4a46; + --j-done: #166534; + --j-done-bg: #f0fdf4; + --j-overtime: #d97706; + + min-height: 100dvh; + display: flex; + flex-direction: column; + background-color: var(--j-paper); + color: var(--j-ink); + font-family: var(--font-rubik), system-ui, sans-serif; + -webkit-font-smoothing: antialiased; +} + +/* Full-bleed hero */ +.j-hero { + background: var(--j-ink); + color: var(--j-paper); + position: relative; + overflow: hidden; +} + +.j-hero::after { + content: ""; + position: absolute; + inset: auto 0 0 0; + height: 1px; + background: linear-gradient( + 90deg, + transparent, + rgb(245 243 239 / 0.15) 20%, + rgb(245 243 239 / 0.15) 80%, + transparent + ); +} + +.j-hero-live-bar { + height: 4px; + background: var(--j-live); + width: 100%; +} + +.j-hero-inner { + width: 100%; + max-width: 80rem; + margin-inline: auto; + padding: clamp(2rem, 5vw, 3.5rem) clamp(1.25rem, 4vw, 2.5rem) clamp(2.5rem, 6vw, 4rem); +} + +.j-hero-grid { + display: grid; + gap: clamp(2rem, 5vw, 3rem); + align-items: end; +} + +@media (min-width: 768px) { + .j-hero-grid { + grid-template-columns: minmax(0, 0.85fr) minmax(0, 1.15fr); + gap: clamp(2rem, 4vw, 4rem); + } + + .j-hero-grid--solo { + grid-template-columns: 1fr; + } +} + +/* Signature: departure-board number */ +.j-location-label { + font-size: clamp(0.75rem, 1.5vw, 0.875rem); + font-weight: 600; + letter-spacing: 0.12em; + text-transform: uppercase; + color: rgb(245 243 239 / 0.45); +} + +.j-location-table-num { + font-family: var(--font-fredoka), system-ui, sans-serif; + font-size: clamp(5.5rem, 20vw, 11rem); + font-weight: 600; + line-height: 0.85; + letter-spacing: -0.04em; + font-variant-numeric: tabular-nums; +} + +.j-location-venue { + font-size: clamp(1rem, 2vw, 1.25rem); + font-weight: 500; + color: rgb(245 243 239 / 0.65); + margin-top: 0.75rem; + max-width: 20ch; + line-height: 1.35; +} + +.j-location-table-num--venue { + font-size: clamp(2.5rem, 10vw, 4rem); + line-height: 1.1; +} + +.j-hero-title { + font-size: clamp(2.25rem, 6vw, 4.5rem); + font-weight: 600; + line-height: 1.05; + letter-spacing: -0.03em; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.j-hero-team { + font-size: clamp(1.125rem, 2.5vw, 1.5rem); + color: rgb(245 243 239 / 0.55); + margin-top: 0.75rem; +} + +.j-hero-timer { + font-size: clamp(1.5rem, 3vw, 2.25rem); + font-weight: 600; + font-variant-numeric: tabular-nums; + letter-spacing: -0.02em; +} + +.j-live-pill { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.375rem 0.875rem; + background: var(--j-live); + color: white; + font-size: 0.8125rem; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + border-radius: 2px; +} + +.j-live-pill-dot { + width: 0.5rem; + height: 0.5rem; + border-radius: 999px; + background: white; + animation: j-pulse 1.5s ease-in-out infinite; +} + +@keyframes j-pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.35; + } +} + +@media (prefers-reduced-motion: reduce) { + .j-live-pill-dot { + animation: none; + } +} + +/* Content area */ +.j-content { + width: 100%; + max-width: 80rem; + margin-inline: auto; + padding: clamp(2rem, 4vw, 3rem) clamp(1.25rem, 4vw, 2.5rem) clamp(6rem, 10vw, 8rem); +} + +.j-content-grid { + display: grid; + gap: clamp(2.5rem, 5vw, 4rem); +} + +@media (min-width: 1024px) { + .j-content-grid { + grid-template-columns: minmax(0, 1fr) minmax(0, 20rem); + gap: 4rem; + } +} + +.j-description { + font-size: clamp(1.125rem, 2vw, 1.375rem); + line-height: 1.75; + color: rgb(20 18 16 / 0.82); + max-width: 42rem; +} + +.j-meta-grid { + display: grid; + gap: 1.5rem; + margin-top: 2.5rem; + padding-top: 2.5rem; + border-top: 1px solid var(--j-border); +} + +@media (min-width: 640px) { + .j-meta-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +.j-meta-label { + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--j-faint); + margin-bottom: 0.5rem; +} + +.j-track-pill { + display: inline-block; + padding: 0.375rem 0.75rem; + font-size: 0.875rem; + font-weight: 500; + border: 1px solid var(--j-border); + background: var(--j-white); + color: var(--j-muted); + border-radius: 2px; +} + +.j-track-pill--quiet { + font-size: 0.8125rem; + padding: 0.25rem 0.5rem; + color: var(--j-faint); + background: transparent; +} + +.j-mock-banner { + background: var(--j-live); + color: white; + padding: 0.75rem 1.25rem; + font-size: 0.875rem; + font-weight: 600; + text-align: center; + line-height: 1.4; +} + +.j-live-ribbon { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.75rem clamp(1.25rem, 4vw, 2.5rem); + background: var(--j-live-bg); + border-bottom: 1px solid rgb(225 29 46 / 0.2); +} + +.j-live-ribbon-btn { + padding: 0.375rem 0.875rem; + font-size: 0.8125rem; + font-weight: 700; + color: white; + background: var(--j-live); + border: none; + border-radius: 2px; + cursor: pointer; +} + +.j-live-ribbon-btn:hover { + opacity: 0.9; +} + +.j-all-done { + text-align: center; + padding: 3rem 1rem; +} + +/* Schedule */ +.j-schedule-panel { + background: var(--j-white); + border: 1px solid var(--j-border); + border-radius: 4px; + padding: 1.5rem; +} + +@media (min-width: 1024px) { + .j-schedule-panel { + position: sticky; + top: 1.5rem; + } +} + +.j-schedule-title { + font-size: 1.125rem; + font-weight: 600; +} + +.j-schedule-row { + display: grid; + grid-template-columns: 5.5rem 1fr auto; + gap: 0.75rem 1.25rem; + align-items: center; + padding: 1.125rem 0; + border-bottom: 1px solid var(--j-border); + text-align: left; + width: 100%; + transition: background 150ms ease; +} + +.j-schedule-row:last-child { + border-bottom: none; +} + +.j-schedule-row:hover { + background: rgb(20 18 16 / 0.02); +} + +.j-schedule-row[aria-current="true"] { + background: var(--j-paper); + margin-inline: -1rem; + padding-inline: 1rem; + border-bottom-color: transparent; +} + +.j-schedule-row--live[aria-current="true"] { + box-shadow: inset 4px 0 0 var(--j-live); +} + +.j-schedule-time { + font-size: 1rem; + font-weight: 600; + font-variant-numeric: tabular-nums; + color: var(--j-muted); +} + +.j-schedule-name { + font-size: 1rem; + font-weight: 600; + line-height: 1.3; +} + +.j-schedule-room { + font-size: 0.8125rem; + color: var(--j-faint); + margin-top: 0.25rem; +} + +/* Notes */ +.j-notes-input { + width: 100%; + min-height: 8rem; + margin-top: 1rem; + padding: 1.25rem; + font-size: 1.0625rem; + line-height: 1.65; + background: var(--j-white); + border: 1px solid var(--j-border); + border-radius: 4px; + color: var(--j-ink); + resize: vertical; + outline: none; +} + +.j-notes-input:focus { + border-color: var(--j-ink); +} + +.j-notes-input::placeholder { + color: var(--j-faint); +} + +/* Footer CTA */ +.j-footer { + position: fixed; + bottom: 0; + left: 0; + right: 0; + z-index: 50; + background: var(--j-paper); + border-top: 1px solid var(--j-border); + padding: 1rem clamp(1.25rem, 4vw, 2.5rem); + padding-bottom: max(1rem, env(safe-area-inset-bottom)); + box-shadow: 0 -8px 32px rgb(20 18 16 / 0.06); +} + +.j-footer-inner { + max-width: 80rem; + margin-inline: auto; + display: flex; + align-items: center; + justify-content: space-between; + gap: 1.5rem; +} + +.j-cta { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 3.25rem; + padding: 0 2rem; + font-size: 1.0625rem; + font-weight: 700; + color: white; + background: var(--j-action); + border: none; + border-radius: 4px; + cursor: pointer; + transition: background 150ms ease; + white-space: nowrap; +} + +.j-cta:hover { + background: var(--j-action-hover); +} + +.j-cta:focus-visible { + outline: 2px solid var(--j-action); + outline-offset: 3px; +} + +.j-cta-secondary { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 3.25rem; + padding: 0 1.25rem; + font-size: 0.9375rem; + font-weight: 600; + color: var(--j-muted); + background: transparent; + border: 1px solid var(--j-border); + border-radius: 4px; + cursor: pointer; + white-space: nowrap; +} + +.j-cta-secondary:hover { + color: var(--j-ink); + border-color: var(--j-muted); +} + +@media (min-width: 640px) { + .j-cta { + min-height: 3.5rem; + padding: 0 2.5rem; + font-size: 1.125rem; + } +} + +@media (prefers-reduced-motion: reduce) { + .j-schedule-row, + .j-cta { + transition: none; + } +} diff --git a/client/app/judging/layout.tsx b/client/app/judging/layout.tsx new file mode 100644 index 0000000..a6871b3 --- /dev/null +++ b/client/app/judging/layout.tsx @@ -0,0 +1,17 @@ +import type { Metadata } from "next"; +import { Toaster } from "@/components/ui/sonner"; +import "./judging.css"; + +export const metadata: Metadata = { + title: "Judge desk · HackCanada", + description: "In-person hackathon judging — schedule, locations, and project details for judges.", +}; + +export default function JudgingLayout({ children }: { children: React.ReactNode }) { + return ( +
+ {children} + +
+ ); +} diff --git a/client/app/judging/page.tsx b/client/app/judging/page.tsx index 80f076a..5bf64e0 100644 --- a/client/app/judging/page.tsx +++ b/client/app/judging/page.tsx @@ -1,5 +1,18 @@ -import { PortalStub } from "@/components/portal-stub"; +import { JudgingPortal } from "./judging-portal"; +import { getJudgingProjects, getJudgingSlots } from "@/lib/judging/get-data"; -export default function JudgingPage() { - return ; +export const dynamic = "force-dynamic"; + +export default async function JudgingPage() { + const { projects, source, mockReason } = await getJudgingProjects(); + const slots = getJudgingSlots(projects, source); + + return ( + + ); } diff --git a/client/app/layout.tsx b/client/app/layout.tsx index d7e4feb..cb8f0fc 100644 --- a/client/app/layout.tsx +++ b/client/app/layout.tsx @@ -13,7 +13,7 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - + +

+ You're done +

+

+ {judgedCount} project{judgedCount === 1 ? "" : "s"} judged this round. Head back to + the organizer desk if you need anything else. +

+ + ); +} diff --git a/client/components/judging/judge-notes-panel.tsx b/client/components/judging/judge-notes-panel.tsx new file mode 100644 index 0000000..f71693c --- /dev/null +++ b/client/components/judging/judge-notes-panel.tsx @@ -0,0 +1,36 @@ +"use client"; + +import { useId } from "react"; +import type { JudgingProject } from "@/lib/judging/types"; + +type JudgeNotesPanelProps = { + project: JudgingProject; + notes: string; + onChange: (notes: string) => void; +}; + +export function JudgeNotesPanel({ project, notes, onChange }: JudgeNotesPanelProps) { + const textareaId = useId(); + + return ( +
+ +

+ Private notes for {project.name} — saved on this device only. +

+