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..f205cbf 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( @@ -233,7 +271,9 @@ describe('CutoffTimeLimitPanel', () => { it('uses theme-aware popover classes for help content', () => { renderPanel(cutoffOnlyRound); - fireEvent.click(screen.getByRole('button', { name: /help/i })); + const helpButton = screen.getByRole('button', { name: /^help$/i, expanded: false }); + fireEvent.click(helpButton); + expect(helpButton).toHaveAttribute('aria-expanded', 'true'); const popoverContent = screen .getByText(/common\.cutoffTimeLimitPopover\.timeLimits/i) @@ -242,31 +282,46 @@ describe('CutoffTimeLimitPanel', () => { expect(popoverContent).toHaveClass('bg-panel'); expect(popoverContent).toHaveClass('text-default'); 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); + fireEvent.click(helpButton); + expect(helpButton).toHaveAttribute('aria-expanded', 'false'); + expect(popoverContent).not.toBeInTheDocument(); + }); - 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..cb01f7b 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) { @@ -250,6 +250,7 @@ function CutoffTimeLimitPopover({ cutoff }: { cutoff: Cutoff | null }) { 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..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'; @@ -30,8 +31,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', @@ -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')}

+ )} 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..6f9e5f7 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(); @@ -547,4 +586,27 @@ describe('CompetitionResultsContainer', () => { 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 0348495..7eef876 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( @@ -210,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); @@ -255,7 +263,7 @@ export function CompetitionResultsTable({ ))} - + {showSecondaryBest && } @@ -280,9 +288,11 @@ export function CompetitionResultsTable({ ? t('competition.results.average') : t('competition.results.best')} - - {t('competition.results.best')} - + {showSecondaryBest && ( + + {t('competition.results.best')} + + )} @@ -351,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 7ff2625..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,9 +93,9 @@ 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.result })), + attempts: result.attempts.map((attempt) => ({ result: attempt.value })), best: result.best, average: result.average, singleRecordTag: normalizeResultRecordTag( 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 e7f465e..c6a467b 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], @@ -690,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 562e6d7..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); @@ -160,7 +161,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..dc0cd6e 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: () => ({ @@ -232,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/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.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 c696a19..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(); @@ -31,7 +43,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, }; }) @@ -62,7 +74,7 @@ export const Competitors = ({ wcif }: { wcif: Competition }) => {
    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 }) => {
    } /> + + + +); + +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/activities.test.ts b/src/lib/activities.test.ts index 48810f2..5223515 100644 --- a/src/lib/activities.test.ts +++ b/src/lib/activities.test.ts @@ -2,7 +2,7 @@ import { Competition } from '@wca/helpers'; import { hasMultipleScheduleLocations } from './activities'; const baseCompetition = { - formatVersion: '1.0', + formatVersion: '2.0', id: 'TestComp2026', name: 'Test Comp 2026', shortName: 'Test Comp', diff --git a/src/lib/api.test.ts b/src/lib/api.test.ts new file mode 100644 index 0000000..e21cb3a --- /dev/null +++ b/src/lib/api.test.ts @@ -0,0 +1,28 @@ +import { fetchWcif } from './api'; + +jest.mock('./wca-env', () => ({ WCA_ORIGIN: 'https://api.worldcubeassociation.org' })); + +describe('fetchWcif', () => { + const originalFetch = global.fetch; + + afterEach(() => { + global.fetch = originalFetch; + }); + + it('requests WCIF v2 explicitly and returns the native response unchanged', async () => { + const wcif = { formatVersion: '2.2', id: 'Example2026', events: [], persons: [] }; + global.fetch = jest.fn().mockResolvedValue({ ok: true, json: async () => wcif }); + + await expect(fetchWcif('Example2026')).resolves.toBe(wcif); + expect(global.fetch).toHaveBeenCalledWith( + 'https://api.worldcubeassociation.org/competitions/Example2026/wcif/version/2', + {}, + ); + }); + + it('preserves API errors', async () => { + global.fetch = jest.fn().mockResolvedValue({ ok: false, text: async () => 'Not found' }); + + await expect(fetchWcif('Missing2026')).rejects.toThrow('Not found'); + }); +}); diff --git a/src/lib/api.ts b/src/lib/api.ts index 1ec9fc1..6020b51 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -53,7 +53,7 @@ export const fetchUserWithCompetitions = async (userId: string) => wcaApiFetch(`/users/${userId}?${fetchUserWithCompetitionsParams.toString()}`); export const fetchWcif = async (competitionId: string) => - wcaApiFetch(`/competitions/${competitionId}/wcif/public`); + wcaApiFetch(`/competitions/${competitionId}/wcif/version/2`); export interface WcaCompetitionResult { id: number; 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 27c00be..65e2aa9 100644 --- a/src/lib/results.ts +++ b/src/lib/results.ts @@ -1,7 +1,7 @@ import { EventId, RankingType, - AttemptResult, + ResultValue, formatMultiResult, decodeMultiResult, formatCentiseconds, @@ -11,8 +11,12 @@ import { export const renderResultByEventId = ( eventId: EventId, rankingType: RankingType, - result: AttemptResult, + result: ResultValue, ) => { + if (result === -1 || result === -2) { + return formatCentiseconds(result); + } + if (eventId === '333fm') { return rankingType === 'average' ? ((result as number) / 100).toFixed(2).toString() : result; } @@ -29,7 +33,7 @@ export const renderCutoff = (cutoff: Cutoff) => { return '-'; } - return `${formatCentiseconds(cutoff.attemptResult)}`; + return `${formatCentiseconds(cutoff.resultValue)}`; }; export const renderCentiseconds = (centiseconds: number) => { diff --git a/src/lib/roundLabels.ts b/src/lib/roundLabels.ts index b2b3fab..434f3de 100644 --- a/src/lib/roundLabels.ts +++ b/src/lib/roundLabels.ts @@ -1,15 +1,10 @@ import { Competition, parseActivityCode } from '@wca/helpers'; import { TFunction } from 'i18next'; -import { CompatibleRound } from './wcif'; export function getEventRoundsForRound(events: Competition['events'] | undefined, roundId: string) { const { eventId } = parseActivityCode(roundId); - return ( - events - ?.find((event) => event.id === eventId) - ?.rounds?.map((candidate) => candidate as CompatibleRound) || [] - ); + return events?.find((event) => event.id === eventId)?.rounds || []; } export function activityCodeToRoundName(t: TFunction, roundId: string) { diff --git a/src/lib/wcif.test.ts b/src/lib/wcif.test.ts index 4bb141c..7c95934 100644 --- a/src/lib/wcif.test.ts +++ b/src/lib/wcif.test.ts @@ -1,111 +1,103 @@ -import { Round } from '@wca/helpers'; -import { getAdvancementConditionForRound, getRoundParticipationRuleset } from './wcif'; +import { ParticipationRuleset, ResultCondition, Round } from '@wca/helpers'; +import { getAdvancementConditionForRound, getLinkedRoundIds } from './wcif'; -describe('wcif participation helpers', () => { - it('backfills stable advancement conditions into a participation ruleset', () => { - const rounds = [ - { - id: '333-r1', - format: 'a', - results: [], - }, - { - id: '333-r2', - format: 'a', - advancementCondition: { - type: 'ranking', - level: 16, - }, - results: [], - }, - ] as unknown as Round[]; - - expect(getRoundParticipationRuleset(rounds, rounds[1])).toEqual({ - participationSource: { - type: 'round', - roundId: '333-r1', - resultCondition: { - type: 'ranking', - value: 16, - }, - }, - }); - }); +const makeRound = ( + id: string, + participationRuleset: ParticipationRuleset | null = null, +): Round => ({ + id, + format: 'a', + linkedRounds: null, + timeLimit: null, + cutoff: null, + participationRuleset, + results: [], + extensions: [], +}); - it('derives current-round advancement from the next round participation ruleset', () => { +describe('wcif participation helpers', () => { + it.each([ + { type: 'ranking', scope: 'average', value: 16 }, + { type: 'percent', scope: 'average', value: 75 }, + { type: 'resultAchieved', scope: 'single', value: 1000 }, + ])('derives current-round advancement from the next round $type ruleset', (resultCondition) => { const rounds = [ - { - id: '333-r1', - format: 'a', - results: [], - }, - { - id: '333-r2', - format: 'a', - participationRuleset: { - participationSource: { - type: 'round', - roundId: '333-r1', - resultCondition: { - type: 'percent', - value: 75, - }, - }, - }, - results: [], - }, - ] as unknown as Round[]; + makeRound('333-r1'), + makeRound('333-r2', { + participationSource: { type: 'round', roundId: '333-r1', resultCondition }, + reservedPlaces: null, + }), + ]; expect(getAdvancementConditionForRound(rounds, rounds[0])).toEqual({ sourceType: 'round', sourceRoundIds: ['333-r1'], targetRoundId: '333-r2', - resultCondition: { - type: 'percent', - value: 75, - }, + resultCondition, reservedPlaces: null, }); + expect(getAdvancementConditionForRound(rounds, rounds[1])).toBeNull(); }); - it('derives linked-round advancement from the next round participation ruleset', () => { + it('derives linked-round advancement and preserves reserved places', () => { + const resultCondition: ResultCondition = { type: 'ranking', scope: 'average', value: 12 }; + const reservedPlaces = { nationalities: ['US'], reservations: 2 }; const rounds = [ - { - id: '333-r1', - format: 'a', - results: [], - }, - { - id: '333-r2', - format: 'a', - results: [], - }, - { - id: '333-r3', - format: 'a', - participationRuleset: { - participationSource: { - type: 'linkedRounds', - roundIds: ['333-r1', '333-r2'], - resultCondition: { - type: 'ranking', - value: 12, - }, - }, + makeRound('333-r1'), + makeRound('333-r2'), + makeRound('333-r3', { + participationSource: { + type: 'linkedRounds', + roundIds: ['333-r1', '333-r2'], + resultCondition, }, - results: [], - }, - ] as unknown as Round[]; + reservedPlaces, + }), + ]; + + for (const round of rounds.slice(0, 2)) { + expect(getAdvancementConditionForRound(rounds, round)).toEqual({ + sourceType: 'linkedRounds', + sourceRoundIds: ['333-r1', '333-r2'], + targetRoundId: '333-r3', + resultCondition, + reservedPlaces, + }); + } + }); - expect(getAdvancementConditionForRound(rounds, rounds[1])).toEqual({ - sourceType: 'linkedRounds', - sourceRoundIds: ['333-r1', '333-r2'], - targetRoundId: '333-r3', - resultCondition: { - type: 'ranking', - value: 12, - }, + it.each([ + null, + { participationSource: null, reservedPlaces: null }, + { participationSource: { type: 'registrations' }, reservedPlaces: null }, + { + participationSource: { type: 'round', roundId: '333-r1', resultCondition: null }, reservedPlaces: null, + }, + { + participationSource: { type: 'linkedRounds', roundIds: ['333-r1'], resultCondition: null }, + reservedPlaces: null, + }, + ])('does not invent an advancement threshold when none is supplied (%j)', (ruleset) => { + const rounds = [makeRound('333-r1'), makeRound('333-r2', ruleset)]; + 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 c2b2f59..b8d1ae3 100644 --- a/src/lib/wcif.ts +++ b/src/lib/wcif.ts @@ -1,16 +1,11 @@ import { - ParticipationResultCondition, - ParticipationRuleset, ParticipationSource, ReservedPlaces, + ResultCondition, Round, parseActivityCode, } from '@wca/helpers'; -type LegacyAdvancementCondition = NonNullable; - -export type ResultCondition = ParticipationResultCondition; - export interface RoundAdvancementCondition { sourceType: ParticipationSource['type']; sourceRoundIds: string[]; @@ -19,99 +14,10 @@ export interface RoundAdvancementCondition { reservedPlaces?: ReservedPlaces | null; } -export type CompatibleRound = Round; - -const averagedFormats = new Set(['a', 'm', '5', 'h']); - -const getRoundResultType = (round: Pick): 'single' | 'average' => - averagedFormats.has(round.format) ? 'average' : 'single'; - -const getLegacyResultCondition = ( - advancementCondition: LegacyAdvancementCondition, - sourceRound?: Round, -): ResultCondition => { - switch (advancementCondition.type) { - case 'ranking': - return { - type: 'ranking', - value: advancementCondition.level, - }; - case 'percent': - return { - type: 'percent', - value: advancementCondition.level, - }; - case 'attemptResult': - return { - type: 'resultAchieved', - scope: sourceRound ? getRoundResultType(sourceRound) : 'single', - value: advancementCondition.level, - }; - } -}; - -const getPreviousRound = (eventRounds: Round[], round: Round): Round | undefined => { - const { eventId, roundNumber } = parseActivityCode(round.id); - - if (!roundNumber || roundNumber <= 1) { - return undefined; - } - - return eventRounds.find((candidate) => candidate.id === `${eventId}-r${roundNumber - 1}`); -}; - -export const getRoundParticipationRuleset = ( - eventRounds: Round[], - round: Round, -): ParticipationRuleset | null => { - if (round.participationRuleset) { - return round.participationRuleset; - } - - if (!round.advancementCondition) { - return null; - } - - const previousRound = getPreviousRound(eventRounds, round); - if (!previousRound) { - return null; - } - - return { - participationSource: { - type: 'round', - roundId: previousRound.id, - resultCondition: getLegacyResultCondition(round.advancementCondition, previousRound), - }, - }; -}; - -const getRoundParticipationSource = ( - eventRounds: Round[], - round: Round, -): ParticipationSource | null => - getRoundParticipationRuleset(eventRounds, round)?.participationSource ?? null; - export const getAdvancementConditionForRound = ( eventRounds: Round[], round: Round, ): RoundAdvancementCondition | null => { - if (round.advancementCondition) { - const { eventId, roundNumber } = parseActivityCode(round.id); - const targetRoundId = - roundNumber != null - ? eventRounds.find((candidate) => candidate.id === `${eventId}-r${roundNumber + 1}`)?.id - : null; - - return { - sourceType: 'round', - sourceRoundIds: [round.id], - targetRoundId: targetRoundId ?? null, - resultCondition: getLegacyResultCondition(round.advancementCondition, round), - reservedPlaces: null, - }; - } - const { roundNumber } = parseActivityCode(round.id); const futureRounds = eventRounds.filter((candidate) => { const parsedCandidate = parseActivityCode(candidate.id); @@ -119,7 +25,7 @@ export const getAdvancementConditionForRound = ( }); const nextEligibleRound = futureRounds.find((candidate) => { - const source = getRoundParticipationSource(eventRounds, candidate); + const source = candidate.participationRuleset?.participationSource; if (!source || source.type === 'registrations') { return false; @@ -136,10 +42,10 @@ export const getAdvancementConditionForRound = ( return null; } - const ruleset = getRoundParticipationRuleset(eventRounds, nextEligibleRound); + const ruleset = nextEligibleRound.participationRuleset; const source = ruleset?.participationSource; - if (!source || source.type === 'registrations') { + if (!source || source.type === 'registrations' || !source.resultCondition) { return null; } @@ -151,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 new file mode 100644 index 0000000..bd4c09d --- /dev/null +++ b/src/pages/Competition/Schedule/EventActivity.test.tsx @@ -0,0 +1,292 @@ +import { render, screen, within } from '@testing-library/react'; +import { Activity, Competition, EventId, Person, Round, RoundFormat } from '@wca/helpers'; +import { LinkRenderer } from '@/lib/linkRenderer'; +import { storybookCompetitionFixture } from '@/storybook/competitionFixtures'; +import { EventActivity } from './EventActivity'; + +let mockWcif: Competition; +const mockSetTitle = jest.fn(); + +jest.mock('@/providers/WCIFProvider', () => ({ + useWCIF: () => ({ wcif: mockWcif, setTitle: mockSetTitle }), +})); +jest.mock('@/i18n', () => ({ __esModule: true, default: { t: (key: string) => key } })); +jest.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) })); +jest.mock('@/components/Breadcrumbs/Breadcrumbs', () => ({ Breadcrumbs: () => null })); +jest.mock('@/components/CutoffTimeLimitPanel', () => ({ CutoffTimeLimitPanel: () => null })); +jest.mock('./Activity', () => ({ niceActivityName: () => 'Test group' })); +jest.mock('./PeopleList', () => ({ PeopleList: () => null })); + +const TestLink: LinkRenderer = ({ children, to, className }) => + className?.includes('table-row') ? ( + {children} + ) : ( + + {children} + + ); + +const renderSeed = ( + eventId: EventId, + { + roundNumber = 1, + format = 'a', + hasPersonalBests = true, + buildPersons, + buildRounds, + }: { + roundNumber?: number; + format?: RoundFormat; + hasPersonalBests?: boolean; + buildPersons?: (person: Person) => Person[]; + buildRounds?: (rounds: Round[]) => Round[]; + } = {}, +) => { + const activity: Activity = { + id: 30, + activityCode: `${eventId}-r${roundNumber}-g1`, + name: 'Test group', + startTime: '2026-10-31T09:00:00Z', + endTime: '2026-10-31T10:00:00Z', + childActivities: [], + extensions: [], + }; + const person: Person = { + registrantId: 1, + name: 'Seed Competitor', + wcaUserId: 1, + countryIso2: 'CH', + assignments: [{ activityId: 30, assignmentCode: 'competitor', stationNumber: null }], + personalBests: hasPersonalBests + ? [ + { + eventId, + type: 'single', + value: 600, + worldRanking: 100, + continentalRanking: 50, + nationalRanking: 10, + }, + { + eventId, + type: 'average', + value: 740, + worldRanking: 200, + continentalRanking: 80, + nationalRanking: 20, + }, + ] + : [], + 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: buildRounds ? buildRounds(rounds) : rounds, + extensions: [], + }, + ], + persons, + }; + + render( + , + ); + const row = screen.getAllByRole('row')[1]; + return within(row).getAllByRole('cell')[1]; +}; + +describe('EventActivity seed results', () => { + it('renders the first-round average from native WCIF v2 personalBest.value', () => { + expect(renderSeed('333').textContent).toBe('7.40'); + }); + + it('renders the first-round single for a single-ranked event', () => { + expect(renderSeed('333bf', { format: '3' }).textContent).toBe('6.00'); + }); + + it('keeps the seed blank when the competitor has no personal bests', () => { + expect(renderSeed('333', { hasPersonalBests: false }).textContent).toBe(''); + }); + + it('keeps later-round seeds based on the previous round average', () => { + expect(renderSeed('333', { roundNumber: 2 }).textContent).toBe('7.10'); + }); + + it('keeps later-round single seeds based on the previous round best', () => { + 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 7c86034..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(() => { @@ -111,40 +137,27 @@ export function EventActivity({ }, {}) as Record; const seedResult = useCallback( - (person) => { + (person: (typeof personsWithPRs)[number]) => { if (!isOfficialEventId(eventId)) { 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?.best; - const singlePr = person.prSingle?.best; + const averagePr = person.prAverage?.value; + const singlePr = person.prSingle?.value; const shouldShowAveragePr = !isRankedBySingle(eventId); - if ((shouldShowAveragePr && !averagePr) || !singlePr) { + const result = shouldShowAveragePr ? averagePr : singlePr; + if (!result || !singlePr) { return ''; } - return renderResultByEventId( - eventId, - shouldShowAveragePr ? 'average' : 'single', - shouldShowAveragePr ? averagePr : singlePr, - ); + return renderResultByEventId(eventId, shouldShowAveragePr ? 'average' : 'single', result); }, - [eventId, prevRound], + [eventId, previousSeed, seedRounds], ); const seedRank = useCallback( @@ -153,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; @@ -171,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/pages/User/userProfileData.ts b/src/pages/User/userProfileData.ts index bdc9019..44e0201 100644 --- a/src/pages/User/userProfileData.ts +++ b/src/pages/User/userProfileData.ts @@ -1,4 +1,4 @@ -import { EventId, RankingType, AttemptResult } from '@wca/helpers'; +import { EventId, RankingType, ResultValue } from '@wca/helpers'; import { WcaCompetitionResult } from '@/lib/api'; import { getEventName, isOfficialEventId, isRankedBySingle } from '@/lib/events'; import { renderCentiseconds, renderResultByEventId } from '@/lib/results'; @@ -101,7 +101,7 @@ export const formatUserResult = ( } if (isOfficialEventId(eventId)) { - return renderResultByEventId(eventId, rankingType, value as AttemptResult); + return renderResultByEventId(eventId, rankingType, value as ResultValue); } return eventId === '333fm' && rankingType === 'average' diff --git a/src/providers/QueryProvider/QueryProvider.test.tsx b/src/providers/QueryProvider/QueryProvider.test.tsx new file mode 100644 index 0000000..f96014c --- /dev/null +++ b/src/providers/QueryProvider/QueryProvider.test.tsx @@ -0,0 +1,37 @@ +import { QueryClient, dehydrate, useIsRestoring } from '@tanstack/react-query'; +import { render, screen, waitFor } from '@testing-library/react'; +import { QueryProvider, queryClient } from './QueryProvider'; + +function RestoreStatus() { + return {useIsRestoring() ? 'restoring' : 'ready'}; +} + +describe('persisted WCIF cache', () => { + afterEach(() => { + queryClient.clear(); + window.localStorage.clear(); + }); + + it.each([ + { buster: '', restored: false }, + { buster: 'wcif-v2', restored: true }, + ])('restores only v2-compatible data (buster: $buster)', async ({ buster, restored }) => { + const previousClient = new QueryClient(); + const data = { formatVersion: restored ? '2.2' : '1.0' }; + previousClient.setQueryData(['wcif', 'Example2026'], data); + window.localStorage.setItem( + 'REACT_QUERY_OFFLINE_CACHE', + JSON.stringify({ timestamp: Date.now(), buster, clientState: dehydrate(previousClient) }), + ); + + render( + + + , + ); + await waitFor(() => expect(screen.getByText('ready')).toBeInTheDocument()); + + expect(queryClient.getQueryData(['wcif', 'Example2026'])).toEqual(restored ? data : undefined); + previousClient.clear(); + }); +}); diff --git a/src/providers/QueryProvider/QueryProvider.tsx b/src/providers/QueryProvider/QueryProvider.tsx index fdf6a2e..5c3f0aa 100644 --- a/src/providers/QueryProvider/QueryProvider.tsx +++ b/src/providers/QueryProvider/QueryProvider.tsx @@ -24,7 +24,7 @@ export function QueryProvider(props: PropsWithChildren) { return ( + persistOptions={{ persister: localStoragePersister, buster: 'wcif-v2' }}> {props.children} ); diff --git a/src/providers/WCIFProvider/WCIFContext.tsx b/src/providers/WCIFProvider/WCIFContext.tsx index 2ff51c3..394406d 100644 --- a/src/providers/WCIFProvider/WCIFContext.tsx +++ b/src/providers/WCIFProvider/WCIFContext.tsx @@ -10,7 +10,7 @@ export interface IWCIFContextType { export const WCIFContext = createContext({ competitionId: '', wcif: { - formatVersion: '1.0', + formatVersion: '2.2', id: '', name: '', shortName: '', @@ -21,6 +21,15 @@ export const WCIFContext = createContext({ startDate: '', venues: [], }, + series: null, + registrationInfo: { + openTime: '', + closeTime: '', + baseEntryFee: 0, + currencyCode: '', + onTheSpotRegistration: false, + useWcaRegistration: false, + }, competitorLimit: 0, extensions: [], }, 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}; } diff --git a/src/storybook/competitionFixtures.ts b/src/storybook/competitionFixtures.ts index 044074e..4cea0e4 100644 --- a/src/storybook/competitionFixtures.ts +++ b/src/storybook/competitionFixtures.ts @@ -1,4 +1,4 @@ -import { Competition, Round } from '@wca/helpers'; +import { Activity, Competition, Person, Round } from '@wca/helpers'; const cloneCompetition = (value: T): T => JSON.parse(JSON.stringify(value)) as T; @@ -8,8 +8,8 @@ const makeChildActivity = ( name: string, startTime: string, endTime: string, - extensions: { id: string; data: Record }[] = [], -) => ({ + extensions: Activity['extensions'] = [], +): Activity => ({ id, activityCode, name, @@ -20,7 +20,7 @@ const makeChildActivity = ( scrambleSetId: null, }); -const mainRoomActivities = [ +const mainRoomActivities: Activity[] = [ { id: 100, activityCode: '333-r1', @@ -34,7 +34,7 @@ const mainRoomActivities = [ '3x3x3 Cube, Round 1, Group 1', '2026-05-03T16:00:00Z', '2026-05-03T16:45:00Z', - [{ id: 'org.cubingusa.natshelper.v1.Group', data: { stageId: 1 } }], + [{ id: 'org.cubingusa.natshelper.v1.Group', specUrl: '', data: { stageId: 1 } }], ), makeChildActivity( 102, @@ -42,7 +42,7 @@ const mainRoomActivities = [ '3x3x3 Cube, Round 1, Group 2', '2026-05-03T16:45:00Z', '2026-05-03T17:30:00Z', - [{ id: 'org.cubingusa.natshelper.v1.Group', data: { stageId: 1 } }], + [{ id: 'org.cubingusa.natshelper.v1.Group', specUrl: '', data: { stageId: 1 } }], ), ], extensions: [], @@ -62,9 +62,10 @@ const mainRoomActivities = [ '2026-05-04T17:00:00Z', '2026-05-04T17:30:00Z', [ - { id: 'org.cubingusa.natshelper.v1.Group', data: { stageId: 1 } }, + { id: 'org.cubingusa.natshelper.v1.Group', specUrl: '', data: { stageId: 1 } }, { id: 'groupifier.ActivityConfig', + specUrl: '', data: { featuredCompetitorWcaUserIds: [1001, 1003], }, @@ -77,7 +78,7 @@ const mainRoomActivities = [ '3x3x3 Cube, Round 2, Group 2', '2026-05-04T17:30:00Z', '2026-05-04T18:00:00Z', - [{ id: 'org.cubingusa.natshelper.v1.Group', data: { stageId: 1 } }], + [{ id: 'org.cubingusa.natshelper.v1.Group', specUrl: '', data: { stageId: 1 } }], ), ], extensions: [], @@ -96,7 +97,7 @@ const mainRoomActivities = [ '3x3x3 Cube, Round 3, Group 1', '2026-05-04T19:00:00Z', '2026-05-04T19:30:00Z', - [{ id: 'org.cubingusa.natshelper.v1.Group', data: { stageId: 1 } }], + [{ id: 'org.cubingusa.natshelper.v1.Group', specUrl: '', data: { stageId: 1 } }], ), makeChildActivity( 122, @@ -104,7 +105,7 @@ const mainRoomActivities = [ '3x3x3 Cube, Round 3, Group 2', '2026-05-04T19:30:00Z', '2026-05-04T20:00:00Z', - [{ id: 'org.cubingusa.natshelper.v1.Group', data: { stageId: 1 } }], + [{ id: 'org.cubingusa.natshelper.v1.Group', specUrl: '', data: { stageId: 1 } }], ), ], extensions: [], @@ -123,7 +124,7 @@ const mainRoomActivities = [ '2x2x2 Cube, Round 1, Group 1', '2026-05-03T18:00:00Z', '2026-05-03T18:30:00Z', - [{ id: 'org.cubingusa.natshelper.v1.Group', data: { stageId: 1 } }], + [{ id: 'org.cubingusa.natshelper.v1.Group', specUrl: '', data: { stageId: 1 } }], ), makeChildActivity( 132, @@ -131,7 +132,7 @@ const mainRoomActivities = [ '2x2x2 Cube, Round 1, Group 2', '2026-05-03T18:30:00Z', '2026-05-03T19:00:00Z', - [{ id: 'org.cubingusa.natshelper.v1.Group', data: { stageId: 1 } }], + [{ id: 'org.cubingusa.natshelper.v1.Group', specUrl: '', data: { stageId: 1 } }], ), ], extensions: [], @@ -139,7 +140,7 @@ const mainRoomActivities = [ }, ]; -const sideRoomActivities = [ +const sideRoomActivities: Activity[] = [ { id: 200, activityCode: '333-r1', @@ -153,7 +154,7 @@ const sideRoomActivities = [ '3x3x3 Cube, Round 1, Group 1', '2026-05-03T16:00:00Z', '2026-05-03T16:45:00Z', - [{ id: 'org.cubingusa.natshelper.v1.Group', data: { stageId: 2 } }], + [{ id: 'org.cubingusa.natshelper.v1.Group', specUrl: '', data: { stageId: 2 } }], ), makeChildActivity( 202, @@ -161,7 +162,7 @@ const sideRoomActivities = [ '3x3x3 Cube, Round 1, Group 2', '2026-05-03T16:45:00Z', '2026-05-03T17:30:00Z', - [{ id: 'org.cubingusa.natshelper.v1.Group', data: { stageId: 2 } }], + [{ id: 'org.cubingusa.natshelper.v1.Group', specUrl: '', data: { stageId: 2 } }], ), ], extensions: [], @@ -179,7 +180,7 @@ const sideRoomActivities = [ }, ]; -const storybookPersons = [ +const storybookPersons: Person[] = [ { registrantId: 1, name: 'Blake Thompson', @@ -199,7 +200,7 @@ const storybookPersons = [ { eventId: '333', type: 'single', - best: 652, + value: 652, worldRanking: 1820, continentalRanking: 710, nationalRanking: 215, @@ -207,7 +208,7 @@ const storybookPersons = [ { eventId: '333', type: 'average', - best: 841, + value: 841, worldRanking: 2410, continentalRanking: 980, nationalRanking: 301, @@ -215,7 +216,7 @@ const storybookPersons = [ { eventId: '222', type: 'single', - best: 187, + value: 187, worldRanking: 930, continentalRanking: 302, nationalRanking: 88, @@ -223,7 +224,7 @@ const storybookPersons = [ { eventId: '222', type: 'average', - best: 259, + value: 259, worldRanking: 1180, continentalRanking: 415, nationalRanking: 120, @@ -236,6 +237,7 @@ const storybookPersons = [ 'https://avatars.worldcubeassociation.org/uploads/user/avatar/2010THOM03/1526357179_thumb.jpeg', }, registration: { + wcaRegistrationId: 1, status: 'accepted', isCompeting: true, eventIds: ['333', '222'], @@ -259,7 +261,7 @@ const storybookPersons = [ { eventId: '333', type: 'single', - best: 701, + value: 701, worldRanking: 2140, continentalRanking: 880, nationalRanking: 142, @@ -267,7 +269,7 @@ const storybookPersons = [ { eventId: '333', type: 'average', - best: 910, + value: 910, worldRanking: 2890, continentalRanking: 1240, nationalRanking: 201, @@ -275,7 +277,7 @@ const storybookPersons = [ { eventId: '222', type: 'single', - best: 244, + value: 244, worldRanking: 1520, continentalRanking: 611, nationalRanking: 102, @@ -287,6 +289,7 @@ const storybookPersons = [ thumbUrl: 'https://avatars.worldcubeassociation.org/uge6fzvlpmz6c8ztn8ey5wi4i8uf', }, registration: { + wcaRegistrationId: 2, status: 'accepted', isCompeting: true, eventIds: ['333', '222'], @@ -310,7 +313,7 @@ const storybookPersons = [ { eventId: '333', type: 'single', - best: 598, + value: 598, worldRanking: 1210, continentalRanking: 420, nationalRanking: 58, @@ -318,7 +321,7 @@ const storybookPersons = [ { eventId: '333', type: 'average', - best: 801, + value: 801, worldRanking: 2010, continentalRanking: 740, nationalRanking: 102, @@ -331,6 +334,7 @@ const storybookPersons = [ 'https://avatars.worldcubeassociation.org/uploads/user/avatar/2014GULA02/1649125122_thumb.jpg', }, registration: { + wcaRegistrationId: 3, status: 'accepted', isCompeting: true, eventIds: ['333'], @@ -354,7 +358,7 @@ const storybookPersons = [ { eventId: '333', type: 'single', - best: 845, + value: 845, worldRanking: 3880, continentalRanking: 1640, nationalRanking: 244, @@ -362,6 +366,7 @@ const storybookPersons = [ ], extensions: [], registration: { + wcaRegistrationId: 4, status: 'accepted', isCompeting: true, eventIds: ['333', '222'], @@ -385,7 +390,7 @@ const storybookPersons = [ { eventId: '333', type: 'single', - best: 630, + value: 630, worldRanking: 1490, continentalRanking: 88, nationalRanking: 21, @@ -393,7 +398,7 @@ const storybookPersons = [ { eventId: '333', type: 'average', - best: 830, + value: 830, worldRanking: 2280, continentalRanking: 131, nationalRanking: 34, @@ -401,6 +406,7 @@ const storybookPersons = [ ], extensions: [], registration: { + wcaRegistrationId: 5, status: 'accepted', isCompeting: true, eventIds: ['333'], @@ -424,6 +430,7 @@ const storybookPersons = [ thumbUrl: 'https://avatars.worldcubeassociation.org/uge6fzvlpmz6c8ztn8ey5wi4i8uf', }, registration: { + wcaRegistrationId: 6, status: 'accepted', isCompeting: false, eventIds: [], @@ -431,6 +438,7 @@ const storybookPersons = [ extensions: [ { id: 'com.competitiongroups.worldsassignments', + specUrl: '', data: { assignments: [ { @@ -445,8 +453,9 @@ const storybookPersons = [ }, ]; -export const storybookCompetitionFixture = { - formatVersion: '1.1', +export const storybookCompetitionFixture: Competition = { + formatVersion: '2.0', + series: null, id: 'SeattleSummerOpen2026', name: 'Seattle Summer Open 2026', shortName: 'Seattle Summer Open 2026', @@ -470,10 +479,8 @@ export const storybookCompetitionFixture = { format: 'a', timeLimit: null, cutoff: null, - advancementCondition: { - type: 'ranking', - level: 24, - }, + linkedRounds: null, + participationRuleset: null, results: [ { personId: 3, ranking: 1, best: 598, average: 801, attempts: [] }, { personId: 1, ranking: 2, best: 652, average: 841, attempts: [] }, @@ -489,9 +496,14 @@ export const storybookCompetitionFixture = { format: 'a', timeLimit: null, cutoff: null, - advancementCondition: { - type: 'ranking', - level: 12, + linkedRounds: null, + participationRuleset: { + reservedPlaces: null, + participationSource: { + type: 'round', + roundId: '333-r1', + resultCondition: { type: 'ranking', scope: 'average', value: 24 }, + }, }, results: [ { personId: 1, ranking: 1, best: 640, average: 820, attempts: [] }, @@ -507,9 +519,14 @@ export const storybookCompetitionFixture = { format: 'a', timeLimit: null, cutoff: null, - advancementCondition: { - type: 'percent', - level: 50, + linkedRounds: null, + participationRuleset: { + reservedPlaces: null, + participationSource: { + type: 'round', + roundId: '333-r2', + resultCondition: { type: 'ranking', scope: 'average', value: 12 }, + }, }, results: [], scrambleSetCount: 1, @@ -533,12 +550,10 @@ export const storybookCompetitionFixture = { }, cutoff: { numberOfAttempts: 2, - attemptResult: 1200, - }, - advancementCondition: { - type: 'ranking', - level: 16, + resultValue: 1200, }, + linkedRounds: null, + participationRuleset: null, results: [ { personId: 1, ranking: 1, best: 187, average: 259, attempts: [] }, { personId: 2, ranking: 2, best: 244, average: 0, attempts: [] }, @@ -573,6 +588,7 @@ export const storybookCompetitionFixture = { extensions: [ { id: 'org.cubingusa.natshelper.v1.Room', + specUrl: '', data: { stages: [ { id: 1, name: 'Blue Stage', color: '#3b82f6' }, @@ -590,6 +606,7 @@ export const storybookCompetitionFixture = { extensions: [ { id: 'org.cubingusa.natshelper.v1.Room', + specUrl: '', data: { stages: [ { id: 1, name: 'Blue Stage', color: '#3b82f6' }, @@ -604,7 +621,7 @@ export const storybookCompetitionFixture = { }, ], }, -} as unknown as Competition; +}; export const storybookCompetitionApiFixture: ApiCompetition = { id: 'SeattleSummerOpen2026', @@ -748,17 +765,20 @@ export const storybookParticipationConditionPercentFixture = makeStorybookCompetitionFixtureWithRoundUpdates({ '333-r1': (round) => ({ ...round, - advancementCondition: null, + linkedRounds: null, + participationRuleset: null, }), '333-r2': (round) => ({ ...round, - advancementCondition: null, + linkedRounds: null, participationRuleset: { + reservedPlaces: null, participationSource: { type: 'round', roundId: '333-r1', resultCondition: { type: 'percent', + scope: 'average', value: 75, }, }, @@ -770,21 +790,25 @@ export const storybookParticipationConditionLinkedRoundsFixture = makeStorybookCompetitionFixtureWithRoundUpdates({ '333-r1': (round) => ({ ...round, - advancementCondition: null, + linkedRounds: null, + participationRuleset: null, }), '333-r2': (round) => ({ ...round, - advancementCondition: null, + linkedRounds: null, + participationRuleset: null, }), '333-r3': (round) => ({ ...round, - advancementCondition: null, + linkedRounds: null, participationRuleset: { + reservedPlaces: null, participationSource: { type: 'linkedRounds', roundIds: ['333-r1', '333-r2'], resultCondition: { type: 'ranking', + scope: 'average', value: 12, }, }, diff --git a/src/types/wca-helpers.d.ts b/src/types/wca-helpers.d.ts deleted file mode 100644 index bb76a48..0000000 --- a/src/types/wca-helpers.d.ts +++ /dev/null @@ -1,44 +0,0 @@ -import '@wca/helpers'; - -declare module '@wca/helpers' { - export type ParticipationResultCondition = - | { - type: 'ranking' | 'percent'; - value: number; - } - | { - type: 'resultAchieved'; - scope: 'single' | 'average'; - value: number | null; - }; - - export type ParticipationSource = - | { - type: 'registrations'; - } - | { - type: 'round'; - roundId: string; - resultCondition: ParticipationResultCondition; - } - | { - type: 'linkedRounds'; - roundIds: string[]; - resultCondition: ParticipationResultCondition; - }; - - export interface ReservedPlaces { - nationalities: string[]; - count?: number; - reservations?: number; - } - - export interface ParticipationRuleset { - participationSource: ParticipationSource; - reservedPlaces?: ReservedPlaces | null; - } - - export interface Round { - participationRuleset?: ParticipationRuleset | null; - } -} diff --git a/yarn.lock b/yarn.lock index 160b23a..9391aa1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3956,10 +3956,10 @@ loupe "^3.1.2" tinyrainbow "^1.2.0" -"@wca/helpers@^1.1.5": - version "1.1.5" - resolved "https://registry.yarnpkg.com/@wca/helpers/-/helpers-1.1.5.tgz#cbf0c3da6ce9a15e6e69eaf413a0cf9cb4775db0" - integrity sha512-Sr/XxkEXctrRKFJuqyAy9KH7q//YevEo+zSCESpVQqieB4Y3KjHYOl54kPaocwAHkWniGMOwZWD6O1Q9MR1d3w== +"@wca/helpers@^2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@wca/helpers/-/helpers-2.0.0.tgz#ace1af3446b552ef54f3a83cc35cc05a74c04ea9" + integrity sha512-VYcby20Y9Uf7FWSxvzHbJRp6eOW7Gb4z7Nvy+8ggjxIKqfndAewxysiYop2LZ6VXnJavtV4SPy2AFhAH+OT4Bg== "@wry/context@^0.7.0", "@wry/context@^0.7.3": version "0.7.4"