diff --git a/src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.stories.tsx b/src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.stories.tsx index e12a099..ff0533c 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,33 @@ 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 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 832e75e..de058ce 100644 --- a/src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.test.tsx +++ b/src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.test.tsx @@ -22,24 +22,196 @@ 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.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`; + } + + 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 (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 ?? '')); + } + + 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: [], + }, + ], + }, + { + 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: [], + }, + ], + }, + ], +}; + 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 +221,7 @@ const round = { advancementCondition: null, } as unknown as Round; -function renderPanel() { +function renderPanel(round: Round) { return render( @@ -59,7 +231,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 +243,31 @@ 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 advance 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% 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 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 615882b..265db36 100644 --- a/src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.tsx +++ b/src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.tsx @@ -1,10 +1,12 @@ 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 { getEventRoundsForRound, joinLabels } from '@/lib/roundLabels'; +import { CompatibleRound, getAdvancementConditionForRound, ResultCondition } from '@/lib/wcif'; import { useWCIF } from '@/providers/WCIFProvider'; export function CutoffTimeLimitPanel({ @@ -20,10 +22,17 @@ export function CutoffTimeLimitPanel({ const cutoff = round.cutoff; const timeLimit = round.timeLimit; const timelimitTime = timeLimit && renderCentiseconds(timeLimit?.centiseconds); + 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], + ); - if (!timeLimit && !cutoff && !round.advancementCondition) return null; - - const level = round.advancementCondition?.level; + if (!timeLimit && !cutoff && !advancement) return null; return (
@@ -44,8 +53,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 ? ', ' : ''} + + + ); + })}
)}
- {round.advancementCondition && ( -
- {round.advancementCondition.type === 'ranking' && ( -
- }} - /> -
- )} - {round.advancementCondition.type === 'percent' && ( -
- }} - /> -
- )} - {round.advancementCondition.type === 'attemptResult' && ( -
- }} - /> -
- )} + {advancement && ( +
+ {renderAdvancementText(t, eventRounds, advancement.sourceType, advancement)}
)}
@@ -125,6 +104,110 @@ export function CutoffTimeLimitPanel({ ); } +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>, +) { + 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) => { + 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 t('common.wca.advancement.linkedRanking', { + level: resultCondition.value, + rounds: sourceRoundsLabel, + what: targetLabel, + }); + case 'percent': + 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 renderSingleRoundAdvancementText( + t: ReturnType['t'], + eventRounds: CompatibleRound[], + advancement: NonNullable>, +) { + const { resultCondition } = advancement; + const targetLabel = getAdvancementTargetLabel(t, eventRounds, advancement.targetRoundId); + + 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 getAdvancementTargetLabel( + t: ReturnType['t'], + eventRounds: CompatibleRound[], + targetRoundId: string | null | undefined, +) { + if (!targetRoundId) { + return t('common.wca.advancement.unknown'); + } + + const targetRoundIndex = eventRounds.findIndex((candidate) => candidate.id === targetRoundId); + if (targetRoundIndex === eventRounds.length - 1) { + return t('common.wca.advancement.final'); + } + + return t('common.wca.advancement.nextRound'); +} + function CutoffTimeLimitPopover({ cutoff }: { cutoff: Cutoff | null }) { const { t } = useTranslation(); const [open, setOpen] = useState(false); diff --git a/src/containers/CompetitionRound/CompetitionRound.stories.tsx b/src/containers/CompetitionRound/CompetitionRound.stories.tsx index 92d0868..15435cc 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,36 @@ export const RoundTwo: Story = { }, }; +export const ParticipationConditionLinkedRounds: Story = { + parameters: { + competitionFixture: storybookParticipationConditionLinkedRoundsFixture, + }, + args: { + competitionId: 'SeattleSummerOpen2026', + roundId: '333-r2', + }, +}; + +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 81aad5b..fa01c69 100644 --- a/src/i18n/en/translation.yaml +++ b/src/i18n/en/translation.yaml @@ -31,9 +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' + 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: '{{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 @@ -180,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 new file mode 100644 index 0000000..4bb141c --- /dev/null +++ b/src/lib/wcif.test.ts @@ -0,0 +1,111 @@ +import { Round } from '@wca/helpers'; +import { 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 Round[]; + + 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 Round[]; + + expect(getAdvancementConditionForRound(rounds, rounds[0])).toEqual({ + sourceType: 'round', + sourceRoundIds: ['333-r1'], + targetRoundId: '333-r2', + 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 Round[]; + + expect(getAdvancementConditionForRound(rounds, rounds[1])).toEqual({ + sourceType: 'linkedRounds', + sourceRoundIds: ['333-r1', '333-r2'], + targetRoundId: '333-r3', + 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..c2b2f59 --- /dev/null +++ b/src/lib/wcif.ts @@ -0,0 +1,153 @@ +import { + ParticipationResultCondition, + ParticipationRuleset, + ParticipationSource, + ReservedPlaces, + Round, + parseActivityCode, +} from '@wca/helpers'; + +type LegacyAdvancementCondition = NonNullable; + +export type ResultCondition = ParticipationResultCondition; + +export interface RoundAdvancementCondition { + sourceType: ParticipationSource['type']; + sourceRoundIds: string[]; + targetRoundId?: string | null; + resultCondition: ResultCondition; + 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); + 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, + targetRoundId: nextEligibleRound.id, + resultCondition: source.resultCondition, + reservedPlaces: ruleset?.reservedPlaces ?? null, + }; +}; diff --git a/src/storybook/competitionFixtures.ts b/src/storybook/competitionFixtures.ts index 178d513..044074e 100644 --- a/src/storybook/competitionFixtures.ts +++ b/src/storybook/competitionFixtures.ts @@ -730,3 +730,64 @@ 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-r1': (round) => ({ + ...round, + advancementCondition: null, + }), + '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; + } +}