From ef9d1048db16cd88d7d08b4fa5b36b420971a418 Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Thu, 16 Apr 2026 18:33:29 -0700 Subject: [PATCH 1/4] fix: show advancement details for participation rounds --- .../CutoffTimeLimitPanel.test.tsx | 139 +++++++++++++- .../CutoffTimeLimitPanel.tsx | 153 +++++++++++---- src/lib/wcif.test.ts | 112 +++++++++++ src/lib/wcif.ts | 181 ++++++++++++++++++ 4 files changed, 543 insertions(+), 42 deletions(-) create mode 100644 src/lib/wcif.test.ts create mode 100644 src/lib/wcif.ts diff --git a/src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.test.tsx b/src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.test.tsx index 832e75e..21ee6db 100644 --- a/src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.test.tsx +++ b/src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.test.tsx @@ -22,24 +22,122 @@ jest.mock('react-tiny-popover', () => ({ })); jest.mock('react-i18next', () => ({ - Trans: ({ i18nKey }: { i18nKey: string }) => i18nKey, + Trans: ({ i18nKey, values }: { i18nKey: string; values?: Record }) => { + if (i18nKey === 'common.wca.advancement.ranking') { + return `Top ${values?.level} to next round`; + } + + if (i18nKey === 'common.wca.advancement.percent') { + return `Top ${values?.level}% to next round`; + } + + if (i18nKey === 'common.wca.cumulativeTimelimit') { + return `Time Limit: ${values?.time} Cumulative`; + } + + if (i18nKey === 'common.wca.cumulativeTimelimitWithrounds') { + return `Time Limit: ${values?.time} Total with:`; + } + + return i18nKey; + }, useTranslation: () => ({ - t: (key: string) => key, + t: (key: string, options?: Record) => { + if (key === 'common.help') { + return 'help'; + } + + if (key === 'common.wca.cutoff') { + return 'Cutoff'; + } + + if (key === 'common.wca.timeLimit') { + return 'Time Limit'; + } + + if (key === 'common.activityCodeToName.round') { + return `Round ${options?.roundNumber}`; + } + + if (options?.defaultValue) { + return String(options.defaultValue) + .replace('{{level}}', String(options.level ?? '')) + .replace('{{rounds}}', String(options.rounds ?? '')) + .replace('{{scope}}', String(options.scope ?? '')) + .replace('{{result}}', String(options.result ?? '')); + } + + return key; + }, }), })); +const wcifMock = { + id: 'TestComp2026', + schedule: { venues: [] }, + events: [ + { + id: '333', + rounds: [ + { + id: '333-r1', + format: 'a', + cutoff: null, + timeLimit: null, + advancementCondition: { + type: 'ranking', + level: 16, + }, + results: [], + }, + { + id: '333-r2', + format: 'a', + cutoff: null, + timeLimit: null, + participationRuleset: { + participationSource: { + type: 'round', + roundId: '333-r1', + resultCondition: { + type: 'percent', + value: 75, + }, + }, + }, + results: [], + }, + { + id: '333-r3', + format: 'a', + cutoff: null, + timeLimit: null, + participationRuleset: { + participationSource: { + type: 'linkedRounds', + roundIds: ['333-r1', '333-r2'], + resultCondition: { + type: 'ranking', + value: 12, + }, + }, + }, + results: [], + }, + ], + }, + ], +}; + jest.mock('@/providers/WCIFProvider', () => ({ useWCIF: () => ({ competitionId: 'TestComp2026', - wcif: { - id: 'TestComp2026', - schedule: { venues: [] }, - }, + wcif: wcifMock, setTitle: () => {}, }), })); -const round = { +const cutoffOnlyRound = { id: '333-r1', cutoff: { numberOfAttempts: 2, @@ -49,7 +147,7 @@ const round = { advancementCondition: null, } as unknown as Round; -function renderPanel() { +function renderPanel(round: Round) { return render( @@ -59,7 +157,7 @@ function renderPanel() { describe('CutoffTimeLimitPanel', () => { it('uses theme-aware popover classes for help content', () => { - renderPanel(); + renderPanel(cutoffOnlyRound); fireEvent.click(screen.getByRole('button', { name: /help/i })); @@ -71,4 +169,27 @@ describe('CutoffTimeLimitPanel', () => { 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); + + expect(screen.getByText('Top 16 to next round')).toBeInTheDocument(); + }); + + it('shows advancement text derived from the next round participation ruleset', () => { + renderPanel({ + ...(wcifMock.events[0].rounds[0] as object), + advancementCondition: null, + } as Round); + + expect(screen.getByText('Top 75% 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); + + expect( + screen.getByText('Top 12 combined across Round 1 and Round 2 advance to next round'), + ).toBeInTheDocument(); + }); }); diff --git a/src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.tsx b/src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.tsx index 615882b..1f15715 100644 --- a/src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.tsx +++ b/src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.tsx @@ -1,10 +1,11 @@ import { Cutoff, Round, parseActivityCode } from '@wca/helpers'; import classNames from 'classnames'; -import { useState } from 'react'; +import { useMemo, useState } from 'react'; import { Trans, useTranslation } from 'react-i18next'; import { Link } from 'react-router-dom'; import { Popover } from 'react-tiny-popover'; import { renderCentiseconds, renderCutoff } from '@/lib/results'; +import { CompatibleRound, getAdvancementConditionForRound, ResultCondition } from '@/lib/wcif'; import { useWCIF } from '@/providers/WCIFProvider'; export function CutoffTimeLimitPanel({ @@ -20,10 +21,20 @@ export function CutoffTimeLimitPanel({ const cutoff = round.cutoff; const timeLimit = round.timeLimit; const timelimitTime = timeLimit && renderCentiseconds(timeLimit?.centiseconds); + const eventRounds = useMemo(() => { + const { eventId } = parseActivityCode(round.id); + return ( + wcif?.events + ?.find((event) => event.id === eventId) + ?.rounds?.map((candidate) => candidate as CompatibleRound) || [] + ); + }, [round.id, wcif?.events]); + const advancement = useMemo( + () => getAdvancementConditionForRound(eventRounds, round as CompatibleRound), + [eventRounds, round], + ); - if (!timeLimit && !cutoff && !round.advancementCondition) return null; - - const level = round.advancementCondition?.level; + if (!timeLimit && !cutoff && !advancement) return null; return (
@@ -86,35 +97,9 @@ export function CutoffTimeLimitPanel({
)} - {round.advancementCondition && ( -
- {round.advancementCondition.type === 'ranking' && ( -
- }} - /> -
- )} - {round.advancementCondition.type === 'percent' && ( -
- }} - /> -
- )} - {round.advancementCondition.type === 'attemptResult' && ( -
- }} - /> -
- )} + {advancement && ( +
+ {renderAdvancementText(t, advancement.sourceType, advancement)}
)}
@@ -125,6 +110,108 @@ export function CutoffTimeLimitPanel({ ); } +function renderAdvancementText( + t: ReturnType['t'], + sourceType: 'registrations' | 'round' | 'linkedRounds', + advancement: NonNullable>, +) { + const isLinkedRounds = sourceType === 'linkedRounds'; + const { resultCondition } = advancement; + const sourceRoundNames = advancement.sourceRoundIds.map((roundId) => + activityCodeToRoundName(t, roundId), + ); + const sourceRoundsLabel = joinLabels(sourceRoundNames); + + switch (resultCondition.type) { + case 'ranking': + return isLinkedRounds ? ( + <> + {t('common.wca.advancement.linkedRanking', { + defaultValue: 'Top {{level}} combined across {{rounds}} advance to next round', + level: resultCondition.value, + rounds: sourceRoundsLabel, + })} + + ) : ( + }} + /> + ); + case 'percent': + return isLinkedRounds ? ( + <> + {t('common.wca.advancement.linkedPercent', { + defaultValue: 'Top {{level}}% combined across {{rounds}} advance to next round', + level: resultCondition.value, + rounds: sourceRoundsLabel, + })} + + ) : ( + }} + /> + ); + case 'resultAchieved': { + const thresholdCondition = resultCondition as Extract< + ResultCondition, + { type: 'resultAchieved' } + >; + const scopeLabel = t(`common.wca.resultType.${thresholdCondition.scope}`, { + defaultValue: thresholdCondition.scope, + }).toLowerCase(); + const resultValue = + thresholdCondition.value === null + ? t('common.wca.advancement.resultThresholdUnknown', { + defaultValue: 'an unknown result', + }) + : renderCentiseconds(thresholdCondition.value); + + return ( + <> + {t( + isLinkedRounds + ? 'common.wca.advancement.linkedResultAchieved' + : 'common.wca.advancement.resultAchieved', + { + defaultValue: isLinkedRounds + ? 'Competitors with a {{scope}} better than {{result}} combined across {{rounds}} advance to next round. Minimum of 25% of competitors must be eliminated.' + : 'Competitors with a {{scope}} better than {{result}} advance to next round. Minimum of 25% of competitors must be eliminated.', + scope: scopeLabel, + result: resultValue, + rounds: sourceRoundsLabel, + }, + )} + + ); + } + } +} + +function activityCodeToRoundName(t: ReturnType['t'], roundId: string) { + const { roundNumber } = parseActivityCode(roundId); + + return t('common.activityCodeToName.round', { + defaultValue: `Round ${roundNumber}`, + roundNumber, + }); +} + +function joinLabels(labels: string[]) { + if (labels.length <= 1) { + return labels[0] || ''; + } + + if (labels.length === 2) { + return `${labels[0]} and ${labels[1]}`; + } + + return `${labels.slice(0, -1).join(', ')}, and ${labels[labels.length - 1]}`; +} + function CutoffTimeLimitPopover({ cutoff }: { cutoff: Cutoff | null }) { const { t } = useTranslation(); const [open, setOpen] = useState(false); diff --git a/src/lib/wcif.test.ts b/src/lib/wcif.test.ts new file mode 100644 index 0000000..fb722c4 --- /dev/null +++ b/src/lib/wcif.test.ts @@ -0,0 +1,112 @@ +import { + CompatibleRound, + getAdvancementConditionForRound, + getRoundParticipationRuleset, +} 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 CompatibleRound[]; + + expect(getRoundParticipationRuleset(rounds, rounds[1])).toEqual({ + participationSource: { + type: 'round', + roundId: '333-r1', + resultCondition: { + type: 'ranking', + value: 16, + }, + }, + }); + }); + + it('derives current-round advancement from the next round participation ruleset', () => { + 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 CompatibleRound[]; + + expect(getAdvancementConditionForRound(rounds, rounds[0])).toEqual({ + sourceType: 'round', + sourceRoundIds: ['333-r1'], + resultCondition: { + type: 'percent', + value: 75, + }, + reservedPlaces: null, + }); + }); + + it('derives linked-round advancement from the next round participation ruleset', () => { + 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, + }, + }, + }, + results: [], + }, + ] as unknown as CompatibleRound[]; + + expect(getAdvancementConditionForRound(rounds, rounds[1])).toEqual({ + sourceType: 'linkedRounds', + sourceRoundIds: ['333-r1', '333-r2'], + resultCondition: { + type: 'ranking', + value: 12, + }, + reservedPlaces: null, + }); + }); +}); diff --git a/src/lib/wcif.ts b/src/lib/wcif.ts new file mode 100644 index 0000000..b36c6b8 --- /dev/null +++ b/src/lib/wcif.ts @@ -0,0 +1,181 @@ +import { Round, parseActivityCode } from '@wca/helpers'; + +type LegacyAdvancementCondition = { + type: 'ranking' | 'percent' | 'attemptResult'; + level: number; +}; + +export type ResultCondition = + | { + type: 'ranking' | 'percent'; + value: number; + } + | { + type: 'resultAchieved'; + scope: 'single' | 'average'; + value: number | null; + }; + +export type ParticipationSource = + | { + type: 'registrations'; + } + | { + type: 'round'; + roundId: string; + resultCondition: ResultCondition; + } + | { + type: 'linkedRounds'; + roundIds: string[]; + resultCondition: ResultCondition; + }; + +export type ReservedPlaces = { + nationalities: string[]; + count?: number; + reservations?: number; +}; + +export type ParticipationRuleset = { + participationSource: ParticipationSource; + reservedPlaces?: ReservedPlaces | null; +}; + +export interface RoundAdvancementCondition { + sourceType: ParticipationSource['type']; + sourceRoundIds: string[]; + resultCondition: ResultCondition; + reservedPlaces?: ReservedPlaces | null; +} + +export type CompatibleRound = Round & { + advancementCondition?: LegacyAdvancementCondition | null; + participationRuleset?: ParticipationRuleset | null; +}; + +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?: CompatibleRound, +): 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: CompatibleRound[], + round: CompatibleRound, +): CompatibleRound | 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: CompatibleRound[], + round: CompatibleRound, +): 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: CompatibleRound[], + round: CompatibleRound, +): ParticipationSource | null => + getRoundParticipationRuleset(eventRounds, round)?.participationSource ?? null; + +export const getAdvancementConditionForRound = ( + eventRounds: CompatibleRound[], + round: CompatibleRound, +): RoundAdvancementCondition | null => { + if (round.advancementCondition) { + return { + sourceType: 'round', + sourceRoundIds: [round.id], + resultCondition: getLegacyResultCondition(round.advancementCondition, round), + reservedPlaces: null, + }; + } + + const { roundNumber } = parseActivityCode(round.id); + const futureRounds = eventRounds.filter((candidate) => { + const parsedCandidate = parseActivityCode(candidate.id); + return (parsedCandidate.roundNumber ?? 0) > (roundNumber ?? 0); + }); + + const nextEligibleRound = futureRounds.find((candidate) => { + const source = getRoundParticipationSource(eventRounds, candidate); + + if (!source || source.type === 'registrations') { + return false; + } + + if (source.type === 'round') { + return source.roundId === round.id; + } + + return source.roundIds.includes(round.id); + }); + + if (!nextEligibleRound) { + return null; + } + + const ruleset = getRoundParticipationRuleset(eventRounds, nextEligibleRound); + const source = ruleset?.participationSource; + + if (!source || source.type === 'registrations') { + return null; + } + + return { + sourceType: source.type, + sourceRoundIds: source.type === 'round' ? [source.roundId] : source.roundIds, + resultCondition: source.resultCondition, + reservedPlaces: ruleset?.reservedPlaces ?? null, + }; +}; From 57e52132e175f87bc22e34db465466c7aeee7aff Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Mon, 20 Apr 2026 14:03:48 -0700 Subject: [PATCH 2/4] add participation condition typing and stories --- .../CutoffTimeLimitPanel.stories.tsx | 20 +++++ .../CompetitionRound.stories.tsx | 22 ++++++ src/lib/wcif.test.ts | 13 ++-- src/lib/wcif.ts | 77 +++++-------------- src/storybook/competitionFixtures.ts | 57 ++++++++++++++ src/types/wca-helpers.d.ts | 44 +++++++++++ 6 files changed, 168 insertions(+), 65 deletions(-) create mode 100644 src/types/wca-helpers.d.ts diff --git a/src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.stories.tsx b/src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.stories.tsx index e12a099..55021f4 100644 --- a/src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.stories.tsx +++ b/src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.stories.tsx @@ -2,6 +2,8 @@ import type { Meta, StoryObj } from '@storybook/react'; import { getStorybookRoundFixture, makeStorybookCompetitionFixtureWithRound, + storybookParticipationConditionLinkedRoundsFixture, + storybookParticipationConditionPercentFixture, } from '@/storybook/competitionFixtures'; import { makeCompetitionContainerDecorator } from '@/storybook/competitionStorybook'; import { CutoffTimeLimitPanel } from './CutoffTimeLimitPanel'; @@ -53,6 +55,24 @@ export const RankingAdvancement: Story = { }, }; +export const ParticipationConditionPercent: Story = { + parameters: { + competitionFixture: storybookParticipationConditionPercentFixture, + }, + args: { + round: storybookParticipationConditionPercentFixture.events[0].rounds[0], + }, +}; + +export const ParticipationConditionLinkedRounds: Story = { + parameters: { + competitionFixture: storybookParticipationConditionLinkedRoundsFixture, + }, + args: { + round: storybookParticipationConditionLinkedRoundsFixture.events[0].rounds[1], + }, +}; + export const CutoffAndTimeLimit: Story = { args: { round: getStorybookRoundFixture('222-r1'), diff --git a/src/containers/CompetitionRound/CompetitionRound.stories.tsx b/src/containers/CompetitionRound/CompetitionRound.stories.tsx index 92d0868..aec8f31 100644 --- a/src/containers/CompetitionRound/CompetitionRound.stories.tsx +++ b/src/containers/CompetitionRound/CompetitionRound.stories.tsx @@ -2,6 +2,8 @@ import type { Meta, StoryObj } from '@storybook/react'; import { makeStorybookCompetitionFixtureWithRound, makeStorybookEventCompetitionFixture, + storybookParticipationConditionLinkedRoundsFixture, + storybookParticipationConditionPercentFixture, } from '@/storybook/competitionFixtures'; import { makeCompetitionContainerDecorator } from '@/storybook/competitionStorybook'; import { CompetitionRoundContainer } from './CompetitionRound'; @@ -24,6 +26,16 @@ export const RoundOne: Story = { }, }; +export const ParticipationConditionPercent: Story = { + parameters: { + competitionFixture: storybookParticipationConditionPercentFixture, + }, + args: { + competitionId: 'SeattleSummerOpen2026', + roundId: '333-r1', + }, +}; + export const RoundTwo: Story = { args: { competitionId: 'SeattleSummerOpen2026', @@ -31,6 +43,16 @@ export const RoundTwo: Story = { }, }; +export const ParticipationConditionLinkedRounds: Story = { + parameters: { + competitionFixture: storybookParticipationConditionLinkedRoundsFixture, + }, + args: { + competitionId: 'SeattleSummerOpen2026', + roundId: '333-r2', + }, +}; + export const FinalRound: Story = { parameters: { competitionFixture: makeStorybookCompetitionFixtureWithRound('333-r3', (round) => ({ diff --git a/src/lib/wcif.test.ts b/src/lib/wcif.test.ts index fb722c4..5b67872 100644 --- a/src/lib/wcif.test.ts +++ b/src/lib/wcif.test.ts @@ -1,8 +1,5 @@ -import { - CompatibleRound, - getAdvancementConditionForRound, - getRoundParticipationRuleset, -} from './wcif'; +import { Round } from '@wca/helpers'; +import { getAdvancementConditionForRound, getRoundParticipationRuleset } from './wcif'; describe('wcif participation helpers', () => { it('backfills stable advancement conditions into a participation ruleset', () => { @@ -21,7 +18,7 @@ describe('wcif participation helpers', () => { }, results: [], }, - ] as unknown as CompatibleRound[]; + ] as unknown as Round[]; expect(getRoundParticipationRuleset(rounds, rounds[1])).toEqual({ participationSource: { @@ -57,7 +54,7 @@ describe('wcif participation helpers', () => { }, results: [], }, - ] as unknown as CompatibleRound[]; + ] as unknown as Round[]; expect(getAdvancementConditionForRound(rounds, rounds[0])).toEqual({ sourceType: 'round', @@ -97,7 +94,7 @@ describe('wcif participation helpers', () => { }, results: [], }, - ] as unknown as CompatibleRound[]; + ] as unknown as Round[]; expect(getAdvancementConditionForRound(rounds, rounds[1])).toEqual({ sourceType: 'linkedRounds', diff --git a/src/lib/wcif.ts b/src/lib/wcif.ts index b36c6b8..af6d1b2 100644 --- a/src/lib/wcif.ts +++ b/src/lib/wcif.ts @@ -1,46 +1,15 @@ -import { Round, parseActivityCode } from '@wca/helpers'; +import { + ParticipationResultCondition, + ParticipationRuleset, + ParticipationSource, + ReservedPlaces, + Round, + parseActivityCode, +} from '@wca/helpers'; -type LegacyAdvancementCondition = { - type: 'ranking' | 'percent' | 'attemptResult'; - level: number; -}; - -export type ResultCondition = - | { - type: 'ranking' | 'percent'; - value: number; - } - | { - type: 'resultAchieved'; - scope: 'single' | 'average'; - value: number | null; - }; +type LegacyAdvancementCondition = NonNullable; -export type ParticipationSource = - | { - type: 'registrations'; - } - | { - type: 'round'; - roundId: string; - resultCondition: ResultCondition; - } - | { - type: 'linkedRounds'; - roundIds: string[]; - resultCondition: ResultCondition; - }; - -export type ReservedPlaces = { - nationalities: string[]; - count?: number; - reservations?: number; -}; - -export type ParticipationRuleset = { - participationSource: ParticipationSource; - reservedPlaces?: ReservedPlaces | null; -}; +export type ResultCondition = ParticipationResultCondition; export interface RoundAdvancementCondition { sourceType: ParticipationSource['type']; @@ -49,19 +18,16 @@ export interface RoundAdvancementCondition { reservedPlaces?: ReservedPlaces | null; } -export type CompatibleRound = Round & { - advancementCondition?: LegacyAdvancementCondition | null; - participationRuleset?: ParticipationRuleset | null; -}; +export type CompatibleRound = Round; const averagedFormats = new Set(['a', 'm', '5', 'h']); -const getRoundResultType = (round: Pick): 'single' | 'average' => +const getRoundResultType = (round: Pick): 'single' | 'average' => averagedFormats.has(round.format) ? 'average' : 'single'; const getLegacyResultCondition = ( advancementCondition: LegacyAdvancementCondition, - sourceRound?: CompatibleRound, + sourceRound?: Round, ): ResultCondition => { switch (advancementCondition.type) { case 'ranking': @@ -83,10 +49,7 @@ const getLegacyResultCondition = ( } }; -const getPreviousRound = ( - eventRounds: CompatibleRound[], - round: CompatibleRound, -): CompatibleRound | undefined => { +const getPreviousRound = (eventRounds: Round[], round: Round): Round | undefined => { const { eventId, roundNumber } = parseActivityCode(round.id); if (!roundNumber || roundNumber <= 1) { @@ -97,8 +60,8 @@ const getPreviousRound = ( }; export const getRoundParticipationRuleset = ( - eventRounds: CompatibleRound[], - round: CompatibleRound, + eventRounds: Round[], + round: Round, ): ParticipationRuleset | null => { if (round.participationRuleset) { return round.participationRuleset; @@ -123,14 +86,14 @@ export const getRoundParticipationRuleset = ( }; const getRoundParticipationSource = ( - eventRounds: CompatibleRound[], - round: CompatibleRound, + eventRounds: Round[], + round: Round, ): ParticipationSource | null => getRoundParticipationRuleset(eventRounds, round)?.participationSource ?? null; export const getAdvancementConditionForRound = ( - eventRounds: CompatibleRound[], - round: CompatibleRound, + eventRounds: Round[], + round: Round, ): RoundAdvancementCondition | null => { if (round.advancementCondition) { return { diff --git a/src/storybook/competitionFixtures.ts b/src/storybook/competitionFixtures.ts index 178d513..2bb83b7 100644 --- a/src/storybook/competitionFixtures.ts +++ b/src/storybook/competitionFixtures.ts @@ -730,3 +730,60 @@ export const makeStorybookCompetitionFixtureWithRound = ( return competition; }; + +export const makeStorybookCompetitionFixtureWithRoundUpdates = ( + updates: Record Round>, +): Competition => { + const competition = cloneCompetition(storybookCompetitionFixture); + + competition.events = competition.events.map((event) => ({ + ...event, + rounds: event.rounds.map((round) => updates[round.id]?.(round) ?? round), + })); + + return competition; +}; + +export const storybookParticipationConditionPercentFixture = + makeStorybookCompetitionFixtureWithRoundUpdates({ + '333-r1': (round) => ({ + ...round, + advancementCondition: null, + }), + '333-r2': (round) => ({ + ...round, + advancementCondition: null, + participationRuleset: { + participationSource: { + type: 'round', + roundId: '333-r1', + resultCondition: { + type: 'percent', + value: 75, + }, + }, + }, + }), + }); + +export const storybookParticipationConditionLinkedRoundsFixture = + makeStorybookCompetitionFixtureWithRoundUpdates({ + '333-r2': (round) => ({ + ...round, + advancementCondition: null, + }), + '333-r3': (round) => ({ + ...round, + advancementCondition: null, + participationRuleset: { + participationSource: { + type: 'linkedRounds', + roundIds: ['333-r1', '333-r2'], + resultCondition: { + type: 'ranking', + value: 12, + }, + }, + }, + }), + }); diff --git a/src/types/wca-helpers.d.ts b/src/types/wca-helpers.d.ts new file mode 100644 index 0000000..bb76a48 --- /dev/null +++ b/src/types/wca-helpers.d.ts @@ -0,0 +1,44 @@ +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; + } +} From ee5398b0c3962f1edc64cbe50f85d2b88dc6e866 Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Mon, 20 Apr 2026 14:33:50 -0700 Subject: [PATCH 3/4] Address CutoffTimeLimitPanel review feedback --- .../CutoffTimeLimitPanel.test.tsx | 8 +++ .../CutoffTimeLimitPanel.tsx | 68 ++++++++++--------- src/i18n/en/translation.yaml | 5 ++ 3 files changed, 49 insertions(+), 32 deletions(-) diff --git a/src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.test.tsx b/src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.test.tsx index 21ee6db..54dcb55 100644 --- a/src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.test.tsx +++ b/src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.test.tsx @@ -31,6 +31,14 @@ jest.mock('react-i18next', () => ({ return `Top ${values?.level}% to next round`; } + if (i18nKey === 'common.wca.advancement.linkedRanking') { + return `Top ${values?.level} combined across ${values?.rounds} advance to next round`; + } + + if (i18nKey === 'common.wca.advancement.linkedPercent') { + return `Top ${values?.level}% combined across ${values?.rounds} advance to next round`; + } + if (i18nKey === 'common.wca.cumulativeTimelimit') { return `Time Limit: ${values?.time} Cumulative`; } diff --git a/src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.tsx b/src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.tsx index 1f15715..7df8a21 100644 --- a/src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.tsx +++ b/src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.tsx @@ -1,4 +1,4 @@ -import { Cutoff, Round, parseActivityCode } from '@wca/helpers'; +import { Competition, Cutoff, Round, parseActivityCode } from '@wca/helpers'; import classNames from 'classnames'; import { useMemo, useState } from 'react'; import { Trans, useTranslation } from 'react-i18next'; @@ -21,14 +21,11 @@ export function CutoffTimeLimitPanel({ const cutoff = round.cutoff; const timeLimit = round.timeLimit; const timelimitTime = timeLimit && renderCentiseconds(timeLimit?.centiseconds); - const eventRounds = useMemo(() => { - const { eventId } = parseActivityCode(round.id); - return ( - wcif?.events - ?.find((event) => event.id === eventId) - ?.rounds?.map((candidate) => candidate as CompatibleRound) || [] - ); - }, [round.id, wcif?.events]); + const cumulativeRoundIds = getCumulativeRoundIds(timeLimit, round.id); + const eventRounds = useMemo( + () => getEventRoundsForRound(wcif?.events, round.id), + [round.id, wcif?.events], + ); const advancement = useMemo( () => getAdvancementConditionForRound(eventRounds, round as CompatibleRound), [eventRounds, round], @@ -55,8 +52,7 @@ export function CutoffTimeLimitPanel({ {timeLimit && timeLimit?.cumulativeRoundIds.length > 0 && - timeLimit.cumulativeRoundIds.filter((activityCode) => activityCode !== round.id) - .length === 0 && ( + cumulativeRoundIds.length === 0 && ( 0 && - timeLimit.cumulativeRoundIds.filter((activityCode) => activityCode !== round.id) - .length > 0 && ( + cumulativeRoundIds.length > 0 && (
}} /> - {timeLimit.cumulativeRoundIds - .filter((activityCode) => activityCode !== round.id) - .map((activityCode, i, arry) => { - const { eventId, roundNumber } = parseActivityCode(activityCode); - return ( - - - {t('common.activityCodeToName.round', { roundNumber })} - {i < arry.length - 1 ? ', ' : ''} - - - ); - })} + {cumulativeRoundIds.map((activityCode, i, arry) => { + const { eventId, roundNumber } = parseActivityCode(activityCode); + return ( + + + {t('common.activityCodeToName.round', { roundNumber })} + {i < arry.length - 1 ? ', ' : ''} + + + ); + })}
)} @@ -110,6 +103,20 @@ export function CutoffTimeLimitPanel({ ); } +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) || [] + ); +} + +function getCumulativeRoundIds(timeLimit: Round['timeLimit'], roundId: string) { + return timeLimit?.cumulativeRoundIds.filter((activityCode) => activityCode !== roundId) || []; +} + function renderAdvancementText( t: ReturnType['t'], sourceType: 'registrations' | 'round' | 'linkedRounds', @@ -177,9 +184,6 @@ function renderAdvancementText( ? 'common.wca.advancement.linkedResultAchieved' : 'common.wca.advancement.resultAchieved', { - defaultValue: isLinkedRounds - ? 'Competitors with a {{scope}} better than {{result}} combined across {{rounds}} advance to next round. Minimum of 25% of competitors must be eliminated.' - : 'Competitors with a {{scope}} better than {{result}} advance to next round. Minimum of 25% of competitors must be eliminated.', scope: scopeLabel, result: resultValue, rounds: sourceRoundsLabel, diff --git a/src/i18n/en/translation.yaml b/src/i18n/en/translation.yaml index 81aad5b..cff3c8e 100644 --- a/src/i18n/en/translation.yaml +++ b/src/i18n/en/translation.yaml @@ -33,7 +33,12 @@ common: advancement: ranking: 'Top {{level}} to next round' percent: 'Top {{level}}% to next round' + linkedRanking: 'Top {{level}} combined across {{rounds}} advance to next round' + linkedPercent: 'Top {{level}}% combined across {{rounds}} advance to next round' attemptResult: Result better than {level} advances to next round. Minimum of 25% of competitors must be eliminated. + resultAchieved: Competitors with a {{scope}} better than {{result}} advance to next round. Minimum of 25% of competitors must be eliminated. + linkedResultAchieved: Competitors with a {{scope}} better than {{result}} combined across {{rounds}} advance to next round. Minimum of 25% of competitors must be eliminated. + resultThresholdUnknown: an unknown result resultType: single: single average: Average From cb09e3e2e0fb7a2dd01b00d2a7ec162333c58885 Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Mon, 20 Apr 2026 16:10:19 -0700 Subject: [PATCH 4/4] Refine advancement copy and linked round UI --- .../CutoffTimeLimitPanel.stories.tsx | 9 + .../CutoffTimeLimitPanel.test.tsx | 80 +++++++- .../CutoffTimeLimitPanel.tsx | 168 ++++++++-------- .../CompetitionRound.stories.tsx | 20 ++ .../CompetitionRound.test.tsx | 190 ++++++++++++++++++ .../CompetitionRound/CompetitionRound.tsx | 24 +++ src/i18n/en/translation.yaml | 20 +- src/lib/roundLabels.ts | 31 +++ src/lib/wcif.test.ts | 2 + src/lib/wcif.ts | 9 + src/storybook/competitionFixtures.ts | 4 + 11 files changed, 458 insertions(+), 99 deletions(-) create mode 100644 src/containers/CompetitionRound/CompetitionRound.test.tsx create mode 100644 src/lib/roundLabels.ts diff --git a/src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.stories.tsx b/src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.stories.tsx index 55021f4..ff0533c 100644 --- a/src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.stories.tsx +++ b/src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.stories.tsx @@ -73,6 +73,15 @@ export const ParticipationConditionLinkedRounds: Story = { }, }; +export const ParticipationConditionLinkedRoundsStart: Story = { + parameters: { + competitionFixture: storybookParticipationConditionLinkedRoundsFixture, + }, + args: { + round: storybookParticipationConditionLinkedRoundsFixture.events[0].rounds[0], + }, +}; + export const CutoffAndTimeLimit: Story = { args: { round: getStorybookRoundFixture('222-r1'), diff --git a/src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.test.tsx b/src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.test.tsx index 54dcb55..de058ce 100644 --- a/src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.test.tsx +++ b/src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.test.tsx @@ -67,10 +67,40 @@ jest.mock('react-i18next', () => ({ return `Round ${options?.roundNumber}`; } + if (key === 'common.wca.advancement.ranking') { + return `Top ${options?.level} advance to ${options?.what}`; + } + + if (key === 'common.wca.advancement.percent') { + return `Top ${options?.level}% advance to ${options?.what}`; + } + + if (key === 'common.wca.advancement.linkedRanking') { + return `Top ${options?.level} in dual rounds ${options?.rounds} advance to ${options?.what}`; + } + + if (key === 'common.wca.advancement.linkedPercent') { + return `Top ${options?.level}% in dual rounds ${options?.rounds} advance to ${options?.what}`; + } + + if (key === 'common.wca.advancement.nextRound') { + return 'next round'; + } + + if (key === 'common.wca.advancement.final') { + return 'final'; + } + + if (key === 'common.wca.advancement.resultThresholdUnknown') { + return 'an unknown result'; + } + if (options?.defaultValue) { return String(options.defaultValue) .replace('{{level}}', String(options.level ?? '')) .replace('{{rounds}}', String(options.rounds ?? '')) + .replace('{{round}}', String(options.round ?? '')) + .replace('{{what}}', String(options.what ?? '')) .replace('{{scope}}', String(options.scope ?? '')) .replace('{{result}}', String(options.result ?? '')); } @@ -134,6 +164,42 @@ const wcifMock = { }, ], }, + { + id: '222', + rounds: [ + { + id: '222-r1', + format: 'a', + cutoff: null, + timeLimit: null, + results: [], + }, + { + id: '222-r2', + format: 'a', + cutoff: null, + timeLimit: null, + results: [], + }, + { + id: '222-r3', + format: 'a', + cutoff: null, + timeLimit: null, + participationRuleset: { + participationSource: { + type: 'linkedRounds', + roundIds: ['222-r1', '222-r2'], + resultCondition: { + type: 'ranking', + value: 8, + }, + }, + }, + results: [], + }, + ], + }, ], }; @@ -181,7 +247,7 @@ describe('CutoffTimeLimitPanel', () => { it('shows the legacy advancement text for stable wcif rounds', () => { renderPanel(wcifMock.events[0].rounds[0] as unknown as Round); - expect(screen.getByText('Top 16 to next round')).toBeInTheDocument(); + expect(screen.getByText('Top 16 advance to next round')).toBeInTheDocument(); }); it('shows advancement text derived from the next round participation ruleset', () => { @@ -190,14 +256,18 @@ describe('CutoffTimeLimitPanel', () => { advancementCondition: null, } as Round); - expect(screen.getByText('Top 75% to next round')).toBeInTheDocument(); + 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); - expect( - screen.getByText('Top 12 combined across Round 1 and Round 2 advance to next round'), - ).toBeInTheDocument(); + 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); + + 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 7df8a21..265db36 100644 --- a/src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.tsx +++ b/src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.tsx @@ -1,10 +1,11 @@ -import { Competition, Cutoff, Round, parseActivityCode } from '@wca/helpers'; +import { Cutoff, Round, parseActivityCode } from '@wca/helpers'; import classNames from 'classnames'; import { useMemo, useState } from 'react'; import { Trans, useTranslation } from 'react-i18next'; 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 { useWCIF } from '@/providers/WCIFProvider'; @@ -92,7 +93,7 @@ export function CutoffTimeLimitPanel({ {advancement && (
- {renderAdvancementText(t, advancement.sourceType, advancement)} + {renderAdvancementText(t, eventRounds, advancement.sourceType, advancement)}
)} @@ -103,117 +104,108 @@ export function CutoffTimeLimitPanel({ ); } -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) || [] - ); -} - function getCumulativeRoundIds(timeLimit: Round['timeLimit'], roundId: string) { return timeLimit?.cumulativeRoundIds.filter((activityCode) => activityCode !== roundId) || []; } function renderAdvancementText( t: ReturnType['t'], + eventRounds: CompatibleRound[], sourceType: 'registrations' | 'round' | 'linkedRounds', advancement: NonNullable>, ) { - const isLinkedRounds = sourceType === 'linkedRounds'; + if (sourceType === 'linkedRounds') { + return renderLinkedRoundsAdvancementText(t, eventRounds, advancement); + } + + return renderSingleRoundAdvancementText(t, eventRounds, advancement); +} + +function renderLinkedRoundsAdvancementText( + t: ReturnType['t'], + eventRounds: CompatibleRound[], + advancement: NonNullable>, +) { const { resultCondition } = advancement; - const sourceRoundNames = advancement.sourceRoundIds.map((roundId) => - activityCodeToRoundName(t, roundId), - ); + const sourceRoundNames = advancement.sourceRoundIds.map((roundId) => { + const roundNumber = parseActivityCode(roundId).roundNumber; + return roundNumber ? roundNumber.toString() : ''; + }); const sourceRoundsLabel = joinLabels(sourceRoundNames); + const targetLabel = getAdvancementTargetLabel(t, eventRounds, advancement.targetRoundId); switch (resultCondition.type) { case 'ranking': - return isLinkedRounds ? ( - <> - {t('common.wca.advancement.linkedRanking', { - defaultValue: 'Top {{level}} combined across {{rounds}} advance to next round', - level: resultCondition.value, - rounds: sourceRoundsLabel, - })} - - ) : ( - }} - /> - ); + return t('common.wca.advancement.linkedRanking', { + level: resultCondition.value, + rounds: sourceRoundsLabel, + what: targetLabel, + }); case 'percent': - return isLinkedRounds ? ( - <> - {t('common.wca.advancement.linkedPercent', { - defaultValue: 'Top {{level}}% combined across {{rounds}} advance to next round', - level: resultCondition.value, - rounds: sourceRoundsLabel, - })} - - ) : ( - }} - /> - ); - case 'resultAchieved': { - const thresholdCondition = resultCondition as Extract< - ResultCondition, - { type: 'resultAchieved' } - >; - const scopeLabel = t(`common.wca.resultType.${thresholdCondition.scope}`, { - defaultValue: thresholdCondition.scope, - }).toLowerCase(); - const resultValue = - thresholdCondition.value === null - ? t('common.wca.advancement.resultThresholdUnknown', { - defaultValue: 'an unknown result', - }) - : renderCentiseconds(thresholdCondition.value); - - return ( - <> - {t( - isLinkedRounds - ? 'common.wca.advancement.linkedResultAchieved' - : 'common.wca.advancement.resultAchieved', - { - scope: scopeLabel, - result: resultValue, - rounds: sourceRoundsLabel, - }, - )} - - ); - } + return t('common.wca.advancement.linkedPercent', { + level: resultCondition.value, + rounds: sourceRoundsLabel, + what: targetLabel, + }); + case 'resultAchieved': + return t('common.wca.advancement.linkedResultAchieved', { + scope: t(`common.wca.advancement.scope.${resultCondition.scope}`), + result: + resultCondition.value === null + ? t('common.wca.advancement.resultThresholdUnknown') + : renderCentiseconds(resultCondition.value), + rounds: sourceRoundsLabel, + what: targetLabel, + }); } } -function activityCodeToRoundName(t: ReturnType['t'], roundId: string) { - const { roundNumber } = parseActivityCode(roundId); +function renderSingleRoundAdvancementText( + t: ReturnType['t'], + eventRounds: CompatibleRound[], + advancement: NonNullable>, +) { + const { resultCondition } = advancement; + const targetLabel = getAdvancementTargetLabel(t, eventRounds, advancement.targetRoundId); - return t('common.activityCodeToName.round', { - defaultValue: `Round ${roundNumber}`, - roundNumber, - }); + switch (resultCondition.type) { + case 'ranking': + return t('common.wca.advancement.ranking', { + level: resultCondition.value, + what: targetLabel, + }); + case 'percent': + return t('common.wca.advancement.percent', { + level: resultCondition.value, + what: targetLabel, + }); + case 'resultAchieved': + return t('common.wca.advancement.resultAchieved', { + scope: t(`common.wca.advancement.scope.${resultCondition.scope}`), + result: + resultCondition.value === null + ? t('common.wca.advancement.resultThresholdUnknown') + : renderCentiseconds(resultCondition.value), + what: targetLabel, + }); + } } -function joinLabels(labels: string[]) { - if (labels.length <= 1) { - return labels[0] || ''; +function getAdvancementTargetLabel( + t: ReturnType['t'], + eventRounds: CompatibleRound[], + targetRoundId: string | null | undefined, +) { + if (!targetRoundId) { + return t('common.wca.advancement.unknown'); } - if (labels.length === 2) { - return `${labels[0]} and ${labels[1]}`; + const targetRoundIndex = eventRounds.findIndex((candidate) => candidate.id === targetRoundId); + if (targetRoundIndex === eventRounds.length - 1) { + return t('common.wca.advancement.final'); } - return `${labels.slice(0, -1).join(', ')}, and ${labels[labels.length - 1]}`; + return t('common.wca.advancement.nextRound'); } function CutoffTimeLimitPopover({ cutoff }: { cutoff: Cutoff | null }) { diff --git a/src/containers/CompetitionRound/CompetitionRound.stories.tsx b/src/containers/CompetitionRound/CompetitionRound.stories.tsx index aec8f31..15435cc 100644 --- a/src/containers/CompetitionRound/CompetitionRound.stories.tsx +++ b/src/containers/CompetitionRound/CompetitionRound.stories.tsx @@ -53,6 +53,26 @@ export const ParticipationConditionLinkedRounds: Story = { }, }; +export const DualRoundWithPreviousRound: Story = { + parameters: { + competitionFixture: storybookParticipationConditionLinkedRoundsFixture, + }, + args: { + competitionId: 'SeattleSummerOpen2026', + roundId: '333-r2', + }, +}; + +export const DualRoundWithNextRound: Story = { + parameters: { + competitionFixture: storybookParticipationConditionLinkedRoundsFixture, + }, + args: { + competitionId: 'SeattleSummerOpen2026', + roundId: '333-r1', + }, +}; + export const FinalRound: Story = { parameters: { competitionFixture: makeStorybookCompetitionFixtureWithRound('333-r3', (round) => ({ diff --git a/src/containers/CompetitionRound/CompetitionRound.test.tsx b/src/containers/CompetitionRound/CompetitionRound.test.tsx new file mode 100644 index 0000000..7578c7f --- /dev/null +++ b/src/containers/CompetitionRound/CompetitionRound.test.tsx @@ -0,0 +1,190 @@ +import '@testing-library/jest-dom'; +import { render, screen } from '@testing-library/react'; +import { Competition } from '@wca/helpers'; +import { AnchorLink } from '@/lib/linkRenderer'; +import { CompetitionRoundContainer } from './CompetitionRound'; + +jest.mock('@/components/Breadcrumbs/Breadcrumbs', () => ({ + Breadcrumbs: () =>
breadcrumbs
, +})); + +jest.mock('@/components/Container', () => ({ + Container: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + +jest.mock('@/components/CutoffTimeLimitPanel', () => ({ + CutoffTimeLimitPanel: () =>
cutoff panel
, +})); + +jest.mock('@/lib/activityCodes', () => ({ + activityCodeToName: (activityCode: string) => activityCode, + parseActivityCodeFlexible: (activityCode: string) => { + const groupMatch = activityCode.match(/-g(\d+)$/); + return { + eventId: '333', + roundNumber: 2, + groupNumber: groupMatch ? parseInt(groupMatch[1], 10) : null, + attemptNumber: null, + }; + }, + toRoundAttemptId: (activityCode: string) => activityCode.replace(/-g\d+$/, ''), +})); + +jest.mock('@/lib/events', () => ({ + getAllEvents: (wcif: Competition) => wcif.events, +})); + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, options?: Record) => { + if (key === 'competition.groups.allGroups') { + return 'All Groups'; + } + + if (key === 'competition.groups.backToEvents') { + return 'Back To Events'; + } + + if (key === 'competition.round.linkedWith') { + return `Dual round with ${options?.rounds}`; + } + + if (key === 'common.activityCodeToName.round') { + return `Round ${options?.roundNumber}`; + } + + return key; + }, + }), +})); + +const linkedRoundsCompetition = { + formatVersion: '1.0', + id: 'TestComp2026', + name: 'Test Competition 2026', + shortName: 'Test Comp 2026', + persons: [], + competitorLimit: null, + extensions: [], + events: [ + { + id: '333', + extensions: [], + rounds: [ + { + id: '333-r1', + format: 'a', + cutoff: null, + timeLimit: null, + advancementCondition: null, + results: [], + }, + { + id: '333-r2', + format: 'a', + cutoff: null, + timeLimit: null, + advancementCondition: null, + results: [], + }, + { + id: '333-r3', + format: 'a', + cutoff: null, + timeLimit: null, + participationRuleset: { + participationSource: { + type: 'linkedRounds', + roundIds: ['333-r1', '333-r2'], + resultCondition: { + type: 'ranking', + value: 12, + }, + }, + }, + results: [], + }, + ], + }, + ], + schedule: { + numberOfDays: 1, + startDate: '2026-05-03', + venues: [ + { + id: 1, + name: 'Main Venue', + latitudeMicrodegrees: 0, + longitudeMicrodegrees: 0, + countryIso2: 'US', + timezone: 'America/Los_Angeles', + rooms: [ + { + id: 1, + name: 'Main Room', + color: '#fff', + activities: [ + { + id: 10, + activityCode: '333-r2', + name: '3x3x3 Cube, Round 2', + startTime: '2026-05-03T16:00:00Z', + endTime: '2026-05-03T17:00:00Z', + childActivities: [ + { + id: 11, + activityCode: '333-r2-g1', + name: '3x3x3 Cube, Round 2, Group 1', + startTime: '2026-05-03T16:00:00Z', + endTime: '2026-05-03T16:30:00Z', + childActivities: [], + extensions: [], + scrambleSetId: null, + }, + ], + extensions: [], + scrambleSetId: null, + }, + ], + extensions: [], + }, + ], + }, + ], + }, +} as unknown as Competition; + +jest.mock('@/providers/WCIFProvider', () => ({ + useWCIF: () => ({ + competitionId: 'TestComp2026', + wcif: linkedRoundsCompetition, + setTitle: () => {}, + }), + useWcifUtils: () => ({ + roundActivies: linkedRoundsCompetition.schedule.venues[0].rooms[0].activities, + }), +})); + +function renderRound(roundId: string) { + return render( + , + ); +} + +describe('CompetitionRoundContainer', () => { + it('shows linked-round context for rounds in a dual-round advancement set', () => { + renderRound('333-r2'); + + expect(screen.getByText('Dual round with Round 1')).toBeInTheDocument(); + }); + + it('does not show linked-round context for rounds outside a dual-round advancement set', () => { + renderRound('333-r3'); + + expect(screen.queryByText(/Dual round with/i)).not.toBeInTheDocument(); + }); +}); diff --git a/src/containers/CompetitionRound/CompetitionRound.tsx b/src/containers/CompetitionRound/CompetitionRound.tsx index ecd83d4..28b2f72 100644 --- a/src/containers/CompetitionRound/CompetitionRound.tsx +++ b/src/containers/CompetitionRound/CompetitionRound.tsx @@ -10,7 +10,9 @@ import { } from '@/lib/activityCodes'; 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 { useWCIF, useWcifUtils } from '@/providers/WCIFProvider'; export interface CompetitionRoundContainerProps { @@ -36,6 +38,23 @@ export function CompetitionRoundContainer({ const events = wcif && getAllEvents(wcif); return events?.flatMap((e) => e.rounds).find((r) => r.id === roundId); }, [roundId, wcif]); + const eventRounds = useMemo( + () => getEventRoundsForRound(wcif?.events, roundId), + [roundId, wcif?.events], + ); + const advancement = useMemo( + () => (round ? getAdvancementConditionForRound(eventRounds, round) : null), + [eventRounds, round], + ); + 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); @@ -53,6 +72,11 @@ export function CompetitionRoundContainer({ ]} />
+ {linkedRoundNames.length > 0 && ( +

+ {t('competition.round.linkedWith', { rounds: joinLabels(linkedRoundNames) })} +

+ )} {round && }
diff --git a/src/i18n/en/translation.yaml b/src/i18n/en/translation.yaml index cff3c8e..fa01c69 100644 --- a/src/i18n/en/translation.yaml +++ b/src/i18n/en/translation.yaml @@ -31,14 +31,20 @@ common: cumulativeTimelimit: 'Time Limit: {{time}} Cumulative' cumulativeTimelimitWithrounds: 'Time Limit: {{time}} Total with: ' advancement: - ranking: 'Top {{level}} to next round' - percent: 'Top {{level}}% to next round' - linkedRanking: 'Top {{level}} combined across {{rounds}} advance to next round' - linkedPercent: 'Top {{level}}% combined across {{rounds}} advance to next round' + ranking: 'Top {{level}} advance to {{what}}' + percent: 'Top {{level}}% advance to {{what}}' + linkedRanking: 'Top {{level}} in dual rounds {{rounds}} advance to {{what}}' + linkedPercent: 'Top {{level}}% in dual rounds {{rounds}} advance to {{what}}' attemptResult: Result better than {level} advances to next round. Minimum of 25% of competitors must be eliminated. - resultAchieved: Competitors with a {{scope}} better than {{result}} advance to next round. Minimum of 25% of competitors must be eliminated. - linkedResultAchieved: Competitors with a {{scope}} better than {{result}} combined across {{rounds}} advance to next round. Minimum of 25% of competitors must be eliminated. + resultAchieved: '{{scope}} < {{result}} advance to {{what}}; minimum of 25% of competitors must be eliminated' + linkedResultAchieved: '{{scope}} < {{result}} in dual rounds {{rounds}} advance to {{what}}; minimum of 25% of competitors must be eliminated' resultThresholdUnknown: an unknown result + nextRound: next round + final: final + unknown: unknown + scope: + single: Singles + average: Averages resultType: single: single average: Average @@ -185,6 +191,8 @@ competition: rankings: title: Rankings name: Name + round: + linkedWith: 'Dual round with {{rounds}}' personalSchedule: registeredEvents: Registered Events viewPersonalRecords: View Personal Records diff --git a/src/lib/roundLabels.ts b/src/lib/roundLabels.ts new file mode 100644 index 0000000..b2b3fab --- /dev/null +++ b/src/lib/roundLabels.ts @@ -0,0 +1,31 @@ +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) || [] + ); +} + +export function activityCodeToRoundName(t: TFunction, roundId: string) { + const { roundNumber } = parseActivityCode(roundId); + + return t('common.activityCodeToName.round', { roundNumber }); +} + +export function joinLabels(labels: string[]) { + if (labels.length <= 1) { + return labels[0] || ''; + } + + if (labels.length === 2) { + return `${labels[0]} & ${labels[1]}`; + } + + return `${labels.slice(0, -1).join(', ')}, & ${labels[labels.length - 1]}`; +} diff --git a/src/lib/wcif.test.ts b/src/lib/wcif.test.ts index 5b67872..4bb141c 100644 --- a/src/lib/wcif.test.ts +++ b/src/lib/wcif.test.ts @@ -59,6 +59,7 @@ describe('wcif participation helpers', () => { expect(getAdvancementConditionForRound(rounds, rounds[0])).toEqual({ sourceType: 'round', sourceRoundIds: ['333-r1'], + targetRoundId: '333-r2', resultCondition: { type: 'percent', value: 75, @@ -99,6 +100,7 @@ describe('wcif participation helpers', () => { expect(getAdvancementConditionForRound(rounds, rounds[1])).toEqual({ sourceType: 'linkedRounds', sourceRoundIds: ['333-r1', '333-r2'], + targetRoundId: '333-r3', resultCondition: { type: 'ranking', value: 12, diff --git a/src/lib/wcif.ts b/src/lib/wcif.ts index af6d1b2..c2b2f59 100644 --- a/src/lib/wcif.ts +++ b/src/lib/wcif.ts @@ -14,6 +14,7 @@ export type ResultCondition = ParticipationResultCondition; export interface RoundAdvancementCondition { sourceType: ParticipationSource['type']; sourceRoundIds: string[]; + targetRoundId?: string | null; resultCondition: ResultCondition; reservedPlaces?: ReservedPlaces | null; } @@ -96,9 +97,16 @@ export const getAdvancementConditionForRound = ( 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, }; @@ -138,6 +146,7 @@ export const getAdvancementConditionForRound = ( return { sourceType: source.type, sourceRoundIds: source.type === 'round' ? [source.roundId] : source.roundIds, + targetRoundId: nextEligibleRound.id, resultCondition: source.resultCondition, reservedPlaces: ruleset?.reservedPlaces ?? null, }; diff --git a/src/storybook/competitionFixtures.ts b/src/storybook/competitionFixtures.ts index 2bb83b7..044074e 100644 --- a/src/storybook/competitionFixtures.ts +++ b/src/storybook/competitionFixtures.ts @@ -768,6 +768,10 @@ export const storybookParticipationConditionPercentFixture = export const storybookParticipationConditionLinkedRoundsFixture = makeStorybookCompetitionFixtureWithRoundUpdates({ + '333-r1': (round) => ({ + ...round, + advancementCondition: null, + }), '333-r2': (round) => ({ ...round, advancementCondition: null,