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
13 changes: 11 additions & 2 deletions src/apps/opportunities/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -380,8 +380,9 @@ noninteractive semantic label, avoiding a duplicate hidden keyboard stop.

All Submissions and My Submissions use the same responsive record-card
contract. Every track-specific field and action receives a visible mobile key,
while Submission Date sorting stays above the card and continues to request
owner-sorted pages.
while Submission Date sorting stays above the card. Marathon Match All Submissions
sorts the complete collection locally to retain global ranks; other date sorts
continue to request owner-sorted pages.
Marathon My Submissions uses compact column spacing so the complete scorer table
fits within its desktop card. The Submission ID and Submission Date columns keep
a wider share of that budget, and cells use 12px horizontal padding, so long
Expand Down Expand Up @@ -660,6 +661,14 @@ Passed, Failed, and Cancelled attempts show their terminal badge instead. If
progress is unavailable, the status label or an em dash remains visible. The
seven-column table fits the padded desktop card; narrower tablets retain
horizontal scrolling and mobile uses the existing record cards.
Marathon Match All Submissions also includes sortable Final Rank and Provisional
Rank columns, including labeled values and sorting on mobile. Ranks are calculated
from the complete latest-submission collection before pagination, even when sorted
by submission date. Provisional ranks order scores high-to-low and break ties by
earlier submission time. Equal final scores share competition ranks (1, 1, 3),
matching community-app. Missing or pending scores remain unranked (`N/A`); final
ranks use the same release gate as final scores. Other challenge types and My
Submissions retain their existing columns.
The Figma keeps separate Provisional Score and Final Score columns and uses
`-` when a final value is not yet available. Winners use Review API's canonical
`GET /v6/projectResult` member-and-placement result instead of inferring a
Expand Down
105 changes: 102 additions & 3 deletions src/apps/opportunities/src/pages/ChallengeDetailsPage.flows.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import { toast } from 'react-toastify'

import { ChallengeDetailsPage } from './ChallengeDetailsPage'
import { submissionAiWorkflowRunResult } from '../components/SubmissionAiReviewDetails'
import { getChallengeSubmissions } from '../services'
import { getAllChallengeSubmissions, getChallengeSubmissions } from '../services'

const mockUseSWR = jest.fn()
const mockDeleteSubmission = jest.fn()
Expand Down Expand Up @@ -654,8 +654,13 @@ describe('ChallengeDetailsPage member flows', () => {
))
.at(-1)
submissionRequest?.[1]()
expect(getChallengeSubmissions)
.toHaveBeenLastCalledWith('challenge-id', 1, perPage, mine ? '123' : undefined, !mine, 'desc')
if (type === 'Marathon Match' && !mine) {
expect(getAllChallengeSubmissions)
.toHaveBeenLastCalledWith('challenge-id')
} else {
expect(getChallengeSubmissions)
.toHaveBeenLastCalledWith('challenge-id', 1, perPage, mine ? '123' : undefined, !mine, 'desc')
}
})

