Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions CLAUDE.md

Large diffs are not rendered by default.

7 changes: 7 additions & 0 deletions docs/refined-design-system.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<h1>` → 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`.

Expand Down Expand Up @@ -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/<slug>` case studies (`SuccessStoryLayout`)
- [x] Global NavBar
- [x] `/nonprofits` — `NonProfitList` rewritten; new calm card
`NonProfitListTileRefined` (the original `NonProfitListTile` is still used
Expand Down
14 changes: 13 additions & 1 deletion ga-events-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,15 +140,27 @@ 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: <number of picks>, 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: <event_id>, 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 |
|------------|--------|------------|-------|
| `admin_deadline_reminder_sent` | `admin/hackathon-edit/sections/DeadlinesSection.js` | `{ event_category: "admin", event_label: <event_id>, value: <hours_before 24\|6\|1> }` | 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: <event_id> }` | A ballot was voided. |
| `admin_peer_vote_publish` | `admin/PeerVoteResults.js` | `{ event_category: "admin", event_label: <event_id> }` | 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/<event>/<round>`). |
| `admin_submissions_csv_export` | `admin/TeamManagement.js` | `{ event_category: "admin", event_label: <event_id> }` | "Export submissions CSV" download. |
| `admin_csv_export` | `admin/ExportCsvButton.js`, `admin/volunteer/VolunteerExportDialog.js`, `admin/JudgingResults.js` | `{ event_category: "admin", event_label: <table>, value: <row count>, 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>`: `event_label` names the table (`volunteer_<type>`, `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: <table>, value: <row count>, 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>`: `event_label` names the table (`volunteer_<type>`, `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

Expand Down
334 changes: 334 additions & 0 deletions src/__tests__/pages/judge-event.test.js
Original file line number Diff line number Diff line change
@@ -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(<HackathonJudgePage />);
// 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(<HackathonJudgePage />);
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(<HackathonJudgePage />);
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(<HackathonJudgePage />);
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(<HackathonJudgePage />);
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(<HackathonJudgePage />);
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(<HackathonJudgePage />);
expect(await screen.findByRole("alert")).toHaveTextContent(
/couldn't load your assignments/i,
);
expect(
screen.getByRole("button", { name: /try again/i }),
).toBeInTheDocument();
});
});
Loading
Loading