Skip to content

feat(judge): dedicated judge portal, organizer invitations, completeness gate UX#550

Merged
0xdevcollins merged 3 commits into
mainfrom
feat/judge_implementation
May 15, 2026
Merged

feat(judge): dedicated judge portal, organizer invitations, completeness gate UX#550
0xdevcollins merged 3 commits into
mainfrom
feat/judge_implementation

Conversation

@0xdevcollins
Copy link
Copy Markdown
Collaborator

@0xdevcollins 0xdevcollins commented May 14, 2026

Frontend half of the judging overhaul. Pairs with backend PR boundlessfi/boundless-nestjs#103 — that one must deploy first.

What ships

New judge portal at `/judge/*`

A separate top-level surface so judges never touch the org dashboard:

  • `/judge` — assignments grouped by state (Open / Awaiting publish / Past)
  • `/judge/invitations` + `/judge/invitations/[token]` — list and accept/decline; works logged out, signs the invitee in or up with email prefill and `callbackUrl` roundtrip through email verification
  • `/judge/[hackathonId]` — overview with ProgressRing, Continue scoring CTA, rubric, deadline countdown, results link
  • `/judge/[hackathonId]/submissions` — paginated queue with filters (Unscored / Scored / All), keyboard nav (J/K/Enter)
  • `/judge/[hackathonId]/submissions/[submissionId]` — two-column scoring page with sliders, queue position, auto-advance, ⌘↵ submit, post-deadline read-only
  • `/judge/[hackathonId]/results` — post-publish leaderboard with podium for top 3

Shared judge components

`ProgressRing`, `ScoreSlider` (with `disabled` prop for post-deadline), `DeadlineBadge`, `KeyboardShortcuts`, `HackathonBanner`, `CountdownBanner` (live ticker, hides when far out, switches to closed state past deadline).

Organizer judges panel

`OrganizerJudgesPanel` replaces the legacy "Add from Organization Members" picker. Email-invite flow + pending invitations list with resend (rotates token) / cancel + history disclosure + status pills. React Query–backed; polls every 60s, fires a parent refresh when an invitation transitions to `ACCEPTED`.

Publish dialog with completeness preview

`GET /judging/completeness` fetched on dialog open; lists incomplete judges with outstanding counts. The Publish button stays disabled until the organizer ticks "I understand the results will be published with incomplete judging," at which point the request sends `acceptPartial: true`.

Conflict-of-role gating

`ActionButtons` reads `viewerRole` off the hackathon detail response and shows "You are managing this hackathon" / "You are judging this hackathon" instead of the Join button.

Legacy modal cleanup

`GradeSubmissionModal` math fix: previously displayed `/100` totals only made sense when weights summed to 100; now uses a true weighted average with `/10` display, and a "% weight" badge computed from the actual proportion. Disqualify link added in organizer-override mode, reusing the existing `DisqualifyDialog`.

Signup roundtrip from invitations

`SignupForm` accepts a prefilled email + `callbackUrl` from query params. `/auth/check-email` stashes the destination in localStorage (Better Auth's verification email link doesn't preserve query params); `/auth/verify-email` honors it after a successful verify.

Uniform email shell

New `renderEmailHtml` / `renderEmailText` / `renderPrimaryButton` in the backend; every transactional email this PR's flows touch ships through that shell. Mint button, single uniform footer with the support address and dynamic copyright year.

Deploy ordering

  1. Backend PR boundlessfi/boundless-nestjs#103 lands first. Migrations + new `/judge/*` and `/judging/completeness` endpoints + `viewerRole` on hackathon detail.
  2. Frontend (this PR) lands after.

If frontend lands first, the new `/judge/*` pages 404 and the organizer publish dialog errors when it tries to fetch completeness.

What's safe even out of order

  • `SignupForm` email prefill + `callbackUrl` pass-through (purely client-side)
  • `/auth/verify-email` localStorage bridge (purely client-side)
  • `ActionButtons` reads `viewerRole` defensively with `(hackathon as any)` — if the field is absent, the existing Join behavior stands
  • `GradeSubmissionModal` math fix has no backend dependency

Tests

Typecheck clean on every touched judge file. Manual smoke test of the invite → accept → score → publish flow on staging is the recommended 15-minute pre-promote sanity check.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Full Judge Portal: dashboard, assignments, submissions list, scoring pages, results leaderboard, and invite/accept judge flows.
    • Judge-facing UI: countdowns, deadline badges, banner, progress ring, score slider, keyboard shortcuts, and portal shell.
  • Improvements

    • Organizer tools: invite/resend/cancel judges, judges panel, disqualify action, and completeness preview with optional "accept partial" publish.
    • Scoring: weight-normalized calculations, visible weight percentages, and displayed score out of 10.
  • UX

    • Email verification now supports post-verify redirect storage; signup/check-email flows accept safe callback URLs.

Review Change Stack

@vercel
Copy link
Copy Markdown

vercel Bot commented May 14, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
boundless-kd16 Ready Ready Preview, Comment May 15, 2026 6:37am

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 14, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 9942d4db-025d-4d98-a12d-73e5cf269813

📥 Commits

Reviewing files that changed from the base of the PR and between e0c5e9c and b85799a.

📒 Files selected for processing (2)
  • components/auth/SignupForm.tsx
  • lib/api/hackathons.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • lib/api/hackathons.ts
  • components/auth/SignupForm.tsx

📝 Walkthrough

Walkthrough

Adds a public Judge Portal: APIs, React Query hooks, UI pages (home, hackathon, submissions, scoring, results, invitations), organizer judge management with completeness gating for publishing, score UI components, criterion ID stabilization, and an email post-verify redirect flow.

Changes

Judge Portal — Public-Facing Feature