it.each([false, true])('shows the full history count for a Marathon viewer signed in: %s', signedIn => {
Expand Down Expand Up @@ -2123,6 +2128,8 @@ describe('ChallengeDetailsPage member flows', () => {
['Rating', ['Zulu', 'Beta', 'unknown']],
['Provisional Score', ['Zulu', 'Beta', 'unknown']],
['Final Score', ['Beta', 'Zulu', 'unknown']],
['Provisional Rank', ['Beta', 'Zulu', 'unknown']],
['Final Rank', ['Zulu', 'Beta', 'unknown']],
])('sorts %s in both directions using displayed values', (label, ascending) => {
mockChallenge = { ...mockChallenge, phases: [], status: 'COMPLETED', type: 'Marathon Match' }
mockSubmissions = [
Expand Down Expand Up @@ -2154,6 +2161,96 @@ describe('ChallengeDetailsPage member flows', () => {
.toHaveAttribute('aria-sort', 'none')
})

it('shows global MM ranks across date-sorted pages and supports mobile rank sorting', () => {
mockChallenge = { ...mockChallenge, phases: [], status: 'COMPLETED', type: 'Marathon Match' }
mockSubmissions = Array.from({ length: 202 }, (_value, index) => ({
finalScore: 300 - index,
id: `submission-${index}`,
provisionalScore: index,
submittedDate: new Date(Date.UTC(2026, 9, 14, 0, index))
.toISOString(),
submitterHandle: `coder${index}`,
}))
renderPage()
fireEvent.click(screen.getByRole('tab', { name: /^Submissions/ }))
expect(mockUseSWR)
.toHaveBeenCalledWith(
['opportunities:submissions', 'challenge-id', undefined, 'all', 'all', 'desc'],
expect.any(Function),
expect.any(Object),
)
expect(screen.getAllByRole('columnheader')
.slice(0, 3)
.map(header => header.textContent))
.toEqual(['Final Rank', 'Provisional Rank', 'Handle'])
const firstRow = screen.getAllByRole('row')[1]
expect(within(firstRow)
.getAllByRole('cell')
.slice(0, 2)
.map(cell => cell.textContent))
.toEqual(['202', '1'])
expect(within(firstRow)
.getAllByRole('cell')[0])
.toHaveAttribute('data-mobile-label', 'Final Rank')
expect(within(firstRow)
.getAllByRole('cell')[1])
.toHaveAttribute('data-mobile-label', 'Provisional Rank')
fireEvent.click(screen.getByRole('button', { name: 'Next page' }))
expect(screen.getAllByRole('row'))
.toHaveLength(3)
expect(within(screen.getAllByRole('row')[1])
.getAllByRole('cell')
.slice(0, 2)
.map(cell => cell.textContent))
.toEqual(['2', '201'])
fireEvent.change(screen.getByRole('combobox', { name: 'Sort submissions by' }), {
target: { value: 'finalRank' },
})
expect(screen.getAllByRole('row'))
.toHaveLength(201)
expect(within(screen.getAllByRole('row')[1])
.getByRole('link', { name: 'coder0' }))
.toBeInTheDocument()
})

it('hides final ranks while Submission is open and marks missing scores unranked', () => {
mockChallenge = { ...mockChallenge, type: 'Marathon Match' }
mockSubmissions = [
{ finalScore: 99, id: 'scored', provisionalScore: 80, submitterHandle: 'scored' },
{ id: 'missing', submitterHandle: 'missing' },
]
renderPage()
fireEvent.click(screen.getByRole('tab', { name: /^Submissions/ }))
expect(screen.getAllByRole('row')
.slice(1)
.map(row => within(row)
.getAllByRole('cell')
.slice(0, 2)
.map(cell => cell.textContent)))
.toEqual([['N/A', '1'], ['N/A', 'N/A']])
fireEvent.click(screen.getByRole('button', { name: 'Final Rank' }))
expect(within(screen.getAllByRole('row')[1])
.getByRole('link', { name: 'scored' }))
.toBeInTheDocument()
})

it.each([
['Development', 'Challenge'],
['Development', 'First2Finish'],
['Quality Assurance', 'Challenge'],
['Design', 'Challenge'],
])('omits MM ranks for %s %s challenges', (track, type) => {
mockProfile = { handle: 'coder', userId: 123 }
mockChallenge = { ...mockChallenge, track, type }
mockSubmissions = [{ id: 'submission-1', submitterHandle: 'coder' }]
renderPage()
fireEvent.click(screen.getByRole('tab', { name: /^Submissions/ }))
expect(screen.queryByRole('columnheader', { name: 'Final Rank' })).not.toBeInTheDocument()
expect(screen.queryByRole('columnheader', { name: 'Provisional Rank' })).not.toBeInTheDocument()
expect(screen.queryByRole('option', { name: 'Final Rank' })).not.toBeInTheDocument()
expect(screen.queryByRole('option', { name: 'Provisional Rank' })).not.toBeInTheDocument()
})

it('keeps My Submissions on date ordering after sorting the public table by handle', () => {
mockProfile = { handle: 'coder', userId: 123 }
mockRegistration = { id: 'resource-id' }
Expand All @@ -2163,6 +2260,8 @@ describe('ChallengeDetailsPage member flows', () => {
fireEvent.click(screen.getByRole('tab', { name: /^Submissions/ }))
fireEvent.click(screen.getByRole('button', { name: 'Handle' }))
fireEvent.click(screen.getByRole('tab', { name: 'My Submissions' }))
expect(screen.queryByRole('columnheader', { name: 'Final Rank' })).not.toBeInTheDocument()
expect(screen.queryByRole('columnheader', { name: 'Provisional Rank' })).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Sort by Submission Date' }))
.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Sort by Handle' }))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,26 @@
th:nth-child(6) { width: 160px; }
}

.marathonTableCard {
overflow-x: auto;
}

.marathonTable {
min-width: 1200px;

th,
td { padding-inline: 12px; }

th:first-child { width: 90px; }
th:nth-child(2) { width: 115px; }
th:nth-child(3) { width: 200px; }
th:nth-child(4) { width: 90px; }
th:nth-child(5) { width: 185px; }
th:nth-child(6),
th:nth-child(7) { width: 170px; }
th:nth-child(8) { width: 140px; }
}

.mySubmissionTable {
th:first-child,
th:nth-child(2),
Expand Down
52 changes: 47 additions & 5 deletions src/apps/opportunities/src/pages/ChallengeDetailsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ import {
isTopgearChallenge,
marathonDashboardIsEnabled,
marathonLeaderboardIsPublic,
marathonSubmissionRanks,
marathonSubmissionScores,
marathonSubmissionTestProgress,
memberProfileUrl,
Expand Down Expand Up @@ -157,6 +158,7 @@ export function challengeAllowsDesignSubmissionDeletion(
}

type SubmissionSortField = 'handle' | 'rating' | 'submittedDate' | 'provisionalScore' | 'finalScore'
| 'provisionalRank' | 'finalRank'

interface SortableColumnHeaderProps {
label: string
Expand Down Expand Up @@ -1294,7 +1296,8 @@ interface SubmissionsTabProps {
/**
* Loads and paginates submissions only after a submission tab is selected.
* Derived-column sorts use the full latest-submission collection before pagination;
* submission-date sorting remains server-paginated.
* Marathon Match ranks also use that collection, including when sorting by date.
* Other submission-date sorting remains server-paginated.
* Marathon Match tables default to 200 rows per page; other challenge types default to 10.
*
* @param props challenge, registration/management rights, viewer identity, My Submissions flag, and callbacks.
Expand Down Expand Up @@ -1364,7 +1367,7 @@ const SubmissionsTab: FC<SubmissionsTabProps> = props => {
&& challengeMetadataFlag(props.challenge, 'submissionsViewable')
const usePublicPreviewPage = privatePreviewGallery && !props.viewerMemberId
const clientSort = !props.mine && !privatePreviewGallery && !privateDesignSubmissions
&& sortBy !== 'submittedDate'
&& (isMarathonMatch || sortBy !== 'submittedDate')
const response: SWRResponse<OpportunityPage<ChallengeSubmission>, Error> = useSWR(
privateDesignSubmissions ? undefined : [
usePublicPreviewPage ? 'opportunities:submission-previews' : 'opportunities:submissions',
Expand Down Expand Up @@ -1442,15 +1445,28 @@ const SubmissionsTab: FC<SubmissionsTabProps> = props => {
props.challenge,
loadedSubmissions,
)
const submissionRanks = useMemo(
() => marathonSubmissionRanks(isMarathonMatch && !props.mine ? loadedSubmissions : []),
[isMarathonMatch, props.mine, loadedSubmissions],
)
const submissions = useMemo(() => {
if (!clientSort) return loadedSubmissions

/**
* Resolves a displayed value for whole-collection sorting.
* @param submission Submission being compared, with any hydrated profile.
* @returns Visible handle, rating, or released score; undefined for missing values.
* @returns Visible handle, rating, date, rank, or released score; undefined for missing values.
* @throws Does not throw.
*/
const valueFor = (submission: ChallengeSubmission): string | number | undefined => {
if (sortBy === 'submittedDate') {
const timestamp = Date.parse(submission.submittedDate ?? submission.createdAt ?? '')
return Number.isFinite(timestamp) ? timestamp : undefined
}

const ranks = submissionRanks.get(submission.id)
if (sortBy === 'provisionalRank') return ranks?.provisionalRank
if (sortBy === 'finalRank') return showAllSubmissionFinalScores ? ranks?.finalRank : undefined
const profile = profilesById.get(challengeSubmissionMemberId(submission) ?? '')
if (sortBy === 'handle') return profile?.handle ?? submissionHandle(submission)
if (sortBy === 'rating') {
Expand Down Expand Up @@ -1480,7 +1496,7 @@ const SubmissionsTab: FC<SubmissionsTabProps> = props => {
})
.slice((page - 1) * perPage, page * perPage)
}, [clientSort, isMarathonMatch, loadedSubmissions, page, perPage, profilesById,
showAllSubmissionFinalScores, sortBy, sortOrder])
showAllSubmissionFinalScores, sortBy, sortOrder, submissionRanks])

/**
* Selects a sortable data field or reverses its current direction.
Expand Down Expand Up @@ -1660,6 +1676,12 @@ const SubmissionsTab: FC<SubmissionsTabProps> = props => {
onChange={event => selectSort(event.target.value as typeof sortBy)}
value={sortBy}
>
{isMarathonMatch && (
<>
<option value='finalRank'>Final Rank</option>
<option value='provisionalRank'>Provisional Rank</option>
</>
)}
<option value='handle'>Handle</option>
{!isDesign && <option value='rating'>Rating</option>}
<option value='submittedDate'>Submission Date</option>
Expand All @@ -1677,8 +1699,10 @@ const SubmissionsTab: FC<SubmissionsTabProps> = props => {
{!privatePreviewGallery && (
<MobileTableSort
label={{
finalRank: 'Final Rank',
finalScore: 'Final Score',
handle: 'Handle',
provisionalRank: 'Provisional Rank',
provisionalScore: isQa ? 'Initial Score' : 'Provisional Score',
rating: 'Rating',
submittedDate: 'Submission Date',
Expand Down Expand Up @@ -1946,7 +1970,11 @@ const SubmissionsTab: FC<SubmissionsTabProps> = props => {
</table>
</div>
) : (
<div className={`${styles.tableCard} ${styles.mobileRecordCard}`}>
<div
className={`${styles.tableCard} ${styles.mobileRecordCard} ${isMarathonMatch
? styles.marathonTableCard
: ''}`}
>
<table className={`${isMarathonMatch
? styles.marathonTable
: isQa
Expand All @@ -1957,6 +1985,8 @@ const SubmissionsTab: FC<SubmissionsTabProps> = props => {
>
<thead>
<tr>
{isMarathonMatch && sortHeader('finalRank', 'Final Rank')}
{isMarathonMatch && sortHeader('provisionalRank', 'Provisional Rank')}
{sortHeader('handle', 'Handle')}
{!isDesign && sortHeader('rating', 'Rating')}
{sortHeader('submittedDate', 'Submission Date')}
Expand All @@ -1976,6 +2006,18 @@ const SubmissionsTab: FC<SubmissionsTabProps> = props => {
?? undefined
return (
<tr key={submission.id}>
{isMarathonMatch && (
<>
<td data-mobile-label='Final Rank'>
{(showAllSubmissionFinalScores
? submissionRanks.get(submission.id)?.finalRank
: undefined) ?? 'N/A'}
</td>
<td data-mobile-label='Provisional Rank'>
{submissionRanks.get(submission.id)?.provisionalRank ?? 'N/A'}
</td>
</>
)}
<td data-mobile-label='Handle'>
<MemberHandle
handle={submissionHandle(submission)}
Expand Down
54 changes: 54 additions & 0 deletions src/apps/opportunities/src/utils/marathon-match.utils.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,66 @@ import {
isMarathonMatchChallenge,
marathonDashboardIsEnabled,
marathonLeaderboardIsPublic,
marathonSubmissionRanks,
marathonSubmissionScores,
marathonSubmissionTestProgress,
shouldShowFinalSubmissionScores,
} from './marathon-match.utils'

describe('Marathon Match challenge detail utilities', () => {
it('ranks scores globally with provisional time tie-breaks and shared final ranks', () => {
const submissions = [
{ finalScore: 90, id: 'later', provisionalScore: 100, submittedDate: '2026-10-14T09:00:00Z' },
{ finalScore: 80, id: 'earlier', provisionalScore: 100, submittedDate: '2026-10-14T08:00:00Z' },
{ finalScore: 90, id: 'invalid-date', provisionalScore: 100, submittedDate: 'invalid' },
{ finalScore: 0, id: 'zero', provisionalScore: 0 },
{ finalScore: -1, id: 'failed', provisionalScore: -1 },
{ finalScore: Number.NaN, id: 'missing', provisionalScore: Number.POSITIVE_INFINITY },
]
expect(Array.from(marathonSubmissionRanks(submissions)))
.toEqual([
['later', { finalRank: 1, provisionalRank: 2 }],
['earlier', { finalRank: 3, provisionalRank: 1 }],
['invalid-date', { finalRank: 1, provisionalRank: 3 }],
['zero', { finalRank: 4, provisionalRank: 4 }],
['failed', { finalRank: 5, provisionalRank: 5 }],
['missing', {}],
])
expect(submissions.map(submission => submission.id))
.toEqual(['later', 'earlier', 'invalid-date', 'zero', 'failed', 'missing'])
})

it('ranks resolved summations and leaves pending or cancelled attempts unranked', () => {
expect(Array.from(marathonSubmissionRanks([
{
finalScore: 999,
id: 'settled',
provisionalScore: 999,
reviewSummation: [
{ aggregateScore: 10, isProvisional: true },
{ aggregateScore: 20, isFinal: true },
],
},
{ finalScore: 30, id: 'higher', provisionalScore: 20 },
{
finalScore: 999,
id: 'pending',
provisionalScore: 999,
reviewSummation: [
{ aggregateScore: -1, isProvisional: true, metadata: { testStatus: 'IN_PROGRESS' } },
{ aggregateScore: -1, isFinal: true, metadata: { testStatus: 'CANCELLED' } },
],
},
])))
.toEqual([
['settled', { finalRank: 2, provisionalRank: 2 }],
['higher', { finalRank: 1, provisionalRank: 1 }],
['pending', {}],
])
expect(marathonSubmissionRanks([]).size)
.toBe(0)
})

it.each(['CANCELLED', 'CANCELED'])('preserves %s and suppresses cancelled phase scores', testStatus => {
const submission = {
finalScore: 91,
Expand Down
Loading
Loading