From 002e0a21cfd1fef29dddbf3ad851569eaf471317 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Fri, 16 Oct 2026 06:46:26 +1100 Subject: [PATCH] PM-6599: Restore Marathon Match submission ranks What was broken Marathon Match All Submissions omitted the provisional and final rank columns available on the old platform. Root cause The Opportunities table displayed scores but did not calculate or render ranks. Date sorting loaded individual pages, which cannot provide challenge-wide rankings. What was changed Add sortable Final Rank and Provisional Rank columns exclusively to Marathon Match All Submissions, with mobile labels and sort options. Rank the complete latest-submission collection before pagination using the existing score resolver and final-score release gate. Preserve the legacy provisional timestamp tie-break and shared final competition ranks. Update the table widths and documentation. Any added/updated tests Add 10 regression cases covering ties, missing and pending scores, resolved summations, both rank sort directions, global pagination, mobile sorting, release timing, and non-MM exclusions. Extend My Submissions coverage to verify its columns remain unchanged. All 713 Opportunities tests pass. The full suite's 22 failing suites and 19 failed tests also reproduce on untouched origin/dev. --- src/apps/opportunities/README.md | 13 ++- .../pages/ChallengeDetailsPage.flows.spec.tsx | 94 +++++++++++++++++++ .../pages/ChallengeDetailsPage.module.scss | 20 ++++ .../src/pages/ChallengeDetailsPage.tsx | 52 +++++++++- .../src/utils/marathon-match.utils.spec.ts | 54 +++++++++++ .../src/utils/marathon-match.utils.ts | 50 ++++++++++ 6 files changed, 276 insertions(+), 7 deletions(-) diff --git a/src/apps/opportunities/README.md b/src/apps/opportunities/README.md index 10d4e2dc9..faf4c0009 100644 --- a/src/apps/opportunities/README.md +++ b/src/apps/opportunities/README.md @@ -376,8 +376,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 @@ -653,6 +654,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 0c24aa5d7..ee52b19e5 100644 --- a/src/apps/opportunities/src/pages/ChallengeDetailsPage.flows.spec.tsx +++ b/src/apps/opportunities/src/pages/ChallengeDetailsPage.flows.spec.tsx @@ -2082,6 +2082,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 = [ @@ -2113,6 +2115,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: 12 }, (_value, index) => ({ + finalScore: 100 - index, + id: `submission-${index}`, + provisionalScore: index, + submittedDate: `2026-10-14T${String(index) + .padStart(2, '0')}:00:00Z`, + 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(['12', '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', '11']) + fireEvent.change(screen.getByRole('combobox', { name: 'Sort submissions by' }), { + target: { value: 'finalRank' }, + }) + expect(screen.getAllByRole('row')) + .toHaveLength(11) + 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' } @@ -2122,6 +2214,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 fba8bed43..c113114e2 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. * * @param props challenge, registration/management rights, viewer identity, My Submissions flag, and callbacks. * @returns submission table/gallery, lifecycle-aware empty state, or request state. @@ -1363,7 +1366,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', @@ -1441,15 +1444,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') { @@ -1479,7 +1495,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. @@ -1658,6 +1674,12 @@ const SubmissionsTab: FC = props => { onChange={event => selectSort(event.target.value as typeof sortBy)} value={sortBy} > + {isMarathonMatch && ( + <> + + + + )} {!isDesign && } @@ -1675,8 +1697,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')} @@ -1974,6 +2004,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