Layer / File(s) Summary
API contracts and judge client
lib/api/judge.ts, lib/api/hackathons/judging.ts
New judge API client types/endpoints (assignments, criteria, submissions, results, queue neighbors, invitations) plus getJudgingCompleteness and publishJudgingResults acceptPartial option.
React Query hooks for judge features
hooks/judge/use-judge-queries.ts, hooks/judge/use-organizer-invitations.ts
Query/mutation hooks for judge data and organizer invitations, with cache keys, refetch policies, and targeted invalidation.
Judge pages and layout
app/judge/layout.tsx, app/judge/page.tsx, app/judge/[hackathonId]/page.tsx, app/judge/[hackathonId]/submissions/*, app/judge/[hackathonId]/results/*, app/judge/invitations/*
Auth-guarded pages for judge home, hackathon dashboard, submissions list, scoring page, results leaderboard, and invitation acceptance flows.
Judge UI shell & navigation
components/judge/JudgePortalShell.tsx, components/judge/KeyboardShortcuts.tsx
Portal shell with sticky header/nav and a shortcuts modal.
Judge components: banner, badges, countdown, ring, slider
components/judge/HackathonBanner.tsx, components/judge/DeadlineBadge.tsx, components/judge/CountdownBanner.tsx, components/judge/ProgressRing.tsx, components/judge/ScoreSlider.tsx, app/globals.css
Visual/interactive components for judge flows: banner, deadline badge, live countdown, circular progress, score slider with CSS-driven gradient.
Invitation list & token page
app/judge/invitations/page.tsx, app/judge/invitations/[token]/page.tsx
Pending invitations list and token-based accept/decline page with email mismatch handling and auth redirects.

Judging Criteria ID Stabilization & Scoring Logic

Layer / File(s) Summary
Tightened JudgingCriterion types
types/hackathon/core.ts, lib/api/hackathons.ts, lib/api/hackathons/judging.ts
JudgingCriterion.id made required (non-optional) with documentation directing clients to rely on id.
Score calculation normalization
components/organization/cards/GradeSubmissionModal/useScoreCalculation.ts
Compute weighted average by dividing weighted sum by totalWeight; percentage normalized to 0–100; add getWeightPercent.
Criterion keying and payloads
components/organization/cards/GradeSubmissionModal/useScoreForm.ts, useSubmissionScores.ts, ScoringSection.tsx
Criterion keys and submission payloads use criterion.id exclusively; ScoringSection accepts optional getWeightPercent to display percent weight.
Modal integration and UI changes
components/organization/cards/GradeSubmissionModal/*
Pass getWeightPercent into ScoringSection, add disqualify dialog and related handlers, change TotalScoreCard denominator from / 100 to / 10.

Organizer Judging Dashboard — Publishing & Judge Management

Layer / File(s) Summary
Organizer judges panel & invite dialog
components/organization/hackathons/judging/OrganizerJudgesPanel.tsx, InviteJudgeDialog.tsx
Panel showing active judges, pending invitations, history; invite/resend/cancel actions and detection of newly accepted invites to trigger parent refresh.
Publish results completeness gating
app/(landing)/organizations/[id]/hackathons/[hackathonId]/judging/page.tsx, lib/api/hackathons/judging.ts
Fetch completeness snapshot on publish-dialog open, show per-judge missing counts, disable publish unless complete or acceptPartial checked; publish handler passes acceptPartial to API.

Auth / Post-verify redirect flow

Layer / File(s) Summary
Persist post-verify callback
app/auth/check-email/page.tsx
Reads callbackUrl query param and stores a sanitized relative path into localStorage under POST_VERIFY_KEY (client-only, error-tolerant).
Consume post-verify callback
app/auth/verify-email/page.tsx
On successful verification reads and validates stored post-verify path from localStorage, clears it, and navigates there if valid, else fallback /.
Signup forwarding
components/auth/SignupForm.tsx
Reads email and sanitized callbackUrl from query params and appends them to /auth/check-email redirect via URLSearchParams.

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly Related PRs

Suggested Reviewers

  • Benjtalkshow

🐰 A judge portal blooms, bright and true,
With scoring forms and countdown clues,
Criteria IDs now stable and strong,
Complete invitations, where judges belong!
✨🏆

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 6.15% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and specifically summarizes the main changes: a new judge portal, organizer invitation system, and completeness checking UI. It directly reflects the primary objectives of the changeset.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/judge_implementation

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 10

🧹 Nitpick comments (5)
app/judge/invitations/[token]/page.tsx (1)

55-55: 💤 Low value

Prefer a descriptive name over underscore suffix.

The variable isPending_ uses an underscore to avoid shadowing the isPending from the query hook. A more descriptive name like isInvitationPending or canTakeAction would improve readability without relying on naming conventions.

✨ Suggested rename
-  const isPending_ = data.status === 'PENDING' && !data.isExpired;
+  const canTakeAction = data.status === 'PENDING' && !data.isExpired;

Then update references on lines 161, etc.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/judge/invitations/`[token]/page.tsx at line 55, Rename the ambiguous
local boolean isPending_ to a descriptive name (e.g., isInvitationPending or
canTakeAction) to avoid shadowing the isPending from the query hook and improve
readability; update the declaration (currently "const isPending_ = data.status
=== 'PENDING' && !data.isExpired") and all usages (references that currently
read isPending_, such as the check around line 161) to the new identifier so the
code consistently uses the clearer name and no longer relies on an underscore
suffix to disambiguate.
components/organization/hackathons/judging/OrganizerJudgesPanel.tsx (2)

404-419: 💤 Low value

Consider extracting formatRel to a shared utility.

The relative date formatting logic in formatRel may be useful across multiple judge-related components. Consider moving it to a shared utilities file (e.g., lib/utils/date-format.ts or alongside other judge utilities) to promote reuse and maintainability.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@components/organization/hackathons/judging/OrganizerJudgesPanel.tsx` around
lines 404 - 419, The inline relative-date formatter formatRel in
OrganizerJudgesPanel.tsx should be extracted to a shared utility so other
judge-related components can reuse it: create a new module (e.g.,
lib/utils/dateFormat.ts) that exports the function (export function
formatRel(value: string): string { ... }), move the existing implementation
there, replace the local implementation in OrganizerJudgesPanel.tsx with an
import of formatRel, and update any other components that implement similar
logic to import this shared utility instead of duplicating code.

87-100: 💤 Low value

Consider wrapping onJudgesChanged in useCallback at the call site.

The effect depends on onJudgesChanged, which may not be memoized in the parent. While the effect correctly guards against false triggers with the prev !== null check, it will still re-run on every parent render. Wrapping the callback in useCallback at the parent component would improve performance.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@components/organization/hackathons/judging/OrganizerJudgesPanel.tsx` around
lines 87 - 100, The effect in OrganizerJudgesPanel relies on the onJudgesChanged
prop and will re-run whenever that function identity changes; to avoid
unnecessary re-runs, memoize the callback at the parent (where onJudgesChanged
is created) using React's useCallback so its identity remains stable unless its
true dependencies change, ensuring the useEffect in OrganizerJudgesPanel (which
compares accepted IDs via previousAcceptedIdsRef) only re-executes when
invitations actually change or when the real dependencies of onJudgesChanged
change.
components/judge/KeyboardShortcuts.tsx (1)

41-41: 💤 Low value

Consider clarifying the dual trigger behavior.

The dialog toggles on both the custom triggerKey and hardcoded Shift+/. If triggerKey is set to something other than ?, users might find having two different triggers confusing. Consider documenting this in the component or making the Shift+/ conditional.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@components/judge/KeyboardShortcuts.tsx` at line 41, The KeyboardShortcuts
component currently toggles on both the prop triggerKey and a hardcoded Shift+/
check (e.key === '/' && e.shiftKey), which can confuse users when triggerKey is
not '?'; update the component so the Shift+/ behavior is either documented or
made conditional: add a prop (e.g., allowShiftSlash: boolean = true) or only
check for Shift+/ when triggerKey === '?' so the handler in the
KeyboardShortcuts component uses triggerKey and the new flag (or the triggerKey
comparison) instead of always checking (e.key === '/' && e.shiftKey); update the
component signature and any usages accordingly and add a short comment
describing the dual-trigger behavior if you choose the documentation approach.
components/organization/hackathons/judging/InviteJudgeDialog.tsx (1)

18-18: 💤 Low value

Consider enhancing email validation regex.

The current regex /^[^\s@]+@[^\s@]+\.[^\s@]+$/ is permissive and accepts technically invalid emails (e.g., user@domain..com). While HTML5 type="email" provides baseline validation, a more robust pattern could catch edge cases for better UX.

📧 Enhanced email validation pattern
-const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+const EMAIL_RE = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@components/organization/hackathons/judging/InviteJudgeDialog.tsx` at line 18,
The EMAIL_RE constant is too permissive and allows invalid forms like
consecutive dots; update the EMAIL_RE definition to a stricter pattern (e.g.,
add a negative lookahead to forbid ".." and require a valid local/host and TLD)
by replacing the existing regex in InviteJudgeDialog.tsx (the EMAIL_RE constant)
with a more robust pattern such as one that includes ^(?!.*\.\.) to prevent
consecutive dots, enforces allowed characters in local and domain parts, and
requires a 2+ letter TLD; keep using the existing HTML5 type="email" input but
rely on the improved EMAIL_RE for client-side validation.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app/`(landing)/hackathons/[slug]/components/header/ActionButtons.tsx:
- Around line 49-56: The code is using an any cast for viewerRole; add a
viewerRole?: 'organizer' | 'judge' | 'participant' | 'guest' to the Hackathon
interface/type in lib/api/hackathons.ts (the Hackathon type) and then remove the
(hackathon as any)?.viewerRole cast in ActionButtons.tsx so viewerRole is
accessed as (hackathon)?.viewerRole; keep the existing hasConflictingRole logic
(viewerRole === 'organizer' || viewerRole === 'judge') unchanged.

In `@app/`(landing)/organizations/[id]/hackathons/[hackathonId]/judging/page.tsx:
- Around line 494-519: The effect that fetches completeness for the publish
dialog leaves stale `completeness` when the dialog reopens or when the fetch
fails; update the useEffect so that when `isPublishDialogOpen` becomes true you
immediately clear the prior state (call `setCompleteness(null)` or similar)
before starting the fetch, and if the fetch resolves with `res.success` false or
the promise rejects ensure you do not keep the old value (i.e., explicitly clear
`completeness` in the `.catch()` and in the non-success branch of `.then()`);
apply the same change to the other identical block that uses
`getJudgingCompleteness` so the dialog always shows a fresh/empty snapshot until
a successful response populates it.

In `@app/auth/verify-email/page.tsx`:
- Around line 21-35: The post-verify redirect reads a stashed string from
window.localStorage (POST_VERIFY_KEY) and currently only checks
stashed.startsWith('/'), which can be bypassed by protocol-relative or malformed
paths; instead parse and validate the value before calling router.push:
construct a URL using the page origin (e.g. new URL(stashed, location.origin))
or reject values that start with '//' or contain backslashes after the initial
slash, then ensure the resolved URL's origin equals location.origin and its
pathname starts with '/' before assigning target and calling router.push; update
the logic around POST_VERIFY_KEY, stashed, and router.push accordingly.

In `@app/judge/`[hackathonId]/page.tsx:
- Around line 139-177: The card currently shows scoring CTAs even when
resultsPublished is true; update the rendering logic to treat a published
results state as finalized by gating any scoring flows on !resultsPublished:
change the title/description branches and the Continue scoring Button so they
only use firstUnscoredId and remaining queue copy when resultsPublished is false
(e.g., use conditions like !resultsPublished && firstUnscoredId before showing
CTA text/button), and ensure when resultsPublished is true the UI always shows
review-only text (e.g., "Review my scores" or final-state copy) instead of
"Continue scoring" or remaining-queue messaging; reference the
variables/resultsPublished, firstUnscoredId, remaining, and the Continue scoring
Button/Link rendering to locate the changes.

In `@app/judge/`[hackathonId]/submissions/[submissionId]/page.tsx:
- Around line 77-80: The page currently computes judgingClosed using
hackathonOverview.judgingEnd but ignores hackathonOverview.resultsPublished,
allowing submitOrError to be invoked via the Cmd/Ctrl+Enter shortcut after
results are published; update the read-only logic to consider both the deadline
and publication by deriving readOnly = judgingClosed ||
!!hackathonOverview?.resultsPublished (or similar) where judgingClosed is
computed, then early-return/disable submit paths by checking readOnly before
invoking submitOrError and also guard the keyboard shortcut handler so it no-ops
when readOnly is true; ensure you update references to judgingClosed/readOnly
and the submitOrError call sites and the shortcut listener to use this combined
check.

In `@components/judge/CountdownBanner.tsx`:
- Around line 36-46: The effect in CountdownBanner currently picks intervalMs
once and never updates as time passes; replace the setInterval approach with a
self-scheduling timer that recalculates getCountdown(deadline) on each tick:
inside useEffect keep the tick function that calls setNow(Date.now()), then
compute parts = getCountdown(deadline) and derive intervalMs from parts.totalMs,
and use window.setTimeout to schedule the next tick with that interval; ensure
the cleanup clears the active timeout id. This uses the existing tick, setNow
and getCountdown symbols and removes the fixed window.setInterval usage so the
frequency updates dynamically as the deadline approaches.

In `@components/judge/DeadlineBadge.tsx`:
- Around line 13-16: The dayDiff function currently uses Math.round which treats
small negative offsets as 0 (e.g., -0.25 -> 0) causing recently-passed deadlines
to display as "Ends today"; update the logic in dayDiff to use Math.floor on the
computed days (i.e., replace Math.round with Math.floor) so any past deadline
yields a negative integer (e.g., -0.1 -> -1) and will correctly render as
overdue.

In `@components/organization/cards/GradeSubmissionModal/index.tsx`:
- Line 105: In GradeSubmissionModal replace the catch parameter type from any to
unknown and make error handling type-safe: change the catch to catch (e:
unknown) and inside the catch branch narrow the type (for example using if (e
instanceof Error) to read e.message, else fall back to String(e) or a default
message) where the code currently reads the error; ensure any direct property
access on the caught value is guarded or cast after an instanceof/type check so
TypeScript rules are satisfied.

In `@components/organization/cards/GradeSubmissionModal/useSubmissionScores.ts`:
- Around line 49-51: getCriterionKey currently returns criterion.id
unconditionally which can be undefined for legacy/incomplete data and will
produce undefined keys in the scores/comments maps (used around where scores and
comments are built and at transformToApiFormat usage). Update getCriterionKey to
return a safe fallback when criterion.id is falsy (e.g., prefer criterion.id but
fall back to a stable string such as `criterion.name`, `criterion.key`, or
generate a deterministic fallback like `criterion-${index}`) so that scores and
comments objects never contain undefined keys; touch the consumer code that
constructs scores/comments (the places referenced near lines 57-59 and 102-104)
to use getCriterionKey consistently. Ensure the fallback is deterministic and
unique per criterion so existing scoring logic and transformToApiFormat continue
to work predictably.

In `@components/organization/hackathons/judging/OrganizerJudgesPanel.tsx`:
- Line 200: The current removal call falls back to judge.id which may be an
assignment ID, causing removeJudge (which expects a userId used in
/judging/judges/${userId}) to fail; update the code to only use judge.userId
(e.g. call onRemoveJudge(judge.userId)) and/or add an explicit guard in the
onRemoveJudge handler to validate presence of userId (reject, show an
error/toast, or disable the remove button) rather than using judge.id; locate
the usage in OrganizerJudgesPanel.tsx (onRemoveJudge, the remove button onClick)
and the removeJudge API call to implement the guard/validation so removal always
supplies a true userId.

---

Nitpick comments:
In `@app/judge/invitations/`[token]/page.tsx:
- Line 55: Rename the ambiguous local boolean isPending_ to a descriptive name
(e.g., isInvitationPending or canTakeAction) to avoid shadowing the isPending
from the query hook and improve readability; update the declaration (currently
"const isPending_ = data.status === 'PENDING' && !data.isExpired") and all
usages (references that currently read isPending_, such as the check around line
161) to the new identifier so the code consistently uses the clearer name and no
longer relies on an underscore suffix to disambiguate.

In `@components/judge/KeyboardShortcuts.tsx`:
- Line 41: The KeyboardShortcuts component currently toggles on both the prop
triggerKey and a hardcoded Shift+/ check (e.key === '/' && e.shiftKey), which
can confuse users when triggerKey is not '?'; update the component so the
Shift+/ behavior is either documented or made conditional: add a prop (e.g.,
allowShiftSlash: boolean = true) or only check for Shift+/ when triggerKey ===
'?' so the handler in the KeyboardShortcuts component uses triggerKey and the
new flag (or the triggerKey comparison) instead of always checking (e.key ===
'/' && e.shiftKey); update the component signature and any usages accordingly
and add a short comment describing the dual-trigger behavior if you choose the
documentation approach.

In `@components/organization/hackathons/judging/InviteJudgeDialog.tsx`:
- Line 18: The EMAIL_RE constant is too permissive and allows invalid forms like
consecutive dots; update the EMAIL_RE definition to a stricter pattern (e.g.,
add a negative lookahead to forbid ".." and require a valid local/host and TLD)
by replacing the existing regex in InviteJudgeDialog.tsx (the EMAIL_RE constant)
with a more robust pattern such as one that includes ^(?!.*\.\.) to prevent
consecutive dots, enforces allowed characters in local and domain parts, and
requires a 2+ letter TLD; keep using the existing HTML5 type="email" input but
rely on the improved EMAIL_RE for client-side validation.

In `@components/organization/hackathons/judging/OrganizerJudgesPanel.tsx`:
- Around line 404-419: The inline relative-date formatter formatRel in
OrganizerJudgesPanel.tsx should be extracted to a shared utility so other
judge-related components can reuse it: create a new module (e.g.,
lib/utils/dateFormat.ts) that exports the function (export function
formatRel(value: string): string { ... }), move the existing implementation
there, replace the local implementation in OrganizerJudgesPanel.tsx with an
import of formatRel, and update any other components that implement similar
logic to import this shared utility instead of duplicating code.
- Around line 87-100: The effect in OrganizerJudgesPanel relies on the
onJudgesChanged prop and will re-run whenever that function identity changes; to
avoid unnecessary re-runs, memoize the callback at the parent (where
onJudgesChanged is created) using React's useCallback so its identity remains
stable unless its true dependencies change, ensuring the useEffect in
OrganizerJudgesPanel (which compares accepted IDs via previousAcceptedIdsRef)
only re-executes when invitations actually change or when the real dependencies
of onJudgesChanged change.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 62989402-7794-40a9-b7fa-8aaaee504734

📥 Commits

Reviewing files that changed from the base of the PR and between 503ec0e and e0c5e9c.

📒 Files selected for processing (39)
  • app/(landing)/hackathons/[slug]/components/header/ActionButtons.tsx
  • app/(landing)/organizations/[id]/hackathons/[hackathonId]/judging/page.tsx
  • app/(landing)/organizations/[id]/hackathons/[hackathonId]/participants/page.tsx
  • app/auth/check-email/page.tsx
  • app/auth/verify-email/page.tsx
  • app/globals.css
  • app/judge/[hackathonId]/page.tsx
  • app/judge/[hackathonId]/results/page.tsx
  • app/judge/[hackathonId]/submissions/[submissionId]/page.tsx
  • app/judge/[hackathonId]/submissions/page.tsx
  • app/judge/invitations/[token]/page.tsx
  • app/judge/invitations/page.tsx
  • app/judge/layout.tsx
  • app/judge/page.tsx
  • components/auth/SignupForm.tsx
  • components/judge/CountdownBanner.tsx
  • components/judge/DeadlineBadge.tsx
  • components/judge/HackathonBanner.tsx
  • components/judge/JudgePortalShell.tsx
  • components/judge/KeyboardShortcuts.tsx
  • components/judge/ProgressRing.tsx
  • components/judge/ScoreSlider.tsx
  • components/judge/utils.ts
  • components/organization/cards/GradeSubmissionModal/ScoringSection.tsx
  • components/organization/cards/GradeSubmissionModal/TotalScoreCard.tsx
  • components/organization/cards/GradeSubmissionModal/index.tsx
  • components/organization/cards/GradeSubmissionModal/useScoreCalculation.ts
  • components/organization/cards/GradeSubmissionModal/useScoreForm.ts
  • components/organization/cards/GradeSubmissionModal/useSubmissionScores.ts
  • components/organization/cards/Participant.tsx
  • components/organization/hackathons/judging/InviteJudgeDialog.tsx
  • components/organization/hackathons/judging/OrganizerJudgesPanel.tsx
  • hooks/judge/use-judge-queries.ts
  • hooks/judge/use-organizer-invitations.ts
  • lib/api/hackathons.ts
  • lib/api/hackathons/judging.ts
  • lib/api/judge.ts
  • lib/utils/hackathon-form-transforms.ts
  • types/hackathon/core.ts

Comment on lines +49 to +56
const viewerRole = (hackathon as any)?.viewerRole as
| 'organizer'
| 'judge'
| 'participant'
| 'guest'
| undefined;
const hasConflictingRole =
viewerRole === 'organizer' || viewerRole === 'judge';
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Avoid 'any' type; add viewerRole to Hackathon type.

The code uses (hackathon as any)?.viewerRole which bypasses TypeScript's type safety. The viewerRole property should be added to the Hackathon type definition in lib/api/hackathons.ts to maintain type safety.

🔧 Recommended fix

Add viewerRole to the Hackathon type in lib/api/hackathons.ts:

 export type Hackathon = {
   id: string;
   name: string;
   // ... other fields
+  viewerRole?: 'organizer' | 'judge' | 'participant' | 'guest';
   // ... rest of fields
 };

Then remove the any assertion:

- const viewerRole = (hackathon as any)?.viewerRole as
-   | 'organizer'
-   | 'judge'
-   | 'participant'
-   | 'guest'
-   | undefined;
+ const viewerRole = hackathon?.viewerRole;

As per coding guidelines: "Do not use 'any' type; always search for proper Trustless Work entity types."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/`(landing)/hackathons/[slug]/components/header/ActionButtons.tsx around
lines 49 - 56, The code is using an any cast for viewerRole; add a viewerRole?:
'organizer' | 'judge' | 'participant' | 'guest' to the Hackathon interface/type
in lib/api/hackathons.ts (the Hackathon type) and then remove the (hackathon as
any)?.viewerRole cast in ActionButtons.tsx so viewerRole is accessed as
(hackathon)?.viewerRole; keep the existing hasConflictingRole logic (viewerRole
=== 'organizer' || viewerRole === 'judge') unchanged.

Comment on lines +494 to +519
// Pull the completeness snapshot every time the dialog opens so the
// organizer sees fresh numbers (a judge may have submitted scores
// since the page loaded).
useEffect(() => {
if (!isPublishDialogOpen) {
setAcceptPartial(false);
return;
}
let cancelled = false;
setCompletenessLoading(true);
getJudgingCompleteness(organizationId, hackathonId)
.then(res => {
if (cancelled) return;
if (res.success && res.data) setCompleteness(res.data);
})
.catch(() => {
// Non-fatal: the dialog still works, organizer just won't see
// the incompleteness summary.
})
.finally(() => {
if (!cancelled) setCompletenessLoading(false);
});
return () => {
cancelled = true;
};
}, [isPublishDialogOpen, organizationId, hackathonId]);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Reset stale completeness state before each preview fetch.

completeness is never cleared when the dialog reopens, and the effect also leaves the old value in place on success: false / rejected requests. That means the warning copy and publish-button gating can reflect a previous snapshot instead of the current judging state.

Suggested fix
   useEffect(() => {
     if (!isPublishDialogOpen) {
+      setCompleteness(null);
+      setCompletenessLoading(false);
       setAcceptPartial(false);
       return;
     }
     let cancelled = false;
+    setCompleteness(null);
     setCompletenessLoading(true);
     getJudgingCompleteness(organizationId, hackathonId)
       .then(res => {
         if (cancelled) return;
-        if (res.success && res.data) setCompleteness(res.data);
+        if (res.success && res.data) {
+          setCompleteness(res.data);
+        } else {
+          setCompleteness(null);
+        }
       })
       .catch(() => {
-        // Non-fatal: the dialog still works, organizer just won't see
-        // the incompleteness summary.
+        if (!cancelled) setCompleteness(null);
       })
       .finally(() => {
         if (!cancelled) setCompletenessLoading(false);
       });

Also applies to: 1178-1184

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/`(landing)/organizations/[id]/hackathons/[hackathonId]/judging/page.tsx
around lines 494 - 519, The effect that fetches completeness for the publish
dialog leaves stale `completeness` when the dialog reopens or when the fetch
fails; update the useEffect so that when `isPublishDialogOpen` becomes true you
immediately clear the prior state (call `setCompleteness(null)` or similar)
before starting the fetch, and if the fetch resolves with `res.success` false or
the promise rejects ensure you do not keep the old value (i.e., explicitly clear
`completeness` in the `.catch()` and in the non-success branch of `.then()`);
apply the same change to the other identical block that uses
`getJudgingCompleteness` so the dialog always shows a fresh/empty snapshot until
a successful response populates it.

Comment on lines +21 to +35
// Honor a post-verify destination stashed by the signup flow
// (e.g. a judge invitation page). Same-origin paths only.
let target = '/';
if (typeof window !== 'undefined') {
try {
const stashed = window.localStorage.getItem(POST_VERIFY_KEY);
if (stashed && stashed.startsWith('/')) {
target = stashed;
}
window.localStorage.removeItem(POST_VERIFY_KEY);
} catch {
// ignore storage errors
}
}
router.push(target);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Insufficient open redirect protection.

The validation stashed.startsWith('/') is not sufficient to prevent open redirects. Protocol-relative URLs like //evil.com or edge cases like /\evil.com may bypass this check in some browsers.

🔒 Recommended fix for robust path validation
           let target = '/';
           if (typeof window !== 'undefined') {
             try {
               const stashed = window.localStorage.getItem(POST_VERIFY_KEY);
-              if (stashed && stashed.startsWith('/')) {
+              if (stashed && stashed.startsWith('/') && !stashed.startsWith('//')) {
                 target = stashed;
               }
               window.localStorage.removeItem(POST_VERIFY_KEY);

Or use a more robust URL validation:

           let target = '/';
           if (typeof window !== 'undefined') {
             try {
               const stashed = window.localStorage.getItem(POST_VERIFY_KEY);
-              if (stashed && stashed.startsWith('/')) {
-                target = stashed;
-              }
+              if (stashed) {
+                try {
+                  const url = new URL(stashed, window.location.origin);
+                  if (url.origin === window.location.origin) {
+                    target = url.pathname + url.search + url.hash;
+                  }
+                } catch {
+                  // Invalid URL, ignore
+                }
+              }
               window.localStorage.removeItem(POST_VERIFY_KEY);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Honor a post-verify destination stashed by the signup flow
// (e.g. a judge invitation page). Same-origin paths only.
let target = '/';
if (typeof window !== 'undefined') {
try {
const stashed = window.localStorage.getItem(POST_VERIFY_KEY);
if (stashed && stashed.startsWith('/')) {
target = stashed;
}
window.localStorage.removeItem(POST_VERIFY_KEY);
} catch {
// ignore storage errors
}
}
router.push(target);
// Honor a post-verify destination stashed by the signup flow
// (e.g. a judge invitation page). Same-origin paths only.
let target = '/';
if (typeof window !== 'undefined') {
try {
const stashed = window.localStorage.getItem(POST_VERIFY_KEY);
if (stashed) {
try {
const url = new URL(stashed, window.location.origin);
if (url.origin === window.location.origin) {
target = url.pathname + url.search + url.hash;
}
} catch {
// Invalid URL, ignore
}
}
window.localStorage.removeItem(POST_VERIFY_KEY);
} catch {
// ignore storage errors
}
}
router.push(target);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/auth/verify-email/page.tsx` around lines 21 - 35, The post-verify
redirect reads a stashed string from window.localStorage (POST_VERIFY_KEY) and
currently only checks stashed.startsWith('/'), which can be bypassed by
protocol-relative or malformed paths; instead parse and validate the value
before calling router.push: construct a URL using the page origin (e.g. new
URL(stashed, location.origin)) or reject values that start with '//' or contain
backslashes after the initial slash, then ensure the resolved URL's origin
equals location.origin and its pathname starts with '/' before assigning target
and calling router.push; update the logic around POST_VERIFY_KEY, stashed, and
router.push accordingly.

Comment on lines +139 to +177
<h2 className='mt-1 text-lg font-semibold text-white'>
{allScored
? 'You have scored every submission'
: firstUnscoredId
? `Continue scoring`
: data.totalSubmissions === 0
? 'Nothing shortlisted yet'
: 'All scored. Awaiting more.'}
</h2>
<p className='mt-1 text-sm text-gray-500'>
{allScored
? 'When the organizer publishes results, you will see the final ranking here.'
: firstUnscoredId
? `${remaining} submission${remaining === 1 ? '' : 's'} left in your queue.`
: data.totalSubmissions === 0
? 'The organizer has not shortlisted any submissions for judging.'
: 'You are caught up. Check back later.'}
</p>
</div>
<div className='flex flex-wrap gap-2'>
{firstUnscoredId ? (
<Button
onClick={() =>
router.push(
`/judge/${hackathonId}/submissions/${firstUnscoredId}`
)
}
className='bg-primary hover:bg-primary/90 text-primary-foreground'
>
Continue scoring
<ArrowRight className='ml-2 h-4 w-4' />
</Button>
) : null}
<Link
href={`/judge/${hackathonId}/submissions`}
className='inline-flex items-center gap-2 rounded-md border border-white/10 bg-transparent px-3 py-2 text-sm text-gray-200 hover:bg-white/5'
>
<ClipboardList className='h-4 w-4' />
{allScored ? 'Review my scores' : 'See full queue'}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Hide scoring CTAs after results are published.

publishJudgingResults({ acceptPartial: true }) makes resultsPublished && firstUnscoredId a valid state now. This card still renders “Continue scoring” plus remaining-queue copy in that case, which contradicts the finalized state and sends judges into a flow that should already be review-only.

Suggested fix
-            <h2 className='mt-1 text-lg font-semibold text-white'>
-              {allScored
-                ? 'You have scored every submission'
-                : firstUnscoredId
-                  ? `Continue scoring`
-                  : data.totalSubmissions === 0
-                    ? 'Nothing shortlisted yet'
-                    : 'All scored. Awaiting more.'}
-            </h2>
+            <h2 className='mt-1 text-lg font-semibold text-white'>
+              {data.resultsPublished
+                ? 'Results have been published'
+                : allScored
+                  ? 'You have scored every submission'
+                  : firstUnscoredId
+                    ? 'Continue scoring'
+                    : data.totalSubmissions === 0
+                      ? 'Nothing shortlisted yet'
+                      : 'All scored. Awaiting more.'}
+            </h2>
             <p className='mt-1 text-sm text-gray-500'>
-              {allScored
+              {data.resultsPublished
+                ? 'Judging is finalized. You can review your scores and the published results below.'
+                : allScored
                 ? 'When the organizer publishes results, you will see the final ranking here.'
                 : firstUnscoredId
                   ? `${remaining} submission${remaining === 1 ? '' : 's'} left in your queue.`
                   : data.totalSubmissions === 0
                     ? 'The organizer has not shortlisted any submissions for judging.'
                     : 'You are caught up. Check back later.'}
             </p>
@@
-            {firstUnscoredId ? (
+            {!data.resultsPublished && firstUnscoredId ? (
               <Button
@@
-              {allScored ? 'Review my scores' : 'See full queue'}
+              {data.resultsPublished || allScored ? 'Review my scores' : 'See full queue'}
             </Link>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/judge/`[hackathonId]/page.tsx around lines 139 - 177, The card currently
shows scoring CTAs even when resultsPublished is true; update the rendering
logic to treat a published results state as finalized by gating any scoring
flows on !resultsPublished: change the title/description branches and the
Continue scoring Button so they only use firstUnscoredId and remaining queue
copy when resultsPublished is false (e.g., use conditions like !resultsPublished
&& firstUnscoredId before showing CTA text/button), and ensure when
resultsPublished is true the UI always shows review-only text (e.g., "Review my
scores" or final-state copy) instead of "Continue scoring" or remaining-queue
messaging; reference the variables/resultsPublished, firstUnscoredId, remaining,
and the Continue scoring Button/Link rendering to locate the changes.

Comment on lines +77 to +80
const judgingClosed = (() => {
if (!hackathonOverview?.judgingEnd) return false;
return getCountdown(hackathonOverview.judgingEnd).isPast;
})();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Treat published results as read-only and block shortcut submits.

With partial publishing enabled, resultsPublished can flip to true before the deadline. This page only derives readOnly from judgingEnd, and submitOrError() is still reachable through Cmd/Ctrl+Enter, so judges can keep sending score mutations after finalization.

Suggested fix
-  const judgingClosed = (() => {
+  const judgingClosed = (() => {
     if (!hackathonOverview?.judgingEnd) return false;
     return getCountdown(hackathonOverview.judgingEnd).isPast;
   })();
@@
-  const submitOrError = useCallback(() => {
+  const submitOrError = useCallback(() => {
+    if (readOnly) return;
+
     const newErrors: Record<string, string | null> = {};
     let valid = true;
@@
   }, [
+    readOnly,
     criteria,
     scores,
     comments,
@@
-  const readOnly = judgingClosed;
+  const readOnly = judgingClosed || data.resultsPublished;
@@
               <textarea
                 id='overall-comment'
                 value={overall}
                 onChange={e => setOverall(e.target.value)}
+                disabled={readOnly}
                 rows={3}
@@
                 <input
                   type='checkbox'
                   checked={autoAdvance}
                   onChange={e => setAutoAdvance(e.target.checked)}
+                  disabled={readOnly}
                   className='accent-primary h-3.5 w-3.5'
                 />

Also applies to: 193-255, 274-277, 537-555, 567-590

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/judge/`[hackathonId]/submissions/[submissionId]/page.tsx around lines 77
- 80, The page currently computes judgingClosed using
hackathonOverview.judgingEnd but ignores hackathonOverview.resultsPublished,
allowing submitOrError to be invoked via the Cmd/Ctrl+Enter shortcut after
results are published; update the read-only logic to consider both the deadline
and publication by deriving readOnly = judgingClosed ||
!!hackathonOverview?.resultsPublished (or similar) where judgingClosed is
computed, then early-return/disable submit paths by checking readOnly before
invoking submitOrError and also guard the keyboard shortcut handler so it no-ops
when readOnly is true; ensure you update references to judgingClosed/readOnly
and the submitOrError call sites and the shortcut listener to use this combined
check.

Comment on lines +36 to +46
useEffect(() => {
if (!deadline) return;
const tick = () => setNow(Date.now());
tick();
// Tick every second under an hour for the live HH:MM:SS feel,
// otherwise every minute to keep this cheap.
const parts = getCountdown(deadline);
const intervalMs = parts.totalMs < 60 * 60 * 1000 ? 1000 : 60_000;
const id = window.setInterval(tick, intervalMs);
return () => window.clearInterval(id);
}, [deadline]);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Interval frequency doesn't update dynamically as deadline approaches.

The useEffect computes intervalMs once when the deadline changes, but doesn't update it as time passes. If the deadline starts 70 minutes away (1-minute ticking), it will continue with 1-minute ticks even after crossing the 1-hour threshold, until the component remounts or the deadline prop changes.

For judging portals where users may keep the tab open for extended periods, this means they won't see the switch to live 1-second updates when the deadline becomes imminent.

🔧 Proposed fix

Recalculate the interval duration on each tick:

   useEffect(() => {
     if (!deadline) return;
+    let id: number;
+    
     const tick = () => {
       setNow(Date.now());
-    };
-    tick();
-    // Tick every second under an hour for the live HH:MM:SS feel,
-    // otherwise every minute to keep this cheap.
-    const parts = getCountdown(deadline);
-    const intervalMs = parts.totalMs < 60 * 60 * 1000 ? 1000 : 60_000;
-    const id = window.setInterval(tick, intervalMs);
+      
+      // Recalculate interval based on current time remaining
+      const parts = getCountdown(deadline);
+      const intervalMs = parts.totalMs < 60 * 60 * 1000 ? 1000 : 60_000;
+      
+      if (id) window.clearInterval(id);
+      id = window.setInterval(tick, intervalMs);
+    };
+    
+    tick(); // Initial tick
     return () => window.clearInterval(id);
   }, [deadline]);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@components/judge/CountdownBanner.tsx` around lines 36 - 46, The effect in
CountdownBanner currently picks intervalMs once and never updates as time
passes; replace the setInterval approach with a self-scheduling timer that
recalculates getCountdown(deadline) on each tick: inside useEffect keep the tick
function that calls setNow(Date.now()), then compute parts =
getCountdown(deadline) and derive intervalMs from parts.totalMs, and use
window.setTimeout to schedule the next tick with that interval; ensure the
cleanup clears the active timeout id. This uses the existing tick, setNow and
getCountdown symbols and removes the fixed window.setInterval usage so the
frequency updates dynamically as the deadline approaches.

Comment on lines +13 to +16
function dayDiff(target: Date): number {
const ms = target.getTime() - Date.now();
return Math.round(ms / (1000 * 60 * 60 * 24));
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Fix rounding logic for recently passed deadlines.

Using Math.round causes recently-passed deadlines (within ~12 hours) to display as "Ends today" instead of "overdue". For example, a deadline that passed 6 hours ago would compute days = Math.round(-0.25) = 0, showing "Ends today" rather than "overdue".

🔧 Proposed fix

Use Math.floor for consistent behavior:

 function dayDiff(target: Date): number {
   const ms = target.getTime() - Date.now();
-  return Math.round(ms / (1000 * 60 * 60 * 24));
+  return Math.floor(ms / (1000 * 60 * 60 * 24));
 }

With Math.floor:

  • 0.9 days left → 0 (shows "Ends today")
  • -0.1 days past → -1 (shows "1 day overdue")

This ensures any past deadline immediately shows as overdue.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function dayDiff(target: Date): number {
const ms = target.getTime() - Date.now();
return Math.round(ms / (1000 * 60 * 60 * 24));
}
function dayDiff(target: Date): number {
const ms = target.getTime() - Date.now();
return Math.floor(ms / (1000 * 60 * 60 * 24));
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@components/judge/DeadlineBadge.tsx` around lines 13 - 16, The dayDiff
function currently uses Math.round which treats small negative offsets as 0
(e.g., -0.25 -> 0) causing recently-passed deadlines to display as "Ends today";
update the logic in dayDiff to use Math.floor on the computed days (i.e.,
replace Math.round with Math.floor) so any past deadline yields a negative
integer (e.g., -0.1 -> -1) and will correctly render as overdue.

} else {
toast.error(res.message || 'Failed to disqualify submission');
}
} catch (e: any) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Replace any with unknown.

The catch clause uses any, which violates the project's TypeScript guidelines. As per coding guidelines, avoid using the any type and use proper types instead.

🔧 Proposed fix
-    } catch (e: any) {
+    } catch (e: unknown) {
       toast.error(e?.message || 'Failed to disqualify submission');

And update the error access to be type-safe:

     } catch (e: unknown) {
-      toast.error(e?.message || 'Failed to disqualify submission');
+      toast.error(
+        e instanceof Error ? e.message : 'Failed to disqualify submission'
+      );
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
} catch (e: any) {
} catch (e: unknown) {
toast.error(
e instanceof Error ? e.message : 'Failed to disqualify submission'
);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@components/organization/cards/GradeSubmissionModal/index.tsx` at line 105, In
GradeSubmissionModal replace the catch parameter type from any to unknown and
make error handling type-safe: change the catch to catch (e: unknown) and inside
the catch branch narrow the type (for example using if (e instanceof Error) to
read e.message, else fall back to String(e) or a default message) where the code
currently reads the error; ensure any direct property access on the caught value
is guarded or cast after an instanceof/type check so TypeScript rules are
satisfied.

Comment on lines 49 to 51
const getCriterionKey = (criterion: JudgingCriterion) => {
return criterion.id || criterion.name || (criterion as any).title;
return criterion.id;
};
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Add fallback for undefined criterion.id to prevent runtime errors.

getCriterionKey now returns criterion.id unconditionally. If criterion.id is undefined (e.g., from legacy data or incomplete API responses), this will create undefined keys in the scores and comments objects at lines 57-59, 102-104, which breaks the scoring logic.

While transformToApiFormat (in lib/utils/hackathon-form-transforms.ts) now ensures new criteria have IDs, existing database records or API responses may still have criteria without an id field.

🛡️ Proposed fix to add defensive fallback
 const getCriterionKey = (criterion: JudgingCriterion) => {
-  return criterion.id;
+  return criterion.id ?? criterion.name ?? criterion.title ?? '';
 };

This restores the fallback logic while preferring id when available, ensuring backwards compatibility with legacy data.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const getCriterionKey = (criterion: JudgingCriterion) => {
return criterion.id || criterion.name || (criterion as any).title;
return criterion.id;
};
const getCriterionKey = (criterion: JudgingCriterion) => {
return criterion.id ?? criterion.name ?? criterion.title ?? '';
};
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@components/organization/cards/GradeSubmissionModal/useSubmissionScores.ts`
around lines 49 - 51, getCriterionKey currently returns criterion.id
unconditionally which can be undefined for legacy/incomplete data and will
produce undefined keys in the scores/comments maps (used around where scores and
comments are built and at transformToApiFormat usage). Update getCriterionKey to
return a safe fallback when criterion.id is falsy (e.g., prefer criterion.id but
fall back to a stable string such as `criterion.name`, `criterion.key`, or
generate a deterministic fallback like `criterion-${index}`) so that scores and
comments objects never contain undefined keys; touch the consumer code that
constructs scores/comments (the places referenced near lines 57-59 and 102-104)
to use getCriterionKey consistently. Ensure the fallback is deterministic and
unique per criterion so existing scoring logic and transformToApiFormat continue
to work predictably.

variant='ghost'
size='sm'
className='text-red-300/80 hover:bg-red-400/10 hover:text-red-300'
onClick={() => onRemoveJudge(judge.userId ?? judge.id)}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check what the CurrentJudge.id field represents and how onRemoveJudge uses it
rg -B 5 -A 10 "onRemoveJudge.*=>" app/\(landing\)/organizations/

Repository: boundlessfi/boundless

Length of output: 2182


🏁 Script executed:

# Find CurrentJudge interface definition
rg "interface CurrentJudge|type CurrentJudge" -A 10

Repository: boundlessfi/boundless

Length of output: 1005


🏁 Script executed:

# Look at OrganizerJudgesPanel.tsx to understand the component context
rg "CurrentJudge" components/organization/hackathons/judging/OrganizerJudgesPanel.tsx -B 2 -A 2

Repository: boundlessfi/boundless

Length of output: 289


🏁 Script executed:

# Check how judge.id is used elsewhere in the file
rg "judge\.id" components/organization/hackathons/judging/OrganizerJudgesPanel.tsx -B 2 -A 2

Repository: boundlessfi/boundless

Length of output: 598


🏁 Script executed:

# Find where currentJudges is fetched/populated
rg "currentJudges|fetchJudges" components/organization/hackathons/judging/OrganizerJudgesPanel.tsx -B 3 -A 3

Repository: boundlessfi/boundless

Length of output: 1161


🏁 Script executed:

# Find the setJudgeToRemove usage and how the value is used
rg "judgeToRemove|setJudgeToRemove" app/\(landing\)/organizations/ -B 2 -A 5

Repository: boundlessfi/boundless

Length of output: 5234


🏁 Script executed:

# Check judge API types to understand id vs userId
rg "interface.*Judge|export.*Judge.*{" lib/api/judge.ts -A 8

Repository: boundlessfi/boundless

Length of output: 195


🏁 Script executed:

# Find handleRemoveJudge function
rg "handleRemoveJudge.*=" app/\(landing\)/organizations/ -B 2 -A 10

Repository: boundlessfi/boundless

Length of output: 1426


🏁 Script executed:

# Check judge API/types more carefully
cat lib/api/judge.ts

Repository: boundlessfi/boundless

Length of output: 11436


🏁 Script executed:

# Find removeJudge function in judge API
rg "removeJudge|export.*remove" lib/api/judge.ts -B 2 -A 5

Repository: boundlessfi/boundless

Length of output: 47


🏁 Script executed:

# Also check if removeJudge is in a hackathons subdirectory
rg "removeJudge|export.*remove" lib/api/hackathons/ -B 2 -A 5

Repository: boundlessfi/boundless

Length of output: 1023


🏁 Script executed:

# Get the full removeJudge implementation
rg -A 8 "export const removeJudge = async" lib/api/hackathons/judging.ts

Repository: boundlessfi/boundless

Length of output: 370


🏁 Script executed:

# Find where currentJudges is fetched to understand the data structure
rg "currentJudges.*=" app/\(landing\)/organizations/\[id\]/hackathons/\[hackathonId\]/judging/page.tsx -B 3 -A 3

Repository: boundlessfi/boundless

Length of output: 1501


🏁 Script executed:

# Find fetchJudges function to see what API it calls and what data it returns
rg "fetchJudges.*=" app/\(landing\)/organizations/\[id\]/hackathons/\[hackathonId\]/judging/page.tsx -B 2 -A 10

Repository: boundlessfi/boundless

Length of output: 487


🏁 Script executed:

# Check if there's a getJudges or listJudges API endpoint
rg "export.*Judges|getJudges|listJudges" lib/api/hackathons/judging.ts -A 5

Repository: boundlessfi/boundless

Length of output: 307


🏁 Script executed:

# Get the full fetchJudges function to see how it populates judges
rg -A 30 "const fetchJudges = useCallback" app/\(landing\)/organizations/\[id\]/hackathons/\[hackathonId\]/judging/page.tsx

Repository: boundlessfi/boundless

Length of output: 1039


🏁 Script executed:

# Check the API response type for getHackathonJudges - what fields does it return?
rg -B 5 -A 15 "export const getHackathonJudges" lib/api/hackathons/judging.ts

Repository: boundlessfi/boundless

Length of output: 586


🏁 Script executed:

# Get more of the fetchJudges function to see how judges are populated and what fields they have
rg -A 60 "const fetchJudges = useCallback" app/\(landing\)/organizations/\[id\]/hackathons/\[hackathonId\]/judging/page.tsx | head -80

Repository: boundlessfi/boundless

Length of output: 1962


🏁 Script executed:

# Look at the full getHackathonJudges API call in context
cat lib/api/hackathons/judging.ts | grep -A 20 "export const getHackathonJudges"

Repository: boundlessfi/boundless

Length of output: 600


🏁 Script executed:

# Search for example judge data or comments about the judges endpoint structure
rg "judging/judges" --type ts --type tsx -B 5 -A 5

Repository: boundlessfi/boundless

Length of output: 93


🏁 Script executed:

# Look at how judges are used after being fetched in the parent component
rg -A 40 "judges = judgesRes.data" app/\(landing\)/organizations/\[id\]/hackathons/\[hackathonId\]/judging/page.tsx

Repository: boundlessfi/boundless

Length of output: 1300


🏁 Script executed:

# Search for backend API documentation or comments about the judges endpoint
rg "judges" lib/api/hackathons/judging.ts -B 10 -A 10

Repository: boundlessfi/boundless

Length of output: 2937


🏁 Script executed:

# Check if there are any TypeScript types defined for the judges response
rg "Judge|judge" lib/api/hackathons/judging.ts | head -40

Repository: boundlessfi/boundless

Length of output: 1559


Use judge.userId for removal, or ensure it's always defined.

The removeJudge API explicitly expects a userId parameter (sent via /judging/judges/${userId}), but the code falls back to judge.id when userId is undefined. Without type definitions for the judges endpoint response, there's no guarantee that id represents a user ID. If id is a judge assignment record ID, this fallback will cause the removal to fail. Either ensure userId is always populated from the backend, or handle the missing userId case explicitly.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@components/organization/hackathons/judging/OrganizerJudgesPanel.tsx` at line
200, The current removal call falls back to judge.id which may be an assignment
ID, causing removeJudge (which expects a userId used in
/judging/judges/${userId}) to fail; update the code to only use judge.userId
(e.g. call onRemoveJudge(judge.userId)) and/or add an explicit guard in the
onRemoveJudge handler to validate presence of userId (reject, show an
error/toast, or disable the remove button) rather than using judge.id; locate
the usage in OrganizerJudgesPanel.tsx (onRemoveJudge, the remove button onClick)
and the removeJudge API call to implement the guard/validation so removal always
supplies a true userId.

One semantic conflict: components/auth/SignupForm.tsx had both branches
adding email prefill logic for the same use case (invitation deep links
landing on signup). Main added a defaultEmail prop; this branch read
?email from URL params. Resolution keeps both: prefer the prop, fall
back to the URL, so callers that wire it through and bare deep links
both work.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@0xdevcollins 0xdevcollins changed the title Feat/judge implementation feat(judge): dedicated judge portal, organizer invitations, completeness gate UX May 15, 2026
@0xdevcollins 0xdevcollins merged commit b34926a into main May 15, 2026
6 of 9 checks passed
0xdevcollins added a commit that referenced this pull request May 15, 2026
* fix(submissions): improve link validation UX, banner display, and error toasts (#547)

- Reconcile frontend link types with backend enum (github, demo, video, document, presentation, other)
- Allow up to 5 "other" links per submission with smart type-picking on Add Link and duplicate guards on type change
- Add 500-character cap and live counter on the optional Introduction field
- Pass banner through initialData on submission edit so the saved banner displays
- Add banner field to ParticipantSubmission so the type compiles
- Set mock-fill participation type from myTeam to avoid INDIVIDUAL submissions while on a team
- Switch submission error toasts to title + description with 8s duration so backend messages are readable
- Update SubmissionLinksTab icon mapping for the new link types

* fix(submissions): correct logo cropping and remove duplicate category badge on submission card (#549)

- Logo overlay on banner uses object-contain so wordmark logos are not cropped
- Render the category badge once instead of twice on the explore submissions card

* feat(blog): add Boundless x Trustless Work hackathon post and fit cover images (#551)

- Add content/blog/boundless-launches-first-hackathon-campaign.mdx for the May 13 hackathon launch announcement.
- BlogCard and BlogPostDetails cover images use object-contain so the full banner is visible (previously object-cover cropped wider banners).
- Update SHARE icons on the blog details page from the old brand color #99FF2D to the current brand primary #2eedaa.

* feat(judge): dedicated judge portal, organizer invitations, completeness gate UX (#550)

* feat: implement judge portal with submission evaluation, invitations, and score calculation workflows

* feat: add countdown banner, support read-only score sliders, and integrate submission disqualification into grading modal

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Nnaji Benjamin <60315147+Benjtalkshow@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
0xdevcollins added a commit that referenced this pull request May 15, 2026
)

* fix(submissions): improve link validation UX, banner display, and error toasts (#547)

- Reconcile frontend link types with backend enum (github, demo, video, document, presentation, other)
- Allow up to 5 "other" links per submission with smart type-picking on Add Link and duplicate guards on type change
- Add 500-character cap and live counter on the optional Introduction field
- Pass banner through initialData on submission edit so the saved banner displays
- Add banner field to ParticipantSubmission so the type compiles
- Set mock-fill participation type from myTeam to avoid INDIVIDUAL submissions while on a team
- Switch submission error toasts to title + description with 8s duration so backend messages are readable
- Update SubmissionLinksTab icon mapping for the new link types

* fix(submissions): correct logo cropping and remove duplicate category badge on submission card (#549)

- Logo overlay on banner uses object-contain so wordmark logos are not cropped
- Render the category badge once instead of twice on the explore submissions card

* feat(blog): add Boundless x Trustless Work hackathon post and fit cover images (#551)

- Add content/blog/boundless-launches-first-hackathon-campaign.mdx for the May 13 hackathon launch announcement.
- BlogCard and BlogPostDetails cover images use object-contain so the full banner is visible (previously object-cover cropped wider banners).
- Update SHARE icons on the blog details page from the old brand color #99FF2D to the current brand primary #2eedaa.

* feat(judge): dedicated judge portal, organizer invitations, completeness gate UX (#550)

* feat: implement judge portal with submission evaluation, invitations, and score calculation workflows

* feat: add countdown banner, support read-only score sliders, and integrate submission disqualification into grading modal

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(hackathons): add "hidden until results" submission visibility mode (#555)

Surfaces the new HIDDEN_UNTIL_RESULTS option (added in the nestjs PR) in
the organizer settings tab. Reorders the three visibility options so the
recommended "Shortlisted only" leads, the new "Hidden until results are
announced" sits in the middle, and "All submissions" comes last. Rewrites
the copy on the "All submissions" choice that incorrectly claimed
disqualified projects would be shown -- they never were on the backend,
and Phase 2 makes that an explicit guarantee. Aligns the form's default
and API-fallback value with the backend default (ACCEPTED_SHORTLISTED,
not ALL) so organizers don't see a misleading initial selection.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Nnaji Benjamin <60315147+Benjtalkshow@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Benjtalkshow added a commit that referenced this pull request May 15, 2026
* fix(submissions): improve link validation UX, banner display, and error toasts (#547)

- Reconcile frontend link types with backend enum (github, demo, video, document, presentation, other)
- Allow up to 5 "other" links per submission with smart type-picking on Add Link and duplicate guards on type change
- Add 500-character cap and live counter on the optional Introduction field
- Pass banner through initialData on submission edit so the saved banner displays
- Add banner field to ParticipantSubmission so the type compiles
- Set mock-fill participation type from myTeam to avoid INDIVIDUAL submissions while on a team
- Switch submission error toasts to title + description with 8s duration so backend messages are readable
- Update SubmissionLinksTab icon mapping for the new link types

* fix(submissions): correct logo cropping and remove duplicate category badge on submission card (#549)

- Logo overlay on banner uses object-contain so wordmark logos are not cropped
- Render the category badge once instead of twice on the explore submissions card

* feat(blog): add Boundless x Trustless Work hackathon post and fit cover images (#551)

- Add content/blog/boundless-launches-first-hackathon-campaign.mdx for the May 13 hackathon launch announcement.
- BlogCard and BlogPostDetails cover images use object-contain so the full banner is visible (previously object-cover cropped wider banners).
- Update SHARE icons on the blog details page from the old brand color #99FF2D to the current brand primary #2eedaa.

* feat(judge): dedicated judge portal, organizer invitations, completeness gate UX (#550)

* feat: implement judge portal with submission evaluation, invitations, and score calculation workflows

* feat: add countdown banner, support read-only score sliders, and integrate submission disqualification into grading modal

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(hackathons): add "hidden until results" submission visibility mode (#555)

Surfaces the new HIDDEN_UNTIL_RESULTS option (added in the nestjs PR) in
the organizer settings tab. Reorders the three visibility options so the
recommended "Shortlisted only" leads, the new "Hidden until results are
announced" sits in the middle, and "All submissions" comes last. Rewrites
the copy on the "All submissions" choice that incorrectly claimed
disqualified projects would be shown -- they never were on the backend,
and Phase 2 makes that an explicit guarantee. Aligns the form's default
and API-fallback value with the backend default (ACCEPTED_SHORTLISTED,
not ALL) so organizers don't see a misleading initial selection.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(hackathons): owner submission visibility, team UX, organizer teams page (#552)

Submission visibility on the public submissions tab
- Surface the user's own submission first on the hackathon submissions tab, regardless of submissionStatusVisibility setting. Team members (non-leader) see their team's submission too.

Find Team tab UX
- Stop replacing the whole tab with MyTeamView when the user has a team. Render MyTeamView at the top and the open-teams list below.
- Filter the user's own team out of the "Other Teams" list to avoid duplication.
- Wire the team count badge on the team-formation tab.

Team details
- New /hackathons/[slug]/teams/[teamId] page with team header, members list (clickable links to profiles), roles needed, leader card, and contact card.
- TeamCard click opens the team details page in a new tab.
- MyTeamView team name is now a clickable link to the same page.
- Add useTeam hook + query key.

Organizer submissions page
- Replace raw Buttons on review actions with BoundlessButton (default / outline / destructive) so colors come from the design system.
- Remove the "Before Deadline" gating on the Approve action; backend organizer override now allows it.

Organizer participants page
- Add Solo Participants and Teams metric cards next to the existing Total Participants and Total Submissions cards.

Organizer submissions page
- Add a metric strip with Total / Solo / Team Submissions sourced from the statistics endpoint.

Organizer Teams page (new)
- New route /organizations/[id]/hackathons/[hackathonId]/teams.
- Stats (Total / Open / Submitted / Not submitted), search by team or leader (server-side), status and submission filters (client-side), server-side pagination matching the participants page pattern, click to view team details in a new tab.
- Add "Teams" entry to the existing organizer hackathon sidebar (HackathonSidebar.tsx).

Image fit fixes for cropped logos/banners
- SubmissionCard banner and logo overlay use object-contain (banner was object-cover, cropping wordmarks and tall banners).
- /me/hackathons/submissions: banner, logo overlay, and small table thumbnail all use object-contain.
- ProjectSidebarHeader logo uses object-contain.

Type changes
- lib/api/hackathons/teams.ts Team interface gains optional hasSubmission flag.

---------

Co-authored-by: Collins Ikechukwu <collinschristroa@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Benjtalkshow added a commit that referenced this pull request May 15, 2026
* fix(submissions): improve link validation UX, banner display, and error toasts (#547)

- Reconcile frontend link types with backend enum (github, demo, video, document, presentation, other)
- Allow up to 5 "other" links per submission with smart type-picking on Add Link and duplicate guards on type change
- Add 500-character cap and live counter on the optional Introduction field
- Pass banner through initialData on submission edit so the saved banner displays
- Add banner field to ParticipantSubmission so the type compiles
- Set mock-fill participation type from myTeam to avoid INDIVIDUAL submissions while on a team
- Switch submission error toasts to title + description with 8s duration so backend messages are readable
- Update SubmissionLinksTab icon mapping for the new link types

* fix(submissions): correct logo cropping and remove duplicate category badge on submission card (#549)

- Logo overlay on banner uses object-contain so wordmark logos are not cropped
- Render the category badge once instead of twice on the explore submissions card

* feat(blog): add Boundless x Trustless Work hackathon post and fit cover images (#551)

- Add content/blog/boundless-launches-first-hackathon-campaign.mdx for the May 13 hackathon launch announcement.
- BlogCard and BlogPostDetails cover images use object-contain so the full banner is visible (previously object-cover cropped wider banners).
- Update SHARE icons on the blog details page from the old brand color #99FF2D to the current brand primary #2eedaa.

* feat(judge): dedicated judge portal, organizer invitations, completeness gate UX (#550)

* feat: implement judge portal with submission evaluation, invitations, and score calculation workflows

* feat: add countdown banner, support read-only score sliders, and integrate submission disqualification into grading modal

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(hackathons): add "hidden until results" submission visibility mode (#555)

Surfaces the new HIDDEN_UNTIL_RESULTS option (added in the nestjs PR) in
the organizer settings tab. Reorders the three visibility options so the
recommended "Shortlisted only" leads, the new "Hidden until results are
announced" sits in the middle, and "All submissions" comes last. Rewrites
the copy on the "All submissions" choice that incorrectly claimed
disqualified projects would be shown -- they never were on the backend,
and Phase 2 makes that an explicit guarantee. Aligns the form's default
and API-fallback value with the backend default (ACCEPTED_SHORTLISTED,
not ALL) so organizers don't see a misleading initial selection.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(hackathons): owner submission visibility, team UX, organizer teams page (#552)

Submission visibility on the public submissions tab
- Surface the user's own submission first on the hackathon submissions tab, regardless of submissionStatusVisibility setting. Team members (non-leader) see their team's submission too.

Find Team tab UX
- Stop replacing the whole tab with MyTeamView when the user has a team. Render MyTeamView at the top and the open-teams list below.
- Filter the user's own team out of the "Other Teams" list to avoid duplication.
- Wire the team count badge on the team-formation tab.

Team details
- New /hackathons/[slug]/teams/[teamId] page with team header, members list (clickable links to profiles), roles needed, leader card, and contact card.
- TeamCard click opens the team details page in a new tab.
- MyTeamView team name is now a clickable link to the same page.
- Add useTeam hook + query key.

Organizer submissions page
- Replace raw Buttons on review actions with BoundlessButton (default / outline / destructive) so colors come from the design system.
- Remove the "Before Deadline" gating on the Approve action; backend organizer override now allows it.

Organizer participants page
- Add Solo Participants and Teams metric cards next to the existing Total Participants and Total Submissions cards.

Organizer submissions page
- Add a metric strip with Total / Solo / Team Submissions sourced from the statistics endpoint.

Organizer Teams page (new)
- New route /organizations/[id]/hackathons/[hackathonId]/teams.
- Stats (Total / Open / Submitted / Not submitted), search by team or leader (server-side), status and submission filters (client-side), server-side pagination matching the participants page pattern, click to view team details in a new tab.
- Add "Teams" entry to the existing organizer hackathon sidebar (HackathonSidebar.tsx).

Image fit fixes for cropped logos/banners
- SubmissionCard banner and logo overlay use object-contain (banner was object-cover, cropping wordmarks and tall banners).
- /me/hackathons/submissions: banner, logo overlay, and small table thumbnail all use object-contain.
- ProjectSidebarHeader logo uses object-contain.

Type changes
- lib/api/hackathons/teams.ts Team interface gains optional hasSubmission flag.

* fix(blog): use object-cover so card and detail covers fill the frame (#558)

The earlier switch to object-contain was meant to preserve the full image, but it left dark letterbox bands around banners on both the BlogCard grid and the post-details hero. With properly-sized cover banners, object-cover fills the frame without visible cropping. Authors should size cover images for a 2:1 ratio on the card and the responsive heights on the details page.

---------

Co-authored-by: Collins Ikechukwu <collinschristroa@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant