diff --git a/CLAUDE.md b/CLAUDE.md
index de21906c..e8d179e4 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -516,7 +516,21 @@ Team docs carry the DevPost-replacement write-up (all optional; absent ⇒ legac
- **Team page (`team/[team_id]/index.js`):** `TeamProjectSection` renders first (`id="project"`, in the TOC) when `hasProjectContent`; tagline `.ohx-lead`, 16:9 `--surface-2` thumbnail box, story, built-with `.ohx-tag`s, link tiles, submission line (public: "Submitted Sat 2:58 PM MST" / "Submitted late …" / quiet "In progress"; members: "Draft — not yet submitted." + "Edit on your dashboard →" → `manageteam#project`). Masthead adds a submission `.ohx-tag` when status is non-null. The member nudge covers missing story / missing demo / not submitted (DevPost is no longer nagged). `
` OG/twitter image + card and the JSON-LD come from `projectMeta`. `next/image` ONLY for `cdn.ohack.dev` (the only allowed `remotePatterns` host besides giphy) — YouTube posters are plain ``.
- **Gallery (`Hackathon/TeamList.js`):** module-scope `ProjectMedia` (16:9 reserved box: thumbnail → `LiteVideoThumbnail` → Fraunces initial placeholder) tops each card; tagline 2-line clamp; first 3 built-with + `+N`; submission tag; DevPost demoted to a quiet "DevPost ↗" link (absent + member → "Add your project story →" to `manageteam#project`); `submittedOnly` toggle (`aria-pressed`, rendered only when n > 0) over a stable sort that keeps active-first as the primary order and submitted-first within it (`useMemo`); "No submitted projects yet." only while the toggle is on. Lazy per-card avatar loading is untouched. No new fetches.
- **Results (`Hackathon/HackathonResults.js` — also rendered outside a `RefinedRoot` on `/results`, so `var(--x, #hex)` fallbacks):** winner cards show `project_tagline` + `awards[]` pills (Part 9 bug #11 — awards were never rendered; guard non-array), DevPost chip only when present; module-scope `HackersChoiceCard` (new `peerVoteEnabled` prop = `!!constraints.peer_vote_enabled`, passed from the event page and `results.js`) fetches `GET /api/hackathons//peer-vote/summary` into a `minHeight 148` slot — published → winner link + "{ballots} peer ballots"; 404/unpublished → "announced at the awards ceremony"; disabled → renders nothing. `HackathonFunnel` sublabels are source-neutral. `Profile/Portfolio/TeamsShowcaseSection.jsx` imports `WINNING_STATUSES` from `constants/teamStatus` (bug #12 — no private copy).
-- **Judge views — demo video only, no process change:** `pages/judge/[event_id].js` and `pages/judge/[event_id]/team/[team_id].js` read `demo_video_url || video_url` (the backend now fills the legacy `video_url` from `demo_video_url`); the scoring page embeds `VideoDisplay` (dynamic, ssr:false, 16:9 container) under the GitHub/DevPost links, gated `!isRound2` exactly like the pre-existing "Watch Pitch Video" button. Rubric, scoring form, rounds, `JudgingRound1/2/Results`, `/about/judges`, `/hackathon-judging-criteria` and `mentorCoverage.js` are untouched — verified with a prettier-normalized diff.
+- **Judge views — demo video only, no process change:** `pages/judge/[event_id].js` and `pages/judge/[event_id]/team/[team_id].js` read `demo_video_url || video_url` (the backend now fills the legacy `video_url` from `demo_video_url`); the scoring page embeds `VideoDisplay` (dynamic, ssr:false, 16:9 container) under the GitHub/DevPost links, gated `!isRound2` exactly like the pre-existing "Watch Pitch Video" button. Rubric, rounds, `/about/judges`, `/hackathon-judging-criteria` and `mentorCoverage.js` are untouched. (The scoring form and `JudgingRound1/2/Results` were later rebuilt by the Oct 2026 judging pass — see "Judging experience" below — without changing the rubric or the results math beyond the documented tie-break.)
+
+## Judging experience (Oct 2026 — judge pages + admin judging tabs)
+
+Built for ~15 judges hitting `/judge` at once during Fall 2026. The rubric (4 categories × 2 criteria, 1–5, 40 max; Accessibility as a separate Round-1 prize), the two rounds and the results math are unchanged — this pass changed the UI, the failure modes and the admin tooling. Backend half: `api/judging/` on `feat/judging-locks-progress` (locks, progress, bulk assignments) — **deploy it first**; every new frontend call treats a 404 as "feature off".
+
+- **`src/lib/judgeApi.js` THROWS `JudgeApiError`** (`{status, body}`; helpers `isRoundLocked`/`isNotFound`/`isForbidden`). The old client caught every error and returned fake `{success: true}` from `submitScore`/`saveDraft`/`getJudgeTeams`, so a 403/409/500 showed "Score submitted successfully!" while the score was lost, and an outage read as "no teams assigned". Never reintroduce swallow-and-stub; callers render the error.
+- **No notistack provider exists in the app** — the judge pages' old `enqueueSnackbar` calls never rendered. Judge pages now use a local MUI `Snackbar`; the admin judging tabs (`JudgingRound1/2`, `JudgingResults`, `JudgeProgressPanel`) take `onSnack` from `JudgingSection` (the host page's snackbar) and mirror every toast through a `notify()` helper.
+- **Shared judge pieces live in `src/components/Judge/`** (all module-scope): `judgingCriteria.js` (rubric data + pure helpers: `calculateTotal` ignores `total`/special keys, `normalizeScores(apiScores, round)`, `scoreDiff`, `isFeedbackValid` = 20 meaningful chars — the ONE source for both scoring surfaces), `ScoreScale` (segmented 1–5 radiogroup with roving tabindex — replaced the MUI Slider, which changed value on an accidental trackpad drag), `InlineScoreAdjuster`, `JudgeTeamCard`, `judgeTheme` (scoped MUI theme over `refinedFormTheme` so Portal'd Dialogs/Snackbars look refined; `quietTotalStyle`).
+- **Judge dashboard `/judge/[event_id]`** (refined `RefinedRoot` page): fetches `getJudgeTeams` + `getJudgeScores` in parallel (token/user via refs, effect keyed on presence — token rotation never wipes an open editor); refetches on `visibilitychange` (15s throttle) only while no editor is open. **Inline "Adjust scores" contract (the accidental-click answer):** the editor exists only after an explicit button press on an already-scored card, values change only through `ScoreScale`, NOTHING is sent until "Save changes" (disabled until a real diff + valid feedback; Cancel/Esc discards), the footer spells out the diff ("1 change · total 24 → 26"), and a save re-submits the full score via `POST /api/judge/score`. Unscored teams go to the full page (first scoring needs the video/criteria context). Locked rounds (`locks[round].locked` from the teams payload) hide Score/Adjust and show a `role="status"` closed notice; a 409 `round_locked` on save flips the local lock and explains. Scores on cards are a small muted line ("Your score · 24 of 40"), no colored boxes.
+- **Scoring page `/judge/[event_id]/team/[team_id]`**: same refined chrome; the total is a small, uncolored muted line (shoulder-surfing); draft autosave is **dirty-gated and only for not-yet-submitted scores** (the old page wrote a draft on every load — including for submitted scores, whose drafts were then never shown again because submitted wins); a submitted score shows "Submitted " + "Update score" and changes are only saved on press; locked ⇒ controls disabled, no submit; submit failure stays on the page with the error (it used to navigate away on the faked success). Demo video + "Watch demo" links stay Round-1-only as before; `getRoundLocks` is fetched alongside (null-tolerant).
+- **Admin `judging/JudgeProgressPanel`** (Round 1 + Round 2 tabs, defaultExpanded accordion): ONE `GET /api/judge/admin/progress//` → judges complete / scores in / drafts-in-progress chips, by-judge table (progress bar, missing teams as chips, drafts, last submitted) or by-team view, CSV, and the **lock toggle** (confirm dialog → `POST /api/judge/admin/locks//`). Polls every 30s while visible and incomplete, refetches on refocus; token via refs. Remounted via a `key` bump after the tab saves assignments.
+- **Bulk assignment saves:** `JudgingRound1.createAssignments` / `JudgingRound2.saveRound2Configuration` call `judgeApi.bulkAssign` (`replace: true` on full panel saves so the backend set equals what's on screen; `false` for single judge/team adds) and fall back to the per-pair POST loop only on 404. `loadExistingAssignments` resolves judges from the already-loaded judge list (network lookup only for unknown ids) and loads panels in parallel.
+- **`JudgingResults`** (rewritten): one `judgeApi.getBulkScores(event,'round2')` + per-panel assignments (was one request per judge × team + per judge). Ranking is `src/components/admin/judging/resultsRanking.js::rankRound2Teams` (unit-tested): average desc; **ties broken by the EARLIEST `project_submitted_at`** — the first time the team pressed "Submit your project" (`submit_project` stamps it once) — a team with no stamp loses the tie; then lower std dev, then name. Averages are compared at the DISPLAYED precision (`averageKey`, two decimals — never an epsilon, which isn't transitive and reorders averages that display differently). **Only currently assigned judges' scores count** (same rule as the old page); a score from a judge later removed from the panel shows in the dialog as "not counted". Rows carry `rank`/`tieGroup`/`tieBrokenBy`; the table shows a "Project submitted" column (event tz, with seconds) and a Tie chip; CSV exports both. The open-dialog row is derived from live state by id (stale-snapshot lesson). The scoring page has a dirty-gated unsaved-changes guard (`beforeunload` + `routeChangeStart` confirm; suppressed for the post-submit redirect).
+- Tests: `src/components/Judge/__tests__/*`, `src/components/admin/judging/__tests__/resultsRanking.test.js`, `src/lib/__tests__/judgeApi.test.js`, `src/__tests__/pages/judge-event.test.js` (page-level: inline adjust end to end, locked round, failed submit stays put, retry card).
## Hackers' Choice peer vote (Sep 2026)
diff --git a/ga-events-reference.md b/ga-events-reference.md
index 160231ee..8bab1f35 100644
--- a/ga-events-reference.md
+++ b/ga-events-reference.md
@@ -140,6 +140,17 @@ Events added by the DevPost-replacement work (`docs/plans/team-dashboard-devpost
| `peer_vote_cta_click` | `PeerVote/PeerVoteCTA.js` | `{ event_label: "event" \| "dashboard", event_id }` | Which surface drove the voter to the page (event page vs team dashboard card). |
| `peer_vote_submitted` | `PeerVote/PeerVotePage.js` | `{ value: , event_id, page: "hackers_choice_vote" }` | Ballot accepted (`POST …/peer-vote/ballot`). Re-votes fire again. No tallies are ever exposed to the client. |
+### Judging (Oct 2026)
+
+Judge-side events fire via `trackEvent({ action, params })` from the refined judge dashboard; admin events via `trackStructuredEvent(EventCategory.ADMIN, …)`.
+
+| Event Name | Source | Parameters | Notes |
+|------------|--------|------------|-------|
+| `judge_dash_start` | `pages/judge/index.js` | `{ event_label: , page: "judge_dashboard" }` | "Open judging" on a hackathon card (pre-existing event, kept). |
+| `judge_inline_adjust_open` | `pages/judge/[event_id].js` | `{ event_id, round, page: "judge_event" }` | Judge pressed "Adjust scores" on an already-scored team card (editor opened; nothing saved yet). |
+| `judge_inline_adjust_saved` | `pages/judge/[event_id].js` | `{ event_id, round, page: "judge_event" }` | Inline "Save changes" succeeded (`POST /api/judge/score` re-submit). Ratio to `_open` = how often judges actually change their mind after a demo. |
+| `judge_score_submitted` | `pages/judge/[event_id]/team/[team_id].js` | `{ event_id, round, event_label: "submit" \| "update", page: "judge_team" }` | Full scoring page submit succeeded; `update` = re-submitting an already-submitted score. |
+
### Admin (`event_category: "admin"`)
| Event Name | Source | Parameters | Notes |
@@ -147,8 +158,9 @@ Events added by the DevPost-replacement work (`docs/plans/team-dashboard-devpost
| `admin_deadline_reminder_sent` | `admin/hackathon-edit/sections/DeadlinesSection.js` | `{ event_category: "admin", event_label: , value: }` | Manual "Send reminder now" (the hourly cron path is server-side and untracked). |
| `admin_peer_vote_void_ballot` | `admin/PeerVoteResults.js` | `{ event_category: "admin", event_label: }` | A ballot was voided. |
| `admin_peer_vote_publish` | `admin/PeerVoteResults.js` | `{ event_category: "admin", event_label: }` | Hackers' Choice published (award appended + public summary written). |
+| `admin_judging_lock` / `admin_judging_unlock` | `admin/judging/JudgeProgressPanel.js` | `{ event_category: "admin", event_label: "round1" \| "round2", event_id }` | Scoring for a round locked/unlocked from the Round 1 / Round 2 admin tabs (`POST /api/judge/admin/locks//`). |
| `admin_submissions_csv_export` | `admin/TeamManagement.js` | `{ event_category: "admin", event_label: }` | "Export submissions CSV" download. |
-| `admin_csv_export` | `admin/ExportCsvButton.js`, `admin/volunteer/VolunteerExportDialog.js`, `admin/JudgingResults.js` | `{ event_category: "admin", event_label:
+ {copy.title} scoring is closed. The organizers
+ have locked this round, so scores can no longer be changed. You
+ can still open any team to review what you submitted.
+
+ Score each assigned team against the four criteria. You can
+ adjust a score from this page any time before the round is
+ locked — nothing changes until you press Save.
+
+
- Judging {event_id.replace("_", " ").toUpperCase()}
-
-
- Review and score the assigned teams for each judging round
-
+
+
+
+
-
-
-
- {/* Team Information Panel */}
-
-
-
- Team Information
-
-
- {/* Problem Statement */}
-
-
- {teamData.problem_statement.nonprofit}
-
-
+
+
+
+
+
+ )}
+
+ {status === "error" && (
+
+
+ We couldn't load this team.
+
+
+ {errorMessage}
+
+
+ load()}
>
- {teamData.problem_statement.title}
-
-
- {teamData.problem_statement.description}
-
-
-
-
-
- {/* Team Members */}
-
-
+
+ Back to your teams
+
+
-
- Team Members
-
-
- {teamData.members.map((member, index) => (
-
-
-
- ))}
-
-
-
-
-
- {/* Project Links */}
-
-
- Project Links
-
-
+ {statusLine}
+
+ {submittedAt && !locked && (
+
+ You can still change this score
+
+ )}
+
+
+
+ {locked && (
+
- You'll be evaluating the DevPost project, video, and GitHub
- repository against the judging criteria below.
-
-
+ {roundLabel(round)} scoring is closed.{" "}
+ The organizers have locked this round.
+ {submittedAt
+ ? " Your submitted score is shown below and can no longer be changed."
+ : " Scores can no longer be submitted for this round."}
+
+
+ )}
+
+
+ {/* ---- Team information (sticky on wide screens) ---- */}
+
- {/* Special Category Score Summary - Only for Round 1 */}
- {round === "round1" && SPECIAL_CATEGORIES.length > 0 && (
-
-
- Special Category
-
- {SPECIAL_CATEGORIES.map((category) =>
- category.subCriteria.map((sub) => (
-
-
- {scores[sub.key] || 0}/{category.maxPoints}
-
-
- {category.name}
-
-
- )),
+ {submittedAt && !locked && (
+
+
+ You submitted this score
+ {formatWhen(submittedAt)
+ ? ` on ${formatWhen(submittedAt)}`
+ : ""}
+ . Changes are not saved until you press{" "}
+ Update score.
+
+
)}
-
-
- Not included in total score
-
-
- )}
- {/* Auto-save Toggle */}
- setAutoSave(e.target.checked)}
- size="small"
- />
- }
- label="Auto-save"
- sx={{ mt: 2 }}
- />
-
-
+ {JUDGING_CRITERIA.map((criterion) => (
+
+ ))}
- {/* Scoring Panel */}
-
-
- Scoring Criteria
-
-
- Evaluate this team across the four key areas below. Each
- criterion is scored from 1-5 points.
-
+ {specials.map((criterion) => (
+
+ Special-category prize — judged separately and not
+ part of the {MAX_TOTAL}-point total.{" "}
+ {criterion.description}
+ {criterion.reference && (
+ <>
+ {" "}
+
+ W3C principles ↗
+
+ >
+ )}
+ >
+ }
+ />
+ ))}
- {JUDGING_CRITERIA.map((criterion) => (
-
- }>
-
-
- {criterion.icon}
-
-
- {criterion.name} ({criterion.maxPoints} points)
-
- sum + (scores[sub.key] || 0), 0)}/${criterion.maxPoints}`}
- size="small"
- color={criterion.color}
+
+ Feedback for the team
+
+
+ Shared with the team after judging, without your name.
+ Name one thing they did well and one concrete thing that
+ would make it better.
+
+
+
-
-
-
-
-
-
- {criterion.subCriteria.map(renderSlider)}
-
-
- ))}
-
- {/* Special Categories Section - Only for Round 1 */}
- {round === "round1" && SPECIAL_CATEGORIES.length > 0 && (
- <>
-
-
- Special Category Prizes
-
-
- These special categories are judged separately and do not
- affect the main scoring. They recognize excellence in
- specific areas.
-
-
- {SPECIAL_CATEGORIES.map((criterion) => (
-
+ {feedbackOk
+ ? `${feedbackCharCount(feedback)} characters`
+ : `${feedbackCharsRemaining(feedback)} more character${feedbackCharsRemaining(feedback) === 1 ? "" : "s"} needed (minimum ${FEEDBACK_MIN_CHARS})`}
+
+
+
+
- }>
-
-
- {criterion.icon}
-
-
-
- {criterion.name} ({criterion.maxPoints} points)
-
-
- Special Category Prize
-
-
- sum + (scores[sub.key] || 0), 0)}/${criterion.maxPoints}`}
- size="small"
- color={criterion.color}
- />
-
-
-
-
-
-
- {criterion.description && (
-
-
- {criterion.description}
- {criterion.reference && (
- <>
- {" "}
-
- Learn more
-
- >
- )}
-
-
- )}
- {criterion.subCriteria.map(renderSlider)}
-
-
- ))}
- >
- )}
-
- {/* Feedback Section */}
-
-
-
-
- Team Feedback
-
-
- Provide constructive feedback to help this team improve
- their solution. Your feedback will be anonymized and shared
- with the team after judging is complete.
-
-
-
-
- Feedback Guidelines: Focus on specific
- strengths and areas for improvement. Be constructive and
- actionable. Consider technical implementation, user
- experience, and overall impact.
-
-
-
- setFeedbackGeneral(e.target.value)}
- placeholder="Provide specific, constructive feedback to help this team improve their solution... Consider commenting on: Technical implementation and code quality, User experience and design, Impact and scalability, Areas for improvement, What they did well"
- variant="outlined"
- sx={{
- "& .MuiOutlinedInput-root": {
- fontSize: "0.95rem",
- lineHeight: 1.5,
- },
- }}
- />
-
-
+ )}
+
+ {locked ? "Back to your teams" : "Cancel"}
+
+ {!locked && (
+ setConfirmOpen(true)}
+ disabled={!canSubmit}
+ aria-disabled={!canSubmit}
+ style={
+ !canSubmit
+ ? { opacity: 0.5, cursor: "not-allowed" }
+ : undefined
+ }
+ >
+ {submittedAt
+ ? "Update score"
+ : "Submit score & feedback"}
+
+ )}
+
+ {submittedAt
+ ? "This replaces the score you submitted earlier. You can change it again until the round is locked."
+ : "You can still change this score later from your dashboard until the round is locked."}
+
+ Organizers assign judges to teams shortly before judging
+ starts. If you've been approved as a judge and expected an
+ event here, ask in the judges channel on Slack.
+
- {/* Video Tutorial CTA Section */}
-
-
-
-
- New to Our Judge Dashboard?
-
-
-
- Watch our complete tutorial to master the judging process! See exactly how to navigate the dashboard, evaluate projects in both rounds, and provide meaningful feedback to teams.
-
-
- }
- >
- Watch Dashboard Tutorial
-
-
-
+
+
+ New to the judge dashboard?
+
+
+ A five-minute tutorial covers both rounds.
+
+
+ See how to navigate the dashboard, evaluate projects in Round
+ 1 and Round 2, and write feedback teams can act on.
+
+
+
+
+ Watch the tutorial
+
+
+ Scoring criteria
+
+
+
- {/* Help Section */}
-
-
- Quick Reference: Judging Process
-
-
- As a judge, you'll evaluate teams in two rounds (in most hackathons):
-
-
-
-
- Round 1: Review 4-minute team pitch videos (from DevPost) and project on GitHub - evaluate based on our scoring criteria
-
-
-
-
- Round 2: Review responses to Round 1 questions, and re-evaluate projects based on new information, then submit final scores. In-person: Live demos with finalist teams, including Q&A sessions
-
-
-
-
- Each project is scored across four categories: Scope, Documentation, Polish, and Security.
-
-
- }
+
+ Quick reference
+
- Dashboard Tutorial
-
-
+
+
+ Round 1 — find the top teams. Watch each
+ team's demo video and look through their GitHub repository,
+ then score the four criteria.
+
+
+ Round 2 — rank the finalists. Finalists demo
+ live and answer questions; you score them again with fresh
+ information.
+
+
+
- Scoring Criteria
-
-
-
-
-
+ Every project is scored on Scope, Documentation, Polish and
+ Security, 1–5 per criterion, 40 points in total. Accessibility
+ is a separate special-category prize.
+
+
+
+
+
>
);
});
-export default JudgeDashboard;
\ No newline at end of file
+export default JudgeDashboard;