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: , 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..1889b2cc --- /dev/null +++ b/src/__tests__/pages/judge-event.test.js @@ -0,0 +1,334 @@ +/** + * /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 { + act, + 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 your changes for alpha/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("offers only the full page when a team is marked judged but no complete score row exists", async () => { + api.getJudgeScores.mockResolvedValue({ scores: [] }); + render(); + const alpha = await screen.findByRole("article", { name: "Alpha" }); + expect( + within(alpha).queryByRole("button", { name: /adjust scores/i }), + ).toBeNull(); + expect( + within(alpha).getByText(/open full scoring page/i), + ).toBeInTheDocument(); + }); + + it("disables Close and other cards' Adjust while a save is in flight, and keeps the error on the card", async () => { + let rejectSave; + api.submitScore.mockReturnValueOnce( + new Promise((_, reject) => { + rejectSave = reject; + }), + ); + api.getJudgeTeams.mockResolvedValue({ + ...teams(), + round1_teams: [ + ...teams().round1_teams, + { + id: "t-other", + name: "Gamma", + judged: true, + score: 24, + members: [], + problem_statement: {}, + }, + ], + }); + api.getJudgeScores.mockResolvedValue({ + scores: [ + ...scores().scores, + { + team_id: "t-other", + round: "round1", + scores: { ...CORE, total: 24 }, + feedback: { general: "Fine work overall, ship it soon please." }, + }, + ], + }); + render(); + const alpha = await screen.findByRole("article", { name: "Alpha" }); + const gamma = screen.getByRole("article", { name: "Gamma" }); + fireEvent.click( + within(alpha).getByRole("button", { name: /adjust scores/i }), + ); + fireEvent.click( + within(alpha) + .getByRole("radiogroup", { name: "Impact on Community" }) + .querySelector('[data-value="4"]'), + ); + fireEvent.click( + within(alpha).getByRole("button", { name: /save changes/i }), + ); + + expect( + await within(alpha).findByRole("button", { name: /^close$/i }), + ).toBeDisabled(); + expect( + within(gamma).getByRole("button", { name: /adjust scores/i }), + ).toBeDisabled(); + + await act(async () => { + rejectSave( + Object.assign(new Error("Couldn't submit your score (HTTP 500)"), { + status: 500, + }), + ); + }); + expect(await within(alpha).findByRole("alert")).toHaveTextContent( + /HTTP 500/, + ); + expect( + within(alpha).getByRole("button", { name: /^close$/i }), + ).toBeEnabled(); + expect( + within(gamma).getByRole("button", { name: /adjust scores/i }), + ).toBeEnabled(); + }); + + 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/__tests__/pages/judge-team.test.js b/src/__tests__/pages/judge-team.test.js new file mode 100644 index 00000000..2fbc8752 --- /dev/null +++ b/src/__tests__/pages/judge-team.test.js @@ -0,0 +1,206 @@ +/** + * /judge/[event_id]/team/[team_id] — the full scoring page. Lives outside + * src/pages on purpose (Next builds every file under pages/ as a route). + * + * Pins the draft-autosave contract the review asked for: never on load, + * once (debounced) after a real change, never for an already-submitted + * score, never when the round is locked; plus the read-only locked state. + */ +import React from "react"; +import { + act, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; + +jest.mock("next/head", () => ({ + __esModule: true, + default: ({ children }) => <>{children}, +})); +jest.mock("next/dynamic", () => () => () => null); +const routerEvents = { on: jest.fn(), off: jest.fn(), emit: jest.fn() }; +jest.mock("next/router", () => ({ + useRouter: () => ({ + isReady: true, + query: { event_id: "fall-2026", team_id: "team-1", round: "round1" }, + asPath: "/judge/fall-2026/team/team-1?round=round1", + replace: jest.fn(), + push: jest.fn(), + events: routerEvents, + }), +})); +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", () => { + const api = { + getTeamDetails: jest.fn(), + getJudgeScores: jest.fn(), + getRoundLocks: jest.fn(), + getDraft: jest.fn(), + saveDraft: jest.fn(), + submitScore: jest.fn(), + }; + class JudgeApiError extends Error {} + 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 TeamScoringPage from "../../pages/judge/[event_id]/team/[team_id]"; + +const api = judgeApi; +const CORE = { + scopeImpact: 3, + scopeComplexity: 3, + documentationCode: 3, + documentationEase: 3, + polishWorkRemaining: 3, + polishCanUseToday: 3, + securityData: 3, + securityRole: 3, +}; +const team = { + id: "team-1", + name: "Alpha", + members: [{ name: "Sam" }], + github_url: "https://github.com/x/y", + problem_statement: { nonprofit: "Helpers Inc", title: "Track donations" }, +}; + +const clickScale = (name, value) => { + const group = screen.getByRole("radiogroup", { name }); + fireEvent.click(group.querySelector(`[data-value="${value}"]`)); +}; + +beforeEach(() => { + jest.useFakeTimers(); + jest.clearAllMocks(); + api.getTeamDetails.mockResolvedValue({ team }); + api.getJudgeScores.mockResolvedValue({ scores: [] }); + api.getRoundLocks.mockResolvedValue({ + locks: { round1: { locked: false }, round2: { locked: false } }, + }); + api.getDraft.mockResolvedValue({ draft: null }); + api.saveDraft.mockResolvedValue({ success: true }); + api.submitScore.mockResolvedValue({ success: true }); +}); +afterEach(() => { + jest.useRealTimers(); +}); + +const renderReady = async () => { + render(); + await screen.findByRole("heading", { level: 1, name: "Alpha" }); + // let the hydrated flag settle (setTimeout 0 in the page) + await act(async () => { + jest.advanceTimersByTime(1); + }); +}; + +describe("/judge/[event_id]/team/[team_id] draft autosave", () => { + it("never saves a draft just from loading the page", async () => { + await renderReady(); + await act(async () => { + jest.advanceTimersByTime(5000); + }); + expect(api.saveDraft).not.toHaveBeenCalled(); + expect(screen.getByText(/not submitted yet/i)).toBeInTheDocument(); + }); + + it("saves one debounced draft after a real change, with the quiet total updated", async () => { + await renderReady(); + clickScale("Impact on Community", 5); + expect(screen.getByText(/total 26 of 40/i)).toBeInTheDocument(); + expect(screen.getByText(/unsaved changes/i)).toBeInTheDocument(); + await act(async () => { + jest.advanceTimersByTime(1900); + }); + expect(api.saveDraft).not.toHaveBeenCalled(); + await act(async () => { + jest.advanceTimersByTime(200); + }); + await waitFor(() => expect(api.saveDraft).toHaveBeenCalledTimes(1)); + const [, teamId, eventId, scores, round] = api.saveDraft.mock.calls[0]; + expect([teamId, eventId, round]).toEqual(["team-1", "fall-2026", "round1"]); + expect(scores.scopeImpact).toBe(5); + }); + + it("never autosaves edits to an already-submitted score; the button reads Update score", async () => { + api.getJudgeScores.mockResolvedValue({ + scores: [ + { + team_id: "team-1", + round: "round1", + scores: { ...CORE, accessibility: 4, total: 24 }, + feedback: { + general: "Good start, clear README, deploy it behind auth next.", + }, + submitted_at: "2026-10-11T20:00:00Z", + }, + ], + }); + await renderReady(); + expect(api.getDraft).not.toHaveBeenCalled(); + expect( + screen.getByRole("button", { name: /update score/i }), + ).toBeInTheDocument(); + clickScale("Data Protection", 1); + await act(async () => { + jest.advanceTimersByTime(5000); + }); + expect(api.saveDraft).not.toHaveBeenCalled(); + expect(screen.getByText(/press update score to save/i)).toBeInTheDocument(); + }); + + it("renders read-only when the round is locked: no submit, controls disabled, no autosave", async () => { + api.getRoundLocks.mockResolvedValue({ + locks: { round1: { locked: true }, round2: { locked: false } }, + }); + await renderReady(); + expect(screen.queryByRole("button", { name: /submit score/i })).toBeNull(); + expect(screen.getByRole("status")).toHaveTextContent( + /round 1 scoring is closed/i, + ); + const radios = screen.getAllByRole("radio"); + expect(radios.every((r) => r.disabled)).toBe(true); + await act(async () => { + jest.advanceTimersByTime(5000); + }); + expect(api.saveDraft).not.toHaveBeenCalled(); + }); + + it("restores a saved draft instead of defaults", async () => { + api.getDraft.mockResolvedValue({ + draft: { + scores: { ...CORE, scopeImpact: 5, accessibility: 2 }, + feedback: { general: "wip" }, + }, + }); + await renderReady(); + const group = screen.getByRole("radiogroup", { + name: "Impact on Community", + }); + expect(group.querySelector('[aria-checked="true"]')).toHaveAttribute( + "data-value", + "5", + ); + expect(screen.getByText(/total 26 of 40/i)).toBeInTheDocument(); + }); +}); diff --git a/src/components/Judge/InlineScoreAdjuster.js b/src/components/Judge/InlineScoreAdjuster.js new file mode 100644 index 00000000..5db21a38 --- /dev/null +++ b/src/components/Judge/InlineScoreAdjuster.js @@ -0,0 +1,377 @@ +import React, { useEffect, useMemo, useRef, useState } from "react"; +import ScoreScale from "./ScoreScale"; +import { + JUDGING_CRITERIA, + calculateTotal, + categoryTotal, + feedbackCharCount, + feedbackCharsRemaining, + hasAllCoreScores, + isFeedbackValid, + roundLabel, + scoreDiff, + specialCategoriesForRound, + MAX_TOTAL, + FEEDBACK_MIN_CHARS, +} from "./judgingCriteria"; + +const rowStyle = { + display: "flex", + flexWrap: "wrap", // phones: the label takes the row, the scale drops under it + alignItems: "center", + justifyContent: "space-between", + gap: "8px 16px", + 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); + // A score with a missing criterion would be rejected by the backend (400 + // "Invalid score for …") — never let Save send one. + const complete = hasAllCoreScores(draft); + const canSave = hasChanges && feedbackOk && complete && !saving; + + const handleKeyDown = (e) => { + // Esc discards — except while typing feedback, where it's too easy to hit. + if (e.key === "Escape" && !saving && e.target?.tagName !== "TEXTAREA") { + 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 ? ( + <> + +