diff --git a/src/apps/opportunities/README.md b/src/apps/opportunities/README.md index a1023b781..4d74e19c1 100644 --- a/src/apps/opportunities/README.md +++ b/src/apps/opportunities/README.md @@ -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 @@ -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 diff --git a/src/apps/opportunities/src/pages/ChallengeDetailsPage.flows.spec.tsx b/src/apps/opportunities/src/pages/ChallengeDetailsPage.flows.spec.tsx index 641548d67..11e014fa0 100644 --- a/src/apps/opportunities/src/pages/ChallengeDetailsPage.flows.spec.tsx +++ b/src/apps/opportunities/src/pages/ChallengeDetailsPage.flows.spec.tsx @@ -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() @@ -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 => { @@ -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 = [ @@ -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' } @@ -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' })) diff --git a/src/apps/opportunities/src/pages/ChallengeDetailsPage.module.scss b/src/apps/opportunities/src/pages/ChallengeDetailsPage.module.scss index 2dc62a34d..9c48bc998 100644 --- a/src/apps/opportunities/src/pages/ChallengeDetailsPage.module.scss +++ b/src/apps/opportunities/src/pages/ChallengeDetailsPage.module.scss @@ -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), diff --git a/src/apps/opportunities/src/pages/ChallengeDetailsPage.tsx b/src/apps/opportunities/src/pages/ChallengeDetailsPage.tsx index afb3f21ec..fee9bbc64 100644 --- a/src/apps/opportunities/src/pages/ChallengeDetailsPage.tsx +++ b/src/apps/opportunities/src/pages/ChallengeDetailsPage.tsx @@ -97,6 +97,7 @@ import { isTopgearChallenge, marathonDashboardIsEnabled, marathonLeaderboardIsPublic, + marathonSubmissionRanks, marathonSubmissionScores, marathonSubmissionTestProgress, memberProfileUrl, @@ -157,6 +158,7 @@ export function challengeAllowsDesignSubmissionDeletion( } type SubmissionSortField = 'handle' | 'rating' | 'submittedDate' | 'provisionalScore' | 'finalScore' + | 'provisionalRank' | 'finalRank' interface SortableColumnHeaderProps { label: string @@ -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. @@ -1364,7 +1367,7 @@ const SubmissionsTab: FC = props => { && challengeMetadataFlag(props.challenge, 'submissionsViewable') const usePublicPreviewPage = privatePreviewGallery && !props.viewerMemberId const clientSort = !props.mine && !privatePreviewGallery && !privateDesignSubmissions - && sortBy !== 'submittedDate' + && (isMarathonMatch || sortBy !== 'submittedDate') const response: SWRResponse, Error> = useSWR( privateDesignSubmissions ? undefined : [ usePublicPreviewPage ? 'opportunities:submission-previews' : 'opportunities:submissions', @@ -1442,15 +1445,28 @@ const SubmissionsTab: FC = 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') { @@ -1480,7 +1496,7 @@ const SubmissionsTab: FC = 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. @@ -1660,6 +1676,12 @@ const SubmissionsTab: FC = props => { onChange={event => selectSort(event.target.value as typeof sortBy)} value={sortBy} > + {isMarathonMatch && ( + <> + + + + )} {!isDesign && } @@ -1677,8 +1699,10 @@ const SubmissionsTab: FC = props => { {!privatePreviewGallery && ( = props => { ) : ( -
+
= props => { > + {isMarathonMatch && sortHeader('finalRank', 'Final Rank')} + {isMarathonMatch && sortHeader('provisionalRank', 'Provisional Rank')} {sortHeader('handle', 'Handle')} {!isDesign && sortHeader('rating', 'Rating')} {sortHeader('submittedDate', 'Submission Date')} @@ -1976,6 +2006,18 @@ const SubmissionsTab: FC = props => { ?? undefined return ( + {isMarathonMatch && ( + <> + + + + )}
+ {(showAllSubmissionFinalScores + ? submissionRanks.get(submission.id)?.finalRank + : undefined) ?? 'N/A'} + + {submissionRanks.get(submission.id)?.provisionalRank ?? 'N/A'} + { + 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, diff --git a/src/apps/opportunities/src/utils/marathon-match.utils.ts b/src/apps/opportunities/src/utils/marathon-match.utils.ts index 741fe49c4..131f757b7 100644 --- a/src/apps/opportunities/src/utils/marathon-match.utils.ts +++ b/src/apps/opportunities/src/utils/marathon-match.utils.ts @@ -15,6 +15,11 @@ export interface MarathonSubmissionScores { provisionalScore?: number } +export interface MarathonSubmissionRanks { + finalRank?: number + provisionalRank?: number +} + export interface MarathonTestProgress { process?: 'Example' | 'Provisional' | 'System' progress?: number @@ -392,6 +397,51 @@ export function marathonSubmissionScores( return { finalScore, provisionalScore } } +/** + * Ranks the complete latest-submission collection for the MM All Submissions table. + * Provisional ties favor earlier submissions; final ties share competition ranks + * (1, 1, 3), matching community-app. Missing or pending scores remain unranked. + * + * @param submissions All latest submissions, with review summations attached, before pagination. + * @returns Ranks keyed by submission ID; callers apply the final-score release gate. + * @throws Does not throw. + */ +export function marathonSubmissionRanks( + submissions: ChallengeSubmission[], +): Map { + const ranks = new Map() + const entries = submissions.map(submission => { + const timestamp = Date.parse(submission.submittedDate ?? submission.createdAt ?? '') + ranks.set(submission.id, {}) + return { + id: submission.id, + ...marathonSubmissionScores(submission), + timestamp: Number.isFinite(timestamp) ? timestamp : Number.POSITIVE_INFINITY, + } + }) + entries.flatMap(entry => (entry.provisionalScore === undefined ? [] : [{ + ...entry, + score: entry.provisionalScore, + }])) + .sort((first, second) => ( + (second.score - first.score) || first.timestamp - second.timestamp + )) + .forEach((entry, index) => { + ranks.set(entry.id, { provisionalRank: index + 1 }) + }) + + let previousScore: number | undefined + let finalRank = 0 + entries.flatMap(entry => (entry.finalScore === undefined ? [] : [{ ...entry, score: entry.finalScore }])) + .sort((first, second) => second.score - first.score) + .forEach((entry, index) => { + if (entry.score !== previousScore) finalRank = index + 1 + ranks.set(entry.id, { ...ranks.get(entry.id), finalRank }) + previousScore = entry.score + }) + return ranks +} + /** * Applies community-app's final-score release timing while leaving Review API * as the authorization and score authority. Marathon Match results stay hidden