From 281377c4791b8c7ea4a64b8b6f08aea6a419da1b Mon Sep 17 00:00:00 2001 From: Greg V <6913307+gregv@users.noreply.github.com> Date: Wed, 21 Oct 2026 23:23:49 -0700 Subject: [PATCH 1/3] feat(judging): refined judge pages, inline score adjustment, round locks, admin progress + submission tie-break MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Judge side - judgeApi now throws JudgeApiError (it faked {success:true} on failed score submits/drafts/team loads — scores were silently lost). - /judge, /judge/[event], /judge/[event]/team/[id] rebuilt on the refined design. - Dashboard: explicit 'Adjust scores' inline editor per scored team — segmented 1–5 controls, nothing persists until 'Save changes', diff summary, Cancel/Esc discards; locked rounds hide scoring and explain. - Scoring page: small uncolored total (shoulder-surfing), dirty-gated draft autosave only before first submit, explicit 'Update score' after, read-only when locked, failed submits stay on the page with the error. - Local MUI snackbars: the app has no notistack provider, so the old toasts never rendered. Admin side - judging/JudgeProgressPanel on Round 1 + Round 2: who submitted / drafts / missing per judge and per team, CSV, lock toggle with confirm; polls 30s. - Round 1/2 saves use POST /api/judge/admin/assignments/bulk (one request per panel instead of one per judge×team), falling back on 404. - JudgingResults rewritten on one bulk-scores call; ties broken by earliest project_submitted_at with a visible column + Tie chip (resultsRanking.js). - Admin tabs mirror toasts through the host page's onSnack. Co-Authored-By: Claude Fable 5.1 --- CLAUDE.md | 14 + ga-events-reference.md | 13 +- src/__tests__/pages/judge-event.test.js | 249 ++ src/components/Judge/InlineScoreAdjuster.js | 365 +++ src/components/Judge/JudgeTeamCard.js | 270 ++ src/components/Judge/ScoreScale.js | 114 + .../__tests__/InlineScoreAdjuster.test.js | 84 + .../Judge/__tests__/judgingCriteria.test.js | 67 + src/components/Judge/judgeTheme.js | 77 + src/components/Judge/judgingCriteria.js | 278 ++ src/components/admin/JudgingResults.js | 1517 ++++++----- src/components/admin/JudgingRound1.js | 199 +- src/components/admin/JudgingRound2.js | 129 +- .../hackathon-edit/sections/JudgingSection.js | 5 + .../admin/judging/JudgeProgressPanel.js | 622 +++++ .../judging/__tests__/resultsRanking.test.js | 105 + .../admin/judging/resultsRanking.js | 120 + src/lib/__tests__/judgeApi.test.js | 104 + src/lib/judgeApi.js | 881 ++---- src/pages/judge/[event_id].js | 1137 ++++---- src/pages/judge/[event_id]/team/[team_id].js | 2368 +++++++---------- src/pages/judge/index.js | 702 ++--- 22 files changed, 5695 insertions(+), 3725 deletions(-) create mode 100644 src/__tests__/pages/judge-event.test.js create mode 100644 src/components/Judge/InlineScoreAdjuster.js create mode 100644 src/components/Judge/JudgeTeamCard.js create mode 100644 src/components/Judge/ScoreScale.js create mode 100644 src/components/Judge/__tests__/InlineScoreAdjuster.test.js create mode 100644 src/components/Judge/__tests__/judgingCriteria.test.js create mode 100644 src/components/Judge/judgeTheme.js create mode 100644 src/components/Judge/judgingCriteria.js create mode 100644 src/components/admin/judging/JudgeProgressPanel.js create mode 100644 src/components/admin/judging/__tests__/resultsRanking.test.js create mode 100644 src/components/admin/judging/resultsRanking.js create mode 100644 src/lib/__tests__/judgeApi.test.js diff --git a/CLAUDE.md b/CLAUDE.md index de21906c..ce633df2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -518,6 +518,20 @@ Team docs carry the DevPost-replacement write-up (all optional; absent ⇒ legac - **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. +## 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. 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). +- 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) Assigned-slate **approval** vote (plan Part 2.5 — chosen over quadratic voting, pairwise comparisons and likes): eligible voters = `isSelected` hackers (server-checked; with `constraints.peer_vote_requires_submission` they must ALSO be on a submitted team), each gets a deterministic exposure-balanced slate of `peer_vote_slate_size` (3–10, default 5) submitted projects **never including their own**, picks up to `peer_vote_max_picks` (default 2) — "Which would you be proud to have built?"; score = Wilson lower bound of approvals/shown; one ballot per voter, picks changeable until close, admin void, admin Publish appends "Hackers' Choice" to `awards[]` + writes the public summary. **No tallies are ever shown to voters** — only the admin results table. Window defaults when `deadlines.voting_*` are unset: opens `late_submission_until || submission`, closes end-of-event (event tz); no `deadlines` or `peer_vote_enabled=false` ⇒ `disabled`. diff --git a/ga-events-reference.md b/ga-events-reference.md index 160231ee..ebad6f33 100644 --- a/ga-events-reference.md +++ b/ga-events-reference.md @@ -140,6 +140,16 @@ 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. | + ### Admin (`event_category: "admin"`) | Event Name | Source | Parameters | Notes | @@ -147,8 +157,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: , value: , event_id }` — the volunteer dialog adds `{ roster: all\|yes\|no, statuses: "all" \| "a\|b", columns: all\|essentials, kept_view_filters }` | Any "Download CSV" on `/admin/hackathons/`: `event_label` names the table (`volunteer_`, `teams`, `team_assignments_*`, `judging_round1_*`, `judging_round2_results`, `peer_vote_*`, `checkin_people`, `nonprofits`, `schedule_countdowns`, `meals`, `links`). | +| `admin_csv_export` | `admin/ExportCsvButton.js`, `admin/volunteer/VolunteerExportDialog.js`, `admin/JudgingResults.js` | `{ event_category: "admin", event_label:
, value: , event_id }` — the volunteer dialog adds `{ roster: all\|yes\|no, statuses: "all" \| "a\|b", columns: all\|essentials, kept_view_filters }` | Any "Download CSV" on `/admin/hackathons/`: `event_label` names the table (`volunteer_`, `teams`, `team_assignments_*`, `judging_round1_*`, `judging_round2_results`, `judging_round1_progress`, `judging_round2_progress`, `peer_vote_*`, `checkin_people`, `nonprofits`, `schedule_countdowns`, `meals`, `links`). | ## Business Intelligence Applications diff --git a/src/__tests__/pages/judge-event.test.js b/src/__tests__/pages/judge-event.test.js new file mode 100644 index 00000000..04ce03d8 --- /dev/null +++ b/src/__tests__/pages/judge-event.test.js @@ -0,0 +1,249 @@ +/** + * /judge/[event_id] — the judge dashboard. Lives outside src/pages on purpose + * (Next builds every file under pages/ as a route). + * + * Covers the inline "Adjust scores" flow end to end against a mocked API: + * nothing is sent until Save, the saved payload carries the recalculated + * total, a locked round hides the editor, and a failed submit stays on the + * card with an error (the old client faked success). + */ +import React from "react"; +import { + fireEvent, + render, + screen, + waitFor, + within, +} from "@testing-library/react"; + +jest.mock("next/head", () => ({ + __esModule: true, + default: ({ children }) => <>{children}, +})); +jest.mock("next/dynamic", () => () => () => null); +jest.mock("next/router", () => ({ + useRouter: () => ({ + isReady: true, + query: { event_id: "fall-2026", tab: "round1" }, + asPath: "/judge/fall-2026?tab=round1", + replace: jest.fn(), + push: jest.fn(), + }), +})); +jest.mock("@propelauth/react", () => ({ + withRequiredAuthInfo: (Component) => Component, + useAuthInfo: () => ({ + accessToken: "tok", + user: { userId: "judge-1", firstName: "Ada" }, + }), +})); +jest.mock("../../lib/ga", () => ({ + trackEvent: jest.fn(), + initFacebookPixel: jest.fn(), +})); + +jest.mock("../../lib/judgeApi", () => { + // Defined inside the factory: jest.mock is hoisted above imports, so an + // outer `const` would be read before initialization. + const api = { + getJudgeTeams: jest.fn(), + getJudgeScores: jest.fn(), + submitScore: jest.fn(), + }; + class JudgeApiError extends Error { + constructor(message, { status, body } = {}) { + super(message); + this.status = status; + this.body = body; + } + } + return { + __esModule: true, + default: api, + JudgeApiError, + isRoundLocked: (err) => + err?.status === 409 && err?.body?.error === "round_locked", + isNotFound: (err) => err?.status === 404, + }; +}); + +import judgeApi from "../../lib/judgeApi"; +import HackathonJudgePage from "../../pages/judge/[event_id]"; + +const api = judgeApi; + +const CORE = { + scopeImpact: 3, + scopeComplexity: 3, + documentationCode: 3, + documentationEase: 3, + polishWorkRemaining: 3, + polishCanUseToday: 3, + securityData: 3, + securityRole: 3, +}; + +const teams = (locks = null) => ({ + round1_teams: [ + { + id: "t-scored", + name: "Alpha", + judged: true, + score: 24, + members: [{ name: "Sam" }], + problem_statement: { nonprofit: "Helpers Inc", nonprofit_id: "np1" }, + github_url: "https://github.com/x/y", + }, + { + id: "t-new", + name: "Beta", + judged: false, + score: null, + members: [], + problem_statement: {}, + }, + ], + round2_teams: [], + event: { title: "Fall 2026" }, + locks, +}); + +const scores = () => ({ + scores: [ + { + team_id: "t-scored", + round: "round1", + scores: { ...CORE, accessibility: 3, total: 24 }, + feedback: { + general: "Good start, clear README, deploy it behind auth next.", + }, + submitted_at: "2026-10-11T20:00:00Z", + }, + ], +}); + +beforeEach(() => { + jest.clearAllMocks(); + api.getJudgeTeams.mockResolvedValue(teams()); + api.getJudgeScores.mockResolvedValue(scores()); + api.submitScore.mockResolvedValue({ success: true }); +}); + +describe("/judge/[event_id]", () => { + it("renders the assigned teams with the right actions", async () => { + render(); + // The h1 falls back to the event id before data arrives, so wait on a card. + const alpha = await screen.findByRole("article", { name: "Alpha" }); + expect( + screen.getByRole("heading", { level: 1, name: /fall 2026/i }), + ).toBeInTheDocument(); + expect( + within(alpha).getByText(/your score · 24 of 40/i), + ).toBeInTheDocument(); + expect( + within(alpha).getByRole("button", { name: /adjust scores/i }), + ).toBeInTheDocument(); + const beta = screen.getByRole("article", { name: "Beta" }); + expect(within(beta).getByText(/score this team/i)).toBeInTheDocument(); + expect( + within(beta).queryByRole("button", { name: /adjust scores/i }), + ).toBeNull(); + }); + + it("adjusts a score inline: nothing is sent until Save, and Save sends the new total", async () => { + render(); + const alpha = await screen.findByRole("article", { name: "Alpha" }); + fireEvent.click( + within(alpha).getByRole("button", { name: /adjust scores/i }), + ); + + const group = within(alpha).getByRole("radiogroup", { + name: "Impact on Community", + }); + fireEvent.click(group.querySelector('[data-value="5"]')); + expect(api.submitScore).not.toHaveBeenCalled(); + + fireEvent.click( + within(alpha).getByRole("button", { name: /save changes/i }), + ); + await waitFor(() => expect(api.submitScore).toHaveBeenCalledTimes(1)); + const [judgeId, teamId, eventId, payload, round, token, feedback] = + api.submitScore.mock.calls[0]; + expect([judgeId, teamId, eventId, round, token]).toEqual([ + "judge-1", + "t-scored", + "fall-2026", + "round1", + "tok", + ]); + expect(payload.scopeImpact).toBe(5); + expect(payload.total).toBe(26); + expect(feedback.general).toMatch(/clear README/); + + expect( + await screen.findByText(/saved — alpha is now 26 of 40/i), + ).toBeInTheDocument(); + expect( + within(alpha).getByText(/your score · 26 of 40/i), + ).toBeInTheDocument(); + expect( + within(alpha).queryByRole("radiogroup", { name: "Impact on Community" }), + ).toBeNull(); + }); + + it("keeps the editor open with an error when the submit fails (never fakes success)", async () => { + api.submitScore.mockRejectedValueOnce( + Object.assign(new Error("Couldn't submit your score (HTTP 500)"), { + status: 500, + }), + ); + render(); + const alpha = await screen.findByRole("article", { name: "Alpha" }); + fireEvent.click( + within(alpha).getByRole("button", { name: /adjust scores/i }), + ); + const group = within(alpha).getByRole("radiogroup", { + name: "Data Protection", + }); + fireEvent.click(group.querySelector('[data-value="1"]')); + fireEvent.click( + within(alpha).getByRole("button", { name: /save changes/i }), + ); + expect(await within(alpha).findByRole("alert")).toHaveTextContent( + /HTTP 500/, + ); + expect( + within(alpha).getByText(/your score · 24 of 40/i), + ).toBeInTheDocument(); + }); + + it("hides scoring actions and shows a closed notice when the round is locked", async () => { + api.getJudgeTeams.mockResolvedValue( + teams({ round1: { locked: true }, round2: { locked: false } }), + ); + render(); + const alpha = await screen.findByRole("article", { name: "Alpha" }); + expect( + within(alpha).queryByRole("button", { name: /adjust scores/i }), + ).toBeNull(); + expect(within(alpha).getByText(/view your score/i)).toBeInTheDocument(); + expect(screen.getByRole("status")).toHaveTextContent( + /round 1 scoring is closed/i, + ); + const beta = screen.getByRole("article", { name: "Beta" }); + expect(within(beta).queryByText(/score this team/i)).toBeNull(); + }); + + it("shows a retry card instead of an empty list when the API fails", async () => { + api.getJudgeTeams.mockRejectedValueOnce( + new Error("Couldn't load your assigned teams (network error)"), + ); + render(); + expect(await screen.findByRole("alert")).toHaveTextContent( + /couldn't load your assignments/i, + ); + expect( + screen.getByRole("button", { name: /try again/i }), + ).toBeInTheDocument(); + }); +}); diff --git a/src/components/Judge/InlineScoreAdjuster.js b/src/components/Judge/InlineScoreAdjuster.js new file mode 100644 index 00000000..17649006 --- /dev/null +++ b/src/components/Judge/InlineScoreAdjuster.js @@ -0,0 +1,365 @@ +import React, { useEffect, useMemo, useRef, useState } from "react"; +import ScoreScale from "./ScoreScale"; +import { + JUDGING_CRITERIA, + calculateTotal, + categoryTotal, + feedbackCharCount, + feedbackCharsRemaining, + isFeedbackValid, + roundLabel, + scoreDiff, + specialCategoriesForRound, + MAX_TOTAL, + FEEDBACK_MIN_CHARS, +} from "./judgingCriteria"; + +const rowStyle = { + display: "flex", + alignItems: "center", + justifyContent: "space-between", + gap: 16, + padding: "10px 0", + borderTop: "1px solid var(--line, #E7E1D4)", +}; + +const WasTag = ({ from }) => ( + + was {from} + +); + +/** + * Inline score editor that lives INSIDE a team card on the judge dashboard. + * + * UX contract (why it's safe from accidental clicks): + * - It only exists after the judge explicitly pressed "Adjust scores". + * - Values change only through the segmented 1–5 buttons (no sliders). + * - NOTHING is sent to the server until "Save changes" is pressed; Cancel or + * Esc discards every change. The button is disabled until something + * actually changed, and the footer spells out what changed. + * - Feedback is kept (and editable) so a re-submit never drops it. + */ +const InlineScoreAdjuster = ({ + team, + round, + initialScores, + initialFeedback = "", + onSave, + onCancel, + saving = false, + error = null, +}) => { + const [draft, setDraft] = useState(() => ({ ...initialScores })); + const [feedback, setFeedback] = useState(initialFeedback || ""); + const [showFeedback, setShowFeedback] = useState( + () => !isFeedbackValid(initialFeedback), + ); + const rootRef = useRef(null); + + // Move focus into the editor when it opens so keyboard users land in it. + useEffect(() => { + const first = rootRef.current?.querySelector( + '[role="radio"][tabindex="0"]', + ); + first?.focus({ preventScroll: true }); + }, []); + + const changes = useMemo( + () => scoreDiff(initialScores, draft, round), + [initialScores, draft, round], + ); + const feedbackChanged = (feedback || "") !== (initialFeedback || ""); + const hasChanges = changes.length > 0 || feedbackChanged; + const feedbackOk = isFeedbackValid(feedback); + const beforeTotal = calculateTotal(initialScores); + const afterTotal = calculateTotal(draft); + const canSave = hasChanges && feedbackOk && !saving; + + const handleKeyDown = (e) => { + if (e.key === "Escape" && !saving) { + e.stopPropagation(); + onCancel(); + } + }; + + const handleSave = () => { + if (!canSave) return; + onSave(draft, feedback); + }; + + const setScore = (key, value) => + setDraft((prev) => ({ ...prev, [key]: value })); + + const specials = specialCategoriesForRound(round); + + return ( +
+
+

+ Adjust scores +

+

+ Nothing changes until you press Save changes. +

+
+ + {JUDGING_CRITERIA.map((criterion) => ( +
+
+

+ {criterion.name} +

+ + {categoryTotal(criterion, draft)}/{criterion.maxPoints} + +
+ {criterion.subCriteria.map((sub) => { + const changed = changes.find((c) => c.key === sub.key); + return ( +
+
+
+ {sub.name} + {changed && } +
+
+ {sub.description} +
+
+ setScore(sub.key, v)} + disabled={saving} + /> +
+ ); + })} +
+ ))} + + {specials.map((criterion) => ( +
+
+

+ {criterion.name} · separate prize, not in total +

+ + {categoryTotal(criterion, draft)}/{criterion.maxPoints} + +
+ {criterion.subCriteria.map((sub) => { + const changed = changes.find((c) => c.key === sub.key); + return ( +
+
+
+ {sub.name} + {changed && } +
+
+ {sub.description} +
+
+ setScore(sub.key, v)} + disabled={saving} + /> +
+ ); + })} +
+ ))} + + {/* Feedback — kept so a re-submit never drops it; editable on demand. */} +
+ {showFeedback ? ( + <> + +