From 21f35eb09f066076d2bdd2d23049f450bf4effb0 Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Mon, 2 Nov 2026 22:32:14 -0800 Subject: [PATCH 1/3] Migrate to WCIF v2 with official helper types --- package.json | 2 +- .../CutoffTimeLimitPanel.stories.tsx | 30 ++-- .../CutoffTimeLimitPanel.test.tsx | 91 +++++++--- .../CutoffTimeLimitPanel.tsx | 12 +- .../CompetitionPerson/CompetitionPerson.tsx | 2 +- .../CompetitionPersonalResults.test.tsx | 73 +++++++- .../CompetitionPersonalResults.tsx | 6 +- .../CompetitionPersonalResultsTable.tsx | 12 +- .../CompetitionPsychSheetEvent.tsx | 2 +- .../CompetitionResults.test.tsx | 71 ++++++-- .../CompetitionResultsTable.tsx | 23 ++- .../CompetitionResults/advancement.ts | 2 +- .../resultsProvider.test.ts | 30 +++- .../CompetitionResults/resultsProvider.ts | 2 +- .../CompetitionRound.stories.tsx | 38 +++-- .../CompetitionRound.test.tsx | 28 ++- .../CompetitionSumOfRanks/rankings.test.ts | 30 +++- .../CompetitionSumOfRanks/rankings.ts | 5 +- src/containers/Competitors/Competitors.tsx | 2 +- .../PersonalBests/PersonalBests.test.tsx | 76 +++++++++ .../PersonalBests/PersonalBests.tsx | 4 +- .../PersonalSchedule/PersonHeader.tsx | 2 + src/hooks/queries/useWcif.ts | 11 +- src/lib/activities.test.ts | 2 +- src/lib/api.test.ts | 28 +++ src/lib/api.ts | 2 +- src/lib/results.ts | 6 +- src/lib/roundLabels.ts | 7 +- src/lib/wcif.test.ts | 160 ++++++++---------- src/lib/wcif.ts | 102 +---------- src/pages/User/userProfileData.ts | 4 +- .../QueryProvider/QueryProvider.test.tsx | 37 ++++ src/providers/QueryProvider/QueryProvider.tsx | 2 +- src/providers/WCIFProvider/WCIFContext.tsx | 11 +- src/storybook/competitionFixtures.ts | 126 ++++++++------ src/types/wca-helpers.d.ts | 44 ----- yarn.lock | 8 +- 37 files changed, 655 insertions(+), 438 deletions(-) create mode 100644 src/containers/PersonalBests/PersonalBests.test.tsx create mode 100644 src/lib/api.test.ts create mode 100644 src/providers/QueryProvider/QueryProvider.test.tsx delete mode 100644 src/types/wca-helpers.d.ts diff --git a/package.json b/package.json index fdcfd33..3dbc801 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "@tanstack/react-query-persist-client": "^5.48.0", "@total-typescript/ts-reset": "^0.3.7", "@uidotdev/usehooks": "^2.4.1", - "@wca/helpers": "^1.1.5", + "@wca/helpers": "^2.0.0", "classnames": "^2.3.2", "country-flag-icons": "^1.5.4", "date-fns": "^2.30.0", diff --git a/src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.stories.tsx b/src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.stories.tsx index ff0533c..f46eab7 100644 --- a/src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.stories.tsx +++ b/src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.stories.tsx @@ -1,35 +1,35 @@ import type { Meta, StoryObj } from '@storybook/react'; import { getStorybookRoundFixture, - makeStorybookCompetitionFixtureWithRound, + makeStorybookCompetitionFixtureWithRoundUpdates, storybookParticipationConditionLinkedRoundsFixture, storybookParticipationConditionPercentFixture, } from '@/storybook/competitionFixtures'; import { makeCompetitionContainerDecorator } from '@/storybook/competitionStorybook'; import { CutoffTimeLimitPanel } from './CutoffTimeLimitPanel'; -const cumulativeTimeLimitCompetitionFixture = makeStorybookCompetitionFixtureWithRound( - '333-r2', - (round) => ({ +const cumulativeTimeLimitCompetitionFixture = makeStorybookCompetitionFixtureWithRoundUpdates({ + '333-r2': (round) => ({ ...round, timeLimit: { centiseconds: 15000, cumulativeRoundIds: ['333-r2', '333-r3'], }, - advancementCondition: { - type: 'attemptResult', - level: 950, - }, }), -); - -const finalRoundCompetitionFixture = makeStorybookCompetitionFixtureWithRound( - '333-r3', - (round) => ({ + '333-r3': (round) => ({ ...round, - advancementCondition: null, + participationRuleset: { + reservedPlaces: null, + participationSource: { + type: 'round', + roundId: '333-r2', + resultCondition: { type: 'resultAchieved', scope: 'single', value: 950 }, + }, + }, }), -); +}); + +const finalRoundCompetitionFixture = makeStorybookCompetitionFixtureWithRoundUpdates({}); const meta = { title: 'Components/Competition/CutoffTimeLimitPanel', diff --git a/src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.test.tsx b/src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.test.tsx index de058ce..ef02a43 100644 --- a/src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.test.tsx +++ b/src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.test.tsx @@ -1,6 +1,6 @@ import '@testing-library/jest-dom'; import { fireEvent, render, screen } from '@testing-library/react'; -import { Round } from '@wca/helpers'; +import { Competition, Round } from '@wca/helpers'; import { MemoryRouter } from 'react-router-dom'; import { CutoffTimeLimitPanel } from './CutoffTimeLimitPanel'; @@ -110,35 +110,54 @@ jest.mock('react-i18next', () => ({ }), })); -const wcifMock = { +const wcifMock: Competition = { + formatVersion: '2.0', + name: 'Test Competition', + shortName: 'Test Competition', + persons: [], + series: null, + competitorLimit: null, + extensions: [], + registrationInfo: { + openTime: '2026-01-01T00:00:00Z', + closeTime: '2026-04-01T00:00:00Z', + baseEntryFee: 0, + currencyCode: 'USD', + onTheSpotRegistration: false, + useWcaRegistration: true, + }, id: 'TestComp2026', - schedule: { venues: [] }, + schedule: { startDate: '2026-05-03', numberOfDays: 1, venues: [] }, events: [ { id: '333', + extensions: [], rounds: [ { id: '333-r1', + extensions: [], format: 'a', cutoff: null, timeLimit: null, - advancementCondition: { - type: 'ranking', - level: 16, - }, + linkedRounds: null, + participationRuleset: null, results: [], }, { id: '333-r2', + extensions: [], format: 'a', cutoff: null, timeLimit: null, + linkedRounds: null, participationRuleset: { + reservedPlaces: null, participationSource: { type: 'round', roundId: '333-r1', resultCondition: { type: 'percent', + scope: 'average', value: 75, }, }, @@ -147,15 +166,19 @@ const wcifMock = { }, { id: '333-r3', + extensions: [], format: 'a', cutoff: null, timeLimit: null, + linkedRounds: null, participationRuleset: { + reservedPlaces: null, participationSource: { type: 'linkedRounds', roundIds: ['333-r1', '333-r2'], resultCondition: { type: 'ranking', + scope: 'average', value: 12, }, }, @@ -166,32 +189,43 @@ const wcifMock = { }, { id: '222', + extensions: [], rounds: [ { id: '222-r1', + extensions: [], format: 'a', cutoff: null, timeLimit: null, + linkedRounds: null, + participationRuleset: null, results: [], }, { id: '222-r2', + extensions: [], format: 'a', cutoff: null, timeLimit: null, + linkedRounds: null, + participationRuleset: null, results: [], }, { id: '222-r3', + extensions: [], format: 'a', cutoff: null, timeLimit: null, + linkedRounds: null, participationRuleset: { + reservedPlaces: null, participationSource: { type: 'linkedRounds', roundIds: ['222-r1', '222-r2'], resultCondition: { type: 'ranking', + scope: 'average', value: 8, }, }, @@ -211,15 +245,19 @@ jest.mock('@/providers/WCIFProvider', () => ({ }), })); -const cutoffOnlyRound = { +const cutoffOnlyRound: Round = { + format: 'a', + extensions: [], + results: [], id: '333-r1', cutoff: { numberOfAttempts: 2, - attemptResult: 1200, + resultValue: 1200, }, timeLimit: null, - advancementCondition: null, -} as unknown as Round; + linkedRounds: null, + participationRuleset: null, +}; function renderPanel(round: Round) { return render( @@ -244,29 +282,40 @@ describe('CutoffTimeLimitPanel', () => { expect(popoverContent).not.toHaveClass('bg-white'); }); - it('shows the legacy advancement text for stable wcif rounds', () => { - renderPanel(wcifMock.events[0].rounds[0] as unknown as Round); - - expect(screen.getByText('Top 16 advance to next round')).toBeInTheDocument(); + it('shows ranking advancement from the target round', () => { + const nextRound = wcifMock.events[0].rounds[1]; + const originalRuleset = nextRound.participationRuleset; + nextRound.participationRuleset = { + reservedPlaces: null, + participationSource: { + type: 'round', + roundId: '333-r1', + resultCondition: { type: 'ranking', scope: 'average', value: 16 }, + }, + }; + + try { + renderPanel(wcifMock.events[0].rounds[0]); + expect(screen.getByText('Top 16 advance to next round')).toBeInTheDocument(); + } finally { + nextRound.participationRuleset = originalRuleset; + } }); it('shows advancement text derived from the next round participation ruleset', () => { - renderPanel({ - ...(wcifMock.events[0].rounds[0] as object), - advancementCondition: null, - } as Round); + renderPanel(wcifMock.events[0].rounds[0]); expect(screen.getByText('Top 75% advance to next round')).toBeInTheDocument(); }); it('shows linked-round advancement text when a later round depends on combined results', () => { - renderPanel(wcifMock.events[0].rounds[1] as unknown as Round); + renderPanel(wcifMock.events[0].rounds[1]); expect(screen.getByText('Top 12 in dual rounds 1 & 2 advance to final')).toBeInTheDocument(); }); it('shows the same dual-round advancement text for the first round in a linked-round set', () => { - renderPanel(wcifMock.events[1].rounds[0] as unknown as Round); + renderPanel(wcifMock.events[1].rounds[0]); expect(screen.getByText('Top 8 in dual rounds 1 & 2 advance to final')).toBeInTheDocument(); }); diff --git a/src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.tsx b/src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.tsx index 265db36..9dc2760 100644 --- a/src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.tsx +++ b/src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.tsx @@ -6,7 +6,7 @@ import { Link } from 'react-router-dom'; import { Popover } from 'react-tiny-popover'; import { renderCentiseconds, renderCutoff } from '@/lib/results'; import { getEventRoundsForRound, joinLabels } from '@/lib/roundLabels'; -import { CompatibleRound, getAdvancementConditionForRound, ResultCondition } from '@/lib/wcif'; +import { getAdvancementConditionForRound } from '@/lib/wcif'; import { useWCIF } from '@/providers/WCIFProvider'; export function CutoffTimeLimitPanel({ @@ -28,7 +28,7 @@ export function CutoffTimeLimitPanel({ [round.id, wcif?.events], ); const advancement = useMemo( - () => getAdvancementConditionForRound(eventRounds, round as CompatibleRound), + () => getAdvancementConditionForRound(eventRounds, round), [eventRounds, round], ); @@ -110,7 +110,7 @@ function getCumulativeRoundIds(timeLimit: Round['timeLimit'], roundId: string) { function renderAdvancementText( t: ReturnType['t'], - eventRounds: CompatibleRound[], + eventRounds: Round[], sourceType: 'registrations' | 'round' | 'linkedRounds', advancement: NonNullable>, ) { @@ -123,7 +123,7 @@ function renderAdvancementText( function renderLinkedRoundsAdvancementText( t: ReturnType['t'], - eventRounds: CompatibleRound[], + eventRounds: Round[], advancement: NonNullable>, ) { const { resultCondition } = advancement; @@ -162,7 +162,7 @@ function renderLinkedRoundsAdvancementText( function renderSingleRoundAdvancementText( t: ReturnType['t'], - eventRounds: CompatibleRound[], + eventRounds: Round[], advancement: NonNullable>, ) { const { resultCondition } = advancement; @@ -193,7 +193,7 @@ function renderSingleRoundAdvancementText( function getAdvancementTargetLabel( t: ReturnType['t'], - eventRounds: CompatibleRound[], + eventRounds: Round[], targetRoundId: string | null | undefined, ) { if (!targetRoundId) { diff --git a/src/containers/CompetitionPerson/CompetitionPerson.tsx b/src/containers/CompetitionPerson/CompetitionPerson.tsx index 61442f5..f68aff2 100644 --- a/src/containers/CompetitionPerson/CompetitionPerson.tsx +++ b/src/containers/CompetitionPerson/CompetitionPerson.tsx @@ -16,7 +16,7 @@ export function CompetitionPersonContainer({ registrantId }: CompetitionPersonCo const { competitionId, wcif, setTitle } = useWCIF(); const { pathname } = useLocation(); - const person = wcif?.persons?.find((p) => p.registrantId.toString() === registrantId) as + const person = wcif?.persons?.find((p) => p.registrantId?.toString() === registrantId) as | (Person & { extensions: Extension[]; }) diff --git a/src/containers/CompetitionPersonalResults/CompetitionPersonalResults.test.tsx b/src/containers/CompetitionPersonalResults/CompetitionPersonalResults.test.tsx index 90d5602..592e19d 100644 --- a/src/containers/CompetitionPersonalResults/CompetitionPersonalResults.test.tsx +++ b/src/containers/CompetitionPersonalResults/CompetitionPersonalResults.test.tsx @@ -79,7 +79,21 @@ jest.mock('react-i18next', () => ({ }), })); -const wcifMock = { +const wcifMock: Competition = { + formatVersion: '2.0', + name: 'Test Competition', + shortName: 'Test Competition', + competitorLimit: null, + extensions: [], + series: null, + registrationInfo: { + openTime: '2026-01-01T00:00:00Z', + closeTime: '2026-04-01T00:00:00Z', + baseEntryFee: 0, + currencyCode: 'USD', + onTheSpotRegistration: false, + useWcaRegistration: true, + }, id: 'TestComp2026', schedule: { startDate: '2026-05-03', @@ -107,17 +121,18 @@ const wcifMock = { format: 'a', cutoff: null, timeLimit: null, - advancementCondition: null, + linkedRounds: null, + participationRuleset: null, results: [ { personId: 1, ranking: 2, attempts: [ - { result: 690, reconstruction: null }, - { result: 732, reconstruction: null }, - { result: 786, reconstruction: null }, - { result: 1034, reconstruction: null }, - { result: 705, reconstruction: null }, + { value: 690, reconstruction: null }, + { value: 732, reconstruction: null }, + { value: 786, reconstruction: null }, + { value: 1034, reconstruction: null }, + { value: 705, reconstruction: null }, ], best: 690, average: 741, @@ -136,14 +151,15 @@ const wcifMock = { format: 'a', cutoff: null, timeLimit: null, - advancementCondition: null, + linkedRounds: null, + participationRuleset: null, results: [], extensions: [], }, ], }, ], -} as unknown as Competition; +}; jest.mock('@/providers/WCIFProvider', () => ({ useWCIF: () => ({ @@ -176,6 +192,45 @@ describe('CompetitionPersonalResultsContainer', () => { mockWcaCompetitionResultsStatus = 'success'; }); + it('finds the competitor after a person without a registrant ID', () => { + wcifMock.persons.unshift({ + registrantId: null, + name: 'Non-competing organizer', + wcaUserId: 1002, + countryIso2: 'US', + extensions: [], + }); + + try { + renderPersonalResults(); + + expect(screen.getByText('Vikram Haldar')).toBeInTheDocument(); + expect(screen.getByText('7.41')).toBeInTheDocument(); + } finally { + wcifMock.persons.shift(); + } + }); + + it('renders WCIF v2 attempt values including DNF and DNS', () => { + const result = wcifMock.events[0].rounds[0].results[0]; + const originalAttempts = result.attempts; + result.attempts = [ + { value: 1234, reconstruction: null }, + { value: -1, reconstruction: null }, + { value: -2, reconstruction: null }, + ]; + + try { + renderPersonalResults(); + + expect(screen.getByText('12.34')).toBeInTheDocument(); + expect(screen.getByText('DNF')).toBeInTheDocument(); + expect(screen.getByText('DNS')).toBeInTheDocument(); + } finally { + result.attempts = originalAttempts; + } + }); + it('shows event tables for the selected competitor results', () => { renderPersonalResults(); diff --git a/src/containers/CompetitionPersonalResults/CompetitionPersonalResults.tsx b/src/containers/CompetitionPersonalResults/CompetitionPersonalResults.tsx index 4c34f16..c95c7c0 100644 --- a/src/containers/CompetitionPersonalResults/CompetitionPersonalResults.tsx +++ b/src/containers/CompetitionPersonalResults/CompetitionPersonalResults.tsx @@ -30,8 +30,8 @@ export function CompetitionPersonalResultsContent({ const isTodayCompetitionDay = wcif ? isCompetitionDay(wcif) : false; const { data: wcaLiveLink, status: wcaLiveFetchStatus } = useWcaLiveCompetitorLink( competitionId, - person.registrantId.toString(), - { enabled: isTodayCompetitionDay }, + person.registrantId?.toString() ?? '', + { enabled: isTodayCompetitionDay && person.registrantId != null }, ); const { data: wcaLiveResults } = useWcaLiveCompetitorResults(wcaLiveLink, { enabled: isTodayCompetitionDay && wcaLiveFetchStatus === 'success', @@ -106,7 +106,7 @@ export function CompetitionPersonalResultsContainer({ const { t } = useTranslation(); const { wcif, competitionId, setTitle } = useWCIF(); - const person = wcif?.persons.find((p) => p.registrantId.toString() === registrantId); + const person = wcif?.persons.find((p) => p.registrantId?.toString() === registrantId); useEffect(() => { if (person) { diff --git a/src/containers/CompetitionPersonalResults/CompetitionPersonalResultsTable.tsx b/src/containers/CompetitionPersonalResults/CompetitionPersonalResultsTable.tsx index 2469073..42bfe44 100644 --- a/src/containers/CompetitionPersonalResults/CompetitionPersonalResultsTable.tsx +++ b/src/containers/CompetitionPersonalResults/CompetitionPersonalResultsTable.tsx @@ -1,4 +1,4 @@ -import { AttemptResult, EventId, Person } from '@wca/helpers'; +import { ResultValue, EventId, Person } from '@wca/helpers'; import classNames from 'classnames'; import { KeyboardEvent, useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -16,10 +16,10 @@ interface PersonalRoundResult { advancing: boolean; advancingQuestionable: boolean; attempts: { - result: AttemptResult; + result: ResultValue; }[]; - best: AttemptResult; - average: AttemptResult; + best: ResultValue; + average: ResultValue; } interface CompetitionPersonalResultsTableProps { @@ -31,7 +31,7 @@ interface CompetitionPersonalResultsTableProps { LinkComponent: LinkRenderer; } -const renderSingleResult = (eventId: string, value: AttemptResult) => { +const renderSingleResult = (eventId: string, value: ResultValue) => { if (value === 0) { return '-'; } @@ -39,7 +39,7 @@ const renderSingleResult = (eventId: string, value: AttemptResult) => { return renderResultByEventId(eventId as EventId, 'single', value); }; -const renderAverageResult = (eventId: string, value: AttemptResult) => { +const renderAverageResult = (eventId: string, value: ResultValue) => { if (value === 0) { return '-'; } diff --git a/src/containers/CompetitionPsychSheetEvent/CompetitionPsychSheetEvent.tsx b/src/containers/CompetitionPsychSheetEvent/CompetitionPsychSheetEvent.tsx index 808a5d9..36fb316 100644 --- a/src/containers/CompetitionPsychSheetEvent/CompetitionPsychSheetEvent.tsx +++ b/src/containers/CompetitionPsychSheetEvent/CompetitionPsychSheetEvent.tsx @@ -141,7 +141,7 @@ export function CompetitionPsychSheetEventContainer({ {person.name} - {prAverage ? renderResultByEventId(eventId, resultType, prAverage.best) : ''} + {prAverage ? renderResultByEventId(eventId, resultType, prAverage.value) : ''} {prAverage diff --git a/src/containers/CompetitionResults/CompetitionResults.test.tsx b/src/containers/CompetitionResults/CompetitionResults.test.tsx index 38d35fb..b427081 100644 --- a/src/containers/CompetitionResults/CompetitionResults.test.tsx +++ b/src/containers/CompetitionResults/CompetitionResults.test.tsx @@ -62,8 +62,17 @@ jest.mock('react-i18next', () => ({ }), })); -const wcifMock = { - formatVersion: '1.0', +const wcifMock: Competition = { + series: null, + registrationInfo: { + openTime: '2026-01-01T00:00:00Z', + closeTime: '2026-04-01T00:00:00Z', + baseEntryFee: 0, + currencyCode: 'USD', + onTheSpotRegistration: false, + useWcaRegistration: true, + }, + formatVersion: '2.0', id: 'TestComp2026', name: 'Test Competition 2026', shortName: 'Test Comp 2026', @@ -111,18 +120,16 @@ const wcifMock = { format: 'a', cutoff: null, timeLimit: null, - advancementCondition: { - type: 'ranking', - level: 1, - }, + linkedRounds: null, + participationRuleset: null, results: [ { personId: 2, ranking: 2, attempts: [ - { result: 1400, reconstruction: null }, - { result: 1500, reconstruction: null }, - { result: 1600, reconstruction: null }, + { value: 1400, reconstruction: null }, + { value: 1500, reconstruction: null }, + { value: 1600, reconstruction: null }, ], best: 1400, average: 1500, @@ -131,9 +138,9 @@ const wcifMock = { personId: 1, ranking: 1, attempts: [ - { result: 1200, reconstruction: null }, - { result: 1300, reconstruction: null }, - { result: 1400, reconstruction: null }, + { value: 1200, reconstruction: null }, + { value: 1300, reconstruction: null }, + { value: 1400, reconstruction: null }, ], best: 1200, average: 1300, @@ -146,7 +153,15 @@ const wcifMock = { format: 'a', cutoff: null, timeLimit: null, - advancementCondition: null, + linkedRounds: null, + participationRuleset: { + reservedPlaces: null, + participationSource: { + type: 'round', + roundId: '333-r1', + resultCondition: { type: 'ranking', scope: 'average', value: 1 }, + }, + }, results: [], extensions: [], }, @@ -161,7 +176,8 @@ const wcifMock = { format: 'a', cutoff: null, timeLimit: null, - advancementCondition: null, + linkedRounds: null, + participationRuleset: null, results: [], extensions: [], }, @@ -170,7 +186,8 @@ const wcifMock = { format: 'a', cutoff: null, timeLimit: null, - advancementCondition: null, + linkedRounds: null, + participationRuleset: null, results: [], extensions: [], }, @@ -188,11 +205,13 @@ const wcifMock = { longitudeMicrodegrees: 0, countryIso2: 'US', timezone: 'America/Los_Angeles', + extensions: [], rooms: [ { id: 1, name: 'Main Room', color: '#ffffff', + extensions: [], activities: [ { id: 100, @@ -219,7 +238,7 @@ const wcifMock = { }, ], }, -} as unknown as Competition; +}; jest.mock('@/providers/WCIFProvider', () => ({ useWCIF: () => ({ @@ -258,6 +277,26 @@ describe('CompetitionResultsContainer', () => { jest.useRealTimers(); }); + it('renders WCIF v2 attempt values including DNF and DNS', () => { + const result = wcifMock.events[0].rounds[0].results[0]; + const originalAttempts = result.attempts; + result.attempts = [ + { value: 1234, reconstruction: null }, + { value: -1, reconstruction: null }, + { value: -2, reconstruction: null }, + ]; + + try { + renderResults('333-r1'); + + expect(screen.getByText('12.34')).toBeInTheDocument(); + expect(screen.getByText('DNF')).toBeInTheDocument(); + expect(screen.getByText('DNS')).toBeInTheDocument(); + } finally { + result.attempts = originalAttempts; + } + }); + it('shows the round chooser without selected round content by default', () => { renderResults(); diff --git a/src/containers/CompetitionResults/CompetitionResultsTable.tsx b/src/containers/CompetitionResults/CompetitionResultsTable.tsx index 0348495..a1bf1f7 100644 --- a/src/containers/CompetitionResults/CompetitionResultsTable.tsx +++ b/src/containers/CompetitionResults/CompetitionResultsTable.tsx @@ -1,4 +1,11 @@ -import { AttemptResult, EventId, Person, RankingType, Round, RoundFormat } from '@wca/helpers'; +import { + ResultValue as WcaResultValue, + EventId, + Person, + RankingType, + Round, + RoundFormat, +} from '@wca/helpers'; import classNames from 'classnames'; import { KeyboardEvent, ReactNode, useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -16,10 +23,10 @@ export interface CompetitionRoundResult { advancing: boolean; advancingQuestionable: boolean; attempts: { - result: AttemptResult; + result: WcaResultValue; }[]; - best: AttemptResult; - average: AttemptResult; + best: WcaResultValue; + average: WcaResultValue; bestAttemptIndex?: number | null; worstAttemptIndex?: number | null; singleRecordTag?: ResultRecordTag; @@ -62,7 +69,7 @@ const renderResultValue = ( return renderResultByEventId(eventId as EventId, rankingType, value); }; -const renderAttemptValue = (eventId: string, value: AttemptResult) => { +const renderAttemptValue = (eventId: string, value: WcaResultValue) => { if (value === 0) { return '-'; } @@ -72,7 +79,7 @@ const renderAttemptValue = (eventId: string, value: AttemptResult) => { const isAverageOfFiveRound = (format: RoundFormat) => format === 'a'; -const isValidAttemptResult = (value: AttemptResult) => Number(value) > 0; +const isValidAttemptResult = (value: WcaResultValue) => Number(value) > 0; const getCalculatedDroppedAttemptIndexes = ( attempts: CompetitionRoundResult['attempts'], @@ -129,7 +136,7 @@ const getDroppedAttemptIndexes = ( const renderAttemptDisplayValue = ( eventId: string, - value: AttemptResult, + value: WcaResultValue, isDroppedAttempt: boolean, ) => { const renderedValue = renderAttemptValue(eventId, value); @@ -187,7 +194,7 @@ const toCompetitionRoundResult = (result: Round['results'][number]): Competition ranking: result.ranking, advancing: false, advancingQuestionable: false, - attempts: result.attempts.map((attempt) => ({ result: attempt.result })), + attempts: result.attempts.map((attempt) => ({ result: attempt.value })), best: result.best, average: result.average, singleRecordTag: normalizeResultRecordTag( diff --git a/src/containers/CompetitionResults/advancement.ts b/src/containers/CompetitionResults/advancement.ts index 7ff2625..4a8c192 100644 --- a/src/containers/CompetitionResults/advancement.ts +++ b/src/containers/CompetitionResults/advancement.ts @@ -77,7 +77,7 @@ export const getStoredRoundResults = ( ranking: result.ranking, advancing: isAdvancingFromStoredResult(round, result, advancementCondition), advancingQuestionable: false, - attempts: result.attempts.map((attempt) => ({ result: attempt.result })), + attempts: result.attempts.map((attempt) => ({ result: attempt.value })), best: result.best, average: result.average, singleRecordTag: normalizeResultRecordTag( diff --git a/src/containers/CompetitionResults/resultsProvider.test.ts b/src/containers/CompetitionResults/resultsProvider.test.ts index e7f465e..75ce581 100644 --- a/src/containers/CompetitionResults/resultsProvider.test.ts +++ b/src/containers/CompetitionResults/resultsProvider.test.ts @@ -41,8 +41,17 @@ const persons: Person[] = [ }, ]; -const wcif = { - formatVersion: '1.0', +const wcif: Competition = { + series: null, + registrationInfo: { + openTime: '2026-01-01T00:00:00Z', + closeTime: '2026-04-01T00:00:00Z', + baseEntryFee: 0, + currencyCode: 'USD', + onTheSpotRegistration: false, + useWcaRegistration: true, + }, + formatVersion: '2.0', id: 'ProviderTest2026', name: 'Provider Test 2026', shortName: 'Provider Test', @@ -59,16 +68,17 @@ const wcif = { format: 'a', cutoff: null, timeLimit: null, - advancementCondition: null, + linkedRounds: null, + participationRuleset: null, extensions: [], results: [ { personId: 1, ranking: 1, attempts: [ - { result: 1200, reconstruction: null }, - { result: 1300, reconstruction: null }, - { result: 1400, reconstruction: null }, + { value: 1200, reconstruction: null }, + { value: 1300, reconstruction: null }, + { value: 1400, reconstruction: null }, ], best: 1200, average: 1300, @@ -80,7 +90,8 @@ const wcif = { format: 'a', cutoff: null, timeLimit: null, - advancementCondition: null, + linkedRounds: null, + participationRuleset: null, extensions: [], results: [], }, @@ -89,7 +100,8 @@ const wcif = { format: 'a', cutoff: null, timeLimit: null, - advancementCondition: null, + linkedRounds: null, + participationRuleset: null, extensions: [], results: [], }, @@ -140,7 +152,7 @@ const wcif = { }, ], }, -} as Competition; +}; const selectedRound = (roundIndex: number) => ({ event: wcif.events[0], diff --git a/src/containers/CompetitionResults/resultsProvider.ts b/src/containers/CompetitionResults/resultsProvider.ts index 562e6d7..a16597e 100644 --- a/src/containers/CompetitionResults/resultsProvider.ts +++ b/src/containers/CompetitionResults/resultsProvider.ts @@ -160,7 +160,7 @@ const getStoredPersonalResults = (wcif: Competition, person: Person): EventResul ranking: result.ranking, advancing: false, advancingQuestionable: false, - attempts: result.attempts.map((attempt) => ({ result: attempt.result })), + attempts: result.attempts.map((attempt) => ({ result: attempt.value })), best: result.best, average: result.average, } diff --git a/src/containers/CompetitionRound/CompetitionRound.stories.tsx b/src/containers/CompetitionRound/CompetitionRound.stories.tsx index 15435cc..074505e 100644 --- a/src/containers/CompetitionRound/CompetitionRound.stories.tsx +++ b/src/containers/CompetitionRound/CompetitionRound.stories.tsx @@ -1,6 +1,6 @@ import type { Meta, StoryObj } from '@storybook/react'; import { - makeStorybookCompetitionFixtureWithRound, + makeStorybookCompetitionFixtureWithRoundUpdates, makeStorybookEventCompetitionFixture, storybookParticipationConditionLinkedRoundsFixture, storybookParticipationConditionPercentFixture, @@ -75,10 +75,7 @@ export const DualRoundWithNextRound: Story = { export const FinalRound: Story = { parameters: { - competitionFixture: makeStorybookCompetitionFixtureWithRound('333-r3', (round) => ({ - ...round, - advancementCondition: null, - })), + competitionFixture: makeStorybookCompetitionFixtureWithRoundUpdates({}), }, args: { competitionId: 'SeattleSummerOpen2026', @@ -98,17 +95,26 @@ export const CutoffAndTimeLimit: Story = { export const CumulativeTimeLimit: Story = { parameters: { - competitionFixture: makeStorybookCompetitionFixtureWithRound('333-r2', (round) => ({ - ...round, - timeLimit: { - centiseconds: 15000, - cumulativeRoundIds: ['333-r2', '333-r3'], - }, - advancementCondition: { - type: 'attemptResult', - level: 950, - }, - })), + competitionFixture: makeStorybookCompetitionFixtureWithRoundUpdates({ + '333-r2': (round) => ({ + ...round, + timeLimit: { + centiseconds: 15000, + cumulativeRoundIds: ['333-r2', '333-r3'], + }, + }), + '333-r3': (round) => ({ + ...round, + participationRuleset: { + reservedPlaces: null, + participationSource: { + type: 'round', + roundId: '333-r2', + resultCondition: { type: 'resultAchieved', scope: 'single', value: 950 }, + }, + }, + }), + }), }, args: { competitionId: 'SeattleSummerOpen2026', diff --git a/src/containers/CompetitionRound/CompetitionRound.test.tsx b/src/containers/CompetitionRound/CompetitionRound.test.tsx index b949b53..2a30cca 100644 --- a/src/containers/CompetitionRound/CompetitionRound.test.tsx +++ b/src/containers/CompetitionRound/CompetitionRound.test.tsx @@ -66,8 +66,17 @@ jest.mock('react-i18next', () => ({ }), })); -const linkedRoundsCompetition = { - formatVersion: '1.0', +const linkedRoundsCompetition: Competition = { + series: null, + registrationInfo: { + openTime: '2026-01-01T00:00:00Z', + closeTime: '2026-04-01T00:00:00Z', + baseEntryFee: 0, + currencyCode: 'USD', + onTheSpotRegistration: false, + useWcaRegistration: true, + }, + formatVersion: '2.0', id: 'TestComp2026', name: 'Test Competition 2026', shortName: 'Test Comp 2026', @@ -81,18 +90,22 @@ const linkedRoundsCompetition = { rounds: [ { id: '333-r1', + extensions: [], format: 'a', cutoff: null, timeLimit: null, - advancementCondition: null, + linkedRounds: null, + participationRuleset: null, results: [], }, { id: '333-r2', + extensions: [], format: 'a', cutoff: null, timeLimit: null, - advancementCondition: null, + linkedRounds: null, + participationRuleset: null, results: [ { personId: 1, @@ -105,15 +118,19 @@ const linkedRoundsCompetition = { }, { id: '333-r3', + extensions: [], format: 'a', cutoff: null, timeLimit: null, + linkedRounds: null, participationRuleset: { + reservedPlaces: null, participationSource: { type: 'linkedRounds', roundIds: ['333-r1', '333-r2'], resultCondition: { type: 'ranking', + scope: 'average', value: 12, }, }, @@ -134,6 +151,7 @@ const linkedRoundsCompetition = { longitudeMicrodegrees: 0, countryIso2: 'US', timezone: 'America/Los_Angeles', + extensions: [], rooms: [ { id: 1, @@ -168,7 +186,7 @@ const linkedRoundsCompetition = { }, ], }, -} as unknown as Competition; +}; jest.mock('@/providers/WCIFProvider', () => ({ useWCIF: () => ({ diff --git a/src/containers/CompetitionSumOfRanks/rankings.test.ts b/src/containers/CompetitionSumOfRanks/rankings.test.ts index 830286d..835955d 100644 --- a/src/containers/CompetitionSumOfRanks/rankings.test.ts +++ b/src/containers/CompetitionSumOfRanks/rankings.test.ts @@ -50,8 +50,17 @@ const persons: Person[] = [ }, ]; -const wcif = { - formatVersion: '1.0', +const wcif: Competition = { + series: null, + registrationInfo: { + openTime: '2026-01-01T00:00:00Z', + closeTime: '2026-04-01T00:00:00Z', + baseEntryFee: 0, + currencyCode: 'USD', + onTheSpotRegistration: false, + useWcaRegistration: true, + }, + formatVersion: '2.0', id: 'SumRanksTest2026', name: 'Sum Ranks Test 2026', shortName: 'Sum Ranks Test', @@ -68,20 +77,21 @@ const wcif = { format: 'a', cutoff: null, timeLimit: null, - advancementCondition: null, + linkedRounds: null, + participationRuleset: null, extensions: [], results: [ { personId: 1, ranking: 1, - attempts: [{ result: 1200, reconstruction: null }], + attempts: [{ value: 1200, reconstruction: null }], best: 1200, average: 1200, }, { personId: 2, ranking: 2, - attempts: [{ result: 1300, reconstruction: null }], + attempts: [{ value: 1300, reconstruction: null }], best: 1300, average: 1300, }, @@ -92,13 +102,14 @@ const wcif = { format: 'a', cutoff: null, timeLimit: null, - advancementCondition: null, + linkedRounds: null, + participationRuleset: null, extensions: [], results: [ { personId: 2, ranking: 1, - attempts: [{ result: 1100, reconstruction: null }], + attempts: [{ value: 1100, reconstruction: null }], best: 1100, average: 1100, }, @@ -109,7 +120,8 @@ const wcif = { format: 'a', cutoff: null, timeLimit: null, - advancementCondition: null, + linkedRounds: null, + participationRuleset: null, extensions: [], results: [], }, @@ -117,7 +129,7 @@ const wcif = { }, ], schedule: { numberOfDays: 1, startDate: '2026-05-03', venues: [] }, -} as Competition; +}; const apiResult = (overrides: Partial = {}): WcaCompetitionResult => ({ id: 9000, diff --git a/src/containers/CompetitionSumOfRanks/rankings.ts b/src/containers/CompetitionSumOfRanks/rankings.ts index 9c50049..eddedec 100644 --- a/src/containers/CompetitionSumOfRanks/rankings.ts +++ b/src/containers/CompetitionSumOfRanks/rankings.ts @@ -193,7 +193,10 @@ export const getSumOfRanks = ( return wcif.persons .filter( - (person) => hasAcceptedRegistration(person) && personIdsWithResults.has(person.registrantId), + (person) => + hasAcceptedRegistration(person) && + person.registrantId != null && + personIdsWithResults.has(person.registrantId), ) .map((person) => { const scores = roundsWithRankings.map((roundWithRankings) => diff --git a/src/containers/Competitors/Competitors.tsx b/src/containers/Competitors/Competitors.tsx index c696a19..dc750cc 100644 --- a/src/containers/Competitors/Competitors.tsx +++ b/src/containers/Competitors/Competitors.tsx @@ -31,7 +31,7 @@ export const Competitors = ({ wcif }: { wcif: Competition }) => { return { ...person, highlight: person.wcaUserId === user?.id, - pinned: pinnedPersons.includes(person.registrantId), + pinned: person.registrantId != null && pinnedPersons.includes(person.registrantId), currentAssignmentCode: assignedActivity?.assignmentCode, }; }) diff --git a/src/containers/PersonalBests/PersonalBests.test.tsx b/src/containers/PersonalBests/PersonalBests.test.tsx new file mode 100644 index 0000000..2a05646 --- /dev/null +++ b/src/containers/PersonalBests/PersonalBests.test.tsx @@ -0,0 +1,76 @@ +import '@testing-library/jest-dom'; +import { render, screen } from '@testing-library/react'; +import { Competition, Person } from '@wca/helpers'; +import { MemoryRouter } from 'react-router-dom'; +import { PersonalBestsContainer } from './PersonalBests'; + +jest.mock('@/lib/events', () => ({ + eventNameById: () => '3x3x3 Cube', +})); + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +it('renders single and average personal bests from WCIF v2 values', () => { + const person: Person = { + registrantId: 1, + name: 'Test Solver', + wcaUserId: 1, + countryIso2: 'US', + extensions: [], + registration: { + wcaRegistrationId: 1, + eventIds: ['333'], + status: 'accepted', + isCompeting: true, + }, + personalBests: [ + { + eventId: '333', + type: 'single', + value: 1234, + worldRanking: 100, + continentalRanking: 50, + nationalRanking: 10, + }, + { + eventId: '333', + type: 'average', + value: 1567, + worldRanking: 200, + continentalRanking: 80, + nationalRanking: 20, + }, + ], + }; + const wcif: Competition = { + formatVersion: '2.0', + id: 'Test2026', + name: 'Test 2026', + shortName: 'Test 2026', + persons: [person], + events: [{ id: '333', rounds: [], extensions: [] }], + schedule: { startDate: '2026-05-03', numberOfDays: 1, venues: [] }, + series: null, + competitorLimit: null, + extensions: [], + registrationInfo: { + openTime: '2026-01-01T00:00:00Z', + closeTime: '2026-04-01T00:00:00Z', + baseEntryFee: 0, + currencyCode: 'USD', + onTheSpotRegistration: false, + useWcaRegistration: true, + }, + }; + + render( + + + , + ); + + expect(screen.getByText('12.34')).toBeInTheDocument(); + expect(screen.getByText('15.67')).toBeInTheDocument(); +}); diff --git a/src/containers/PersonalBests/PersonalBests.tsx b/src/containers/PersonalBests/PersonalBests.tsx index 3b19aa0..224d2be 100644 --- a/src/containers/PersonalBests/PersonalBests.tsx +++ b/src/containers/PersonalBests/PersonalBests.tsx @@ -57,7 +57,7 @@ export function PersonalBestsContainer({ wcif, person }: PersonalBestsContainerP {t('common.wca.resultType.single')} - {renderResultByEventId(eventId, 'single', singlePb.best)} + {renderResultByEventId(eventId, 'single', singlePb.value)} @@ -74,7 +74,7 @@ export function PersonalBestsContainer({ wcif, person }: PersonalBestsContainerP {t('common.wca.resultType.average')} - {renderResultByEventId(eventId, 'average', averagePb.best)} + {renderResultByEventId(eventId, 'average', averagePb.value)} diff --git a/src/containers/PersonalSchedule/PersonHeader.tsx b/src/containers/PersonalSchedule/PersonHeader.tsx index 6f1f280..44b59a6 100644 --- a/src/containers/PersonalSchedule/PersonHeader.tsx +++ b/src/containers/PersonalSchedule/PersonHeader.tsx @@ -60,7 +60,9 @@ export const PersonHeader: React.FC = ({ person }) => { diff --git a/src/containers/CompetitionPersonalResults/CompetitionPersonalResults.tsx b/src/containers/CompetitionPersonalResults/CompetitionPersonalResults.tsx index c95c7c0..4648ba6 100644 --- a/src/containers/CompetitionPersonalResults/CompetitionPersonalResults.tsx +++ b/src/containers/CompetitionPersonalResults/CompetitionPersonalResults.tsx @@ -8,6 +8,7 @@ import { useWcaLiveCompetitorLink, useWcaLiveCompetitorResults } from '@/hooks/q import { useWcaCompetitionResults } from '@/hooks/queries/useWcaResults'; import { isCompetitionDay } from '@/lib/competitionDates'; import { AnchorLink, LinkRenderer } from '@/lib/linkRenderer'; +import { getLinkedRoundIds } from '@/lib/wcif'; import { useWCIF } from '@/providers/WCIFProvider'; import { CompetitionPersonalResultsTable } from './CompetitionPersonalResultsTable'; @@ -75,6 +76,15 @@ export function CompetitionPersonalResultsContent({ {eventResults.map((eventResult) => (

{eventResult.eventName}

+ {wcif.events + .find((event) => event.id === eventResult.eventId) + ?.rounds.some( + (round, _, rounds) => + eventResult.rounds.some((result) => result.roundId === round.id) && + getLinkedRoundIds(rounds, round).length > 1, + ) && ( +

{t('competition.results.dualPersonalNote')}

+ )} { expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); }); + it('opens an empty linked second round and keeps partner navigation available', () => { + const rounds = wcifMock.events[0].rounds; + const previous = rounds.map((round) => round.linkedRounds); + rounds[0].linkedRounds = [rounds[0].id, rounds[1].id]; + rounds[1].linkedRounds = [rounds[0].id, rounds[1].id]; + try { + renderResults(rounds[1].id); + expect(screen.getByText('No results yet.')).toBeInTheDocument(); + expect(screen.getByText('competition.results.dualRoundNote')).toBeInTheDocument(); + expect( + screen + .getAllByRole('link', { name: 'Round 1' }) + .some( + (link) => link.getAttribute('href') === '/competitions/TestComp2026/results/333-r1', + ), + ).toBe(true); + expect(screen.queryByText('Round not found.')).not.toBeInTheDocument(); + } finally { + rounds.forEach((round, index) => { + round.linkedRounds = previous[index]; + }); + } + }); }); diff --git a/src/containers/CompetitionResults/CompetitionResults.tsx b/src/containers/CompetitionResults/CompetitionResults.tsx index 27c8dcb..2e9859e 100644 --- a/src/containers/CompetitionResults/CompetitionResults.tsx +++ b/src/containers/CompetitionResults/CompetitionResults.tsx @@ -18,8 +18,10 @@ import { isCompetitionDay } from '@/lib/competitionDates'; import { getEventName } from '@/lib/events'; import { AnchorLink, LinkRenderer } from '@/lib/linkRenderer'; import { findRoundWithEvent, getAllRoundsWithEvents } from '@/lib/rounds'; +import { getLinkedRoundIds } from '@/lib/wcif'; import { useWCIF } from '@/providers/WCIFProvider'; import { CompetitionResultsTable } from './CompetitionResultsTable'; +import { LinkedRoundsNotice } from './LinkedRoundsNotice'; import { ResultsEventRoundSelector } from './ResultsEventRoundSelector'; import { getWcaApiResultsByRoundId } from './resultSources'; import { getRoundResultsFromSources } from './resultsProvider'; @@ -156,8 +158,10 @@ export function CompetitionResultsContainer({ () => wcif ? getAllRoundsWithEvents(wcif).filter( - ({ round, roundNumber }) => + ({ event, round, roundNumber }) => roundNumber === 1 || + getLinkedRoundIds(event.rounds, round).length > 1 || + round.participationRuleset?.participationSource?.type === 'registrations' || round.results.length > 0 || (apiResultsByRoundId.get(round.id)?.length ?? 0) > 0, ) @@ -209,6 +213,8 @@ export function CompetitionResultsContainer({ const round = findRoundWithEvent(wcif, selectedRoundId); return round && (round.roundNumber === 1 || + getLinkedRoundIds(round.event.rounds, round.round).length > 1 || + round.round.participationRuleset?.participationSource?.type === 'registrations' || round.round.results.length > 0 || (apiResultsByRoundId.get(round.round.id)?.length ?? 0) > 0 || isTodayCompetitionDay) @@ -275,6 +281,12 @@ export function CompetitionResultsContainer({ })} + ({ + useTranslation: () => ({ t: (key: string) => key.split('.').pop() }), +})); + +const makeResult = (overrides: Partial = {}): CompetitionRoundResult => ({ + id: 1, + personId: null, + personName: 'Competitor One', + ranking: 1, + advancing: false, + advancingQuestionable: false, + attempts: [{ result: 35 }, { result: -1 }, { result: 31 }], + best: 31, + average: -1, + ...overrides, +}); + +function renderTable(eventId: string, format: RoundFormat, results: CompetitionRoundResult[]) { + const round: Round = { + id: `${eventId}-r1`, + format, + cutoff: null, + timeLimit: null, + linkedRounds: null, + participationRuleset: null, + extensions: [], + results: [], + }; + + return render( + , + ); +} + +describe('CompetitionResultsTable', () => { + it.each([-1, -2])('renders FMC sentinel %s in attempts, means, bests and details', (value) => { + renderTable('333fm', 'm', [ + makeResult({ attempts: [{ result: value }], best: value, average: value }), + ]); + const label = value === -1 ? 'DNF' : 'DNS'; + const row = screen.getAllByRole('row')[1]; + expect(within(row).getAllByText(label)).toHaveLength(3); + expect(row).not.toHaveTextContent('-0.0'); + + fireEvent.click(row); + expect(within(screen.getByRole('dialog')).getAllByText(label)).toHaveLength(3); + }); + + it('preserves FMC zero, missing attempts and roster-only blank cells', () => { + renderTable('333fm', 'm', [ + makeResult({ attempts: [{ result: 0 }], best: 0, average: 0 }), + makeResult({ id: 2, personName: 'Competitor Two' }), + makeResult({ + id: 3, + personName: 'Roster Only', + ranking: null, + attempts: [], + best: 0, + average: 0, + }), + ]); + const row = screen.getByRole('row', { name: /Competitor One/ }); + expect( + within(row) + .getAllByRole('cell') + .slice(2) + .map((cell) => cell.textContent), + ).toEqual(['-', '-', '-', '-', '-']); + const roster = screen.getByRole('row', { name: /Roster Only/ }); + expect( + within(roster) + .getAllByRole('cell') + .filter((_, index) => index !== 1) + .every((cell) => cell.textContent === ''), + ).toBe(true); + + fireEvent.click(row); + const dialog = within(screen.getByRole('dialog')); + expect(dialog.queryByText('attempts')).not.toBeInTheDocument(); + expect(dialog.queryByText('best')).not.toBeInTheDocument(); + expect(dialog.queryByText('average')).not.toBeInTheDocument(); + }); + + it.each(['1', '2', '3'])('shows one Best column for best-of-%s rounds', (format) => { + const { container } = renderTable('333mbf', format, [ + makeResult({ + attempts: [{ result: 970060000 }, { result: -1 }], + best: 970060000, + average: 0, + singleRecordTag: 'NR', + }), + ]); + + expect(screen.getAllByRole('columnheader', { name: 'best' })).toHaveLength(1); + expect(screen.queryByRole('columnheader', { name: 'average' })).not.toBeInTheDocument(); + expect(screen.getAllByRole('columnheader')).toHaveLength(5); + expect(container.querySelectorAll('col')).toHaveLength(5); + expect(within(screen.getAllByRole('row')[1]).getAllByRole('cell')).toHaveLength(5); + expect(screen.getAllByText('2/2 10:00')).toHaveLength(2); + expect(screen.getByLabelText('NR record')).toBeInTheDocument(); + }); + + it.each(['a', 'm'])('keeps average and Best columns for format %s', (format) => { + const { container } = renderTable('333fm', format, [makeResult({ average: 3300 })]); + expect(screen.getAllByRole('columnheader', { name: 'best' })).toHaveLength(1); + expect(screen.getByRole('columnheader', { name: 'average' })).toBeInTheDocument(); + expect(screen.getAllByRole('columnheader')).toHaveLength(7); + expect(container.querySelectorAll('col')).toHaveLength(7); + expect(within(screen.getAllByRole('row')[1]).getAllByRole('cell')).toHaveLength(7); + expect(screen.getByText('33.00')).toBeInTheDocument(); + }); +}); diff --git a/src/containers/CompetitionResults/CompetitionResultsTable.tsx b/src/containers/CompetitionResults/CompetitionResultsTable.tsx index a1bf1f7..7eef876 100644 --- a/src/containers/CompetitionResults/CompetitionResultsTable.tsx +++ b/src/containers/CompetitionResults/CompetitionResultsTable.tsx @@ -217,6 +217,7 @@ export function CompetitionResultsTable({ const { t } = useTranslation(); const [selectedResult, setSelectedResult] = useState(null); const primaryRankingType = getPrimaryRankingType(round.format); + const showSecondaryBest = primaryRankingType === 'average'; const attemptColumnCount = Math.max(0, ...results.map((result) => result.attempts.length)); const sortedResults = sortResultsByRanking(results); @@ -262,7 +263,7 @@ export function CompetitionResultsTable({ ))} - + {showSecondaryBest && } @@ -287,9 +288,11 @@ export function CompetitionResultsTable({ ? t('competition.results.average') : t('competition.results.best')} - - {t('competition.results.best')} - + {showSecondaryBest && ( + + {t('competition.results.best')} + + )} @@ -358,18 +361,17 @@ export function CompetitionResultsTable({ )} - - {isRosterOnly ? ( - '' - ) : ( - - {renderResultValue(eventId, 'single', result)} - - )} - + {showSecondaryBest && ( + + {isRosterOnly ? ( + '' + ) : ( + + {renderResultValue(eventId, 'single', result)} + + )} + + )} ); })} diff --git a/src/containers/CompetitionResults/LinkedRoundsNotice.tsx b/src/containers/CompetitionResults/LinkedRoundsNotice.tsx new file mode 100644 index 0000000..7aa003d --- /dev/null +++ b/src/containers/CompetitionResults/LinkedRoundsNotice.tsx @@ -0,0 +1,39 @@ +import { Round } from '@wca/helpers'; +import { useTranslation } from 'react-i18next'; +import { LinkRenderer } from '@/lib/linkRenderer'; +import { activityCodeToRoundName } from '@/lib/roundLabels'; +import { getLinkedRoundIds } from '@/lib/wcif'; + +export function LinkedRoundsNotice({ + competitionId, + round, + eventRounds, + LinkComponent, +}: { + competitionId: string; + round: Round; + eventRounds: Round[]; + LinkComponent: LinkRenderer; +}) { + const { t } = useTranslation(); + const linkedIds = getLinkedRoundIds(eventRounds, round); + if (linkedIds.length < 2) return null; + + return ( +
+

{t('competition.results.dualRoundNote')}

+
+ {linkedIds + .filter((id) => id !== round.id) + .map((id) => ( + + {activityCodeToRoundName(t, id)} + + ))} +
+
+ ); +} diff --git a/src/containers/CompetitionResults/advancement.test.ts b/src/containers/CompetitionResults/advancement.test.ts new file mode 100644 index 0000000..e405dc9 --- /dev/null +++ b/src/containers/CompetitionResults/advancement.test.ts @@ -0,0 +1,128 @@ +import { ResultCondition, Round } from '@wca/helpers'; +import { getAdvancementConditionForRound, getLinkedRoundIds } from '@/lib/wcif'; +import { getStoredRoundResults } from './advancement'; +import * as hongKongRounds from './fixtures/CubeAgainstCancerHongKong2026.json'; +import * as fmcRounds from './fixtures/FMCEastMelbourne2026.json'; + +const makeRound = (id: string, results: Round['results'] = []): Round => ({ + id, + format: 'a', + linkedRounds: null, + participationRuleset: null, + cutoff: null, + timeLimit: null, + results, + extensions: [], +}); +const result = (personId: number, ranking: number, average: number) => ({ + personId, + ranking, + average, + best: average, + attempts: [{ value: average, reconstruction: null }], +}); +const linkedRounds = (condition: ResultCondition) => { + const rounds = [ + makeRound('333-r1', [result(1, 2, 1500), result(2, 3, 2000)]), + makeRound('333-r2', [result(1, 2, 900), result(3, 1, 800), result(4, 4, 2200)]), + makeRound('333-r3'), + ]; + rounds[2].participationRuleset = { + participationSource: { + type: 'linkedRounds', + roundIds: ['333-r1', '333-r2'], + resultCondition: condition, + }, + reservedPlaces: null, + }; + return rounds; +}; + +describe('dual-round stored results', () => { + it('uses the distinct union of entrants for percentage advancement', () => { + const rounds = linkedRounds({ type: 'percent', scope: 'average', value: 50 }); + const results = getStoredRoundResults( + rounds[0], + getAdvancementConditionForRound(rounds, rounds[0]), + rounds, + ); + expect(results.map(({ personId, advancing }) => ({ personId, advancing }))).toEqual([ + { personId: 1, advancing: true }, + { personId: 2, advancing: false }, + ]); + }); + + it('checks result thresholds in either component without replacing component values or rank', () => { + const rounds = linkedRounds({ type: 'resultAchieved', scope: 'average', value: 1000 }); + const results = getStoredRoundResults( + rounds[0], + getAdvancementConditionForRound(rounds, rounds[0]), + rounds, + ); + expect(results[0]).toMatchObject({ + ranking: 2, + average: 1500, + advancing: true, + attempts: [{ result: 1500 }], + }); + expect(results[1].advancing).toBe(false); + }); + + it('does not qualify DNF, DNS or missing values through a result threshold', () => { + const rounds = linkedRounds({ type: 'resultAchieved', scope: 'average', value: 1000 }); + rounds[0].results[0].average = -1; + rounds[1].results[0].average = -2; + expect( + getStoredRoundResults( + rounds[0], + getAdvancementConditionForRound(rounds, rounds[0]), + rounds, + )[0].advancing, + ).toBe(false); + }); + + it.each([ + ['FMC East Melbourne 2026', fmcRounds], + ['Cube Against Cancer Hong Kong 2026', hongKongRounds], + ])('preserves official shared ranks and component results for %s', (_, fixture) => { + const rounds = fixture as Round[]; + for (const round of rounds.slice(0, 2)) { + expect(getLinkedRoundIds(rounds, round)).toEqual(rounds.slice(0, 2).map(({ id }) => id)); + const results = getStoredRoundResults( + round, + getAdvancementConditionForRound(rounds, round), + rounds, + ); + expect( + results.map(({ personId, ranking, best, average }) => ({ + personId, + ranking, + best, + average, + })), + ).toEqual( + round.results.map(({ personId, ranking, best, average }) => ({ + personId, + ranking, + best, + average, + })), + ); + // Later participation may differ after withdrawals and replacements. Keep + // this assertion on the stated qualifying condition, not the final roster. + const condition = getAdvancementConditionForRound(rounds, round)!; + const entrants = new Set( + rounds.slice(0, 2).flatMap(({ results }) => results.map(({ personId }) => personId)), + ).size; + const limit = + condition.resultCondition.type === 'percent' + ? Math.ceil((entrants * condition.resultCondition.value) / 100) + : condition.resultCondition.value!; + expect( + results.every( + ({ ranking, advancing }) => advancing === (ranking != null && ranking <= limit), + ), + ).toBe(true); + } + }); +}); diff --git a/src/containers/CompetitionResults/advancement.ts b/src/containers/CompetitionResults/advancement.ts index 4a8c192..d940a32 100644 --- a/src/containers/CompetitionResults/advancement.ts +++ b/src/containers/CompetitionResults/advancement.ts @@ -3,7 +3,7 @@ import { RoundAdvancementCondition } from '@/lib/wcif'; import { CompetitionRoundResult } from './CompetitionResultsTable'; import { normalizeResultRecordTag } from './ResultRecordBadge'; -const averagedFormats = new Set(['a', 'm', '5', 'h']); +const averagedFormats = new Set(['a', 'm']); const getPrimaryRankingType = (round: Pick): RankingType => averagedFormats.has(round.format) ? 'average' : 'single'; @@ -30,15 +30,27 @@ const isAdvancingFromStoredResult = ( round: Round, result: Round['results'][number], advancementCondition: RoundAdvancementCondition | null, + eventRounds: Round[], ) => { if (!advancementCondition) { return false; } const { resultCondition } = advancementCondition; - const rankedResultCount = round.results.filter( - (roundResult) => roundResult.ranking != null, - ).length; + // A dual-round percentage uses distinct competitors across both components, + // including people who only competed in one. WCIF already supplies shared ranks. + const sourceRounds = + advancementCondition.sourceType === 'linkedRounds' + ? eventRounds.filter((sourceRound) => + advancementCondition.sourceRoundIds.includes(sourceRound.id), + ) + : [round]; + const sourceResults = sourceRounds.flatMap((sourceRound) => sourceRound.results); + const rankedResultCount = new Set( + sourceResults + .filter((roundResult) => roundResult.ranking != null) + .map((roundResult) => roundResult.personId), + ).size; const rankLimit = getRankLimit(resultCondition, rankedResultCount); if (rankLimit != null) { @@ -54,14 +66,20 @@ const isAdvancingFromStoredResult = ( } const scope = resultCondition.scope ?? getPrimaryRankingType(round); - const resultValue = getResultValue(result, scope); - - return resultValue > 0 && resultValue < resultCondition.value; + return sourceResults.some((sourceResult) => { + const resultValue = getResultValue(sourceResult, scope); + return ( + sourceResult.personId === result.personId && + resultValue > 0 && + resultValue < resultCondition.value! + ); + }); }; export const getStoredRoundResults = ( round: Round, advancementCondition: RoundAdvancementCondition | null, + eventRounds: Round[] = [round], ): CompetitionRoundResult[] => round.results.map((result) => { const resultWithRecords = result as typeof result & { @@ -75,7 +93,7 @@ export const getStoredRoundResults = ( id: result.personId, personId: result.personId, ranking: result.ranking, - advancing: isAdvancingFromStoredResult(round, result, advancementCondition), + advancing: isAdvancingFromStoredResult(round, result, advancementCondition, eventRounds), advancingQuestionable: false, attempts: result.attempts.map((attempt) => ({ result: attempt.value })), best: result.best, diff --git a/src/containers/CompetitionResults/fixtures/CubeAgainstCancerHongKong2026.json b/src/containers/CompetitionResults/fixtures/CubeAgainstCancerHongKong2026.json new file mode 100644 index 0000000..21657c4 --- /dev/null +++ b/src/containers/CompetitionResults/fixtures/CubeAgainstCancerHongKong2026.json @@ -0,0 +1,1663 @@ +[ + { + "id": "333-r1", + "format": "a", + "timeLimit": { "centiseconds": 30000, "cumulativeRoundIds": [] }, + "cutoff": null, + "scrambleSetCount": 6, + "results": [ + { + "personId": 16, + "ranking": 1, + "attempts": [ + { "value": 631, "reconstruction": null }, + { "value": 683, "reconstruction": null }, + { "value": 601, "reconstruction": null }, + { "value": 629, "reconstruction": null }, + { "value": 700, "reconstruction": null } + ], + "best": 601, + "average": 648 + }, + { + "personId": 24, + "ranking": 2, + "attempts": [ + { "value": 785, "reconstruction": null }, + { "value": 522, "reconstruction": null }, + { "value": 754, "reconstruction": null }, + { "value": 1197, "reconstruction": null }, + { "value": 790, "reconstruction": null } + ], + "best": 522, + "average": 776 + }, + { + "personId": 18, + "ranking": 3, + "attempts": [ + { "value": 691, "reconstruction": null }, + { "value": 870, "reconstruction": null }, + { "value": 850, "reconstruction": null }, + { "value": 938, "reconstruction": null }, + { "value": 882, "reconstruction": null } + ], + "best": 691, + "average": 867 + }, + { + "personId": 13, + "ranking": 4, + "attempts": [ + { "value": 823, "reconstruction": null }, + { "value": 769, "reconstruction": null }, + { "value": 861, "reconstruction": null }, + { "value": 750, "reconstruction": null }, + { "value": 892, "reconstruction": null } + ], + "best": 750, + "average": 818 + }, + { + "personId": 30, + "ranking": 5, + "attempts": [ + { "value": 944, "reconstruction": null }, + { "value": 804, "reconstruction": null }, + { "value": 1251, "reconstruction": null }, + { "value": 785, "reconstruction": null }, + { "value": 801, "reconstruction": null } + ], + "best": 785, + "average": 850 + }, + { + "personId": 31, + "ranking": 6, + "attempts": [ + { "value": 1963, "reconstruction": null }, + { "value": 698, "reconstruction": null }, + { "value": 883, "reconstruction": null }, + { "value": 800, "reconstruction": null }, + { "value": 809, "reconstruction": null } + ], + "best": 698, + "average": 831 + }, + { + "personId": 6, + "ranking": 7, + "attempts": [ + { "value": 792, "reconstruction": null }, + { "value": 871, "reconstruction": null }, + { "value": 697, "reconstruction": null }, + { "value": 952, "reconstruction": null }, + { "value": 1091, "reconstruction": null } + ], + "best": 697, + "average": 872 + }, + { + "personId": 41, + "ranking": 8, + "attempts": [ + { "value": 908, "reconstruction": null }, + { "value": 853, "reconstruction": null }, + { "value": 683, "reconstruction": null }, + { "value": 757, "reconstruction": null }, + { "value": 1014, "reconstruction": null } + ], + "best": 683, + "average": 839 + }, + { + "personId": 49, + "ranking": 9, + "attempts": [ + { "value": 865, "reconstruction": null }, + { "value": 869, "reconstruction": null }, + { "value": 722, "reconstruction": null }, + { "value": 813, "reconstruction": null }, + { "value": 1355, "reconstruction": null } + ], + "best": 722, + "average": 849 + }, + { + "personId": 10, + "ranking": 10, + "attempts": [ + { "value": 846, "reconstruction": null }, + { "value": 826, "reconstruction": null }, + { "value": 895, "reconstruction": null }, + { "value": 838, "reconstruction": null }, + { "value": 988, "reconstruction": null } + ], + "best": 826, + "average": 860 + }, + { + "personId": 58, + "ranking": 11, + "attempts": [ + { "value": 938, "reconstruction": null }, + { "value": 959, "reconstruction": null }, + { "value": 931, "reconstruction": null }, + { "value": 1012, "reconstruction": null }, + { "value": 986, "reconstruction": null } + ], + "best": 931, + "average": 961 + }, + { + "personId": 25, + "ranking": 12, + "attempts": [ + { "value": 1379, "reconstruction": null }, + { "value": 921, "reconstruction": null }, + { "value": 833, "reconstruction": null }, + { "value": 1042, "reconstruction": null }, + { "value": 1082, "reconstruction": null } + ], + "best": 833, + "average": 1015 + }, + { + "personId": 15, + "ranking": 13, + "attempts": [ + { "value": -1, "reconstruction": null }, + { "value": -1, "reconstruction": null }, + { "value": -1, "reconstruction": null }, + { "value": -1, "reconstruction": null }, + { "value": -1, "reconstruction": null } + ], + "best": -1, + "average": -1 + }, + { + "personId": 28, + "ranking": 14, + "attempts": [ + { "value": 1686, "reconstruction": null }, + { "value": 1082, "reconstruction": null }, + { "value": 893, "reconstruction": null }, + { "value": 1262, "reconstruction": null }, + { "value": 984, "reconstruction": null } + ], + "best": 893, + "average": 1109 + }, + { + "personId": 22, + "ranking": 15, + "attempts": [ + { "value": 1265, "reconstruction": null }, + { "value": 774, "reconstruction": null }, + { "value": 906, "reconstruction": null }, + { "value": 928, "reconstruction": null }, + { "value": 1066, "reconstruction": null } + ], + "best": 774, + "average": 967 + }, + { + "personId": 34, + "ranking": 16, + "attempts": [ + { "value": 1031, "reconstruction": null }, + { "value": 1317, "reconstruction": null }, + { "value": 807, "reconstruction": null }, + { "value": 958, "reconstruction": null }, + { "value": 995, "reconstruction": null } + ], + "best": 807, + "average": 995 + }, + { + "personId": 32, + "ranking": 17, + "attempts": [ + { "value": 1131, "reconstruction": null }, + { "value": 1016, "reconstruction": null }, + { "value": 915, "reconstruction": null }, + { "value": 1057, "reconstruction": null }, + { "value": 947, "reconstruction": null } + ], + "best": 915, + "average": 1007 + }, + { + "personId": 12, + "ranking": 18, + "attempts": [ + { "value": 1325, "reconstruction": null }, + { "value": 986, "reconstruction": null }, + { "value": 916, "reconstruction": null }, + { "value": 1024, "reconstruction": null }, + { "value": 1024, "reconstruction": null } + ], + "best": 916, + "average": 1011 + }, + { + "personId": 9, + "ranking": 19, + "attempts": [ + { "value": 1458, "reconstruction": null }, + { "value": 1164, "reconstruction": null }, + { "value": 1062, "reconstruction": null }, + { "value": 1110, "reconstruction": null }, + { "value": 1038, "reconstruction": null } + ], + "best": 1038, + "average": 1112 + }, + { + "personId": 50, + "ranking": 20, + "attempts": [ + { "value": 1018, "reconstruction": null }, + { "value": 1164, "reconstruction": null }, + { "value": 884, "reconstruction": null }, + { "value": 1039, "reconstruction": null }, + { "value": 1126, "reconstruction": null } + ], + "best": 884, + "average": 1061 + }, + { + "personId": 5, + "ranking": 21, + "attempts": [ + { "value": 967, "reconstruction": null }, + { "value": 1379, "reconstruction": null }, + { "value": 1130, "reconstruction": null }, + { "value": 1188, "reconstruction": null }, + { "value": 1073, "reconstruction": null } + ], + "best": 967, + "average": 1130 + }, + { + "personId": 23, + "ranking": 22, + "attempts": [ + { "value": 1022, "reconstruction": null }, + { "value": 1052, "reconstruction": null }, + { "value": 919, "reconstruction": null }, + { "value": 1300, "reconstruction": null }, + { "value": 1412, "reconstruction": null } + ], + "best": 919, + "average": 1125 + }, + { + "personId": 19, + "ranking": 23, + "attempts": [ + { "value": 810, "reconstruction": null }, + { "value": 1932, "reconstruction": null }, + { "value": 905, "reconstruction": null }, + { "value": 1128, "reconstruction": null }, + { "value": -1, "reconstruction": null } + ], + "best": 810, + "average": 1322 + }, + { + "personId": 21, + "ranking": 24, + "attempts": [ + { "value": 923, "reconstruction": null }, + { "value": 1035, "reconstruction": null }, + { "value": 1229, "reconstruction": null }, + { "value": 1933, "reconstruction": null }, + { "value": 1525, "reconstruction": null } + ], + "best": 923, + "average": 1263 + }, + { + "personId": 20, + "ranking": 25, + "attempts": [ + { "value": 949, "reconstruction": null }, + { "value": 1141, "reconstruction": null }, + { "value": 1139, "reconstruction": null }, + { "value": 1340, "reconstruction": null }, + { "value": 1182, "reconstruction": null } + ], + "best": 949, + "average": 1154 + }, + { + "personId": 35, + "ranking": 26, + "attempts": [ + { "value": 1366, "reconstruction": null }, + { "value": 1479, "reconstruction": null }, + { "value": 994, "reconstruction": null }, + { "value": 1038, "reconstruction": null }, + { "value": 928, "reconstruction": null } + ], + "best": 928, + "average": 1133 + }, + { + "personId": 48, + "ranking": 27, + "attempts": [ + { "value": 1262, "reconstruction": null }, + { "value": 1212, "reconstruction": null }, + { "value": 1171, "reconstruction": null }, + { "value": 1185, "reconstruction": null }, + { "value": 1215, "reconstruction": null } + ], + "best": 1171, + "average": 1204 + }, + { + "personId": 38, + "ranking": 28, + "attempts": [ + { "value": 1538, "reconstruction": null }, + { "value": 1356, "reconstruction": null }, + { "value": 1267, "reconstruction": null }, + { "value": 1212, "reconstruction": null }, + { "value": 1074, "reconstruction": null } + ], + "best": 1074, + "average": 1278 + }, + { + "personId": 17, + "ranking": 29, + "attempts": [ + { "value": 1105, "reconstruction": null }, + { "value": 1118, "reconstruction": null }, + { "value": 1143, "reconstruction": null }, + { "value": 1391, "reconstruction": null }, + { "value": 1725, "reconstruction": null } + ], + "best": 1105, + "average": 1217 + }, + { + "personId": 43, + "ranking": 30, + "attempts": [ + { "value": 1216, "reconstruction": null }, + { "value": 1304, "reconstruction": null }, + { "value": 1203, "reconstruction": null }, + { "value": 1306, "reconstruction": null }, + { "value": 1508, "reconstruction": null } + ], + "best": 1203, + "average": 1275 + }, + { + "personId": 2, + "ranking": 31, + "attempts": [ + { "value": 1173, "reconstruction": null }, + { "value": 1269, "reconstruction": null }, + { "value": 1175, "reconstruction": null }, + { "value": 1510, "reconstruction": null }, + { "value": 1226, "reconstruction": null } + ], + "best": 1173, + "average": 1223 + }, + { + "personId": 4, + "ranking": 32, + "attempts": [ + { "value": 1232, "reconstruction": null }, + { "value": 2772, "reconstruction": null }, + { "value": 1488, "reconstruction": null }, + { "value": 1133, "reconstruction": null }, + { "value": 1112, "reconstruction": null } + ], + "best": 1112, + "average": 1284 + }, + { + "personId": 7, + "ranking": 33, + "attempts": [ + { "value": 1444, "reconstruction": null }, + { "value": 1282, "reconstruction": null }, + { "value": 1327, "reconstruction": null }, + { "value": 1587, "reconstruction": null }, + { "value": 1392, "reconstruction": null } + ], + "best": 1282, + "average": 1388 + }, + { + "personId": 36, + "ranking": 34, + "attempts": [ + { "value": 1131, "reconstruction": null }, + { "value": 1614, "reconstruction": null }, + { "value": 1271, "reconstruction": null }, + { "value": 1636, "reconstruction": null }, + { "value": 1185, "reconstruction": null } + ], + "best": 1131, + "average": 1357 + }, + { + "personId": 60, + "ranking": 35, + "attempts": [ + { "value": 1300, "reconstruction": null }, + { "value": 1515, "reconstruction": null }, + { "value": 1684, "reconstruction": null }, + { "value": 1363, "reconstruction": null }, + { "value": 1676, "reconstruction": null } + ], + "best": 1300, + "average": 1518 + }, + { + "personId": 3, + "ranking": 36, + "attempts": [ + { "value": 1514, "reconstruction": null }, + { "value": 1411, "reconstruction": null }, + { "value": 1354, "reconstruction": null }, + { "value": 1123, "reconstruction": null }, + { "value": 1404, "reconstruction": null } + ], + "best": 1123, + "average": 1390 + }, + { + "personId": 27, + "ranking": 37, + "attempts": [ + { "value": 1344, "reconstruction": null }, + { "value": 1166, "reconstruction": null }, + { "value": 1452, "reconstruction": null }, + { "value": -1, "reconstruction": null }, + { "value": 1407, "reconstruction": null } + ], + "best": 1166, + "average": 1401 + }, + { + "personId": 51, + "ranking": 38, + "attempts": [ + { "value": 1970, "reconstruction": null }, + { "value": 1936, "reconstruction": null }, + { "value": 1694, "reconstruction": null }, + { "value": 2052, "reconstruction": null }, + { "value": 1636, "reconstruction": null } + ], + "best": 1636, + "average": 1867 + }, + { + "personId": 59, + "ranking": 39, + "attempts": [ + { "value": 1438, "reconstruction": null }, + { "value": 1361, "reconstruction": null }, + { "value": 1574, "reconstruction": null }, + { "value": 1598, "reconstruction": null }, + { "value": 1386, "reconstruction": null } + ], + "best": 1361, + "average": 1466 + }, + { + "personId": 46, + "ranking": 40, + "attempts": [ + { "value": 1470, "reconstruction": null }, + { "value": 1453, "reconstruction": null }, + { "value": 1489, "reconstruction": null }, + { "value": 1429, "reconstruction": null }, + { "value": 1996, "reconstruction": null } + ], + "best": 1429, + "average": 1471 + }, + { + "personId": 63, + "ranking": 41, + "attempts": [ + { "value": 1238, "reconstruction": null }, + { "value": 1540, "reconstruction": null }, + { "value": 1490, "reconstruction": null }, + { "value": 1387, "reconstruction": null }, + { "value": 1605, "reconstruction": null } + ], + "best": 1238, + "average": 1472 + }, + { + "personId": 47, + "ranking": 42, + "attempts": [ + { "value": 1415, "reconstruction": null }, + { "value": 1575, "reconstruction": null }, + { "value": 1211, "reconstruction": null }, + { "value": 1682, "reconstruction": null }, + { "value": 1548, "reconstruction": null } + ], + "best": 1211, + "average": 1513 + }, + { + "personId": 40, + "ranking": 43, + "attempts": [ + { "value": 1752, "reconstruction": null }, + { "value": 1890, "reconstruction": null }, + { "value": 1608, "reconstruction": null }, + { "value": 3054, "reconstruction": null }, + { "value": 1818, "reconstruction": null } + ], + "best": 1608, + "average": 1820 + }, + { + "personId": 26, + "ranking": 44, + "attempts": [ + { "value": 1572, "reconstruction": null }, + { "value": 1498, "reconstruction": null }, + { "value": 1871, "reconstruction": null }, + { "value": 1974, "reconstruction": null }, + { "value": 1630, "reconstruction": null } + ], + "best": 1498, + "average": 1691 + }, + { + "personId": 52, + "ranking": 45, + "attempts": [ + { "value": 1947, "reconstruction": null }, + { "value": 2085, "reconstruction": null }, + { "value": 1735, "reconstruction": null }, + { "value": 2213, "reconstruction": null }, + { "value": 1642, "reconstruction": null } + ], + "best": 1642, + "average": 1922 + }, + { + "personId": 42, + "ranking": 46, + "attempts": [ + { "value": 1633, "reconstruction": null }, + { "value": 1937, "reconstruction": null }, + { "value": 1881, "reconstruction": null }, + { "value": 1188, "reconstruction": null }, + { "value": 2042, "reconstruction": null } + ], + "best": 1188, + "average": 1817 + }, + { + "personId": 55, + "ranking": 47, + "attempts": [ + { "value": -1, "reconstruction": null }, + { "value": 1988, "reconstruction": null }, + { "value": 1700, "reconstruction": null }, + { "value": 1609, "reconstruction": null }, + { "value": 1736, "reconstruction": null } + ], + "best": 1609, + "average": 1808 + }, + { + "personId": 57, + "ranking": 48, + "attempts": [ + { "value": 2094, "reconstruction": null }, + { "value": 2193, "reconstruction": null }, + { "value": 1729, "reconstruction": null }, + { "value": 2226, "reconstruction": null }, + { "value": 2215, "reconstruction": null } + ], + "best": 1729, + "average": 2167 + }, + { + "personId": 45, + "ranking": 49, + "attempts": [ + { "value": 2159, "reconstruction": null }, + { "value": 2520, "reconstruction": null }, + { "value": 2105, "reconstruction": null }, + { "value": 1943, "reconstruction": null }, + { "value": 1994, "reconstruction": null } + ], + "best": 1943, + "average": 2086 + }, + { + "personId": 14, + "ranking": 50, + "attempts": [ + { "value": 2213, "reconstruction": null }, + { "value": 2470, "reconstruction": null }, + { "value": 2564, "reconstruction": null }, + { "value": 1747, "reconstruction": null }, + { "value": 2799, "reconstruction": null } + ], + "best": 1747, + "average": 2416 + }, + { + "personId": 8, + "ranking": 51, + "attempts": [ + { "value": 1838, "reconstruction": null }, + { "value": 3496, "reconstruction": null }, + { "value": 3722, "reconstruction": null }, + { "value": 3489, "reconstruction": null }, + { "value": 1896, "reconstruction": null } + ], + "best": 1838, + "average": 2960 + }, + { + "personId": 33, + "ranking": 52, + "attempts": [ + { "value": 2445, "reconstruction": null }, + { "value": 2052, "reconstruction": null }, + { "value": 2207, "reconstruction": null }, + { "value": 2209, "reconstruction": null }, + { "value": 2126, "reconstruction": null } + ], + "best": 2052, + "average": 2181 + }, + { + "personId": 37, + "ranking": 53, + "attempts": [ + { "value": 2354, "reconstruction": null }, + { "value": -1, "reconstruction": null }, + { "value": 2157, "reconstruction": null }, + { "value": 2297, "reconstruction": null }, + { "value": 2121, "reconstruction": null } + ], + "best": 2121, + "average": 2269 + }, + { + "personId": 1, + "ranking": 54, + "attempts": [ + { "value": 2987, "reconstruction": null }, + { "value": 1929, "reconstruction": null }, + { "value": 2251, "reconstruction": null }, + { "value": 2600, "reconstruction": null }, + { "value": 2569, "reconstruction": null } + ], + "best": 1929, + "average": 2473 + }, + { + "personId": 61, + "ranking": 55, + "attempts": [ + { "value": 2324, "reconstruction": null }, + { "value": 2899, "reconstruction": null }, + { "value": 1758, "reconstruction": null }, + { "value": 2659, "reconstruction": null }, + { "value": 2721, "reconstruction": null } + ], + "best": 1758, + "average": 2568 + }, + { + "personId": 11, + "ranking": 56, + "attempts": [ + { "value": 3056, "reconstruction": null }, + { "value": 3422, "reconstruction": null }, + { "value": 3105, "reconstruction": null }, + { "value": 3126, "reconstruction": null }, + { "value": 3030, "reconstruction": null } + ], + "best": 3030, + "average": 3096 + }, + { + "personId": 44, + "ranking": 57, + "attempts": [ + { "value": 3953, "reconstruction": null }, + { "value": 5370, "reconstruction": null }, + { "value": 5864, "reconstruction": null }, + { "value": 3837, "reconstruction": null }, + { "value": 3532, "reconstruction": null } + ], + "best": 3532, + "average": 4387 + } + ], + "extensions": [], + "linkedRounds": ["333-r1", "333-r2"], + "participationRuleset": { + "participationSource": { "type": "registrations" }, + "reservedPlaces": null + } + }, + { + "id": "333-r2", + "format": "a", + "timeLimit": { "centiseconds": 30000, "cumulativeRoundIds": [] }, + "cutoff": null, + "scrambleSetCount": 6, + "results": [ + { + "personId": 16, + "ranking": 1, + "attempts": [ + { "value": 619, "reconstruction": null }, + { "value": 694, "reconstruction": null }, + { "value": 1033, "reconstruction": null }, + { "value": 613, "reconstruction": null }, + { "value": 823, "reconstruction": null } + ], + "best": 613, + "average": 712 + }, + { + "personId": 24, + "ranking": 2, + "attempts": [ + { "value": -1, "reconstruction": null }, + { "value": 706, "reconstruction": null }, + { "value": 620, "reconstruction": null }, + { "value": 914, "reconstruction": null }, + { "value": 645, "reconstruction": null } + ], + "best": 620, + "average": 755 + }, + { + "personId": 18, + "ranking": 3, + "attempts": [ + { "value": 941, "reconstruction": null }, + { "value": 726, "reconstruction": null }, + { "value": 625, "reconstruction": null }, + { "value": 900, "reconstruction": null }, + { "value": 665, "reconstruction": null } + ], + "best": 625, + "average": 764 + }, + { + "personId": 13, + "ranking": 4, + "attempts": [ + { "value": 908, "reconstruction": null }, + { "value": 842, "reconstruction": null }, + { "value": 879, "reconstruction": null }, + { "value": 1065, "reconstruction": null }, + { "value": 720, "reconstruction": null } + ], + "best": 720, + "average": 876 + }, + { + "personId": 30, + "ranking": 5, + "attempts": [ + { "value": 1176, "reconstruction": null }, + { "value": 660, "reconstruction": null }, + { "value": 880, "reconstruction": null }, + { "value": 815, "reconstruction": null }, + { "value": 780, "reconstruction": null } + ], + "best": 660, + "average": 825 + }, + { + "personId": 31, + "ranking": 6, + "attempts": [ + { "value": 1024, "reconstruction": null }, + { "value": 823, "reconstruction": null }, + { "value": 936, "reconstruction": null }, + { "value": 881, "reconstruction": null }, + { "value": 875, "reconstruction": null } + ], + "best": 823, + "average": 897 + }, + { + "personId": 6, + "ranking": 7, + "attempts": [ + { "value": 840, "reconstruction": null }, + { "value": 709, "reconstruction": null }, + { "value": 951, "reconstruction": null }, + { "value": 1743, "reconstruction": null }, + { "value": 672, "reconstruction": null } + ], + "best": 672, + "average": 833 + }, + { + "personId": 41, + "ranking": 8, + "attempts": [ + { "value": 836, "reconstruction": null }, + { "value": 800, "reconstruction": null }, + { "value": 1221, "reconstruction": null }, + { "value": 826, "reconstruction": null }, + { "value": 905, "reconstruction": null } + ], + "best": 800, + "average": 856 + }, + { + "personId": 49, + "ranking": 9, + "attempts": [ + { "value": 803, "reconstruction": null }, + { "value": 1114, "reconstruction": null }, + { "value": 946, "reconstruction": null }, + { "value": 961, "reconstruction": null }, + { "value": 942, "reconstruction": null } + ], + "best": 803, + "average": 950 + }, + { + "personId": 10, + "ranking": 10, + "attempts": [ + { "value": 1181, "reconstruction": null }, + { "value": 984, "reconstruction": null }, + { "value": 1008, "reconstruction": null }, + { "value": 890, "reconstruction": null }, + { "value": 1321, "reconstruction": null } + ], + "best": 890, + "average": 1058 + }, + { + "personId": 58, + "ranking": 11, + "attempts": [ + { "value": 989, "reconstruction": null }, + { "value": 1264, "reconstruction": null }, + { "value": 914, "reconstruction": null }, + { "value": 866, "reconstruction": null }, + { "value": 835, "reconstruction": null } + ], + "best": 835, + "average": 923 + }, + { + "personId": 25, + "ranking": 12, + "attempts": [ + { "value": 806, "reconstruction": null }, + { "value": 941, "reconstruction": null }, + { "value": 1080, "reconstruction": null }, + { "value": 909, "reconstruction": null }, + { "value": 927, "reconstruction": null } + ], + "best": 806, + "average": 926 + }, + { + "personId": 15, + "ranking": 13, + "attempts": [ + { "value": 1062, "reconstruction": null }, + { "value": 1128, "reconstruction": null }, + { "value": 853, "reconstruction": null }, + { "value": 947, "reconstruction": null }, + { "value": 812, "reconstruction": null } + ], + "best": 812, + "average": 954 + }, + { + "personId": 28, + "ranking": 14, + "attempts": [ + { "value": 1051, "reconstruction": null }, + { "value": 847, "reconstruction": null }, + { "value": 1061, "reconstruction": null }, + { "value": 982, "reconstruction": null }, + { "value": 836, "reconstruction": null } + ], + "best": 836, + "average": 960 + }, + { + "personId": 22, + "ranking": 15, + "attempts": [ + { "value": 958, "reconstruction": null }, + { "value": 1035, "reconstruction": null }, + { "value": 880, "reconstruction": null }, + { "value": 1069, "reconstruction": null }, + { "value": 1021, "reconstruction": null } + ], + "best": 880, + "average": 1005 + }, + { + "personId": 34, + "ranking": 16, + "attempts": [ + { "value": 1079, "reconstruction": null }, + { "value": 912, "reconstruction": null }, + { "value": 1019, "reconstruction": null }, + { "value": 1085, "reconstruction": null }, + { "value": 1233, "reconstruction": null } + ], + "best": 912, + "average": 1061 + }, + { + "personId": 32, + "ranking": 17, + "attempts": [ + { "value": 1036, "reconstruction": null }, + { "value": 1325, "reconstruction": null }, + { "value": 1096, "reconstruction": null }, + { "value": -1, "reconstruction": null }, + { "value": 971, "reconstruction": null } + ], + "best": 971, + "average": 1152 + }, + { + "personId": 12, + "ranking": 18, + "attempts": [ + { "value": 1198, "reconstruction": null }, + { "value": 966, "reconstruction": null }, + { "value": 1021, "reconstruction": null }, + { "value": 929, "reconstruction": null }, + { "value": 1260, "reconstruction": null } + ], + "best": 929, + "average": 1062 + }, + { + "personId": 9, + "ranking": 19, + "attempts": [ + { "value": 1337, "reconstruction": null }, + { "value": 960, "reconstruction": null }, + { "value": 984, "reconstruction": null }, + { "value": 853, "reconstruction": null }, + { "value": 1199, "reconstruction": null } + ], + "best": 853, + "average": 1048 + }, + { + "personId": 50, + "ranking": 20, + "attempts": [ + { "value": 774, "reconstruction": null }, + { "value": 874, "reconstruction": null }, + { "value": 1454, "reconstruction": null }, + { "value": -1, "reconstruction": null }, + { "value": 970, "reconstruction": null } + ], + "best": 774, + "average": 1099 + }, + { + "personId": 5, + "ranking": 21, + "attempts": [ + { "value": 1129, "reconstruction": null }, + { "value": 999, "reconstruction": null }, + { "value": 1499, "reconstruction": null }, + { "value": 983, "reconstruction": null }, + { "value": 1056, "reconstruction": null } + ], + "best": 983, + "average": 1061 + }, + { + "personId": 23, + "ranking": 22, + "attempts": [ + { "value": 942, "reconstruction": null }, + { "value": 1069, "reconstruction": null }, + { "value": 1217, "reconstruction": null }, + { "value": 883, "reconstruction": null }, + { "value": 1427, "reconstruction": null } + ], + "best": 883, + "average": 1076 + }, + { + "personId": 19, + "ranking": 23, + "attempts": [ + { "value": 1185, "reconstruction": null }, + { "value": 1235, "reconstruction": null }, + { "value": 1064, "reconstruction": null }, + { "value": 1004, "reconstruction": null }, + { "value": 984, "reconstruction": null } + ], + "best": 984, + "average": 1084 + }, + { + "personId": 21, + "ranking": 24, + "attempts": [ + { "value": 989, "reconstruction": null }, + { "value": 1224, "reconstruction": null }, + { "value": 1044, "reconstruction": null }, + { "value": 978, "reconstruction": null }, + { "value": 1334, "reconstruction": null } + ], + "best": 978, + "average": 1086 + }, + { + "personId": 20, + "ranking": 25, + "attempts": [ + { "value": 1107, "reconstruction": null }, + { "value": 1267, "reconstruction": null }, + { "value": 1191, "reconstruction": null }, + { "value": 930, "reconstruction": null }, + { "value": 1030, "reconstruction": null } + ], + "best": 930, + "average": 1109 + }, + { + "personId": 35, + "ranking": 26, + "attempts": [ + { "value": 1191, "reconstruction": null }, + { "value": 1193, "reconstruction": null }, + { "value": 1540, "reconstruction": null }, + { "value": 1356, "reconstruction": null }, + { "value": 1311, "reconstruction": null } + ], + "best": 1191, + "average": 1287 + }, + { + "personId": 48, + "ranking": 27, + "attempts": [ + { "value": 1089, "reconstruction": null }, + { "value": 1228, "reconstruction": null }, + { "value": 2018, "reconstruction": null }, + { "value": 1154, "reconstruction": null }, + { "value": 1127, "reconstruction": null } + ], + "best": 1089, + "average": 1170 + }, + { + "personId": 38, + "ranking": 28, + "attempts": [ + { "value": 1044, "reconstruction": null }, + { "value": 1340, "reconstruction": null }, + { "value": 2246, "reconstruction": null }, + { "value": 1015, "reconstruction": null }, + { "value": 1191, "reconstruction": null } + ], + "best": 1015, + "average": 1192 + }, + { + "personId": 17, + "ranking": 29, + "attempts": [ + { "value": 1202, "reconstruction": null }, + { "value": 1212, "reconstruction": null }, + { "value": 1127, "reconstruction": null }, + { "value": 1175, "reconstruction": null }, + { "value": 1207, "reconstruction": null } + ], + "best": 1127, + "average": 1195 + }, + { + "personId": 43, + "ranking": 30, + "attempts": [ + { "value": 2154, "reconstruction": null }, + { "value": 1017, "reconstruction": null }, + { "value": 1239, "reconstruction": null }, + { "value": 1079, "reconstruction": null }, + { "value": 1351, "reconstruction": null } + ], + "best": 1017, + "average": 1223 + }, + { + "personId": 2, + "ranking": 31, + "attempts": [ + { "value": 1151, "reconstruction": null }, + { "value": 1140, "reconstruction": null }, + { "value": 1443, "reconstruction": null }, + { "value": 1296, "reconstruction": null }, + { "value": 1694, "reconstruction": null } + ], + "best": 1140, + "average": 1297 + }, + { + "personId": 4, + "ranking": 32, + "attempts": [ + { "value": 1280, "reconstruction": null }, + { "value": 1183, "reconstruction": null }, + { "value": 1283, "reconstruction": null }, + { "value": 1332, "reconstruction": null }, + { "value": 1048, "reconstruction": null } + ], + "best": 1048, + "average": 1249 + }, + { + "personId": 7, + "ranking": 33, + "attempts": [ + { "value": 1287, "reconstruction": null }, + { "value": 1310, "reconstruction": null }, + { "value": 1477, "reconstruction": null }, + { "value": 1281, "reconstruction": null }, + { "value": 1446, "reconstruction": null } + ], + "best": 1281, + "average": 1348 + }, + { + "personId": 36, + "ranking": 34, + "attempts": [ + { "value": 1930, "reconstruction": null }, + { "value": 1363, "reconstruction": null }, + { "value": 1367, "reconstruction": null }, + { "value": 1204, "reconstruction": null }, + { "value": 1338, "reconstruction": null } + ], + "best": 1204, + "average": 1356 + }, + { + "personId": 60, + "ranking": 35, + "attempts": [ + { "value": 1275, "reconstruction": null }, + { "value": 1747, "reconstruction": null }, + { "value": 967, "reconstruction": null }, + { "value": 1085, "reconstruction": null }, + { "value": 2333, "reconstruction": null } + ], + "best": 967, + "average": 1369 + }, + { + "personId": 3, + "ranking": 36, + "attempts": [ + { "value": 1753, "reconstruction": null }, + { "value": 1194, "reconstruction": null }, + { "value": 1446, "reconstruction": null }, + { "value": 1321, "reconstruction": null }, + { "value": 1556, "reconstruction": null } + ], + "best": 1194, + "average": 1441 + }, + { + "personId": 27, + "ranking": 37, + "attempts": [ + { "value": 1180, "reconstruction": null }, + { "value": 1468, "reconstruction": null }, + { "value": 1260, "reconstruction": null }, + { "value": -1, "reconstruction": null }, + { "value": 1966, "reconstruction": null } + ], + "best": 1180, + "average": 1565 + }, + { + "personId": 51, + "ranking": 38, + "attempts": [ + { "value": 1356, "reconstruction": null }, + { "value": 1630, "reconstruction": null }, + { "value": 1537, "reconstruction": null }, + { "value": 1411, "reconstruction": null }, + { "value": 1267, "reconstruction": null } + ], + "best": 1267, + "average": 1435 + }, + { + "personId": 59, + "ranking": 39, + "attempts": [ + { "value": 1504, "reconstruction": null }, + { "value": 1579, "reconstruction": null }, + { "value": 1734, "reconstruction": null }, + { "value": 1502, "reconstruction": null }, + { "value": 1247, "reconstruction": null } + ], + "best": 1247, + "average": 1528 + }, + { + "personId": 46, + "ranking": 40, + "attempts": [ + { "value": 1383, "reconstruction": null }, + { "value": 1530, "reconstruction": null }, + { "value": 1733, "reconstruction": null }, + { "value": 1714, "reconstruction": null }, + { "value": 1440, "reconstruction": null } + ], + "best": 1383, + "average": 1561 + }, + { + "personId": 63, + "ranking": 41, + "attempts": [ + { "value": 1566, "reconstruction": null }, + { "value": 1356, "reconstruction": null }, + { "value": 1617, "reconstruction": null }, + { "value": -1, "reconstruction": null }, + { "value": 1277, "reconstruction": null } + ], + "best": 1277, + "average": 1513 + }, + { + "personId": 47, + "ranking": 42, + "attempts": [ + { "value": 1892, "reconstruction": null }, + { "value": 1660, "reconstruction": null }, + { "value": 1217, "reconstruction": null }, + { "value": 1222, "reconstruction": null }, + { "value": 1591, "reconstruction": null } + ], + "best": 1217, + "average": 1491 + }, + { + "personId": 40, + "ranking": 43, + "attempts": [ + { "value": 1571, "reconstruction": null }, + { "value": 1661, "reconstruction": null }, + { "value": 1696, "reconstruction": null }, + { "value": 1496, "reconstruction": null }, + { "value": 1753, "reconstruction": null } + ], + "best": 1496, + "average": 1643 + }, + { + "personId": 26, + "ranking": 44, + "attempts": [ + { "value": 1689, "reconstruction": null }, + { "value": 2035, "reconstruction": null }, + { "value": 1885, "reconstruction": null }, + { "value": 1877, "reconstruction": null }, + { "value": 2045, "reconstruction": null } + ], + "best": 1689, + "average": 1932 + }, + { + "personId": 52, + "ranking": 45, + "attempts": [ + { "value": 1896, "reconstruction": null }, + { "value": 1342, "reconstruction": null }, + { "value": 1876, "reconstruction": null }, + { "value": 1529, "reconstruction": null }, + { "value": 1857, "reconstruction": null } + ], + "best": 1342, + "average": 1754 + }, + { + "personId": 42, + "ranking": 46, + "attempts": [ + { "value": 1648, "reconstruction": null }, + { "value": -1, "reconstruction": null }, + { "value": 1850, "reconstruction": null }, + { "value": 1858, "reconstruction": null }, + { "value": 1712, "reconstruction": null } + ], + "best": 1648, + "average": 1807 + }, + { + "personId": 55, + "ranking": 47, + "attempts": [ + { "value": 1631, "reconstruction": null }, + { "value": 2016, "reconstruction": null }, + { "value": 1762, "reconstruction": null }, + { "value": 1867, "reconstruction": null }, + { "value": 1804, "reconstruction": null } + ], + "best": 1631, + "average": 1811 + }, + { + "personId": 57, + "ranking": 48, + "attempts": [ + { "value": 2240, "reconstruction": null }, + { "value": 1941, "reconstruction": null }, + { "value": 1986, "reconstruction": null }, + { "value": 1763, "reconstruction": null }, + { "value": 1621, "reconstruction": null } + ], + "best": 1621, + "average": 1897 + }, + { + "personId": 45, + "ranking": 49, + "attempts": [ + { "value": 1858, "reconstruction": null }, + { "value": 1986, "reconstruction": null }, + { "value": 1790, "reconstruction": null }, + { "value": 2005, "reconstruction": null }, + { "value": 1894, "reconstruction": null } + ], + "best": 1790, + "average": 1913 + }, + { + "personId": 14, + "ranking": 50, + "attempts": [ + { "value": 2066, "reconstruction": null }, + { "value": 2064, "reconstruction": null }, + { "value": 2439, "reconstruction": null }, + { "value": 1944, "reconstruction": null }, + { "value": 1764, "reconstruction": null } + ], + "best": 1764, + "average": 2025 + }, + { + "personId": 8, + "ranking": 51, + "attempts": [ + { "value": 1960, "reconstruction": null }, + { "value": 2279, "reconstruction": null }, + { "value": 2025, "reconstruction": null }, + { "value": 1815, "reconstruction": null }, + { "value": 2102, "reconstruction": null } + ], + "best": 1815, + "average": 2029 + }, + { + "personId": 33, + "ranking": 52, + "attempts": [ + { "value": 2016, "reconstruction": null }, + { "value": 2482, "reconstruction": null }, + { "value": 2189, "reconstruction": null }, + { "value": 2468, "reconstruction": null }, + { "value": 2664, "reconstruction": null } + ], + "best": 2016, + "average": 2380 + }, + { + "personId": 37, + "ranking": 53, + "attempts": [ + { "value": 2125, "reconstruction": null }, + { "value": 2630, "reconstruction": null }, + { "value": 2247, "reconstruction": null }, + { "value": 2251, "reconstruction": null }, + { "value": 2565, "reconstruction": null } + ], + "best": 2125, + "average": 2354 + }, + { + "personId": 1, + "ranking": 54, + "attempts": [ + { "value": 2193, "reconstruction": null }, + { "value": 2539, "reconstruction": null }, + { "value": 3005, "reconstruction": null }, + { "value": 2737, "reconstruction": null }, + { "value": 2078, "reconstruction": null } + ], + "best": 2078, + "average": 2490 + }, + { + "personId": 61, + "ranking": 55, + "attempts": [ + { "value": 3013, "reconstruction": null }, + { "value": 2556, "reconstruction": null }, + { "value": 3455, "reconstruction": null }, + { "value": 1966, "reconstruction": null }, + { "value": 3119, "reconstruction": null } + ], + "best": 1966, + "average": 2896 + }, + { + "personId": 11, + "ranking": 56, + "attempts": [ + { "value": 4446, "reconstruction": null }, + { "value": 3698, "reconstruction": null }, + { "value": 3806, "reconstruction": null }, + { "value": 2896, "reconstruction": null }, + { "value": 3110, "reconstruction": null } + ], + "best": 2896, + "average": 3538 + }, + { + "personId": 44, + "ranking": 57, + "attempts": [ + { "value": 3987, "reconstruction": null }, + { "value": 3573, "reconstruction": null }, + { "value": 4880, "reconstruction": null }, + { "value": 4783, "reconstruction": null }, + { "value": 3625, "reconstruction": null } + ], + "best": 3573, + "average": 4132 + } + ], + "extensions": [], + "linkedRounds": ["333-r1", "333-r2"], + "participationRuleset": { + "participationSource": { "type": "registrations" }, + "reservedPlaces": null + } + }, + { + "id": "333-r3", + "format": "a", + "timeLimit": { "centiseconds": 30000, "cumulativeRoundIds": [] }, + "cutoff": null, + "scrambleSetCount": 1, + "results": [ + { + "personId": 16, + "ranking": 1, + "attempts": [ + { "value": 951, "reconstruction": null }, + { "value": 740, "reconstruction": null }, + { "value": 721, "reconstruction": null }, + { "value": 687, "reconstruction": null }, + { "value": 658, "reconstruction": null } + ], + "best": 658, + "average": 716 + }, + { + "personId": 18, + "ranking": 2, + "attempts": [ + { "value": 739, "reconstruction": null }, + { "value": 1030, "reconstruction": null }, + { "value": 750, "reconstruction": null }, + { "value": 768, "reconstruction": null }, + { "value": 647, "reconstruction": null } + ], + "best": 647, + "average": 752 + }, + { + "personId": 24, + "ranking": 3, + "attempts": [ + { "value": -1, "reconstruction": null }, + { "value": 947, "reconstruction": null }, + { "value": 730, "reconstruction": null }, + { "value": 611, "reconstruction": null }, + { "value": 699, "reconstruction": null } + ], + "best": 611, + "average": 792 + }, + { + "personId": 30, + "ranking": 4, + "attempts": [ + { "value": 848, "reconstruction": null }, + { "value": 769, "reconstruction": null }, + { "value": 913, "reconstruction": null }, + { "value": 772, "reconstruction": null }, + { "value": 700, "reconstruction": null } + ], + "best": 700, + "average": 796 + }, + { + "personId": 13, + "ranking": 5, + "attempts": [ + { "value": 769, "reconstruction": null }, + { "value": 847, "reconstruction": null }, + { "value": 860, "reconstruction": null }, + { "value": 724, "reconstruction": null }, + { "value": 868, "reconstruction": null } + ], + "best": 724, + "average": 825 + }, + { + "personId": 49, + "ranking": 6, + "attempts": [ + { "value": 1076, "reconstruction": null }, + { "value": 716, "reconstruction": null }, + { "value": 813, "reconstruction": null }, + { "value": 1039, "reconstruction": null }, + { "value": 856, "reconstruction": null } + ], + "best": 716, + "average": 903 + }, + { + "personId": 6, + "ranking": 7, + "attempts": [ + { "value": 782, "reconstruction": null }, + { "value": 843, "reconstruction": null }, + { "value": 974, "reconstruction": null }, + { "value": 975, "reconstruction": null }, + { "value": 939, "reconstruction": null } + ], + "best": 782, + "average": 919 + }, + { + "personId": 41, + "ranking": 8, + "attempts": [ + { "value": 1299, "reconstruction": null }, + { "value": 1011, "reconstruction": null }, + { "value": 973, "reconstruction": null }, + { "value": 777, "reconstruction": null }, + { "value": 807, "reconstruction": null } + ], + "best": 777, + "average": 930 + }, + { + "personId": 10, + "ranking": 9, + "attempts": [ + { "value": 885, "reconstruction": null }, + { "value": 939, "reconstruction": null }, + { "value": 951, "reconstruction": null }, + { "value": 1078, "reconstruction": null }, + { "value": 925, "reconstruction": null } + ], + "best": 885, + "average": 938 + }, + { + "personId": 31, + "ranking": 10, + "attempts": [ + { "value": 832, "reconstruction": null }, + { "value": 1063, "reconstruction": null }, + { "value": 1118, "reconstruction": null }, + { "value": 1180, "reconstruction": null }, + { "value": 953, "reconstruction": null } + ], + "best": 832, + "average": 1045 + } + ], + "extensions": [], + "linkedRounds": null, + "participationRuleset": { + "participationSource": { + "type": "linkedRounds", + "roundIds": ["333-r1", "333-r2"], + "resultCondition": { "type": "ranking", "scope": "average", "value": 10 } + }, + "reservedPlaces": null + } + } +] diff --git a/src/containers/CompetitionResults/fixtures/FMCEastMelbourne2026.json b/src/containers/CompetitionResults/fixtures/FMCEastMelbourne2026.json new file mode 100644 index 0000000..7093e56 --- /dev/null +++ b/src/containers/CompetitionResults/fixtures/FMCEastMelbourne2026.json @@ -0,0 +1,513 @@ +[ + { + "id": "333fm-r1", + "format": "m", + "timeLimit": null, + "cutoff": null, + "scrambleSetCount": 1, + "results": [ + { + "personId": 7, + "ranking": 1, + "attempts": [ + { "value": 27, "reconstruction": null }, + { "value": 25, "reconstruction": null }, + { "value": 19, "reconstruction": null } + ], + "best": 19, + "average": 2367 + }, + { + "personId": 3, + "ranking": 2, + "attempts": [ + { "value": 24, "reconstruction": null }, + { "value": 22, "reconstruction": null }, + { "value": 19, "reconstruction": null } + ], + "best": 19, + "average": 2167 + }, + { + "personId": 20, + "ranking": 3, + "attempts": [ + { "value": 27, "reconstruction": null }, + { "value": 25, "reconstruction": null }, + { "value": 21, "reconstruction": null } + ], + "best": 21, + "average": 2433 + }, + { + "personId": 1, + "ranking": 4, + "attempts": [ + { "value": 25, "reconstruction": null }, + { "value": 32, "reconstruction": null }, + { "value": 39, "reconstruction": null } + ], + "best": 25, + "average": 3200 + }, + { + "personId": 19, + "ranking": 5, + "attempts": [ + { "value": 35, "reconstruction": null }, + { "value": -1, "reconstruction": null }, + { "value": 31, "reconstruction": null } + ], + "best": 31, + "average": -1 + }, + { + "personId": 23, + "ranking": 6, + "attempts": [ + { "value": 39, "reconstruction": null }, + { "value": 40, "reconstruction": null }, + { "value": 37, "reconstruction": null } + ], + "best": 37, + "average": 3867 + }, + { + "personId": 18, + "ranking": 7, + "attempts": [ + { "value": 50, "reconstruction": null }, + { "value": 55, "reconstruction": null }, + { "value": 56, "reconstruction": null } + ], + "best": 50, + "average": 5367 + }, + { + "personId": 6, + "ranking": 8, + "attempts": [ + { "value": 42, "reconstruction": null }, + { "value": 38, "reconstruction": null }, + { "value": 47, "reconstruction": null } + ], + "best": 38, + "average": 4233 + }, + { + "personId": 4, + "ranking": 9, + "attempts": [ + { "value": 38, "reconstruction": null }, + { "value": 50, "reconstruction": null }, + { "value": 48, "reconstruction": null } + ], + "best": 38, + "average": 4533 + }, + { + "personId": 16, + "ranking": 10, + "attempts": [ + { "value": 48, "reconstruction": null }, + { "value": 51, "reconstruction": null }, + { "value": 51, "reconstruction": null } + ], + "best": 48, + "average": 5000 + }, + { + "personId": 2, + "ranking": 11, + "attempts": [ + { "value": -1, "reconstruction": null }, + { "value": -1, "reconstruction": null }, + { "value": 30, "reconstruction": null } + ], + "best": 30, + "average": -1 + }, + { + "personId": 15, + "ranking": 12, + "attempts": [ + { "value": -2, "reconstruction": null }, + { "value": -2, "reconstruction": null }, + { "value": 40, "reconstruction": null } + ], + "best": 40, + "average": -1 + }, + { + "personId": 5, + "ranking": 13, + "attempts": [ + { "value": 31, "reconstruction": null }, + { "value": -1, "reconstruction": null }, + { "value": -1, "reconstruction": null } + ], + "best": 31, + "average": -1 + }, + { + "personId": 10, + "ranking": 14, + "attempts": [ + { "value": 35, "reconstruction": null }, + { "value": 40, "reconstruction": null }, + { "value": -1, "reconstruction": null } + ], + "best": 35, + "average": -1 + }, + { + "personId": 9, + "ranking": 15, + "attempts": [ + { "value": -1, "reconstruction": null }, + { "value": 37, "reconstruction": null }, + { "value": -1, "reconstruction": null } + ], + "best": 37, + "average": -1 + }, + { + "personId": 22, + "ranking": 16, + "attempts": [ + { "value": 46, "reconstruction": null }, + { "value": -1, "reconstruction": null }, + { "value": 50, "reconstruction": null } + ], + "best": 46, + "average": -1 + } + ], + "extensions": [], + "linkedRounds": ["333fm-r1", "333fm-r2"], + "participationRuleset": { + "participationSource": { "type": "registrations" }, + "reservedPlaces": null + } + }, + { + "id": "333fm-r2", + "format": "m", + "timeLimit": null, + "cutoff": null, + "scrambleSetCount": 1, + "results": [ + { + "personId": 7, + "ranking": 1, + "attempts": [ + { "value": 20, "reconstruction": null }, + { "value": 20, "reconstruction": null }, + { "value": 22, "reconstruction": null } + ], + "best": 20, + "average": 2067 + }, + { + "personId": 3, + "ranking": 2, + "attempts": [ + { "value": 21, "reconstruction": null }, + { "value": 20, "reconstruction": null }, + { "value": 23, "reconstruction": null } + ], + "best": 20, + "average": 2133 + }, + { + "personId": 20, + "ranking": 3, + "attempts": [ + { "value": 23, "reconstruction": null }, + { "value": 22, "reconstruction": null }, + { "value": 22, "reconstruction": null } + ], + "best": 22, + "average": 2233 + }, + { + "personId": 1, + "ranking": 4, + "attempts": [ + { "value": 34, "reconstruction": null }, + { "value": 32, "reconstruction": null }, + { "value": 29, "reconstruction": null } + ], + "best": 29, + "average": 3167 + }, + { + "personId": 19, + "ranking": 5, + "attempts": [ + { "value": 39, "reconstruction": null }, + { "value": 28, "reconstruction": null }, + { "value": 38, "reconstruction": null } + ], + "best": 28, + "average": 3500 + }, + { + "personId": 23, + "ranking": 6, + "attempts": [ + { "value": -1, "reconstruction": null }, + { "value": 39, "reconstruction": null }, + { "value": 34, "reconstruction": null } + ], + "best": 34, + "average": -1 + }, + { + "personId": 18, + "ranking": 7, + "attempts": [ + { "value": 43, "reconstruction": null }, + { "value": 43, "reconstruction": null }, + { "value": 40, "reconstruction": null } + ], + "best": 40, + "average": 4200 + }, + { + "personId": 6, + "ranking": 8, + "attempts": [ + { "value": -1, "reconstruction": null }, + { "value": 41, "reconstruction": null }, + { "value": 32, "reconstruction": null } + ], + "best": 32, + "average": -1 + }, + { + "personId": 4, + "ranking": 9, + "attempts": [ + { "value": 43, "reconstruction": null }, + { "value": 41, "reconstruction": null }, + { "value": 43, "reconstruction": null } + ], + "best": 41, + "average": 4233 + }, + { + "personId": 2, + "ranking": 11, + "attempts": [ + { "value": -1, "reconstruction": null }, + { "value": 24, "reconstruction": null }, + { "value": 25, "reconstruction": null } + ], + "best": 24, + "average": -1 + }, + { + "personId": 15, + "ranking": 12, + "attempts": [ + { "value": 27, "reconstruction": null }, + { "value": -1, "reconstruction": null }, + { "value": -1, "reconstruction": null } + ], + "best": 27, + "average": -1 + }, + { + "personId": 5, + "ranking": 13, + "attempts": [ + { "value": 31, "reconstruction": null }, + { "value": -1, "reconstruction": null }, + { "value": 34, "reconstruction": null } + ], + "best": 31, + "average": -1 + }, + { + "personId": 10, + "ranking": 14, + "attempts": [ + { "value": 37, "reconstruction": null }, + { "value": -1, "reconstruction": null }, + { "value": -2, "reconstruction": null } + ], + "best": 37, + "average": -1 + }, + { + "personId": 9, + "ranking": 15, + "attempts": [ + { "value": -1, "reconstruction": null }, + { "value": 38, "reconstruction": null }, + { "value": 36, "reconstruction": null } + ], + "best": 36, + "average": -1 + }, + { + "personId": 22, + "ranking": 16, + "attempts": [ + { "value": 42, "reconstruction": null }, + { "value": -1, "reconstruction": null }, + { "value": 49, "reconstruction": null } + ], + "best": 42, + "average": -1 + } + ], + "extensions": [], + "linkedRounds": ["333fm-r1", "333fm-r2"], + "participationRuleset": { + "participationSource": { "type": "registrations" }, + "reservedPlaces": null + } + }, + { + "id": "333fm-r3", + "format": "m", + "timeLimit": null, + "cutoff": null, + "scrambleSetCount": 1, + "results": [ + { + "personId": 3, + "ranking": 1, + "attempts": [ + { "value": 23, "reconstruction": null }, + { "value": 21, "reconstruction": null }, + { "value": 20, "reconstruction": null } + ], + "best": 20, + "average": 2133 + }, + { + "personId": 7, + "ranking": 2, + "attempts": [ + { "value": 25, "reconstruction": null }, + { "value": 19, "reconstruction": null }, + { "value": 22, "reconstruction": null } + ], + "best": 19, + "average": 2200 + }, + { + "personId": 2, + "ranking": 3, + "attempts": [ + { "value": 27, "reconstruction": null }, + { "value": 29, "reconstruction": null }, + { "value": 26, "reconstruction": null } + ], + "best": 26, + "average": 2733 + }, + { + "personId": 1, + "ranking": 4, + "attempts": [ + { "value": 31, "reconstruction": null }, + { "value": 30, "reconstruction": null }, + { "value": 33, "reconstruction": null } + ], + "best": 30, + "average": 3133 + }, + { + "personId": 9, + "ranking": 5, + "attempts": [ + { "value": 41, "reconstruction": null }, + { "value": 37, "reconstruction": null }, + { "value": 40, "reconstruction": null } + ], + "best": 37, + "average": 3933 + }, + { + "personId": 4, + "ranking": 6, + "attempts": [ + { "value": 47, "reconstruction": null }, + { "value": 47, "reconstruction": null }, + { "value": 31, "reconstruction": null } + ], + "best": 31, + "average": 4167 + }, + { + "personId": 6, + "ranking": 7, + "attempts": [ + { "value": 43, "reconstruction": null }, + { "value": 41, "reconstruction": null }, + { "value": 46, "reconstruction": null } + ], + "best": 41, + "average": 4333 + }, + { + "personId": 18, + "ranking": 8, + "attempts": [ + { "value": 49, "reconstruction": null }, + { "value": 43, "reconstruction": null }, + { "value": 45, "reconstruction": null } + ], + "best": 43, + "average": 4567 + }, + { + "personId": 22, + "ranking": 9, + "attempts": [ + { "value": 41, "reconstruction": null }, + { "value": 53, "reconstruction": null }, + { "value": 51, "reconstruction": null } + ], + "best": 41, + "average": 4833 + }, + { + "personId": 15, + "ranking": 10, + "attempts": [ + { "value": -1, "reconstruction": null }, + { "value": -1, "reconstruction": null }, + { "value": 29, "reconstruction": null } + ], + "best": 29, + "average": -1 + }, + { + "personId": 5, + "ranking": 11, + "attempts": [ + { "value": 33, "reconstruction": null }, + { "value": 41, "reconstruction": null }, + { "value": -1, "reconstruction": null } + ], + "best": 33, + "average": -1 + } + ], + "extensions": [], + "linkedRounds": null, + "participationRuleset": { + "participationSource": { + "type": "linkedRounds", + "roundIds": ["333fm-r1", "333fm-r2"], + "resultCondition": { "type": "percent", "scope": "average", "value": 75 } + }, + "reservedPlaces": null + } + } +] diff --git a/src/containers/CompetitionResults/fixtures/README.md b/src/containers/CompetitionResults/fixtures/README.md new file mode 100644 index 0000000..4306821 --- /dev/null +++ b/src/containers/CompetitionResults/fixtures/README.md @@ -0,0 +1,12 @@ +# Dual-round regression fixtures + +Round-only extracts from the public WCIF v2 endpoints, inspected 2026-11-03: + +- https://www.worldcubeassociation.org/api/v0/competitions/FMCEastMelbourne2026/wcif/version/2 +- https://www.worldcubeassociation.org/api/v0/competitions/CubeAgainstCancerHongKong2026/wcif/version/2 + +Only the relevant event rounds are retained (FMC and 3x3, respectively). Person profiles and registration data are excluded. Extensions are cleared. Attempts and component values remain intact, with the official shared dual-round rankings. + +WCA Regulation 9v4 defines ranking by the better result of either round; 9v4a retains both component results; 9v5 prohibits elimination between components. Advancement percentages count distinct competitors across the linked source rounds. Later participants need not match the original qualification estimate because of withdrawals/replacements. + +Published-results endpoint verified separately on 2026-11-03: Hong Kong uses `1`, `2`, `f` for its three 3x3 rounds; FMC East Melbourne returned no published results. WCIF is available for both. diff --git a/src/containers/CompetitionResults/resultsProvider.test.ts b/src/containers/CompetitionResults/resultsProvider.test.ts index 75ce581..c6a467b 100644 --- a/src/containers/CompetitionResults/resultsProvider.test.ts +++ b/src/containers/CompetitionResults/resultsProvider.test.ts @@ -702,4 +702,42 @@ describe('resultsProvider', () => { ]); }); }); + it('keeps published dual-round IDs when a competitor skipped the first component', () => { + // Published Cube Against Cancer Hong Kong 2026 uses 1, 2 and f. + const competition: Competition = { + ...wcif, + events: wcif.events.map((event) => ({ + ...event, + rounds: event.rounds.map((round, index) => ({ + ...round, + results: [], + linkedRounds: index < 2 ? ['333-r1', '333-r2'] : null, + })), + })), + }; + const results = getPersonalResultsFromSources({ + wcif: competition, + person: persons[0], + wcaLiveResults: [], + wcaApiResults: [ + apiResult({ round_type_id: '1', wca_id: persons[1].wcaId, name: persons[1].name }), + apiResult({ + round_type_id: '2', + wca_id: persons[0].wcaId, + name: persons[0].name, + average: 900, + }), + apiResult({ + round_type_id: 'f', + wca_id: persons[0].wcaId, + name: persons[0].name, + average: 850, + }), + ], + }); + expect(results[0].rounds).toMatchObject([ + { roundId: '333-r2', average: 900 }, + { roundId: '333-r3', average: 850 }, + ]); + }); }); diff --git a/src/containers/CompetitionResults/resultsProvider.ts b/src/containers/CompetitionResults/resultsProvider.ts index a16597e..a1baddf 100644 --- a/src/containers/CompetitionResults/resultsProvider.ts +++ b/src/containers/CompetitionResults/resultsProvider.ts @@ -129,6 +129,7 @@ export const getRoundResultsFromSources = ({ const storedRoundResults = getStoredRoundResults( selectedRound.round, getAdvancementConditionForRound(selectedRound.event.rounds, selectedRound.round), + selectedRound.event.rounds, ); const apiRoundResults = getApiRoundResults(wcif, selectedRound.round.id, wcaApiResults); diff --git a/src/containers/CompetitionRound/CompetitionRound.test.tsx b/src/containers/CompetitionRound/CompetitionRound.test.tsx index 2a30cca..dc0cd6e 100644 --- a/src/containers/CompetitionRound/CompetitionRound.test.tsx +++ b/src/containers/CompetitionRound/CompetitionRound.test.tsx @@ -250,4 +250,18 @@ describe('CompetitionRoundContainer', () => { expect(screen.getByText('See Results')).toHaveClass('btn-light'); }); + it('shows explicit dual-final links even without a later advancement rule', () => { + const rounds = linkedRoundsCompetition.events[0].rounds; + const previousLinks = rounds[1].linkedRounds; + const previousRules = rounds[2].participationRuleset; + rounds[1].linkedRounds = ['333-r1', '333-r2']; + rounds[2].participationRuleset = null; + try { + renderRound('333-r2'); + expect(screen.getByText('Dual round with Round 1')).toBeInTheDocument(); + } finally { + rounds[1].linkedRounds = previousLinks; + rounds[2].participationRuleset = previousRules; + } + }); }); diff --git a/src/containers/CompetitionRound/CompetitionRound.tsx b/src/containers/CompetitionRound/CompetitionRound.tsx index eb3c87e..812bc53 100644 --- a/src/containers/CompetitionRound/CompetitionRound.tsx +++ b/src/containers/CompetitionRound/CompetitionRound.tsx @@ -13,7 +13,7 @@ import { getAllEvents } from '@/lib/events'; import { AnchorLink, LinkRenderer } from '@/lib/linkRenderer'; import { activityCodeToRoundName, getEventRoundsForRound, joinLabels } from '@/lib/roundLabels'; import { formatDateTimeRange } from '@/lib/time'; -import { getAdvancementConditionForRound } from '@/lib/wcif'; +import { getLinkedRoundIds } from '@/lib/wcif'; import { useWCIF, useWcifUtils } from '@/providers/WCIFProvider'; export interface CompetitionRoundContainerProps { @@ -43,19 +43,15 @@ export function CompetitionRoundContainer({ () => getEventRoundsForRound(wcif?.events, roundId), [roundId, wcif?.events], ); - const advancement = useMemo( - () => (round ? getAdvancementConditionForRound(eventRounds, round) : null), - [eventRounds, round], + const linkedRoundNames = useMemo( + () => + round + ? getLinkedRoundIds(eventRounds, round) + .filter((sourceRoundId) => sourceRoundId !== roundId) + .map((sourceRoundId) => activityCodeToRoundName(t, sourceRoundId)) + : [], + [eventRounds, round, roundId, t], ); - const linkedRoundNames = useMemo(() => { - if (!advancement || advancement.sourceType !== 'linkedRounds') { - return []; - } - - return advancement.sourceRoundIds - .filter((sourceRoundId) => sourceRoundId !== roundId) - .map((sourceRoundId) => activityCodeToRoundName(t, sourceRoundId)); - }, [advancement, roundId, t]); const rounds = roundActivies.filter((ra) => toRoundAttemptId(ra.activityCode) === roundId); const groups = rounds.flatMap((r) => r.childActivities); diff --git a/src/containers/Competitors/Competitors.test.tsx b/src/containers/Competitors/Competitors.test.tsx new file mode 100644 index 0000000..2d9b92d --- /dev/null +++ b/src/containers/Competitors/Competitors.test.tsx @@ -0,0 +1,98 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { Competition, Person } from '@wca/helpers'; +import { Link, MemoryRouter, Route, Routes, useLocation, useNavigate } from 'react-router-dom'; +import { Competitors } from './Competitors'; + +jest.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) })); +jest.mock('@/hooks/UsePinnedPersons', () => ({ usePinnedPersons: () => ({ pinnedPersons: [] }) })); +jest.mock('@/hooks/useOngoingActivities', () => ({ + useOngoingActivities: () => ({ ongoingActivities: [] }), +})); +jest.mock('@/providers/AuthProvider', () => ({ useAuth: () => ({ user: null }) })); +jest.mock('./CompetitorListItem', () => ({ + CompetitorListItem: ({ person }: { person: Person }) => ( +
  • + {person.name} +
  • + ), +})); + +const wcif = { + persons: [ + { + name: 'Raphaël Sauge', + registrantId: 1, + wcaUserId: 1, + registration: { status: 'accepted', eventIds: ['333'] }, + }, + { + name: 'Tin Huang', + registrantId: 2, + wcaUserId: 2, + registration: { status: 'accepted', eventIds: ['333'] }, + }, + ], +} as Competition; + +function Navigation() { + const navigate = useNavigate(); + const location = useLocation(); + return ( + <> + + + + {location.pathname} + {location.search} + + + ); +} + +function renderCompetition(initialPath = '/competitions/Test2026') { + return render( + + + + Home} /> + } /> + Person schedule} + /> + + , + ); +} + +it('restores the filtered list on Back and supports Forward without keystroke history entries', () => { + renderCompetition(); + fireEvent.change(screen.getByRole('searchbox', { name: 'common.search' }), { + target: { value: 'Raphaël' }, + }); + expect(screen.queryByRole('link', { name: 'Tin Huang' })).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('link', { name: 'Raphaël Sauge' })); + expect(screen.getByText('Person schedule')).toBeInTheDocument(); + fireEvent.click(screen.getByText('Back')); + expect(screen.getByRole('searchbox')).toHaveValue('Raphaël'); + expect(screen.queryByRole('link', { name: 'Tin Huang' })).not.toBeInTheDocument(); + fireEvent.click(screen.getByText('Forward')); + expect(screen.getByText('Person schedule')).toBeInTheDocument(); + fireEvent.click(screen.getByText('Back')); + fireEvent.click(screen.getByText('Back')); + expect(screen.getByText('Home')).toBeInTheDocument(); +}); + +it('reads direct-link search, preserves other params and removes search when cleared', () => { + renderCompetition('/competitions/Test2026?search=Tin&other=kept'); + expect(screen.getByRole('searchbox')).toHaveValue('Tin'); + expect(screen.queryByRole('link', { name: 'Raphaël Sauge' })).not.toBeInTheDocument(); + fireEvent.change(screen.getByRole('searchbox'), { target: { value: '' } }); + expect(screen.getAllByRole('link')).toHaveLength(2); + expect(screen.getByTestId('location')).toHaveTextContent('/competitions/Test2026?other=kept'); +}); + +it('renders no unpinned competitors for a search with no match', () => { + renderCompetition('/competitions/Test2026?search=unknown'); + expect(screen.queryAllByRole('link')).toHaveLength(0); +}); diff --git a/src/containers/Competitors/Competitors.tsx b/src/containers/Competitors/Competitors.tsx index dc750cc..1dac564 100644 --- a/src/containers/Competitors/Competitors.tsx +++ b/src/containers/Competitors/Competitors.tsx @@ -1,7 +1,7 @@ import { Competition, Person } from '@wca/helpers'; -import { useMemo, useState } from 'react'; +import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import { useParams } from 'react-router-dom'; +import { useParams, useSearchParams } from 'react-router-dom'; import { usePinnedPersons } from '@/hooks/UsePinnedPersons'; import { useOngoingActivities } from '@/hooks/useOngoingActivities'; import { acceptedRegistration } from '@/lib/person'; @@ -11,7 +11,19 @@ import { CompetitorListItem } from './CompetitorListItem'; export const Competitors = ({ wcif }: { wcif: Competition }) => { const { t } = useTranslation(); - const [input, setInput] = useState(''); + const [searchParams, setSearchParams] = useSearchParams(); + const input = searchParams.get('search') || ''; + const setInput = (value: string) => { + setSearchParams( + (previous) => { + const next = new URLSearchParams(previous); + if (value) next.set('search', value); + else next.delete('search'); + return next; + }, + { replace: true }, + ); + }; const { competitionId } = useParams() as { competitionId: string }; const { user } = useAuth(); @@ -62,7 +74,7 @@ export const Competitors = ({ wcif }: { wcif: Competition }) => {
    diff --git a/src/hooks/useOngoingActivities/useOngoingActivities.test.ts b/src/hooks/useOngoingActivities/useOngoingActivities.test.ts new file mode 100644 index 0000000..fbaa275 --- /dev/null +++ b/src/hooks/useOngoingActivities/useOngoingActivities.test.ts @@ -0,0 +1,108 @@ +import { renderHook } from '@testing-library/react'; +import { Competition } from '@wca/helpers'; +import { getAllActivities } from '@/lib/activities'; +import { useWCIF } from '@/providers/WCIFProvider'; +import { ActivitiesSubscriptionDocument, useActivitiesQuery } from '@/queries'; +import { useOngoingActivities } from './useOngoingActivities'; + +jest.mock('@/lib/activities', () => ({ getAllActivities: jest.fn() })); +jest.mock('@/providers/WCIFProvider', () => ({ useWCIF: jest.fn() })); +jest.mock('@/queries', () => ({ + ActivitiesSubscriptionDocument: 'ActivitiesSubscriptionDocument', + useActivitiesQuery: jest.fn(), +})); + +const unsubscribe = jest.fn(); +const subscribeToMore = jest.fn((_options: unknown) => unsubscribe); +const setWCIF = (id?: string) => { + jest.mocked(useWCIF).mockReturnValue({ + competitionId: id || '', + wcif: id ? ({ id } as Competition) : undefined, + setTitle: jest.fn(), + }); +}; + +describe('useOngoingActivities', () => { + beforeEach(() => { + jest.clearAllMocks(); + setWCIF(); + jest.mocked(getAllActivities).mockReturnValue([]); + jest.mocked(useActivitiesQuery).mockReturnValue({ + data: { activities: [] }, + subscribeToMore, + } as unknown as ReturnType); + }); + + it('does not subscribe with an undefined competition while WCIF is loading or missing', () => { + renderHook(() => useOngoingActivities('MissingCompetition2026')); + + expect(useActivitiesQuery).toHaveBeenCalledWith(''); + expect(subscribeToMore).not.toHaveBeenCalled(); + }); + + it('subscribes when the requested competition loads and cleans up on unmount', () => { + const { rerender, unmount } = renderHook(() => useOngoingActivities('Example2026')); + + setWCIF('Example2026'); + rerender(); + + expect(useActivitiesQuery).toHaveBeenLastCalledWith('Example2026'); + expect(subscribeToMore).toHaveBeenCalledWith( + expect.objectContaining({ + document: ActivitiesSubscriptionDocument, + variables: { competitionIds: ['Example2026'] }, + }), + ); + unmount(); + expect(unsubscribe).toHaveBeenCalledTimes(1); + }); + + it('does not recreate the subscription when the same WCIF is refreshed', () => { + setWCIF('Example2026'); + const { rerender } = renderHook(() => useOngoingActivities('Example2026')); + + setWCIF('Example2026'); + rerender(); + + expect(subscribeToMore).toHaveBeenCalledTimes(1); + expect(unsubscribe).not.toHaveBeenCalled(); + }); + + it('stops the old subscription while a different competition loads', () => { + setWCIF('First2026'); + const { rerender } = renderHook(({ id }) => useOngoingActivities(id), { + initialProps: { id: 'First2026' }, + }); + + rerender({ id: 'Second2026' }); + + expect(unsubscribe).toHaveBeenCalledTimes(1); + expect(useActivitiesQuery).toHaveBeenLastCalledWith(''); + expect(subscribeToMore).toHaveBeenCalledTimes(1); + + setWCIF('Second2026'); + rerender({ id: 'Second2026' }); + + expect(subscribeToMore).toHaveBeenLastCalledWith( + expect.objectContaining({ variables: { competitionIds: ['Second2026'] } }), + ); + }); + + it('replaces changed live activities and preserves unrelated activities', () => { + setWCIF('Example2026'); + renderHook(() => useOngoingActivities('Example2026')); + const options = subscribeToMore.mock.calls[0][0] as unknown as { + updateQuery: (previous: unknown, update: unknown) => unknown; + }; + const otherActivity = { activityId: 2, startTime: '09:00', endTime: '' }; + const previous = { + activities: [{ activityId: 1, startTime: '08:00', endTime: '' }, otherActivity], + }; + const activity = { activityId: 1, startTime: '08:00', endTime: '09:00' }; + + expect(options.updateQuery(previous, { subscriptionData: { data: { activity } } })).toEqual({ + activities: [otherActivity, activity], + }); + expect(options.updateQuery(previous, { subscriptionData: {} })).toBe(previous); + }); +}); diff --git a/src/hooks/useOngoingActivities/useOngoingActivities.ts b/src/hooks/useOngoingActivities/useOngoingActivities.ts index dcb47e4..3d3fac0 100644 --- a/src/hooks/useOngoingActivities/useOngoingActivities.ts +++ b/src/hooks/useOngoingActivities/useOngoingActivities.ts @@ -5,22 +5,26 @@ import { ActivitiesSubscriptionDocument, NotifyCompActivity, useActivitiesQuery export const useOngoingActivities = (competitionId: string) => { const { wcif } = useWCIF(); - const activities = wcif ? getAllActivities(wcif) : []; + const loadedCompetitionId = wcif?.id === competitionId ? competitionId : undefined; + const activities = wcif && loadedCompetitionId ? getAllActivities(wcif) : []; - const { data, subscribeToMore } = useActivitiesQuery(competitionId!); - // const { data: data2 } = useActivitiesSubscription(competitionId!); - const liveActivities = data?.activities.filter((a) => !a.endTime && a.startTime); // TODO: use subscription + const { data, subscribeToMore } = useActivitiesQuery(loadedCompetitionId || ''); + const liveActivities = data?.activities.filter((a) => !a.endTime && a.startTime); // these are the activities that are currently ongoing const ongoingActivities = activities.filter((a) => liveActivities?.some((b) => b.activityId === a.id), ); useEffect(() => { + if (!loadedCompetitionId) { + return; + } + const unsub = subscribeToMore<{ activity: NotifyCompActivity; }>({ document: ActivitiesSubscriptionDocument, - variables: { competitionIds: [wcif?.id] }, + variables: { competitionIds: [loadedCompetitionId] }, updateQuery: (prev, { subscriptionData }) => { if (!subscriptionData?.data?.activity) { return prev; @@ -39,7 +43,7 @@ export const useOngoingActivities = (competitionId: string) => { }); return () => unsub(); - }, [subscribeToMore, wcif]); + }, [loadedCompetitionId, subscribeToMore]); return { data, diff --git a/src/i18n/en/translation.yaml b/src/i18n/en/translation.yaml index aae1be7..5dda002 100644 --- a/src/i18n/en/translation.yaml +++ b/src/i18n/en/translation.yaml @@ -1,5 +1,9 @@ common: appName: 'Competition Groups' + help: 'Help' + retry: 'Try again' + home: 'Back to home' + bookmarkCompetition: 'Bookmark competition' wca: event: 'Event' round: 'Round' @@ -175,6 +179,9 @@ home: upcomingCompetitions: 'Upcoming Competitions' yourCompetitions: 'Your Competitions' competition: + loadError: 'Unable to load this competition.' + loadErrorHint: 'The competition may not exist, or its data may be temporarily unavailable. Check the address or try again.' + refreshError: 'Unable to refresh competition data. Showing the last available data.' competitors: viewMyAssignments: 'View My Assignments' viewCompetitionInformation: 'View Competition Information' @@ -210,6 +217,8 @@ competition: unknownCompetitor: 'Competitor #{{personId}}' allResults: All results viewLiveResults: View live results + dualPersonalNote: 'Dual-round ranks use the better result from either linked round. Each row shows that round’s own results.' + dualRoundNote: 'Dual-round standings: rank is based on the better result from either linked round. Attempts, best and average below belong to the selected round. No competitors are eliminated between the linked rounds.' resultsSourceNote: Results are merged from WCIF and WCA Live when available. WCA Live data may be delayed. personalResults: eventResults: Event results diff --git a/src/layouts/CompetitionLayout/CompetitionLayout.test.tsx b/src/layouts/CompetitionLayout/CompetitionLayout.test.tsx new file mode 100644 index 0000000..aecc77e --- /dev/null +++ b/src/layouts/CompetitionLayout/CompetitionLayout.test.tsx @@ -0,0 +1,118 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { useWcif } from '@/hooks/queries/useWcif'; +import { CompetitionLayout } from './CompetitionLayout'; + +jest.mock('@/hooks/queries/useWcif', () => ({ useWcif: jest.fn() })); +jest.mock('@/hooks/useCompetitionAnalytics', () => ({ useCompetitionAnalytics: jest.fn() })); +jest.mock('@/providers/AppProvider', () => ({ useApp: () => ({ online: true }) })); +jest.mock('@/providers/WCIFProvider', () => ({ + WCIFProvider: ({ children }: { children: React.ReactNode }) => <>{children}, +})); +jest.mock('./CompetitionLayout.tabs', () => ({ useCompetitionLayoutTabs: () => ({ tabs: [] }) })); +jest.mock('@/components', () => ({ + ErrorFallback: () =>
    Render failed
    , + LastFetchedAt: () =>
    Last fetched
    , + NoteBox: () => null, + NotifyCompRemoteBar: () =>
    Remote bar
    , +})); +jest.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) })); + +const mockUseWcif = useWcif as jest.Mock; +const refetch = jest.fn(); +const view = () => ( + + + }> + Competition content
    } /> + + + +); + +beforeAll(() => { + HTMLElement.prototype.scrollTo = jest.fn(); +}); + +beforeEach(() => { + jest.clearAllMocks(); + mockUseWcif.mockReturnValue({ + data: undefined, + error: null, + dataUpdatedAt: 0, + isFetching: true, + refetch, + }); +}); + +it('does not mount competition content while initial data is loading', () => { + render(view()); + expect(screen.queryByText('Competition content')).not.toBeInTheDocument(); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + expect(screen.queryByText('Remote bar')).not.toBeInTheDocument(); +}); + +it('shows an actionable error after an asynchronous initial failure', () => { + const { rerender } = render(view()); + mockUseWcif.mockReturnValue({ + error: new Error('Raw HTML response'), + dataUpdatedAt: 0, + isFetching: false, + refetch, + }); + rerender(view()); + expect(screen.getByRole('alert')).toHaveTextContent('competition.loadErrorHint'); + expect(screen.queryByText('Raw HTML response')).not.toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'common.home' })).toHaveAttribute('href', '/'); + fireEvent.click(screen.getByRole('button', { name: 'common.retry' })); + expect(refetch).toHaveBeenCalledTimes(1); + expect(screen.queryByText('Competition content')).not.toBeInTheDocument(); +}); + +it('renders a successful competition even when its people list is empty', () => { + mockUseWcif.mockReturnValue({ + data: { persons: [] }, + error: null, + dataUpdatedAt: 0, + isFetching: false, + refetch, + }); + render(view()); + expect(screen.getByText('Competition content')).toBeInTheDocument(); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); +}); + +it('keeps cached content visible on refresh failure and disables retry while fetching', () => { + mockUseWcif.mockReturnValue({ + data: { persons: [] }, + error: new Error('Offline'), + dataUpdatedAt: 0, + isFetching: true, + refetch, + }); + render(view()); + expect(screen.getByText('Competition content')).toBeInTheDocument(); + expect(screen.getByRole('alert')).toHaveTextContent('competition.refreshError'); + expect(screen.queryByText('competition.loadErrorHint')).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'common.retry' })).toBeDisabled(); +}); + +it('replaces an initial error with content after a successful retry', () => { + mockUseWcif.mockReturnValue({ + error: new Error('Offline'), + dataUpdatedAt: 0, + isFetching: false, + refetch, + }); + const { rerender } = render(view()); + mockUseWcif.mockReturnValue({ + data: { persons: [] }, + error: null, + dataUpdatedAt: 0, + isFetching: false, + refetch, + }); + rerender(view()); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + expect(screen.getByText('Competition content')).toBeInTheDocument(); +}); diff --git a/src/layouts/CompetitionLayout/CompetitionLayout.tsx b/src/layouts/CompetitionLayout/CompetitionLayout.tsx index c0764b6..ced849f 100644 --- a/src/layouts/CompetitionLayout/CompetitionLayout.tsx +++ b/src/layouts/CompetitionLayout/CompetitionLayout.tsx @@ -1,9 +1,11 @@ import classNames from 'classnames'; import { useEffect, useRef } from 'react'; import { ErrorBoundary } from 'react-error-boundary'; -import { Outlet, useLocation, useParams } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { Link, Outlet, useLocation, useParams } from 'react-router-dom'; import { BarLoader } from 'react-spinners'; import { ErrorFallback, LastFetchedAt, NoteBox, NotifyCompRemoteBar } from '@/components'; +import { Button } from '@/components/Button'; import { Container } from '@/components/Container'; import { StyledNavLink } from '@/components/StyledNavLink/StyledNavLink'; import { useWcif } from '@/hooks/queries/useWcif'; @@ -13,13 +15,14 @@ import { WCIFProvider } from '@/providers/WCIFProvider'; import { useCompetitionLayoutTabs } from './CompetitionLayout.tabs'; export function CompetitionLayout() { + const { t } = useTranslation(); const { online } = useApp(); const { competitionId } = useParams(); const { pathname } = useLocation(); const ref = useRef(null); - const { data: wcif, dataUpdatedAt, isFetching } = useWcif(competitionId!); + const { data: wcif, dataUpdatedAt, isFetching, error, refetch } = useWcif(competitionId!); useCompetitionAnalytics(competitionId); @@ -55,7 +58,7 @@ export function CompetitionLayout() { return (
    - {Header} + {wcif && Header} {!online && (
    @@ -71,9 +74,29 @@ export function CompetitionLayout() {
    - - - + {error && ( + +
    +

    {t(wcif ? 'competition.refreshError' : 'competition.loadError')}

    + {!wcif &&

    {t('competition.loadErrorHint')}

    } +
    + + {!wcif && ( + + {t('common.home')} + + )} +
    +
    +
    + )} + {wcif && ( + + + + )} {!!dataUpdatedAt && (
    @@ -84,7 +107,7 @@ export function CompetitionLayout() { )}
    - {competitionId && } + {wcif && competitionId && }
    ); diff --git a/src/lib/results.test.ts b/src/lib/results.test.ts new file mode 100644 index 0000000..407c4bd --- /dev/null +++ b/src/lib/results.test.ts @@ -0,0 +1,31 @@ +import { EventId, RankingType } from '@wca/helpers'; +import { renderResultByEventId } from './results'; + +describe('renderResultByEventId', () => { + it.each(['333fm', '333', '333mbf'])( + 'renders DNF and DNS for %s singles and averages', + (eventId) => { + for (const rankingType of ['single', 'average'] as RankingType[]) { + expect(renderResultByEventId(eventId, rankingType, -1)).toBe('DNF'); + expect(renderResultByEventId(eventId, rankingType, -2)).toBe('DNS'); + } + }, + ); + + it('preserves FMC move counts and two-decimal means', () => { + expect(renderResultByEventId('333fm', 'single', 31)).toBe(31); + expect(renderResultByEventId('333fm', 'average', 2133)).toBe('21.33'); + expect(renderResultByEventId('333fm', 'average', 3000)).toBe('30.00'); + }); + + it('preserves zero formatting for callers that manage missing results', () => { + expect(renderResultByEventId('333fm', 'single', 0)).toBe(0); + expect(renderResultByEventId('333fm', 'average', 0)).toBe('0.00'); + expect(renderResultByEventId('333', 'single', 0)).toBe('0.00'); + }); + + it('preserves timed and encoded multi-blind results', () => { + expect(renderResultByEventId('333', 'single', 1234)).toBe('12.34'); + expect(renderResultByEventId('333mbf', 'single', 970060000)).toBe('2/2 10:00'); + }); +}); diff --git a/src/lib/results.ts b/src/lib/results.ts index ee24088..65e2aa9 100644 --- a/src/lib/results.ts +++ b/src/lib/results.ts @@ -13,6 +13,10 @@ export const renderResultByEventId = ( rankingType: RankingType, result: ResultValue, ) => { + if (result === -1 || result === -2) { + return formatCentiseconds(result); + } + if (eventId === '333fm') { return rankingType === 'average' ? ((result as number) / 100).toFixed(2).toString() : result; } diff --git a/src/lib/wcif.test.ts b/src/lib/wcif.test.ts index 1ced601..7c95934 100644 --- a/src/lib/wcif.test.ts +++ b/src/lib/wcif.test.ts @@ -1,5 +1,5 @@ import { ParticipationRuleset, ResultCondition, Round } from '@wca/helpers'; -import { getAdvancementConditionForRound } from './wcif'; +import { getAdvancementConditionForRound, getLinkedRoundIds } from './wcif'; const makeRound = ( id: string, @@ -83,3 +83,21 @@ describe('wcif participation helpers', () => { expect(getAdvancementConditionForRound(rounds, rounds[0])).toBeNull(); }); }); + +describe('linked round discovery', () => { + it('recognizes explicit dual finals without an advancement target', () => { + const rounds = [makeRound('333-r1'), makeRound('333-r2')]; + rounds.forEach((round) => { + round.linkedRounds = ['333-r1', '333-r2']; + }); + expect(getLinkedRoundIds(rounds, rounds[1])).toEqual(['333-r1', '333-r2']); + expect(getAdvancementConditionForRound(rounds, rounds[0])).toBeNull(); + }); + + it('uses reverse links, removes duplicates and ignores unknown round IDs', () => { + const rounds = [makeRound('333-r1'), makeRound('333-r2'), makeRound('333-r3')]; + rounds[0].linkedRounds = ['333-r2', '333-r1', '333-r1', '333-r9']; + expect(getLinkedRoundIds(rounds, rounds[1])).toEqual(['333-r1', '333-r2']); + expect(getLinkedRoundIds(rounds, rounds[2])).toEqual([]); + }); +}); diff --git a/src/lib/wcif.ts b/src/lib/wcif.ts index 0f0e3f5..b8d1ae3 100644 --- a/src/lib/wcif.ts +++ b/src/lib/wcif.ts @@ -57,3 +57,20 @@ export const getAdvancementConditionForRound = ( reservedPlaces: ruleset?.reservedPlaces ?? null, }; }; + +/** Explicit WCIF links also describe dual finals, which have no advancement rules. */ +export const getLinkedRoundIds = (eventRounds: Round[], round: Round): string[] => { + const linkedIds = + round.linkedRounds ?? + eventRounds.find((candidate) => candidate.linkedRounds?.includes(round.id))?.linkedRounds ?? + eventRounds.flatMap((candidate) => { + const source = candidate.participationRuleset?.participationSource; + return source?.type === 'linkedRounds' && source.roundIds.includes(round.id) + ? source.roundIds + : []; + }); + const ids = new Set(linkedIds); + if (ids.size === 0) return []; + ids.add(round.id); + return eventRounds.filter((candidate) => ids.has(candidate.id)).map((candidate) => candidate.id); +}; diff --git a/src/pages/Competition/Schedule/EventActivity.test.tsx b/src/pages/Competition/Schedule/EventActivity.test.tsx index 78cf23e..bd4c09d 100644 --- a/src/pages/Competition/Schedule/EventActivity.test.tsx +++ b/src/pages/Competition/Schedule/EventActivity.test.tsx @@ -1,5 +1,5 @@ import { render, screen, within } from '@testing-library/react'; -import { Activity, Competition, EventId, Person, RoundFormat } from '@wca/helpers'; +import { Activity, Competition, EventId, Person, Round, RoundFormat } from '@wca/helpers'; import { LinkRenderer } from '@/lib/linkRenderer'; import { storybookCompetitionFixture } from '@/storybook/competitionFixtures'; import { EventActivity } from './EventActivity'; @@ -32,7 +32,15 @@ const renderSeed = ( roundNumber = 1, format = 'a', hasPersonalBests = true, - }: { roundNumber?: number; format?: RoundFormat; hasPersonalBests?: boolean } = {}, + buildPersons, + buildRounds, + }: { + roundNumber?: number; + format?: RoundFormat; + hasPersonalBests?: boolean; + buildPersons?: (person: Person) => Person[]; + buildRounds?: (rounds: Round[]) => Round[]; + } = {}, ) => { const activity: Activity = { id: 30, @@ -71,33 +79,35 @@ const renderSeed = ( : [], extensions: [], }; + const persons = buildPersons ? buildPersons(person) : [person]; const firstRound = { ...storybookCompetitionFixture.events[0].rounds[0], id: `${eventId}-r1`, format, results: [{ personId: 1, ranking: 1, best: 580, average: 710, attempts: [] }], }; + const rounds = [firstRound, { ...firstRound, id: `${eventId}-r2`, results: [] }]; mockWcif = { ...storybookCompetitionFixture, events: [ { id: eventId, - rounds: [firstRound, { ...firstRound, id: `${eventId}-r2`, results: [] }], + rounds: buildRounds ? buildRounds(rounds) : rounds, extensions: [], }, ], - persons: [person], + persons, }; render( , ); - const row = screen.getByText('Seed Competitor').closest('tr')!; + const row = screen.getAllByRole('row')[1]; return within(row).getAllByRole('cell')[1]; }; @@ -122,3 +132,161 @@ describe('EventActivity seed results', () => { expect(renderSeed('333bf', { roundNumber: 2, format: '3' }).textContent).toBe('5.80'); }); }); + +describe('EventActivity seed ordering', () => { + const buildPersons = (person: Person): Person[] => [ + { + ...person, + name: 'Heron Sato', + personalBests: person.personalBests!.map((pb) => ({ + ...pb, + value: pb.type === 'single' ? 25558 : 30000, + worldRanking: pb.type === 'single' ? 8836 : 3744, + })), + }, + { + ...person, + registrantId: 2, + name: 'Sofia Rubbo Tha', + personalBests: person + .personalBests!.filter((pb) => pb.type === 'single') + .map((pb) => ({ ...pb, value: 20931, worldRanking: 7524 })), + }, + { + ...person, + registrantId: 3, + name: 'No single ranking', + personalBests: person + .personalBests!.filter((pb) => pb.type === 'average') + .map((pb) => ({ ...pb, worldRanking: 1 })), + }, + ]; + + it.each(['333bf', '444bf', '555bf', '333mbf'])( + 'orders %s by single rank even when an average rank exists', + (eventId) => { + renderSeed(eventId, { format: '3', buildPersons }); + const rows = screen.getAllByRole('row').slice(1); + expect(rows.map((row) => within(row).getAllByRole('cell')[0].textContent)).toEqual([ + 'Sofia Rubbo Tha', + 'Heron Sato', + 'No single ranking', + ]); + if (eventId === '333bf') { + expect(within(rows[0]).getAllByRole('cell')[1].textContent).toBe('3:29.31'); + expect(within(rows[1]).getAllByRole('cell')[1].textContent).toBe('4:15.58'); + } + expect(within(rows[2]).getAllByRole('cell')[1].textContent).toBe(''); + }, + ); + + it('keeps average-ranked events ordered by average rather than single rank', () => { + renderSeed('333', { buildPersons }); + expect(screen.getAllByRole('row')[1].textContent).toContain('Heron Sato'); + }); +}); + +describe('EventActivity dual-round seeds', () => { + it('uses personal bests for a registrations-based second component, not the first component results', () => { + expect( + renderSeed('333', { + roundNumber: 2, + buildRounds: (rounds) => + rounds.map((round) => ({ + ...round, + participationRuleset: { + reservedPlaces: null, + participationSource: { type: 'registrations' }, + }, + })), + }).textContent, + ).toBe('7.40'); + }); + + it('uses the explicitly named source round instead of the numerically previous round', () => { + expect( + renderSeed('333', { + roundNumber: 3, + buildRounds: (rounds) => [ + ...rounds, + { + ...rounds[1], + id: '333-r3', + participationRuleset: { + reservedPlaces: null, + participationSource: { + type: 'round', + roundId: '333-r1', + resultCondition: null, + }, + }, + }, + ], + }).textContent, + ).toBe('7.10'); + }); + + it.each([ + [710, 690, '6.90'], + [710, -1, '7.10'], + [-1, 690, '6.90'], + [710, 0, '7.10'], + ])( + 'uses the best valid dual-round average (%s / %s)', + (firstAverage, secondAverage, expected) => { + expect( + renderSeed('333', { + roundNumber: 3, + buildRounds: (rounds) => [ + { ...rounds[0], results: [{ ...rounds[0].results[0], average: Number(firstAverage) }] }, + { + ...rounds[1], + results: [{ ...rounds[0].results[0], average: Number(secondAverage) }], + }, + { + ...rounds[1], + id: '333-r3', + participationRuleset: { + reservedPlaces: null, + participationSource: { + type: 'linkedRounds', + roundIds: ['333-r1', '333-r2'], + resultCondition: { type: 'ranking', scope: 'average', value: 12 }, + }, + }, + }, + ], + }).textContent, + ).toBe(expected); + }, + ); +}); + +it('keeps official combined rankings and supports competitors present in only one component', () => { + renderSeed('333', { + roundNumber: 3, + buildPersons: (person) => [person, { ...person, registrantId: 2, name: 'Other component' }], + buildRounds: (rounds) => [ + { ...rounds[0], results: [{ ...rounds[0].results[0], ranking: 2, average: 710 }] }, + { + ...rounds[1], + results: [{ ...rounds[0].results[0], personId: 2, ranking: 1, average: 720 }], + }, + { + ...rounds[1], + id: '333-r3', + participationRuleset: { + reservedPlaces: null, + participationSource: { + type: 'linkedRounds', + roundIds: ['333-r1', '333-r2'], + resultCondition: { type: 'ranking', scope: 'average', value: 12 }, + }, + }, + }, + ], + }); + const rows = screen.getAllByRole('row').slice(1); + expect(rows[0].textContent).toBe('Other component7.20'); + expect(rows[1].textContent).toBe('Seed Competitor7.10'); +}); diff --git a/src/pages/Competition/Schedule/EventActivity.tsx b/src/pages/Competition/Schedule/EventActivity.tsx index 2e85e64..0ab13c6 100644 --- a/src/pages/Competition/Schedule/EventActivity.tsx +++ b/src/pages/Competition/Schedule/EventActivity.tsx @@ -50,9 +50,35 @@ export function EventActivity({ return event.rounds?.find((r) => r.id === `${eventId}-r${roundNumber}`); }, [event, eventId, roundNumber]); - const prevRound = useMemo( - () => roundNumber && event?.rounds?.find((r) => r.id === `${eventId}-r${roundNumber - 1}`), - [event?.rounds, eventId, roundNumber], + const seedRounds = useMemo(() => { + const source = round?.participationRuleset?.participationSource; + if (source?.type === 'registrations') return []; + + const sourceRoundIds = + source?.type === 'linkedRounds' + ? source.roundIds + : [source?.type === 'round' ? source.roundId : `${eventId}-r${(roundNumber || 1) - 1}`]; + return event?.rounds?.filter((candidate) => sourceRoundIds.includes(candidate.id)) ?? []; + }, [event?.rounds, eventId, round, roundNumber]); + + const previousSeed = useCallback( + (person: Person) => + seedRounds + .flatMap((sourceRound) => + sourceRound.results + .filter((result) => result.personId?.toString() === person.registrantId?.toString()) + .map((result) => { + const source = round?.participationRuleset?.participationSource; + const type = + (source?.type !== 'registrations' && source?.resultCondition?.scope) || + (['a', 'm'].includes(sourceRound.format) ? 'average' : 'single'); + return { result, type, value: type === 'average' ? result.average : result.best }; + }), + ) + // Linked rounds are ranked by the best result from either component. + // A missing/failed result must not displace a successful result. + .sort((a, b) => (a.value > 0 ? a.value : Infinity) - (b.value > 0 ? b.value : Infinity))[0], + [round, seedRounds], ); useEffect(() => { @@ -116,19 +142,9 @@ export function EventActivity({ return ''; } - if (prevRound) { - const prevRoundResults = prevRound.results?.find( - (r) => r.personId?.toString() === person.registrantId?.toString(), - ); - if (!prevRoundResults) { - return ''; - } - - if (['a', 'm'].includes(prevRound.format)) { - return renderResultByEventId(eventId, 'average', prevRoundResults.average); - } - - return renderResultByEventId(eventId, 'single', prevRoundResults.best); + if (seedRounds.length) { + const seed = previousSeed(person); + return seed ? renderResultByEventId(eventId, seed.type, seed.value) : ''; } const averagePr = person.prAverage?.value; @@ -141,7 +157,7 @@ export function EventActivity({ return renderResultByEventId(eventId, shouldShowAveragePr ? 'average' : 'single', result); }, - [eventId, prevRound], + [eventId, previousSeed, seedRounds], ); const seedRank = useCallback( @@ -150,15 +166,8 @@ export function EventActivity({ return ''; } - if (prevRound) { - const prevRoundResults = prevRound.results?.find( - (r) => r.personId?.toString() === person.registrantId?.toString(), - ); - if (!prevRoundResults) { - return ''; - } - - return prevRoundResults.ranking; + if (seedRounds.length) { + return previousSeed(person)?.result.ranking ?? ''; } const averagePr = person.prAverage; @@ -168,13 +177,9 @@ export function EventActivity({ return ''; } - if (averagePr) { - return averagePr.worldRanking; - } - - return singlePr.worldRanking; + return shouldShowAveragePr ? averagePr?.worldRanking : singlePr.worldRanking; }, - [eventId, prevRound], + [eventId, previousSeed, seedRounds], ); const stationNumber = (assignmentCode) => (person) => { diff --git a/src/providers/WCIFProvider/WCIFProvider.tsx b/src/providers/WCIFProvider/WCIFProvider.tsx index 9d041c6..8e40cb7 100644 --- a/src/providers/WCIFProvider/WCIFProvider.tsx +++ b/src/providers/WCIFProvider/WCIFProvider.tsx @@ -12,7 +12,7 @@ export interface WCIFProviderProps { export function WCIFProvider({ competitionId, children }: WCIFProviderProps) { const [title, setTitle] = useState(''); - const { data: wcif, error } = useWcif(competitionId); + const { data: wcif } = useWcif(competitionId); useEffect(() => { if (!competitionId) { @@ -54,15 +54,5 @@ export function WCIFProvider({ competitionId, children }: WCIFProviderProps) { }; }, [competitionId, wcif]); - return ( - - {error && ( -
    -

    Error loading competition:

    -

    {error?.toString()}

    -
    - )} - {children} -
    - ); + return {children}; }