diff --git a/CLAUDE.md b/CLAUDE.md index de21906c..45edc319 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,9 @@ Full spec + rollout checklist: **`docs/refined-design-system.md`** (read this before extending the look to more pages). Migrated so far: `/` (`src/pages/index.js`), `/projects` (`src/components/ProjectList/*`), the global NavBar (incl. a refined mobile layout — centered logo + refined dropdown), `/about`, `/about/judges`, `/about/mentors` (`Mentorship` rewritten as refined sections), `/about/success-stories`, `/about/completion`, `/sponsor`, `/nonprofits` (`NonProfitList` + new calm card `NonProfitListTileRefined`; original `NonProfitListTile` stays for event pages), `/blog` (`BlogPage`; reused `News` list kept), `/onboarding` (refined chrome; wizard logic intact; **July 2026 content overhaul** — 9 steps: Welcome, Mission, How It Works [full lifecycle incl. post-hackathon Definition of Done + project-status ladder], Get Involved [hacker/mentor/judge/volunteer/nonprofit role cards], Using the Site [site map + first-steps guide + "Anatomy of a project page" numbered walkthrough (problem → code → plan → people; mirrors ProblemStatement.js section order — keep in sync) + "public portfolio" band: GitHub/Slack/demo-videos/public-profile are what recruiters can review; work is documented as GitHub Issues for public credit — no issues in a repo → pull the code and write them like a PM], Slack, Introduce Yourself, FAQs, Feedback. New sections: `HowItWorksSection`/`RolesSection`/`WebsiteTourSection` in `src/components/Onboarding/`; `JudgingOverview`/`MentoringOverview`/`BuddySystem` are now orphaned — don't re-add them or their claims. FAQ answers must reflect real flows: there are NO project leads, NO commenting on projects, NO buddy system — joining a project = "Want to help?" toggle / its Slack channel / GitHub repo (write Issues like a PM if none exist) / next hackathon. **Design contract:** step content renders inside a scoped `onboardingTheme` ThemeProvider (in `pages/onboarding/index.js` — Fraunces headings, Hanken body, navy/terracotta palette, flat hairline Papers/Cards; don't reintroduce elevation shadows, hover-lifts, or rainbow chips) and every step opens with the shared `StepHeader` (renders `

`; the page masthead owns the `

`). Slack step leads with the two-account gotcha (an ohack.dev login [usually Google] does NOT create a Slack account — join via `/signup`) and real channel deep links: #introductions C01EY49JV8U, #ask-a-mentor C01E5CGDQ74, #random C06BRHRS5BQ, plus per-project #npo-* channels; never reference #help/#team-formation/#buddy-matching/#project-matching — they don't exist), `/signup` (full rewrite: editorial hero, "What you get" benefit cards, numbered join steps beside the framed `join_slack_1.png`, navy CTA band; primary CTA is now a real `` — dropped the JS `router.push`/`window.open` branch; `handleSignupClick` only fires the `CompleteRegistration` GA event), `/volunteer`, `/about/hackers`, `/about/process` (refined chrome; Mermaid flow + Gantt diagrams kept in calm card frames), `/praise` (refined chrome; `PraiseBoard` feed kept), `/hack/code-of-conduct` (full rewrite: editorial hero, core-values cards, numbered accessibility list, expected-vs-unacceptable two-col w/ +/× markers, navy CTA band), `/profile/[userid]` public (`PublicProfile`), and a full refined pass on the own `/profile` editor (`Profile.js` — editorial masthead, hairline sticky tab strip, scoped navy/terracotta MUI theme, `PanelHeader` on every tab, privacy legend on Basic Info, hairline rules in Volunteer History, `.ohx-card` Giveaway Entries; form logic untouched), `/myfeedback` (full rewrite: editorial hero, navy CircularProgress score ring w/ Fraunces number, hairline skill accordions w/ navy LinearProgress bars, `.ohx-card` entries + `.ohx-tag` chips) and `/feedback/[userid]` (`GiveFeedback`, `ssr:false`: hero + `Section`-framed form, navy-themed MUI Select/Radio/Slider/Checkbox, native `.ohx-btn` submit; also fixed a pre-existing hooks-order bug where `if (!user)` returned before `useEffect`), `/hack/[event_id]/manageteam` (**Sep 2026: rebuilt as the Team Dashboard** — `src/components/TeamDashboard/*`, the DevPost replacement; composition + invariants in the "Team Dashboard" section below), and `/hack/[event_id]/team/[team_id]` (full rewrite: editorial masthead + hairline `.ohx-card` sections + a **deep-linkable sticky Table of Contents** — sections are `
` with hover `#` copy-link anchors (keyboard accessible via `&:focus-visible`), the TOC lists only present sections (`aria-current="location"`) and tracks the active one via `IntersectionObserver`; embedded `MentorTeamPanel`/`TeamCompletionChecklist` keep their own styling in anchored `headed={false}` wrappers). **Key invariants on this page:** (1) Status labels come from `TEAM_STATUS_OPTIONS.find()` via `statusLabel()` — never render raw enum strings like `NONPROFIT_SELECTED`; winning statuses get a 🏆 prefix from `getWinningStatus()`; `INACTIVE` skips the status tag since the dot already conveys it. (2) `SectionBlock` is **module-scope** (not inside the component) — defining it inside caused a remount storm on every IntersectionObserver `setActiveId` tick, wiping in-progress mentor note drafts and reloading the demo iframe; it receives `copiedId`/`onCopyLink` as props. (3) `parseLocalDate` from `src/lib/dateUtils.js` is used for all event window checks (`eventHasStarted`, `eventEnded`) so date-only strings aren't parsed as UTC midnight. (4) Membership check uses `useTeamMembership(eventId, teamId)` from `src/hooks/use-team-membership.js` — shared by the page and passed as `isOnTeam`/`membershipChecked` props to `TeamCompletionChecklist` (no duplicate fetch). Page uses result to show: "Manage your team" primary CTA for members, a combined nudge card for missing DevPost/demo, and a ghost "Want to join this team?" affordance for non-members when status is joinable. (5) Nonprofit: never render `selected_nonprofit_id` text — only show when `nonprofitData.name` is available; `getStaticProps` fetches `{ name, description }` into `nonprofitData`; a "Nonprofit partner" section card links to `/nonprofit/`. (6) `awards[]` rendered as `ohx-tag--accent` chips after the status tag. (7) All GitHub repos in `github_links[]` rendered (handles both string and `{link,name}` shapes). (8) `getStaticProps` rethrows network errors (ISR keeps the last good version) and returns `notFound` only on genuine 404. (9) **Project section first** (Sep 2026): `TeamProjectSection` (`id="project"`) renders before every other section when `hasProjectContent(team)`; members without a write-up get the "Tell the story of what you built." card → `manageteam#project`; legacy teams (no `project_*`) render no Project section and no submission tag. (10) **OG + JSON-LD come from `src/components/Teams/projectMeta.js`** (`buildTeamOgImage`/`buildTeamDescription`/`buildProjectJsonLd`) — never hand-compose them in the page; see "Project pages & gallery". **Harmonized (not full rewrite):** `/hack` index (keeps its bespoke finder + `HackPageNav`; hero/CTAs/Support band recolored via inline `RX` tokens). The upcoming/current event cards were refined too: `HackathonList` full-mode heading → Fraunces eyebrow; `EventFeature` full card rewritten to a refined hairline card (eyebrow date + quiet type tag, Fraunces title, navy donation rings, navy-primary/ghost event-link buttons, "View event →"; fixed the old nested-anchor bug; var-fallback colors since `/hack` isn't a `RefinedRoot`). `ImpactMetrics` (shared with the archive) → warm `--surface-2` tiles with Fraunces navy numbers + "Impact at a glance" overline (was rainbow `color`-prop numbers). `/hack/[event_id]` got a **masthead-led refined pass**: body wrapped in `` (it's now the `
`; all section IDs + `TableOfContents` + `FloatingNavigation` preserved), `HackathonHeader` rebuilt into a scope-independent editorial masthead (inline styles w/ `var(--x, fallback)` so `/agenda` + `/census` also benefit), refined "Build a team" buttons + recap teaser. Phase-2 refined the **event-only** sub-components: `NonprofitList` (calm clickable cards + navy view-toggle), `EventConstraints`, `DonationProgress` (navy rings), `EventCountdown` (navy countdown card replacing the gradient + flat timeline), `TeamList`/`TeamCard` (flat hairline cards, Fraunces team names, refined "Team members" label + navy Join button — `TeamList` is event-page-only despite earlier substring matches in admin/event-teams; **profile avatars load lazily per-card via IntersectionObserver with `rootMargin: "200px"`, tracked by `fetchedTeamProfilesRef` Set** — do NOT restore the eager `fetchTeamMemberProfiles(teams)` call on mount that fired N parallel requests for all 30+ cards at once). **Backend C7 enrichment:** `get_single_hackathon_event` (`services/hackathons_service.py`) now calls `_enrich_teams_users_batch` after converting team DocumentReferences — one `db.get_all` across all members of all teams, deduped, so the frontend receives `users[]` already as `{id, user_id, name, nickname, profile_image}` objects. `HackathonResults.js`/`TeamList.js` handle both the old id-string and new object shapes (backwards-compatible). The list endpoint `get_hackathon_list` is NOT enriched — only the single-event getter., and `HackathonLeaderboard` (flat cards, warm stat tiles w/ terracotta icons + Fraunces navy numbers, squared navy Org button). **Phase 3 (done) finished the event page**: `EventLinks` (6 rainbow app buttons → one calm uniform card set + calm social-proof strip), `InteractiveFAQ` (flat hairline accordions), `HackathonResults` (calm `--surface-2` frame, Fraunces navy stat numbers; gold/silver/bronze winner medals kept), `VolunteerList` (`PersonCard` → flat hairline). The **shared** ones (`InteractiveFAQ`, `HackathonResults`) use **inline CSS-var fallbacks** (`var(--ink,#16181D)`) so they render refined on their non-`RefinedRoot` routes too (verified on `/hack/[event_id]/results` + `OnboardingFAQ`) — no per-route gating. A final chrome pass refined `TableOfContents` (navy active pill, was magenta), `FloatingNavigation` (navy FAB), `VolunteerList` (navy "Learn more" + navy availability chips; in-person stays green), and `MentorAvailability` (navy banner/Slack button). The event page is now fully refined end-to-end. Also refined: `/hack/request` (host-an-event page + `HackathonRequestForm`) and `/office-hours`. **`/hack/request` is student-first** — org-type selectable cards default to **university** (was corporate); the **$5k budget minimum was removed** (slider min `$0`, never blocks submit, reframed as optional funding); the donation-% ask is **corporate-only** (forced to 0 at submit otherwise); phone is optional; rainbow Papers → calm `--surface-2` cards; everything navy/terracotta via a scoped MUI `ThemeProvider` (`createTheme(base, …)`). **`formData` keys are preserved** — `HackathonRequestDetailDialog`, the edit page `/hack/request/[request_id]`, and the backend all depend on them. **Scroll gotcha:** step nav now `scrollIntoView`s a `formTopRef` on the form's Paper (`scrollMarginTop:88`), NOT `window.scrollTo(0,0)` (which overshot to page-top now that the form sits below the hero). `/office-hours` now renders its JSON-LD, uses the idempotent `initFacebookPixel` (was a raw `ReactPixel.init` — CWV rule), and dropped the artificial 1s loading delay + dead ICS code. Also refined: `/nonprofit/[nonprofit_id]` (`NonProfit.js` — editorial masthead, meta row as `.ohx-tag`/`.ohx-link`s, projects list, canonical www fix) and `/project/[project_id]` (`Project.js` — `RefinedRoot` chrome, soft breadcrumb, canonical). Both share `ProblemStatement.js` which was fully restyled (purple gradient hero → calm `--surface-2` band; gradient chips → `.ohx-tag`; gradient metric tiles → Fraunces navy tiles; gradient section headers → hairline `.ohx-card`; gradient CTAs → `.ohx-btn`). **Key invariant:** `ProblemStatement` is ONLY ever inside a `RefinedRoot`, so it may use scoped `.ohx-*` classes directly (no inline `var(--x, fallback)` needed). Pass `headingLevel="h2"` from the nonprofit page (N cards, nonprofit name is the `

`) and `headingLevel="h1"` from the project page (project title is the `

`). The "Want to help?" toggle is hidden when `status === "production"` (live projects don't need volunteers) but stays visible for users already helping so they can toggle off; `maintenance` status keeps the toggle. **"Code & Tasks" (July 2026 discoverability fix):** it is an ALWAYS-VISIBLE section (`id="code-and-tasks-"` — unique per problem statement because nonprofit pages render N cards; `scrollMarginTop: 96`) directly after the Project Description — never move it back into the collapsed accordion trio (burying it was the bug; only References/Events stay accordions). Repo list = `problem_statement.github` (handles legacy string shape) MERGED with repos from hackathon teams in the already-fetched event data (`team.problem_statements` includes the ps id; `selected_nonprofit_id`-vs-`resolvedNonprofits` fallback applies ONLY to teams with no `problem_statements` — avoids claiming a sibling project's repos on multi-project nonprofits), deduped by `normalizeRepoLink`; team repos render under a "from hackathon teams" label with "Built by {team} ({event})" attribution, and a duplicate just enriches the project-level card. Live issue data (counts + top-5 open titles per repo) is fetched through the public backend proxy `GET /api/github/issues?org&repo&state=all` ONLY once the section scrolls near (fire-once IntersectionObserver, `rootMargin: 200px`) — batched Promise.all + `issuesRequestedRef` dedupe + ONE setState (never per-repo), errors swallowed (static links still render); backend caches this route 10 min. `RepoCard` + repo-link helpers are **module-scope** (SectionBlock remount lesson). When no repos exist at all, render the "Where's the code?" `--surface-2` card (event-page links + `slack_channel` button) — never hide the section silently. Header top row shows an `#code-and-tasks` anchor `.ohx-tag` when repos exist. Backend fix-forward: `approve_team` (`api/teams/teams_service.py::_link_repo_to_problem_statements`) now also appends the created repo to the linked problem statement(s)' `github` array (team `problem_statements` refs preferred; single-PS-nonprofit fallback; ambiguous → skip + log) and clears `get_single_problem_statement_old`'s cache. **Events & Teams section:** `src/components/Events/Events.js` was rewritten as refined event cards — title is `event.title` (NOT `{location} {type}` — that rendered a street address as the heading), Past/Upcoming `.ohx-tag`, compact date range via `parseLocalDate`, location + description as muted meta, constraint `.ohx-tag`s, ghost Event-page/DevPost buttons, plus a **read-only** teams list (name, member first-names, link to `/hack//team/`) fed by `teamsByEvent` from the same ProblemStatement memo that derives team repos. The old interactive join/leave path was dead (its `teams` state was never populated) and was removed from ProblemStatement along with the per-user `userDetails` fetch — `event-teams.js` and `event-team.js` are now orphaned; don't re-add join/leave here (team joining lives on the event page / manageteam). **Reference Documents:** `ReferenceItem.js` restyled from outlined MUI buttons to hairline row-cards (`ohx-card--hover` `` rows: navy kind icon + sentence-case name + kind `.ohx-tag` [GitHub/Slides/Document/Video/Link] + open-in-new indicator; `docs.google.com/presentation` now classified as Slides); video references keep their `VideoDisplay` embed. Only ProblemStatement consumes it, so `.ohx-*` classes are safe there. `styles/nonprofit/styles.js` is now orphaned — leave in place. -See `docs/refined-design-system.md`. Pattern for new pages: keep `Head`/`getStaticProps`/schema verbatim, add ``, wrap body in ``, rebuild sections with `.ohx-*` classes, one `

`, preserve GA. +See `docs/refined-design-system.md`. Pattern for new pages: keep `Head`/`getStaticProps`/schema verbatim, wrap body in `` (or the `RefinedPage` shell below — fonts load globally, do NOT add ``), rebuild sections with `.ohx-*` classes, one `

`, preserve GA. + +**Refined pages batch (Oct 2026):** `src/components/design/RefinedPage.js` is the shell for simple content pages — `RefinedPage` (RefinedRoot + `.ohx-wrap` section that clears the 64px NavBar; `narrow` for `.ohx-narrow`, `as="article"`), `PageMasthead` (eyebrow → the ONE `

` with optional `italic` accent phrase → `.ohx-lead`), `BackLink`, `Prose` (`.ohx-prose` — Fraunces h2/h3, 70ch measure, accent list markers, navy underlined links; added to `refined.js`), `LinkRow` (one primary `.ohx-btn` + soft `.ohx-link`s) and `CtaBand` (navy closing band). Migrated with it: `/404` (random sleeping image in a reserved 4:3 card; GA `404_error` kept), `/contact` (form logic, reCAPTCHA, `?type=` prefill and the mentor/judge read-first Dialog untouched; MUI fields under `refinedFormTheme` + `refinedFieldSx`; submit is a native `.ohx-btn--primary`), `/feedback` (index), `/about/why` + `/about/why/[title]` (the `whyPages[slug].Content` MUI bodies render inside `Prose`, whose `.ohx-prose .MuiTypography-*` rules restyle their `h4`→Fraunces / `body1`→17px; the 10 `src/components/content/*Content.js` files now use `component="h2"` and no inline 14px), `/about/privacy-policy` (`LAST_UPDATED` constant = the date the policy TEXT last changed — it used to print today's date), `/hack/liability-waiver`, `/hack/photo-release`, `/praise/[id]`, `/hack/[event_id]/results` + `/hack/results` (chrome only; `HackathonResults`/`HackathonFunnel` unchanged), and the four `/about/success-stories/` case studies, which are now plain `story` data objects rendered by `src/components/SuccessStories/SuccessStoryLayout.js` (owns the Head: canonical `www`, fixed `og:url` — they pointed at a non-existent `/success-stories/…` path — Article + BreadcrumbList JSON-LD via `serializeJsonLd`, CDN `og:image`s). **Still not refined** (next batches): the SEO pillar pages (`/coding-for-nonprofits`, `/hackathon-for-social-good`, `/hackathon-judge-opportunities`, `/hackathon-judging-criteria`, `/volunteer/professional-development-volunteering`, `/about/hackers/software-engineering-career-path`, `/about/locations/asu-tempe-arizona`), the tutorial pages (`/about/judges/overview`, `/about/mentors/overview`, `/judge/overview`), `/about/hearts` + `/community-champions` (700-line components), `/blog/[blog_id]`, `/cert`, `/hack/[event_id]/{media,agenda}`, `/store/*`, `/signup2`, `/unsubscribe`; intentionally bespoke: `/12-years-of-social-good`, `/about/style-guide`, print pages (`/hack/flier`, `/hack/[event_id]/print-timeline`), the planning board (own theme), redirect stubs. The homepage and projects page were reimagined away from the old busy/multi-gradient look into a calm "civic editorial" system. Shared tokens + utility classes live in `src/components/design/refined.js`: @@ -516,7 +518,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/docs/refined-design-system.md b/docs/refined-design-system.md index f549abb5..cf190a44 100644 --- a/docs/refined-design-system.md +++ b/docs/refined-design-system.md @@ -67,6 +67,12 @@ Never reintroduce the old rainbow of status chips. single replacement for all the old colored MUI chips. - Sponsor logos: wrap a logo row in `.ohx-sponsors` → logos render grayscale at low opacity, regain color on hover (classic restrained treatment). +- Prose: `.ohx-prose` for long-form content (legal pages, essays, case studies) — + Fraunces `h2`/`h3`, 70ch measure, accent list markers, navy underlined links. +- Page shell: `src/components/design/RefinedPage.js` exports `RefinedPage` + (RefinedRoot + `.ohx-wrap` section with navbar clearance), `PageMasthead` + (eyebrow → the one `

` → lead), `BackLink`, `Prose`, `CtaBand` (navy band, + one primary + one ghost) and `LinkRow` for simple content pages. - Motion: `.rise` = one staggered fade-up on load; set per-element `style={{ animationDelay: 'Nms' }}`. Honors `prefers-reduced-motion`. @@ -118,6 +124,7 @@ visually, disable cache via CDP (`Network.setCacheDisabled` + ## Rollout status - [x] `/` (homepage) - [x] `/projects` +- [x] Oct 2026 batch via `RefinedPage`: `/404`, `/contact`, `/feedback`, `/about/why` (+ `[title]`), `/about/privacy-policy`, `/hack/liability-waiver`, `/hack/photo-release`, `/praise/[id]`, `/hack/[event_id]/results`, `/hack/results`, the four `/about/success-stories/` case studies (`SuccessStoryLayout`) - [x] Global NavBar - [x] `/nonprofits` — `NonProfitList` rewritten; new calm card `NonProfitListTileRefined` (the original `NonProfitListTile` is still used 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 ? ( + <> + +