From a5b298d4725ae83bebaba374e826694590feda3e Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Sat, 2 May 2026 07:22:40 -0700 Subject: [PATCH 01/13] Add WCIF v2 round support --- src/lib/api/wcaAPI.test.ts | 33 +++++- src/lib/api/wcaAPI.ts | 6 +- src/lib/domain/persons.test.ts | 30 ++++++ src/lib/domain/persons.ts | 3 +- src/lib/wcif/index.ts | 1 + src/lib/wcif/rounds.test.ts | 100 ++++++++++++++++++ src/lib/wcif/rounds.ts | 57 ++++++++++ src/lib/wcif/v2-types.d.ts | 33 ++++++ .../validation/eventRoundValidation.test.ts | 72 +++++++++++++ .../wcif/validation/eventRoundValidation.ts | 3 +- src/pages/Competition/Export/index.tsx | 16 ++- src/pages/Competition/Rooms/Room.tsx | 17 +-- 12 files changed, 349 insertions(+), 22 deletions(-) create mode 100644 src/lib/wcif/rounds.test.ts create mode 100644 src/lib/wcif/rounds.ts create mode 100644 src/lib/wcif/v2-types.d.ts diff --git a/src/lib/api/wcaAPI.test.ts b/src/lib/api/wcaAPI.test.ts index 847c12a..ecd9513 100644 --- a/src/lib/api/wcaAPI.test.ts +++ b/src/lib/api/wcaAPI.test.ts @@ -3,6 +3,8 @@ import { getMe, getPastManageableCompetitions, getUpcomingManageableCompetitions, + getWcif, + patchWcif, saveWcifChanges, wcaApiFetch, } from './wcaAPI'; @@ -83,7 +85,7 @@ describe('wcaAPI', () => { await saveWcifChanges(previousWcif, newWcif); expect(globalThis.fetch).toHaveBeenCalledWith( - 'https://wca.test/api/v0/competitions/Comp/wcif', + 'https://wca.test/api/v0/competitions/Comp/wcif/version/2', expect.objectContaining({ method: 'PATCH', body: JSON.stringify({ name: 'New' }), @@ -105,8 +107,35 @@ describe('wcaAPI', () => { await saveWcifChanges(wcif, wcif); expect(globalThis.fetch).not.toHaveBeenCalledWith( - 'https://wca.test/api/v0/competitions/Comp/wcif', + 'https://wca.test/api/v0/competitions/Comp/wcif/version/2', expect.objectContaining({ method: 'PATCH' }) ); }); + + it('fetches WCIF from the version 2 endpoint', async () => { + mockFetch({ json: vi.fn().mockResolvedValue({ id: 'Comp' }) }); + + await getWcif('Comp'); + + expect(globalThis.fetch).toHaveBeenCalledWith( + 'https://wca.test/api/v0/competitions/Comp/wcif/version/2', + expect.objectContaining({ + headers: expect.any(Headers), + }) + ); + }); + + it('patches WCIF to the version 2 endpoint', async () => { + mockFetch({ json: vi.fn().mockResolvedValue({ id: 'Comp' }) }); + + await patchWcif('Comp', { name: 'Updated' } as any); + + expect(globalThis.fetch).toHaveBeenCalledWith( + 'https://wca.test/api/v0/competitions/Comp/wcif/version/2', + expect.objectContaining({ + method: 'PATCH', + body: JSON.stringify({ name: 'Updated' }), + }) + ); + }); }); diff --git a/src/lib/api/wcaAPI.ts b/src/lib/api/wcaAPI.ts index 8313d6c..25a6a78 100644 --- a/src/lib/api/wcaAPI.ts +++ b/src/lib/api/wcaAPI.ts @@ -10,6 +10,8 @@ import { type Competition } from '@wca/helpers'; import { pick } from 'lodash'; const wcaAccessToken = (): string | null => getLocalStorage('accessToken'); +const WCIF_VERSION = '2'; +const wcifPath = (competitionId: string) => `/competitions/${competitionId}/wcif/version/${WCIF_VERSION}`; export const getMe = (): Promise<{ me: WcaUser }> => { return wcaApiFetch(`/me`); @@ -45,13 +47,13 @@ export const getPastManageableCompetitions = (): Promise => - wcaApiFetch(`/competitions/${competitionId}/wcif`); + wcaApiFetch(wcifPath(competitionId)); export const patchWcif = ( competitionId: string, wcif: Partial ): Promise => - wcaApiFetch(`/competitions/${competitionId}/wcif`, { + wcaApiFetch(wcifPath(competitionId), { method: 'PATCH', body: JSON.stringify(wcif), }); diff --git a/src/lib/domain/persons.test.ts b/src/lib/domain/persons.test.ts index 1b1ae49..c8179cd 100644 --- a/src/lib/domain/persons.test.ts +++ b/src/lib/domain/persons.test.ts @@ -204,6 +204,36 @@ describe('shouldBeInRound', () => { const test = shouldBeInRound(round); expect(test(createMockPerson())).toBe(false); }); + + it('uses registration for linked registration rounds beyond round 1', () => { + const round: Round = { + id: 'clock-r2', + format: 'a', + timeLimit: null, + cutoff: null, + advancementCondition: null, + scrambleSetCount: 1, + results: [], + participationRuleset: { + participationSource: { + type: 'registrations', + }, + reservedPlaces: null, + }, + extensions: [], + }; + + const test = shouldBeInRound(round); + const personRegistered = createMockPerson({ + registration: { ...createMockPerson().registration!, eventIds: ['clock'] }, + }); + const personNotRegistered = createMockPerson({ + registration: { ...createMockPerson().registration!, eventIds: ['333'] }, + }); + + expect(test(personRegistered)).toBe(true); + expect(test(personNotRegistered)).toBe(false); + }); }); describe('personsShouldBeInRound', () => { diff --git a/src/lib/domain/persons.ts b/src/lib/domain/persons.ts index cf12973..61be176 100644 --- a/src/lib/domain/persons.ts +++ b/src/lib/domain/persons.ts @@ -10,6 +10,7 @@ import { type Result, type Round, } from '@wca/helpers'; +import { usesRegistrationParticipation } from '../wcif/rounds'; /** * @param {Person} person @@ -48,7 +49,7 @@ export const shouldBeInRound = (round: Round) => { const { eventId, roundNumber } = parseActivityCode(round.id); - if (roundNumber === 1) { + if (usesRegistrationParticipation(round) || roundNumber === 1) { return (person: Person) => acceptedRegistration(person) && registeredForEvent(eventId)(person); } else { // WCA Live will be the single source of truth for who's in the next round diff --git a/src/lib/wcif/index.ts b/src/lib/wcif/index.ts index 6e58f1c..d5c2ef5 100644 --- a/src/lib/wcif/index.ts +++ b/src/lib/wcif/index.ts @@ -3,3 +3,4 @@ export * from './validation'; export * from './groups'; export * from './activities'; export * from './persons'; +export * from './rounds'; diff --git a/src/lib/wcif/rounds.test.ts b/src/lib/wcif/rounds.test.ts new file mode 100644 index 0000000..26baaaf --- /dev/null +++ b/src/lib/wcif/rounds.test.ts @@ -0,0 +1,100 @@ +import { + getAdvancementConditionForRound, + getDerivedAdvancementCondition, + usesRegistrationParticipation, +} from './rounds'; +import { buildEvent, buildRound } from '../../store/reducers/_tests_/helpers'; +import { describe, expect, it } from 'vitest'; + +describe('usesRegistrationParticipation', () => { + it('treats linked second rounds with registration participation as registration-based', () => { + const round = buildRound({ + id: 'clock-r2', + participationRuleset: { + participationSource: { + type: 'registrations', + }, + reservedPlaces: null, + }, + }); + + expect(usesRegistrationParticipation(round)).toBe(true); + }); +}); + +describe('getDerivedAdvancementCondition', () => { + it('maps linked-round result conditions into legacy advancement conditions', () => { + const round = buildRound({ + id: 'clock-r3', + participationRuleset: { + participationSource: { + type: 'linkedRounds', + roundIds: ['clock-r1', 'clock-r2'], + resultCondition: { + type: 'percent', + scope: 'average', + value: 75, + }, + }, + reservedPlaces: null, + }, + }); + + expect(getDerivedAdvancementCondition(round)).toEqual({ + type: 'percent', + level: 75, + }); + }); +}); + +describe('getAdvancementConditionForRound', () => { + it('derives advancement for linked source rounds from the target round participation ruleset', () => { + const event = buildEvent({ + id: 'clock', + rounds: [ + buildRound({ + id: 'clock-r1', + participationRuleset: { + participationSource: { + type: 'registrations', + }, + reservedPlaces: null, + }, + }), + buildRound({ + id: 'clock-r2', + participationRuleset: { + participationSource: { + type: 'registrations', + }, + reservedPlaces: null, + }, + }), + buildRound({ + id: 'clock-r3', + participationRuleset: { + participationSource: { + type: 'linkedRounds', + roundIds: ['clock-r1', 'clock-r2'], + resultCondition: { + type: 'percent', + scope: 'average', + value: 75, + }, + }, + reservedPlaces: null, + }, + }), + ], + }); + + expect(getAdvancementConditionForRound(event, 'clock-r1')).toEqual({ + type: 'percent', + level: 75, + }); + expect(getAdvancementConditionForRound(event, 'clock-r2')).toEqual({ + type: 'percent', + level: 75, + }); + }); +}); diff --git a/src/lib/wcif/rounds.ts b/src/lib/wcif/rounds.ts new file mode 100644 index 0000000..abe67d2 --- /dev/null +++ b/src/lib/wcif/rounds.ts @@ -0,0 +1,57 @@ +import { parseActivityCode } from '../domain/activities'; +import { type AdvancementCondition, type Event, type ParticipationRuleset, type Round } from '@wca/helpers'; + +const hasLegacyAdvancementCondition = ( + round: Round +): round is Round & { advancementCondition: AdvancementCondition } => + round.advancementCondition !== null; + +export const getParticipationRuleset = (round: Round): ParticipationRuleset | null => + round.participationRuleset ?? null; + +export const usesRegistrationParticipation = (round: Round): boolean => { + const participationSource = getParticipationRuleset(round)?.participationSource; + + if (participationSource?.type === 'registrations') { + return true; + } + + return parseActivityCode(round.id).roundNumber === 1; +}; + +export const getDerivedAdvancementCondition = ( + round: Round +): AdvancementCondition | null => { + const participationSource = getParticipationRuleset(round)?.participationSource; + + if (participationSource?.type !== 'linkedRounds') { + return null; + } + + return { + type: participationSource.resultCondition.type, + level: participationSource.resultCondition.value, + }; +}; + +export const getAdvancementConditionForRound = ( + event: Event, + roundId: string +): AdvancementCondition | null => { + const round = event.rounds.find((candidate) => candidate.id === roundId); + + if (!round) { + return null; + } + + if (hasLegacyAdvancementCondition(round)) { + return round.advancementCondition; + } + + const nextRound = event.rounds.find((candidate) => { + const participationSource = getParticipationRuleset(candidate)?.participationSource; + return participationSource?.type === 'linkedRounds' && participationSource.roundIds.includes(roundId); + }); + + return nextRound ? getDerivedAdvancementCondition(nextRound) : null; +}; diff --git a/src/lib/wcif/v2-types.d.ts b/src/lib/wcif/v2-types.d.ts new file mode 100644 index 0000000..0a8025e --- /dev/null +++ b/src/lib/wcif/v2-types.d.ts @@ -0,0 +1,33 @@ +import '@wca/helpers'; + +declare module '@wca/helpers' { + export interface RegistrationsParticipationSource { + type: 'registrations'; + } + + export interface ParticipationResultCondition { + type: 'ranking' | 'percent' | 'attemptResult'; + scope?: 'average' | 'single'; + value: number; + } + + export interface LinkedRoundsParticipationSource { + type: 'linkedRounds'; + roundIds: string[]; + resultCondition: ParticipationResultCondition; + } + + export type ParticipationSource = + | RegistrationsParticipationSource + | LinkedRoundsParticipationSource; + + export interface ParticipationRuleset { + participationSource: ParticipationSource; + reservedPlaces: unknown | null; + } + + export interface Round { + linkedRounds?: string[] | null; + participationRuleset?: ParticipationRuleset | null; + } +} diff --git a/src/lib/wcif/validation/eventRoundValidation.test.ts b/src/lib/wcif/validation/eventRoundValidation.test.ts index d69d1a1..5d2acf5 100644 --- a/src/lib/wcif/validation/eventRoundValidation.test.ts +++ b/src/lib/wcif/validation/eventRoundValidation.test.ts @@ -155,6 +155,78 @@ describe('validateAdvancementConditions', () => { expect(errors).toHaveLength(0); }); + it('should accept v2 linked-round advancement derived from a later round participation ruleset', () => { + const event: Event = { + id: 'clock', + rounds: [ + { + id: 'clock-r1', + format: 'a', + timeLimit: null, + cutoff: null, + advancementCondition: null, + results: [], + scrambleSetCount: 2, + linkedRounds: ['clock-r1', 'clock-r2'], + participationRuleset: { + participationSource: { + type: 'registrations', + }, + reservedPlaces: null, + }, + extensions: [], + }, + { + id: 'clock-r2', + format: 'a', + timeLimit: null, + cutoff: null, + advancementCondition: null, + results: [], + scrambleSetCount: 2, + linkedRounds: ['clock-r1', 'clock-r2'], + participationRuleset: { + participationSource: { + type: 'registrations', + }, + reservedPlaces: null, + }, + extensions: [], + }, + { + id: 'clock-r3', + format: 'a', + timeLimit: null, + cutoff: null, + advancementCondition: null, + results: [], + scrambleSetCount: 2, + linkedRounds: null, + participationRuleset: { + participationSource: { + type: 'linkedRounds', + roundIds: ['clock-r1', 'clock-r2'], + resultCondition: { + type: 'percent', + scope: 'average', + value: 75, + }, + }, + reservedPlaces: null, + }, + extensions: [], + }, + ], + competitorLimit: null, + qualification: null, + extensions: [], + }; + + const errors = validateAdvancementConditions(event); + + expect(errors).toHaveLength(0); + }); + it('should return no errors for event with single round', () => { const event: Event = { id: '333', diff --git a/src/lib/wcif/validation/eventRoundValidation.ts b/src/lib/wcif/validation/eventRoundValidation.ts index e7a12a6..13530c6 100644 --- a/src/lib/wcif/validation/eventRoundValidation.ts +++ b/src/lib/wcif/validation/eventRoundValidation.ts @@ -5,6 +5,7 @@ import { NO_SCHEDULE_ACTIVITIES_FOR_ROUND, type ValidationError, } from './types'; +import { getAdvancementConditionForRound } from '../rounds'; import type { Competition, Event } from '@wca/helpers'; import { flatMap } from 'lodash'; @@ -30,7 +31,7 @@ export const validateEventHasRounds = (event: Event): ValidationError | null => */ export const validateAdvancementConditions = (event: Event): ValidationError[] => { return flatMap(event.rounds.slice(0, -1), (round) => - round.advancementCondition + getAdvancementConditionForRound(event, round.id) ? [] : [ { diff --git a/src/pages/Competition/Export/index.tsx b/src/pages/Competition/Export/index.tsx index df1c29b..d9facb8 100644 --- a/src/pages/Competition/Export/index.tsx +++ b/src/pages/Competition/Export/index.tsx @@ -11,6 +11,7 @@ import { useAppSelector } from '../../../store'; import { Button, Typography } from '@mui/material'; import { type Activity, + type AdvancementCondition, type Assignment, type Event, type Person, @@ -22,15 +23,11 @@ import { download, generateCsv, mkConfig } from 'export-to-csv'; import { flatten } from 'lodash'; import { useCallback } from 'react'; import Grid from '@mui/material/GridLegacy'; - -type AdvancementConditionLike = { - type: 'ranking' | 'percent' | 'attemptResult'; - level: number; -}; +import { getAdvancementConditionForRound } from '../../../lib/wcif/rounds'; type AssignmentWithActivity = Assignment & { activity: ActivityWithParent }; -const advancementConditionToText = ({ type, level }: AdvancementConditionLike): string => { +const advancementConditionToText = ({ type, level }: AdvancementCondition): string => { switch (type) { case 'ranking': return `Top ${level}`; @@ -263,9 +260,10 @@ const ExportPage = () => { ? `1 or 2 < ${formatCentiseconds(round.cutoff.attemptResult)}` : '', round_format: roundFormatShortById(round.format), - advancement_condition: round.advancementCondition - ? advancementConditionToText(round.advancementCondition) - : '', + advancement_condition: (() => { + const advancementCondition = getAdvancementConditionForRound(event, round.id); + return advancementCondition ? advancementConditionToText(advancementCondition) : ''; + })(), round_number: parseActivityCode(round.id)?.roundNumber ?? '', }; diff --git a/src/pages/Competition/Rooms/Room.tsx b/src/pages/Competition/Rooms/Room.tsx index 3a825a9..357dde0 100644 --- a/src/pages/Competition/Rooms/Room.tsx +++ b/src/pages/Competition/Rooms/Room.tsx @@ -2,6 +2,7 @@ import { generateNextChildActivityId, parseActivityCode } from '../../../lib/dom import { advancingCompetitors } from '../../../lib/domain/formulas'; import { acceptedRegistrations } from '../../../lib/domain/persons'; import { getGroupData } from '../../../lib/wcif/extensions'; +import { getParticipationRuleset } from '../../../lib/wcif/rounds'; import { useAppSelector } from '../../../store'; import { updateRoundChildActivities, @@ -162,17 +163,19 @@ const Room = ({ room }: RoomProps) => { return null; } - const previousRound = roundNumber > 1 ? event.rounds[roundNumber - 2] : null; - const advancementCondition = previousRound?.advancementCondition; + const participationSource = getParticipationRuleset(round)?.participationSource; const estimatedCompetitors = - roundNumber === 1 + participationSource?.type === 'registrations' || roundNumber === 1 ? (eventRegistrationCounts[eventId] ?? 0) - : advancementCondition && - (advancementCondition.type === 'percent' || - advancementCondition.type === 'ranking') + : participationSource?.type === 'linkedRounds' && + (participationSource.resultCondition.type === 'percent' || + participationSource.resultCondition.type === 'ranking') ? advancingCompetitors( - advancementCondition as { type: 'percent' | 'ranking'; level: number }, + { + type: participationSource.resultCondition.type, + level: participationSource.resultCondition.value, + }, eventRegistrationCounts[eventId] ?? 0 ) : 0; From 5b57991d23277e794e0e519d2c07805281383671 Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Sat, 2 May 2026 07:41:12 -0700 Subject: [PATCH 02/13] Show dual-round status on round page --- src/lib/api/localStorage.test.ts | 3 +- src/lib/wcif/rounds.test.ts | 67 +++++++++++++++++++ src/lib/wcif/rounds.ts | 40 +++++++++++ .../Competition/Round/RoundContainer.tsx | 12 ++++ 4 files changed, 121 insertions(+), 1 deletion(-) diff --git a/src/lib/api/localStorage.test.ts b/src/lib/api/localStorage.test.ts index 50c5ce9..ed617b6 100644 --- a/src/lib/api/localStorage.test.ts +++ b/src/lib/api/localStorage.test.ts @@ -1,5 +1,4 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { getLocalStorage, localStorageKey, setLocalStorage } from './localStorage'; afterEach(() => { localStorage.clear(); @@ -14,6 +13,7 @@ describe('localStorage helpers', () => { writable: true, }); await import('./wca-env'); + const { localStorageKey } = await import('./localStorage'); expect(localStorageKey('token')).toContain('delegate-dashboard.example-application-id.token'); }); @@ -24,6 +24,7 @@ describe('localStorage helpers', () => { writable: true, }); await import('./wca-env'); + const { getLocalStorage, setLocalStorage } = await import('./localStorage'); setLocalStorage('token', 'abc123'); expect(getLocalStorage('token')).toBe('abc123'); diff --git a/src/lib/wcif/rounds.test.ts b/src/lib/wcif/rounds.test.ts index 26baaaf..a300f99 100644 --- a/src/lib/wcif/rounds.test.ts +++ b/src/lib/wcif/rounds.test.ts @@ -1,6 +1,7 @@ import { getAdvancementConditionForRound, getDerivedAdvancementCondition, + getDualRoundDetails, usesRegistrationParticipation, } from './rounds'; import { buildEvent, buildRound } from '../../store/reducers/_tests_/helpers'; @@ -98,3 +99,69 @@ describe('getAdvancementConditionForRound', () => { }); }); }); + +describe('getDualRoundDetails', () => { + it('identifies source rounds in a dual-round configuration', () => { + const event = buildEvent({ + id: 'clock', + rounds: [ + buildRound({ id: 'clock-r1' }), + buildRound({ id: 'clock-r2' }), + buildRound({ + id: 'clock-r3', + participationRuleset: { + participationSource: { + type: 'linkedRounds', + roundIds: ['clock-r1', 'clock-r2'], + resultCondition: { + type: 'percent', + scope: 'average', + value: 75, + }, + }, + reservedPlaces: null, + }, + }), + ], + }); + + expect(getDualRoundDetails(event, 'clock-r1')).toEqual({ + linkedRoundIds: ['clock-r1', 'clock-r2'], + targetRoundId: 'clock-r3', + isSourceRound: true, + isTargetRound: false, + }); + }); + + it('identifies the target round in a dual-round configuration', () => { + const event = buildEvent({ + id: 'clock', + rounds: [ + buildRound({ id: 'clock-r1' }), + buildRound({ id: 'clock-r2' }), + buildRound({ + id: 'clock-r3', + participationRuleset: { + participationSource: { + type: 'linkedRounds', + roundIds: ['clock-r1', 'clock-r2'], + resultCondition: { + type: 'percent', + scope: 'average', + value: 75, + }, + }, + reservedPlaces: null, + }, + }), + ], + }); + + expect(getDualRoundDetails(event, 'clock-r3')).toEqual({ + linkedRoundIds: ['clock-r1', 'clock-r2'], + targetRoundId: 'clock-r3', + isSourceRound: false, + isTargetRound: true, + }); + }); +}); diff --git a/src/lib/wcif/rounds.ts b/src/lib/wcif/rounds.ts index abe67d2..50cd4c0 100644 --- a/src/lib/wcif/rounds.ts +++ b/src/lib/wcif/rounds.ts @@ -1,6 +1,13 @@ import { parseActivityCode } from '../domain/activities'; import { type AdvancementCondition, type Event, type ParticipationRuleset, type Round } from '@wca/helpers'; +export interface DualRoundDetails { + linkedRoundIds: string[]; + targetRoundId: string; + isSourceRound: boolean; + isTargetRound: boolean; +} + const hasLegacyAdvancementCondition = ( round: Round ): round is Round & { advancementCondition: AdvancementCondition } => @@ -55,3 +62,36 @@ export const getAdvancementConditionForRound = ( return nextRound ? getDerivedAdvancementCondition(nextRound) : null; }; + +export const getDualRoundDetails = ( + event: Event, + roundId: string +): DualRoundDetails | null => { + for (const candidate of event.rounds) { + const participationSource = getParticipationRuleset(candidate)?.participationSource; + + if (participationSource?.type !== 'linkedRounds' || participationSource.roundIds.length < 2) { + continue; + } + + if (candidate.id === roundId) { + return { + linkedRoundIds: participationSource.roundIds, + targetRoundId: candidate.id, + isSourceRound: false, + isTargetRound: true, + }; + } + + if (participationSource.roundIds.includes(roundId)) { + return { + linkedRoundIds: participationSource.roundIds, + targetRoundId: candidate.id, + isSourceRound: true, + isTargetRound: false, + }; + } + } + + return null; +}; diff --git a/src/pages/Competition/Round/RoundContainer.tsx b/src/pages/Competition/Round/RoundContainer.tsx index 4ab6957..b5a3c51 100644 --- a/src/pages/Competition/Round/RoundContainer.tsx +++ b/src/pages/Competition/Round/RoundContainer.tsx @@ -7,11 +7,14 @@ import ConfigureStationNumbersDialog from '../../../dialogs/ConfigureStationNumb import { RawRoundActivitiesDataDialog } from '../../../dialogs/RawRoundActivitiesDataDialog'; import { RawRoundDataDialog } from '../../../dialogs/RawRoundDataDialog'; import { RoundActionButtons } from '../../../components/RoundActionButtons'; +import { activityCodeToName } from '../../../lib/domain/activities'; +import { getDualRoundDetails } from '../../../lib/wcif/rounds'; import { useRoundActions } from './hooks/useRoundActions'; import { useRoundData } from './hooks/useRoundData'; import { useRoundDialogs } from './hooks/useRoundDialogs'; import DistributedAttemptRoundView from './DistributedAttemptRoundView'; import NormalRoundView from './NormalRoundView'; +import { Alert } from '@mui/material'; import { type Round } from '@wca/helpers'; import { ConfirmProvider } from 'material-ui-confirm'; @@ -52,6 +55,8 @@ const RoundContainer = ({ roundId, activityCode, eventId, round }: RoundContaine roundActivities, }); + const event = wcif?.events.find((candidate) => candidate.id === eventId); + const dualRoundDetails = event ? getDualRoundDetails(event, round.id) : null; if (roundActivities.length === 0) { return (
@@ -140,6 +145,13 @@ const RoundContainer = ({ roundId, activityCode, eventId, round }: RoundContaine return ( + {dualRoundDetails && ( + + This event is configured as dual rounds.{' '} + {dualRoundDetails.linkedRoundIds.map(activityCodeToName).join(' and ')} feed into{' '} + {activityCodeToName(dualRoundDetails.targetRoundId)}. + + )} {isDistributedAttemptRoundLevel ? ( Date: Sat, 2 May 2026 07:42:57 -0700 Subject: [PATCH 03/13] Split advancement checks from display logic --- src/lib/wcif/rounds.test.ts | 51 +++++++++++++++++++++++++- src/lib/wcif/rounds.ts | 22 +++++++++++ src/pages/Competition/Export/index.tsx | 4 +- 3 files changed, 73 insertions(+), 4 deletions(-) diff --git a/src/lib/wcif/rounds.test.ts b/src/lib/wcif/rounds.test.ts index a300f99..260a90e 100644 --- a/src/lib/wcif/rounds.test.ts +++ b/src/lib/wcif/rounds.test.ts @@ -1,5 +1,6 @@ import { getAdvancementConditionForRound, + getDisplayAdvancementConditionForRound, getDerivedAdvancementCondition, getDualRoundDetails, usesRegistrationParticipation, @@ -49,6 +50,52 @@ describe('getDerivedAdvancementCondition', () => { }); describe('getAdvancementConditionForRound', () => { + it('returns true when a linked source round has downstream participation conditions', () => { + const event = buildEvent({ + id: 'clock', + rounds: [ + buildRound({ + id: 'clock-r1', + participationRuleset: { + participationSource: { + type: 'registrations', + }, + reservedPlaces: null, + }, + }), + buildRound({ + id: 'clock-r2', + participationRuleset: { + participationSource: { + type: 'registrations', + }, + reservedPlaces: null, + }, + }), + buildRound({ + id: 'clock-r3', + participationRuleset: { + participationSource: { + type: 'linkedRounds', + roundIds: ['clock-r1', 'clock-r2'], + resultCondition: { + type: 'percent', + scope: 'average', + value: 75, + }, + }, + reservedPlaces: null, + }, + }), + ], + }); + + expect(getAdvancementConditionForRound(event, 'clock-r1')).toBe(true); + expect(getAdvancementConditionForRound(event, 'clock-r2')).toBe(true); + }); +}); + +describe('getDisplayAdvancementConditionForRound', () => { it('derives advancement for linked source rounds from the target round participation ruleset', () => { const event = buildEvent({ id: 'clock', @@ -89,11 +136,11 @@ describe('getAdvancementConditionForRound', () => { ], }); - expect(getAdvancementConditionForRound(event, 'clock-r1')).toEqual({ + expect(getDisplayAdvancementConditionForRound(event, 'clock-r1')).toEqual({ type: 'percent', level: 75, }); - expect(getAdvancementConditionForRound(event, 'clock-r2')).toEqual({ + expect(getDisplayAdvancementConditionForRound(event, 'clock-r2')).toEqual({ type: 'percent', level: 75, }); diff --git a/src/lib/wcif/rounds.ts b/src/lib/wcif/rounds.ts index 50cd4c0..0198edb 100644 --- a/src/lib/wcif/rounds.ts +++ b/src/lib/wcif/rounds.ts @@ -44,6 +44,28 @@ export const getDerivedAdvancementCondition = ( export const getAdvancementConditionForRound = ( event: Event, roundId: string +): boolean => { + const round = event.rounds.find((candidate) => candidate.id === roundId); + + if (!round) { + return false; + } + + if (hasLegacyAdvancementCondition(round)) { + return true; + } + + const nextRound = event.rounds.find((candidate) => { + const participationSource = getParticipationRuleset(candidate)?.participationSource; + return participationSource?.type === 'linkedRounds' && participationSource.roundIds.includes(roundId); + }); + + return Boolean(nextRound); +}; + +export const getDisplayAdvancementConditionForRound = ( + event: Event, + roundId: string ): AdvancementCondition | null => { const round = event.rounds.find((candidate) => candidate.id === roundId); diff --git a/src/pages/Competition/Export/index.tsx b/src/pages/Competition/Export/index.tsx index d9facb8..cc0a735 100644 --- a/src/pages/Competition/Export/index.tsx +++ b/src/pages/Competition/Export/index.tsx @@ -23,7 +23,7 @@ import { download, generateCsv, mkConfig } from 'export-to-csv'; import { flatten } from 'lodash'; import { useCallback } from 'react'; import Grid from '@mui/material/GridLegacy'; -import { getAdvancementConditionForRound } from '../../../lib/wcif/rounds'; +import { getDisplayAdvancementConditionForRound } from '../../../lib/wcif/rounds'; type AssignmentWithActivity = Assignment & { activity: ActivityWithParent }; @@ -261,7 +261,7 @@ const ExportPage = () => { : '', round_format: roundFormatShortById(round.format), advancement_condition: (() => { - const advancementCondition = getAdvancementConditionForRound(event, round.id); + const advancementCondition = getDisplayAdvancementConditionForRound(event, round.id); return advancementCondition ? advancementConditionToText(advancementCondition) : ''; })(), round_number: parseActivityCode(round.id)?.roundNumber ?? '', From 203536405c94d547ab7ef41cde613be16f3e3d69 Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Sat, 2 May 2026 07:45:06 -0700 Subject: [PATCH 04/13] Keep dual rounds visible in selector --- .../_tests_/RoundSelector.test.tsx | 75 ++++++++++++++++++- src/components/RoundSelector/index.tsx | 15 ++-- src/lib/wcif/rounds.ts | 11 +++ 3 files changed, 94 insertions(+), 7 deletions(-) diff --git a/src/components/RoundSelector/_tests_/RoundSelector.test.tsx b/src/components/RoundSelector/_tests_/RoundSelector.test.tsx index 73811a5..2eb06c7 100644 --- a/src/components/RoundSelector/_tests_/RoundSelector.test.tsx +++ b/src/components/RoundSelector/_tests_/RoundSelector.test.tsx @@ -5,6 +5,9 @@ import userEvent from '@testing-library/user-event'; import type { AppState } from '../../../store/initialState'; const useAppSelector = vi.fn(); +const { earliestStartTimeForRoundMock } = vi.hoisted(() => ({ + earliestStartTimeForRoundMock: vi.fn(() => new Date('2020-01-01T00:00:00Z')), +})); vi.mock('../../../store', () => ({ useAppSelector: (...args: unknown[]) => useAppSelector(...args), @@ -21,7 +24,7 @@ vi.mock('../../../lib/domain/activities', async () => { return { ...actual, - earliestStartTimeForRound: vi.fn(() => new Date('2020-01-01T00:00:00Z')), + earliestStartTimeForRound: earliestStartTimeForRoundMock, }; }); @@ -125,4 +128,74 @@ describe('RoundSelector', () => { expect(activityCodes).toEqual(['333fm-r1', '333fm-r1-a1', '333fm-r1-a2', '333fm-r1-a3']); expect(nestingLevels).toEqual(['0', '1', '1', '1']); }); + + it('shows linked dual-round source rounds even before they start', () => { + earliestStartTimeForRoundMock.mockReturnValueOnce(new Date('3020-01-01T00:00:00Z')); + earliestStartTimeForRoundMock.mockReturnValueOnce(new Date('3020-01-01T00:00:00Z')); + earliestStartTimeForRoundMock.mockReturnValueOnce(new Date('3020-01-01T00:00:00Z')); + + const wcif = { + id: 'TestComp', + events: [ + { + id: 'clock', + rounds: [ + { + id: 'clock-r1', + format: 'a', + results: [], + timeLimit: null, + cutoff: null, + participationRuleset: { + participationSource: { type: 'registrations' }, + reservedPlaces: null, + }, + extensions: [], + }, + { + id: 'clock-r2', + format: 'a', + results: [], + timeLimit: null, + cutoff: null, + participationRuleset: { + participationSource: { type: 'registrations' }, + reservedPlaces: null, + }, + extensions: [], + }, + { + id: 'clock-r3', + format: 'a', + results: [], + timeLimit: null, + cutoff: null, + participationRuleset: { + participationSource: { + type: 'linkedRounds', + roundIds: ['clock-r1', 'clock-r2'], + resultCondition: { type: 'percent', scope: 'average', value: 75 }, + }, + reservedPlaces: null, + }, + extensions: [], + }, + ], + }, + ], + }; + + const state = { wcif } as unknown as AppState; + useAppSelector.mockImplementation((selector: (state: AppState) => unknown) => selector(state)); + + const { getAllByTestId } = renderWithProviders( + undefined} /> + ); + + expect(getAllByTestId('round-item')).toHaveLength(2); + expect(getAllByTestId('round-item').map((item) => item.getAttribute('data-code'))).toEqual([ + 'clock-r1', + 'clock-r2', + ]); + }); }); diff --git a/src/components/RoundSelector/index.tsx b/src/components/RoundSelector/index.tsx index dc9c80a..33132a1 100644 --- a/src/components/RoundSelector/index.tsx +++ b/src/components/RoundSelector/index.tsx @@ -1,9 +1,9 @@ import { earliestStartTimeForRound, hasDistributedAttempts, - parseActivityCode, } from '../../lib/domain/activities'; import { eventNameById } from '../../lib/domain/events'; +import { isAlwaysVisibleRound } from '../../lib/wcif/rounds'; import { useCommandPrompt } from '../../providers/CommandPromptProvider'; import { useAppSelector } from '../../store'; import RoundListItem from './RoundListItem'; @@ -29,11 +29,15 @@ const RoundSelector = ({ onSelected }: RoundSelectorProps) => { const attemptCountForRound = (round: Round) => (round.format === 'm' ? 3 : +round.format); - const shouldShowRound = (round: Round) => { + const shouldShowRound = (eventId: string, round: Round) => { if (!wcif) return false; - const { roundNumber } = parseActivityCode(round.id); - if (roundNumber === 1 || showAllRounds) { + const event = wcif.events.find((candidate) => candidate.id === eventId); + if (!event) { + return false; + } + + if (showAllRounds || isAlwaysVisibleRound(event, round)) { return true; } @@ -51,9 +55,8 @@ const RoundSelector = ({ onSelected }: RoundSelectorProps) => { const rounds = wcif ? wcif.events - .map((e) => e.rounds) + .map((e) => e.rounds.filter((round) => shouldShowRound(e.id, round))) .flat() - .filter(shouldShowRound) : []; const roundIds = rounds.flatMap((r) => { diff --git a/src/lib/wcif/rounds.ts b/src/lib/wcif/rounds.ts index 0198edb..d5e4bdd 100644 --- a/src/lib/wcif/rounds.ts +++ b/src/lib/wcif/rounds.ts @@ -117,3 +117,14 @@ export const getDualRoundDetails = ( return null; }; + +export const isAlwaysVisibleRound = (event: Event, round: Round): boolean => { + const { roundNumber } = parseActivityCode(round.id); + + if (roundNumber === 1) { + return true; + } + + const dualRoundDetails = getDualRoundDetails(event, round.id); + return Boolean(dualRoundDetails?.isSourceRound && usesRegistrationParticipation(round)); +}; From a81c83dd6d3323614c1e5109c30b5d445717322a Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Sat, 2 May 2026 07:55:57 -0700 Subject: [PATCH 05/13] Refine dual round round header --- src/components/RoundStatisticsCard.tsx | 50 +++++++ .../Competition/Round/NormalRoundView.tsx | 9 ++ .../Competition/Round/RoundContainer.tsx | 126 +++++++++--------- .../Round/hooks/useRoundActions.ts | 60 ++++++++- .../Competition/Round/hooks/useRoundData.ts | 10 ++ .../Round/utils/dualRoundAssignments.test.ts | 55 ++++++++ .../Round/utils/dualRoundAssignments.ts | 70 ++++++++++ 7 files changed, 313 insertions(+), 67 deletions(-) create mode 100644 src/pages/Competition/Round/utils/dualRoundAssignments.test.ts create mode 100644 src/pages/Competition/Round/utils/dualRoundAssignments.ts diff --git a/src/components/RoundStatisticsCard.tsx b/src/components/RoundStatisticsCard.tsx index 475accc..17fb45f 100644 --- a/src/components/RoundStatisticsCard.tsx +++ b/src/components/RoundStatisticsCard.tsx @@ -6,12 +6,15 @@ import { byName } from '../lib/utils/utils'; import { cumulativeGroupCount } from '../lib/wcif/groups'; import { RoundLimitInfo } from './RoundLimitInfo'; import { + Button, Card, + CardContent, CardActions, CardHeader, Divider, List, ListItemButton, + ListItemText, ListSubheader, Table, TableBody, @@ -22,6 +25,7 @@ import { } from '@mui/material'; import { type Competition, type Person, type Round } from '@wca/helpers'; import { type ReactNode } from 'react'; +import { Link as RouterLink } from 'react-router-dom'; interface RoundStatisticsCardProps { activityCode: string; @@ -37,6 +41,11 @@ interface RoundStatisticsCardProps { onOpenPersonsDialog: (title: string, persons: Person[]) => void; onOpenPersonsAssignmentsDialog: () => void; actionButtons: ReactNode; + linkedRounds?: Array<{ + roundId: string; + onCopyAssignments?: () => void; + }>; + competitionId?: string; } export const RoundStatisticsCard = ({ @@ -53,6 +62,8 @@ export const RoundStatisticsCard = ({ onOpenPersonsDialog, onOpenPersonsAssignmentsDialog, actionButtons, + linkedRounds = [], + competitionId, }: RoundStatisticsCardProps) => { return ( @@ -73,6 +84,45 @@ export const RoundStatisticsCard = ({ /> } /> + {linkedRounds.length > 0 && ( + <> + + + Linked Rounds + + + {linkedRounds.map(({ roundId, onCopyAssignments }) => ( + + + {onCopyAssignments && ( + + )} + + ))} + + + + + )} Stages}> {roundActivities.map(({ id, startTime, endTime, room }) => ( diff --git a/src/pages/Competition/Round/NormalRoundView.tsx b/src/pages/Competition/Round/NormalRoundView.tsx index c57f84c..8c9639a 100644 --- a/src/pages/Competition/Round/NormalRoundView.tsx +++ b/src/pages/Competition/Round/NormalRoundView.tsx @@ -26,6 +26,11 @@ interface NormalRoundViewProps { expectedRegistrations?: number; } | null; sortedGroups: Activity[]; + competitionId?: string; + linkedRounds?: Array<{ + roundId: string; + onCopyAssignments?: () => void; + }>; } /** @@ -48,6 +53,8 @@ const NormalRoundView = ({ actionButtons, adamRoundConfig, sortedGroups, + competitionId, + linkedRounds, }: NormalRoundViewProps) => { const pluralizeWord = (count: number, singular: string, plural?: string) => count === 1 ? singular : plural || singular + 's'; @@ -81,6 +88,8 @@ const NormalRoundView = ({ onOpenPersonsDialog={onOpenPersonsDialog} onOpenPersonsAssignmentsDialog={onOpenPersonsAssignmentsDialog} actionButtons={actionButtons} + competitionId={competitionId} + linkedRounds={linkedRounds} /> diff --git a/src/pages/Competition/Round/RoundContainer.tsx b/src/pages/Competition/Round/RoundContainer.tsx index b5a3c51..7246d63 100644 --- a/src/pages/Competition/Round/RoundContainer.tsx +++ b/src/pages/Competition/Round/RoundContainer.tsx @@ -27,7 +27,6 @@ interface RoundContainerProps { const RoundContainer = ({ roundId, activityCode, eventId, round }: RoundContainerProps) => { const dialogs = useRoundDialogs(); - const { wcif, personsShouldBeInRound, @@ -40,15 +39,17 @@ const RoundContainer = ({ roundId, activityCode, eventId, round }: RoundContaine adamRoundConfig, isDistributedAttemptRoundLevel, distributedAttemptGroups, + linkedRoundIds, } = useRoundData(activityCode, round); - const { handleGenerateAssignments, handleAssignToRoundAttempt, handleResetAttemptAssignments, handleResetAll, handleResetNonScrambling, + handleCopyAssignments, } = useRoundActions({ + wcif, round, activityCode, groups, @@ -57,16 +58,13 @@ const RoundContainer = ({ roundId, activityCode, eventId, round }: RoundContaine const event = wcif?.events.find((candidate) => candidate.id === eventId); const dualRoundDetails = event ? getDualRoundDetails(event, round.id) : null; + const linkedRounds = linkedRoundIds.map((linkedRoundId) => ({ + roundId: linkedRoundId, + onCopyAssignments: + linkedRoundId === round.id ? undefined : () => handleCopyAssignments(linkedRoundId, round.id), + })); if (roundActivities.length === 0) { - return ( -
- No Group Activities found.
-
- ); - } - - if (!round) { - return null; + return
No Group Activities found.
; } const actionButtons = ( @@ -88,8 +86,57 @@ const RoundContainer = ({ roundId, activityCode, eventId, round }: RoundContaine /> ); - const commonDialogs = ( - <> + return ( + + {dualRoundDetails && ( + + This event is configured as dual rounds.{' '} + {dualRoundDetails.linkedRoundIds.map(activityCodeToName).join(' and ')} feed into{' '} + {activityCodeToName(dualRoundDetails.targetRoundId)}. + + )} + {isDistributedAttemptRoundLevel ? ( + dialogs.rawRoundData.setOpen(true)} + onOpenRawActivitiesData={() => dialogs.rawRoundActivitiesData.setOpen(true)} + onOpenPersonsDialog={dialogs.personsDialog.open} + onOpenPersonsAssignmentsDialog={() => dialogs.personsAssignments.setOpen(true)} + actionButtons={actionButtons} + adamRoundConfig={adamRoundConfig} + distributedAttemptGroups={distributedAttemptGroups} + /> + ) : ( + dialogs.rawRoundData.setOpen(true)} + onOpenRawActivitiesData={() => dialogs.rawRoundActivitiesData.setOpen(true)} + onOpenPersonsDialog={dialogs.personsDialog.open} + onOpenPersonsAssignmentsDialog={() => dialogs.personsAssignments.setOpen(true)} + actionButtons={actionButtons} + adamRoundConfig={adamRoundConfig} + sortedGroups={sortedGroups} + competitionId={wcif?.id} + linkedRounds={linkedRounds} + /> + )} dialogs.configureAssignments.setOpen(false)} @@ -140,59 +187,6 @@ const RoundContainer = ({ roundId, activityCode, eventId, round }: RoundContaine onClose={() => dialogs.rawRoundActivitiesData.setOpen(false)} activityCode={activityCode} /> - - ); - - return ( - - {dualRoundDetails && ( - - This event is configured as dual rounds.{' '} - {dualRoundDetails.linkedRoundIds.map(activityCodeToName).join(' and ')} feed into{' '} - {activityCodeToName(dualRoundDetails.targetRoundId)}. - - )} - {isDistributedAttemptRoundLevel ? ( - dialogs.rawRoundData.setOpen(true)} - onOpenRawActivitiesData={() => dialogs.rawRoundActivitiesData.setOpen(true)} - onOpenPersonsDialog={dialogs.personsDialog.open} - onOpenPersonsAssignmentsDialog={() => dialogs.personsAssignments.setOpen(true)} - actionButtons={actionButtons} - adamRoundConfig={adamRoundConfig} - distributedAttemptGroups={distributedAttemptGroups} - /> - ) : ( - dialogs.rawRoundData.setOpen(true)} - onOpenRawActivitiesData={() => dialogs.rawRoundActivitiesData.setOpen(true)} - onOpenPersonsDialog={dialogs.personsDialog.open} - onOpenPersonsAssignmentsDialog={() => dialogs.personsAssignments.setOpen(true)} - actionButtons={actionButtons} - adamRoundConfig={adamRoundConfig} - sortedGroups={sortedGroups} - /> - )} - {commonDialogs} ); }; diff --git a/src/pages/Competition/Round/hooks/useRoundActions.ts b/src/pages/Competition/Round/hooks/useRoundActions.ts index 72cf009..478f897 100644 --- a/src/pages/Competition/Round/hooks/useRoundActions.ts +++ b/src/pages/Competition/Round/hooks/useRoundActions.ts @@ -1,17 +1,21 @@ import { parseActivityCode } from '../../../../lib/domain/activities'; import { type ActivityWithParent, type ActivityWithRoom } from '../../../../lib/domain/types'; +import { buildCopyRoundAssignments } from '../utils/dualRoundAssignments'; import { + bulkAddPersonAssignments, bulkRemovePersonAssignments, generateAssignments, generateRoundAttemptAssignments, updateRoundChildActivities, } from '../../../../store/actions'; -import { type Round } from '@wca/helpers'; +import { type Competition, type Round } from '@wca/helpers'; import { useConfirm } from 'material-ui-confirm'; +import { useSnackbar } from 'notistack'; import { useCallback } from 'react'; import { useDispatch } from 'react-redux'; interface UseRoundActionsParams { + wcif: Competition | null; round: Round | undefined; activityCode: string; groups: ActivityWithParent[]; @@ -19,6 +23,7 @@ interface UseRoundActionsParams { } export const useRoundActions = ({ + wcif, round, activityCode, groups, @@ -26,6 +31,7 @@ export const useRoundActions = ({ }: UseRoundActionsParams) => { const dispatch = useDispatch(); const confirm = useConfirm(); + const { enqueueSnackbar } = useSnackbar(); const handleGenerateAssignments = useCallback(() => { if (!round) return; @@ -137,11 +143,63 @@ export const useRoundActions = ({ }); }, [confirm, dispatch, groups]); + const handleCopyAssignments = useCallback( + (sourceRoundId: string, targetRoundId: string) => { + if (!wcif || !round) { + return; + } + + const { assignments, targetActivityIds, copiedCount, skippedCount } = buildCopyRoundAssignments( + wcif, + sourceRoundId, + targetRoundId + ); + + if (targetActivityIds.length === 0) { + enqueueSnackbar('Target round has no generated groups to copy into.', { variant: 'warning' }); + return; + } + + if (copiedCount === 0) { + enqueueSnackbar('No source assignments found to copy.', { variant: 'warning' }); + return; + } + + confirm({ + description: `Copy ${copiedCount} assignments into ${targetRoundId}? Existing assignments in the target round will be replaced.`, + confirmationText: 'Copy', + cancellationText: 'Cancel', + }) + .then(() => { + dispatch( + bulkRemovePersonAssignments( + targetActivityIds.map((activityId) => ({ + activityId, + })) + ) + ); + dispatch(bulkAddPersonAssignments(assignments)); + + enqueueSnackbar( + skippedCount > 0 + ? `Copied ${copiedCount} assignments. Skipped ${skippedCount} without a matching target group.` + : `Copied ${copiedCount} assignments.`, + { variant: skippedCount > 0 ? 'warning' : 'success' } + ); + }) + .catch((e) => { + console.error(e); + }); + }, + [confirm, dispatch, enqueueSnackbar, round, wcif] + ); + return { handleGenerateAssignments, handleAssignToRoundAttempt, handleResetAttemptAssignments, handleResetAll, handleResetNonScrambling, + handleCopyAssignments, }; }; diff --git a/src/pages/Competition/Round/hooks/useRoundData.ts b/src/pages/Competition/Round/hooks/useRoundData.ts index 489efa0..285fb35 100644 --- a/src/pages/Competition/Round/hooks/useRoundData.ts +++ b/src/pages/Competition/Round/hooks/useRoundData.ts @@ -5,6 +5,7 @@ import { } from '../../../../lib/domain/activities/activityCode'; import { byGroupNumber } from '../../../../lib/domain/activities/activityUtils'; import { type ActivityWithParent, type ActivityWithRoom } from '../../../../lib/domain/types'; +import { getDualRoundDetails } from '../../../../lib/wcif/rounds'; import { allChildActivities, findAllActivities, @@ -39,10 +40,16 @@ interface RoundDataResult { attemptNumber: number; activities: ActivityWithRoom[]; }>; + linkedRoundIds: string[]; + targetRoundId: string | null; + isDualRoundSourceRound: boolean; } export const useRoundData = (activityCode: string, round: Round | undefined): RoundDataResult => { const wcif = useAppSelector((state) => state.wcif); + const eventId = round ? parseActivityCode(round.id).eventId : undefined; + const event = eventId ? wcif?.events.find((candidate) => candidate.id === eventId) : undefined; + const dualRoundDetails = event && round ? getDualRoundDetails(event, round.id) : null; const personsShouldBeInRound = useAppSelector((state) => round ? selectPersonsShouldBeInRound(state)(round) : [] @@ -158,5 +165,8 @@ export const useRoundData = (activityCode: string, round: Round | undefined): Ro adamRoundConfig, isDistributedAttemptRoundLevel, distributedAttemptGroups, + linkedRoundIds: dualRoundDetails?.linkedRoundIds ?? [], + targetRoundId: dualRoundDetails?.targetRoundId ?? null, + isDualRoundSourceRound: dualRoundDetails?.isSourceRound ?? false, }; }; diff --git a/src/pages/Competition/Round/utils/dualRoundAssignments.test.ts b/src/pages/Competition/Round/utils/dualRoundAssignments.test.ts new file mode 100644 index 0000000..a81c878 --- /dev/null +++ b/src/pages/Competition/Round/utils/dualRoundAssignments.test.ts @@ -0,0 +1,55 @@ +import { buildCopyRoundAssignments } from './dualRoundAssignments'; +import { buildActivity, buildPerson, buildWcifWithEvents, buildEvent, buildRound } from '../../../../store/reducers/_tests_/helpers'; +import { describe, expect, it } from 'vitest'; + +describe('buildCopyRoundAssignments', () => { + it('copies assignments from one linked round to another by room and group number', () => { + const round1 = buildActivity({ + id: 100, + activityCode: 'clock-r1', + childActivities: [ + buildActivity({ id: 101, activityCode: 'clock-r1-g1', childActivities: [] }), + buildActivity({ id: 102, activityCode: 'clock-r1-g2', childActivities: [] }), + ], + }); + const round2 = buildActivity({ + id: 200, + activityCode: 'clock-r2', + childActivities: [ + buildActivity({ id: 201, activityCode: 'clock-r2-g1', childActivities: [] }), + buildActivity({ id: 202, activityCode: 'clock-r2-g2', childActivities: [] }), + ], + }); + const person = buildPerson({ + registrantId: 7, + assignments: [ + { activityId: 101, assignmentCode: 'competitor', stationNumber: 3 }, + { activityId: 102, assignmentCode: 'staff-judge', stationNumber: null }, + ], + }); + + const wcif = buildWcifWithEvents( + [round1, round2], + [ + buildEvent({ id: 'clock', rounds: [buildRound({ id: 'clock-r1' }), buildRound({ id: 'clock-r2' })] }), + ], + [person] + ); + + const result = buildCopyRoundAssignments(wcif, 'clock-r1', 'clock-r2'); + + expect(result.targetActivityIds).toEqual([201, 202]); + expect(result.copiedCount).toBe(2); + expect(result.skippedCount).toBe(0); + expect(result.assignments).toEqual([ + { + registrantId: 7, + assignment: { activityId: 201, assignmentCode: 'competitor', stationNumber: 3 }, + }, + { + registrantId: 7, + assignment: { activityId: 202, assignmentCode: 'staff-judge', stationNumber: null }, + }, + ]); + }); +}); diff --git a/src/pages/Competition/Round/utils/dualRoundAssignments.ts b/src/pages/Competition/Round/utils/dualRoundAssignments.ts new file mode 100644 index 0000000..9f6487f --- /dev/null +++ b/src/pages/Competition/Round/utils/dualRoundAssignments.ts @@ -0,0 +1,70 @@ +import { findGroupActivitiesByRound } from '../../../../lib/wcif/activities'; +import { parseActivityCode } from '../../../../lib/domain/activities'; +import { type BulkInProgressAssignments } from '../../../../lib/types'; +import { type Competition } from '@wca/helpers'; + +const buildGroupKey = (roomId: number | undefined, groupNumber: number | undefined) => + roomId !== undefined && groupNumber !== undefined ? `${roomId}:${groupNumber}` : null; + +export interface CopyRoundAssignmentsResult { + assignments: BulkInProgressAssignments; + targetActivityIds: number[]; + copiedCount: number; + skippedCount: number; +} + +export const buildCopyRoundAssignments = ( + wcif: Competition, + sourceRoundId: string, + targetRoundId: string +): CopyRoundAssignmentsResult => { + const sourceGroups = findGroupActivitiesByRound(wcif, sourceRoundId); + const targetGroups = findGroupActivitiesByRound(wcif, targetRoundId); + + const sourceGroupsById = new Map(sourceGroups.map((activity) => [activity.id, activity])); + const targetGroupsByKey = new Map( + targetGroups.flatMap((activity) => { + const { groupNumber } = parseActivityCode(activity.activityCode); + const key = buildGroupKey(activity.parent.room?.id, groupNumber); + return key ? [[key, activity] as const] : []; + }) + ); + + const targetActivityIds = targetGroups.map((activity) => activity.id); + const assignments: BulkInProgressAssignments = []; + let skippedCount = 0; + + wcif.persons.forEach((person) => { + person.assignments?.forEach((assignment) => { + const sourceActivity = sourceGroupsById.get(+assignment.activityId); + + if (!sourceActivity) { + return; + } + + const { groupNumber } = parseActivityCode(sourceActivity.activityCode); + const targetKey = buildGroupKey(sourceActivity.parent.room?.id, groupNumber); + const targetActivity = targetKey ? targetGroupsByKey.get(targetKey) : undefined; + + if (!targetActivity) { + skippedCount++; + return; + } + + assignments.push({ + registrantId: person.registrantId, + assignment: { + ...assignment, + activityId: targetActivity.id, + }, + }); + }); + }); + + return { + assignments, + targetActivityIds, + copiedCount: assignments.length, + skippedCount, + }; +}; From 9c4f577f2c37d86a7c35c985c9a4bba7190d5821 Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Sat, 2 May 2026 08:00:17 -0700 Subject: [PATCH 06/13] Show participation condition on round page --- src/components/RoundLimitInfo.tsx | 20 ++++++- src/components/RoundStatisticsCard.tsx | 3 + src/lib/wcif/rounds.test.ts | 74 +++++++++++++++++++++++++ src/lib/wcif/rounds.ts | 77 +++++++++++++++++++++++++- src/pages/Competition/Export/index.tsx | 24 +------- 5 files changed, 172 insertions(+), 26 deletions(-) diff --git a/src/components/RoundLimitInfo.tsx b/src/components/RoundLimitInfo.tsx index 7d5d98e..e0b3070 100644 --- a/src/components/RoundLimitInfo.tsx +++ b/src/components/RoundLimitInfo.tsx @@ -1,15 +1,21 @@ import { mayMakeCutoff, mayMakeTimeLimit } from '../lib/domain/persons'; +import { getParticipationConditionTextForRound } from '../lib/wcif/rounds'; import { renderResultByEventId } from '../lib/utils/utils'; import { Box, Divider, Tooltip, Typography } from '@mui/material'; -import { type EventId, formatCentiseconds, type Person, type Round } from '@wca/helpers'; +import { type Event, type EventId, formatCentiseconds, type Person, type Round } from '@wca/helpers'; interface RoundLimitInfoProps { + event: Event | null; round: Round; eventId: string; personsShouldBeInRound: Person[]; } -export const RoundLimitInfo = ({ round, eventId, personsShouldBeInRound }: RoundLimitInfoProps) => { +export const RoundLimitInfo = ({ event, round, eventId, personsShouldBeInRound }: RoundLimitInfoProps) => { + const participationConditionText = event + ? getParticipationConditionTextForRound(event, round.id) + : null; + return ( {round.timeLimit && ( @@ -25,7 +31,7 @@ export const RoundLimitInfo = ({ round, eventId, personsShouldBeInRound }: Round )} - + {round.timeLimit && round.cutoff && } {round.cutoff && ( @@ -42,6 +48,14 @@ export const RoundLimitInfo = ({ round, eventId, personsShouldBeInRound }: Round )} + {participationConditionText && ( + <> + {(round.timeLimit || round.cutoff) && } + + Participation: {participationConditionText} + + + )} ); }; diff --git a/src/components/RoundStatisticsCard.tsx b/src/components/RoundStatisticsCard.tsx index 17fb45f..d130d9b 100644 --- a/src/components/RoundStatisticsCard.tsx +++ b/src/components/RoundStatisticsCard.tsx @@ -65,6 +65,8 @@ export const RoundStatisticsCard = ({ linkedRounds = [], competitionId, }: RoundStatisticsCardProps) => { + const event = wcif?.events.find((candidate) => candidate.id === eventId) ?? null; + return ( { }); }); +describe('formatAdvancementCondition', () => { + it('formats ranking and percent advancement conditions', () => { + expect(formatAdvancementCondition({ type: 'ranking', level: 14 })).toBe('Top 14'); + expect(formatAdvancementCondition({ type: 'percent', level: 40 })).toBe('Top 40%'); + }); +}); + describe('getAdvancementConditionForRound', () => { it('returns true when a linked source round has downstream participation conditions', () => { const event = buildEvent({ @@ -212,3 +221,68 @@ describe('getDualRoundDetails', () => { }); }); }); + +describe('getParticipationConditionTextForRound', () => { + it('formats legacy advancement text toward the next round', () => { + const event = buildEvent({ + id: '333', + rounds: [ + buildRound({ + id: '333-r1', + advancementCondition: { type: 'ranking', level: 14 }, + }), + buildRound({ id: '333-r2' }), + ], + }); + + expect(getParticipationConditionTextForRound(event, '333-r1')).toBe('Top 14 to round 2'); + }); + + it('formats dual-round participation text for linked source and target rounds', () => { + const event = buildEvent({ + id: 'clock', + rounds: [ + buildRound({ + id: 'clock-r1', + participationRuleset: { + participationSource: { + type: 'registrations', + }, + reservedPlaces: null, + }, + }), + buildRound({ + id: 'clock-r2', + participationRuleset: { + participationSource: { + type: 'registrations', + }, + reservedPlaces: null, + }, + }), + buildRound({ + id: 'clock-r3', + participationRuleset: { + participationSource: { + type: 'linkedRounds', + roundIds: ['clock-r1', 'clock-r2'], + resultCondition: { + type: 'percent', + scope: 'average', + value: 40, + }, + }, + reservedPlaces: null, + }, + }), + ], + }); + + expect(getParticipationConditionTextForRound(event, 'clock-r1')).toBe( + 'Top 40% from dual rounds R1 & R2 to round 3' + ); + expect(getParticipationConditionTextForRound(event, 'clock-r3')).toBe( + 'Top 40% from dual rounds R1 & R2 to round 3' + ); + }); +}); diff --git a/src/lib/wcif/rounds.ts b/src/lib/wcif/rounds.ts index d5e4bdd..fb81831 100644 --- a/src/lib/wcif/rounds.ts +++ b/src/lib/wcif/rounds.ts @@ -1,5 +1,5 @@ import { parseActivityCode } from '../domain/activities'; -import { type AdvancementCondition, type Event, type ParticipationRuleset, type Round } from '@wca/helpers'; +import { formatCentiseconds, type AdvancementCondition, type Event, type ParticipationRuleset, type Round } from '@wca/helpers'; export interface DualRoundDetails { linkedRoundIds: string[]; @@ -41,6 +41,27 @@ export const getDerivedAdvancementCondition = ( }; }; +export const formatAdvancementCondition = ({ type, level }: AdvancementCondition): string => { + switch (type) { + case 'ranking': + return `Top ${level}`; + case 'percent': + return `Top ${level}%`; + case 'attemptResult': + if (level === -2) { + return '> DNS'; + } + + if (level === -1) { + return '> DNF'; + } + + return `< ${formatCentiseconds(level)}`; + default: + return ''; + } +}; + export const getAdvancementConditionForRound = ( event: Event, roundId: string @@ -85,6 +106,60 @@ export const getDisplayAdvancementConditionForRound = ( return nextRound ? getDerivedAdvancementCondition(nextRound) : null; }; +const formatShortRoundLabel = (roundId: string): string => { + const { roundNumber } = parseActivityCode(roundId); + return roundNumber ? `R${roundNumber}` : roundId; +}; + +const formatLongRoundLabel = (roundId: string): string => { + const { roundNumber } = parseActivityCode(roundId); + return roundNumber ? `round ${roundNumber}` : roundId; +}; + +const findLinkedTargetRound = (event: Event, roundId: string): Round | undefined => + event.rounds.find((candidate) => { + const participationSource = getParticipationRuleset(candidate)?.participationSource; + return participationSource?.type === 'linkedRounds' && participationSource.roundIds.includes(roundId); + }); + +export const getParticipationConditionTextForRound = ( + event: Event, + roundId: string +): string | null => { + const round = event.rounds.find((candidate) => candidate.id === roundId); + + if (!round) { + return null; + } + + const participationSource = getParticipationRuleset(round)?.participationSource; + + if (participationSource?.type === 'linkedRounds') { + const sourceRounds = participationSource.roundIds.map(formatShortRoundLabel).join(' & '); + return `${formatAdvancementCondition({ + type: participationSource.resultCondition.type, + level: participationSource.resultCondition.value, + })} from dual rounds ${sourceRounds} to ${formatLongRoundLabel(round.id)}`; + } + + if (hasLegacyAdvancementCondition(round)) { + const currentRoundIndex = event.rounds.findIndex((candidate) => candidate.id === roundId); + const nextRound = currentRoundIndex >= 0 ? event.rounds[currentRoundIndex + 1] : undefined; + + return nextRound + ? `${formatAdvancementCondition(round.advancementCondition)} to ${formatLongRoundLabel(nextRound.id)}` + : null; + } + + const linkedTargetRound = findLinkedTargetRound(event, roundId); + + if (!linkedTargetRound) { + return null; + } + + return getParticipationConditionTextForRound(event, linkedTargetRound.id); +}; + export const getDualRoundDetails = ( event: Event, roundId: string diff --git a/src/pages/Competition/Export/index.tsx b/src/pages/Competition/Export/index.tsx index cc0a735..72baad2 100644 --- a/src/pages/Competition/Export/index.tsx +++ b/src/pages/Competition/Export/index.tsx @@ -11,7 +11,6 @@ import { useAppSelector } from '../../../store'; import { Button, Typography } from '@mui/material'; import { type Activity, - type AdvancementCondition, type Assignment, type Event, type Person, @@ -23,29 +22,10 @@ import { download, generateCsv, mkConfig } from 'export-to-csv'; import { flatten } from 'lodash'; import { useCallback } from 'react'; import Grid from '@mui/material/GridLegacy'; -import { getDisplayAdvancementConditionForRound } from '../../../lib/wcif/rounds'; +import { formatAdvancementCondition, getDisplayAdvancementConditionForRound } from '../../../lib/wcif/rounds'; type AssignmentWithActivity = Assignment & { activity: ActivityWithParent }; -const advancementConditionToText = ({ type, level }: AdvancementCondition): string => { - switch (type) { - case 'ranking': - return `Top ${level}`; - case 'percent': - return `Top ${level}%`; - case 'attemptResult': - if (level === -2) { - return '> DNS'; - } else if (level === -1) { - return '> DNF'; - } else { - return `< ${formatCentiseconds(level)}`; - } - default: - return ''; - } -}; - const csvOptions = { fieldSeparator: ',', quoteStrings: true, @@ -262,7 +242,7 @@ const ExportPage = () => { round_format: roundFormatShortById(round.format), advancement_condition: (() => { const advancementCondition = getDisplayAdvancementConditionForRound(event, round.id); - return advancementCondition ? advancementConditionToText(advancementCondition) : ''; + return advancementCondition ? formatAdvancementCondition(advancementCondition) : ''; })(), round_number: parseActivityCode(round.id)?.roundNumber ?? '', }; From 3d2926a945d39ed6a2f80701e9c965e11cd2d16f Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Sat, 2 May 2026 08:01:52 -0700 Subject: [PATCH 07/13] Guard missing advancement conditions --- src/lib/wcif/rounds.test.ts | 5 +++++ src/lib/wcif/rounds.ts | 17 +++++++++++++---- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/src/lib/wcif/rounds.test.ts b/src/lib/wcif/rounds.test.ts index eb4108f..aec5955 100644 --- a/src/lib/wcif/rounds.test.ts +++ b/src/lib/wcif/rounds.test.ts @@ -56,6 +56,11 @@ describe('formatAdvancementCondition', () => { expect(formatAdvancementCondition({ type: 'ranking', level: 14 })).toBe('Top 14'); expect(formatAdvancementCondition({ type: 'percent', level: 40 })).toBe('Top 40%'); }); + + it('returns an empty string for missing advancement conditions', () => { + expect(formatAdvancementCondition(null)).toBe(''); + expect(formatAdvancementCondition(undefined)).toBe(''); + }); }); describe('getAdvancementConditionForRound', () => { diff --git a/src/lib/wcif/rounds.ts b/src/lib/wcif/rounds.ts index fb81831..aa03d57 100644 --- a/src/lib/wcif/rounds.ts +++ b/src/lib/wcif/rounds.ts @@ -11,7 +11,7 @@ export interface DualRoundDetails { const hasLegacyAdvancementCondition = ( round: Round ): round is Round & { advancementCondition: AdvancementCondition } => - round.advancementCondition !== null; + round.advancementCondition != null; export const getParticipationRuleset = (round: Round): ParticipationRuleset | null => round.participationRuleset ?? null; @@ -41,7 +41,15 @@ export const getDerivedAdvancementCondition = ( }; }; -export const formatAdvancementCondition = ({ type, level }: AdvancementCondition): string => { +export const formatAdvancementCondition = ( + advancementCondition: AdvancementCondition | null | undefined +): string => { + if (!advancementCondition) { + return ''; + } + + const { type, level } = advancementCondition; + switch (type) { case 'ranking': return `Top ${level}`; @@ -145,9 +153,10 @@ export const getParticipationConditionTextForRound = ( if (hasLegacyAdvancementCondition(round)) { const currentRoundIndex = event.rounds.findIndex((candidate) => candidate.id === roundId); const nextRound = currentRoundIndex >= 0 ? event.rounds[currentRoundIndex + 1] : undefined; + const advancementText = formatAdvancementCondition(round.advancementCondition); - return nextRound - ? `${formatAdvancementCondition(round.advancementCondition)} to ${formatLongRoundLabel(nextRound.id)}` + return nextRound && advancementText + ? `${advancementText} to ${formatLongRoundLabel(nextRound.id)}` : null; } From ce7f6526c86170b08109a2f060454a49d82275a0 Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Sat, 2 May 2026 08:05:00 -0700 Subject: [PATCH 08/13] Accept v2 participation rulesets in validation --- src/lib/wcif/rounds.test.ts | 3 +- src/lib/wcif/rounds.ts | 7 +-- .../validation/eventRoundValidation.test.ts | 48 +++++++++++++++++++ 3 files changed, 51 insertions(+), 7 deletions(-) diff --git a/src/lib/wcif/rounds.test.ts b/src/lib/wcif/rounds.test.ts index aec5955..e5dbb64 100644 --- a/src/lib/wcif/rounds.test.ts +++ b/src/lib/wcif/rounds.test.ts @@ -64,7 +64,7 @@ describe('formatAdvancementCondition', () => { }); describe('getAdvancementConditionForRound', () => { - it('returns true when a linked source round has downstream participation conditions', () => { + it('returns true when a round has a v2 participation ruleset', () => { const event = buildEvent({ id: 'clock', rounds: [ @@ -106,6 +106,7 @@ describe('getAdvancementConditionForRound', () => { expect(getAdvancementConditionForRound(event, 'clock-r1')).toBe(true); expect(getAdvancementConditionForRound(event, 'clock-r2')).toBe(true); + expect(getAdvancementConditionForRound(event, 'clock-r3')).toBe(true); }); }); diff --git a/src/lib/wcif/rounds.ts b/src/lib/wcif/rounds.ts index aa03d57..598d537 100644 --- a/src/lib/wcif/rounds.ts +++ b/src/lib/wcif/rounds.ts @@ -84,12 +84,7 @@ export const getAdvancementConditionForRound = ( return true; } - const nextRound = event.rounds.find((candidate) => { - const participationSource = getParticipationRuleset(candidate)?.participationSource; - return participationSource?.type === 'linkedRounds' && participationSource.roundIds.includes(roundId); - }); - - return Boolean(nextRound); + return getParticipationRuleset(round) !== null; }; export const getDisplayAdvancementConditionForRound = ( diff --git a/src/lib/wcif/validation/eventRoundValidation.test.ts b/src/lib/wcif/validation/eventRoundValidation.test.ts index 5d2acf5..d86156f 100644 --- a/src/lib/wcif/validation/eventRoundValidation.test.ts +++ b/src/lib/wcif/validation/eventRoundValidation.test.ts @@ -227,6 +227,54 @@ describe('validateAdvancementConditions', () => { expect(errors).toHaveLength(0); }); + it('should accept registration participation rulesets for non-final rounds', () => { + const event: Event = { + id: 'sq1', + rounds: [ + { + id: 'sq1-r1', + format: 'a', + timeLimit: { + centiseconds: 12000, + cumulativeRoundIds: [], + }, + cutoff: { + numberOfAttempts: 2, + attemptResult: 6000, + }, + advancementCondition: null, + results: [], + scrambleSetCount: 2, + linkedRounds: null, + participationRuleset: { + participationSource: { + type: 'registrations', + }, + reservedPlaces: null, + }, + extensions: [], + }, + { + id: 'sq1-r2', + format: 'a', + timeLimit: null, + cutoff: null, + advancementCondition: null, + results: [], + scrambleSetCount: 1, + extensions: [], + }, + ], + competitorLimit: null, + qualification: null, + extensions: [], + }; + + const errors = validateAdvancementConditions(event); + + expect(errors).toHaveLength(0); + }); + it('should return no errors for event with single round', () => { const event: Event = { id: '333', From b5e01395c30f2118df8cb3b299cbe17beb22e1d0 Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Sat, 2 May 2026 08:08:48 -0700 Subject: [PATCH 09/13] Split round participation metadata --- src/components/RoundLimitInfo.tsx | 20 +++++- src/lib/wcif/rounds.test.ts | 71 +++++++++++++++++++- src/lib/wcif/rounds.ts | 105 +++++++++++++++++++++++------- 3 files changed, 169 insertions(+), 27 deletions(-) diff --git a/src/components/RoundLimitInfo.tsx b/src/components/RoundLimitInfo.tsx index e0b3070..5f703be 100644 --- a/src/components/RoundLimitInfo.tsx +++ b/src/components/RoundLimitInfo.tsx @@ -1,5 +1,8 @@ import { mayMakeCutoff, mayMakeTimeLimit } from '../lib/domain/persons'; -import { getParticipationConditionTextForRound } from '../lib/wcif/rounds'; +import { + getParticipationConditionTextForRound, + getParticipationSourceTextForRound, +} from '../lib/wcif/rounds'; import { renderResultByEventId } from '../lib/utils/utils'; import { Box, Divider, Tooltip, Typography } from '@mui/material'; import { type Event, type EventId, formatCentiseconds, type Person, type Round } from '@wca/helpers'; @@ -12,6 +15,7 @@ interface RoundLimitInfoProps { } export const RoundLimitInfo = ({ event, round, eventId, personsShouldBeInRound }: RoundLimitInfoProps) => { + const participationSourceText = event ? getParticipationSourceTextForRound(event, round.id) : null; const participationConditionText = event ? getParticipationConditionTextForRound(event, round.id) : null; @@ -48,11 +52,21 @@ export const RoundLimitInfo = ({ event, round, eventId, personsShouldBeInRound } )} - {participationConditionText && ( + {participationSourceText && ( <> {(round.timeLimit || round.cutoff) && } - Participation: {participationConditionText} + Participation Source: {participationSourceText} + + + )} + {participationConditionText && ( + <> + {(round.timeLimit || round.cutoff || participationSourceText) && ( + + )} + + Next Round: {participationConditionText} )} diff --git a/src/lib/wcif/rounds.test.ts b/src/lib/wcif/rounds.test.ts index e5dbb64..79c7e74 100644 --- a/src/lib/wcif/rounds.test.ts +++ b/src/lib/wcif/rounds.test.ts @@ -5,6 +5,7 @@ import { getDerivedAdvancementCondition, getDualRoundDetails, getParticipationConditionTextForRound, + getParticipationSourceTextForRound, usesRegistrationParticipation, } from './rounds'; import { buildEvent, buildRound } from '../../store/reducers/_tests_/helpers'; @@ -287,8 +288,74 @@ describe('getParticipationConditionTextForRound', () => { expect(getParticipationConditionTextForRound(event, 'clock-r1')).toBe( 'Top 40% from dual rounds R1 & R2 to round 3' ); - expect(getParticipationConditionTextForRound(event, 'clock-r3')).toBe( - 'Top 40% from dual rounds R1 & R2 to round 3' + expect(getParticipationConditionTextForRound(event, 'clock-r3')).toBeNull(); + }); +}); + +describe('getParticipationSourceTextForRound', () => { + it('formats registration participation for first rounds', () => { + const event = buildEvent({ + id: 'sq1', + rounds: [ + buildRound({ + id: 'sq1-r1', + participationRuleset: { + participationSource: { + type: 'registrations', + }, + reservedPlaces: null, + }, + }), + ], + }); + + expect(getParticipationSourceTextForRound(event, 'sq1-r1')).toBe( + 'Open to all registered competitors' + ); + }); + + it('formats dual-round participation source for target rounds', () => { + const event = buildEvent({ + id: 'clock', + rounds: [ + buildRound({ + id: 'clock-r1', + participationRuleset: { + participationSource: { + type: 'registrations', + }, + reservedPlaces: null, + }, + }), + buildRound({ + id: 'clock-r2', + participationRuleset: { + participationSource: { + type: 'registrations', + }, + reservedPlaces: null, + }, + }), + buildRound({ + id: 'clock-r3', + participationRuleset: { + participationSource: { + type: 'linkedRounds', + roundIds: ['clock-r1', 'clock-r2'], + resultCondition: { + type: 'percent', + scope: 'average', + value: 40, + }, + }, + reservedPlaces: null, + }, + }), + ], + }); + + expect(getParticipationSourceTextForRound(event, 'clock-r3')).toBe( + 'Top 40% from dual rounds R1 & R2' ); }); }); diff --git a/src/lib/wcif/rounds.ts b/src/lib/wcif/rounds.ts index 598d537..cfd6904 100644 --- a/src/lib/wcif/rounds.ts +++ b/src/lib/wcif/rounds.ts @@ -1,5 +1,11 @@ import { parseActivityCode } from '../domain/activities'; -import { formatCentiseconds, type AdvancementCondition, type Event, type ParticipationRuleset, type Round } from '@wca/helpers'; +import { + formatCentiseconds, + type AdvancementCondition, + type Event, + type ParticipationRuleset, + type Round, +} from '@wca/helpers'; export interface DualRoundDetails { linkedRoundIds: string[]; @@ -26,9 +32,7 @@ export const usesRegistrationParticipation = (round: Round): boolean => { return parseActivityCode(round.id).roundNumber === 1; }; -export const getDerivedAdvancementCondition = ( - round: Round -): AdvancementCondition | null => { +export const getDerivedAdvancementCondition = (round: Round): AdvancementCondition | null => { const participationSource = getParticipationRuleset(round)?.participationSource; if (participationSource?.type !== 'linkedRounds') { @@ -70,10 +74,7 @@ export const formatAdvancementCondition = ( } }; -export const getAdvancementConditionForRound = ( - event: Event, - roundId: string -): boolean => { +export const getAdvancementConditionForRound = (event: Event, roundId: string): boolean => { const round = event.rounds.find((candidate) => candidate.id === roundId); if (!round) { @@ -103,7 +104,9 @@ export const getDisplayAdvancementConditionForRound = ( const nextRound = event.rounds.find((candidate) => { const participationSource = getParticipationRuleset(candidate)?.participationSource; - return participationSource?.type === 'linkedRounds' && participationSource.roundIds.includes(roundId); + return ( + participationSource?.type === 'linkedRounds' && participationSource.roundIds.includes(roundId) + ); }); return nextRound ? getDerivedAdvancementCondition(nextRound) : null; @@ -122,10 +125,22 @@ const formatLongRoundLabel = (roundId: string): string => { const findLinkedTargetRound = (event: Event, roundId: string): Round | undefined => event.rounds.find((candidate) => { const participationSource = getParticipationRuleset(candidate)?.participationSource; - return participationSource?.type === 'linkedRounds' && participationSource.roundIds.includes(roundId); + return ( + participationSource?.type === 'linkedRounds' && participationSource.roundIds.includes(roundId) + ); }); -export const getParticipationConditionTextForRound = ( +const findPreviousRound = (event: Event, roundId: string): Round | undefined => { + const currentRoundIndex = event.rounds.findIndex((candidate) => candidate.id === roundId); + return currentRoundIndex > 0 ? event.rounds[currentRoundIndex - 1] : undefined; +}; + +const findNextSequentialRound = (event: Event, roundId: string): Round | undefined => { + const currentRoundIndex = event.rounds.findIndex((candidate) => candidate.id === roundId); + return currentRoundIndex >= 0 ? event.rounds[currentRoundIndex + 1] : undefined; +}; + +export const getParticipationSourceTextForRound = ( event: Event, roundId: string ): string | null => { @@ -142,17 +157,18 @@ export const getParticipationConditionTextForRound = ( return `${formatAdvancementCondition({ type: participationSource.resultCondition.type, level: participationSource.resultCondition.value, - })} from dual rounds ${sourceRounds} to ${formatLongRoundLabel(round.id)}`; + })} from dual rounds ${sourceRounds}`; } - if (hasLegacyAdvancementCondition(round)) { - const currentRoundIndex = event.rounds.findIndex((candidate) => candidate.id === roundId); - const nextRound = currentRoundIndex >= 0 ? event.rounds[currentRoundIndex + 1] : undefined; - const advancementText = formatAdvancementCondition(round.advancementCondition); + if (participationSource?.type === 'registrations') { + return 'Open to all registered competitors'; + } + + const previousRound = findPreviousRound(event, roundId); - return nextRound && advancementText - ? `${advancementText} to ${formatLongRoundLabel(nextRound.id)}` - : null; + if (previousRound && hasLegacyAdvancementCondition(previousRound)) { + const advancementText = formatAdvancementCondition(previousRound.advancementCondition); + return advancementText ? `${advancementText} from ${formatLongRoundLabel(previousRound.id)}` : null; } const linkedTargetRound = findLinkedTargetRound(event, roundId); @@ -161,13 +177,58 @@ export const getParticipationConditionTextForRound = ( return null; } - return getParticipationConditionTextForRound(event, linkedTargetRound.id); + return getParticipationSourceTextForRound(event, linkedTargetRound.id); }; -export const getDualRoundDetails = ( +export const getParticipationConditionTextForRound = ( event: Event, roundId: string -): DualRoundDetails | null => { +): string | null => { + const round = event.rounds.find((candidate) => candidate.id === roundId); + + if (!round) { + return null; + } + + const linkedTargetRound = findLinkedTargetRound(event, roundId); + + if (linkedTargetRound) { + const linkedTargetSource = getParticipationRuleset(linkedTargetRound)?.participationSource; + + if (linkedTargetSource?.type === 'linkedRounds') { + const sourceRounds = linkedTargetSource.roundIds.map(formatShortRoundLabel).join(' & '); + return `${formatAdvancementCondition({ + type: linkedTargetSource.resultCondition.type, + level: linkedTargetSource.resultCondition.value, + })} from dual rounds ${sourceRounds} to ${formatLongRoundLabel(linkedTargetRound.id)}`; + } + } + + const nextRound = findNextSequentialRound(event, roundId); + + if (!nextRound) { + return null; + } + + const nextParticipationSource = getParticipationRuleset(nextRound)?.participationSource; + + if (nextParticipationSource?.type === 'linkedRounds') { + const sourceRounds = nextParticipationSource.roundIds.map(formatShortRoundLabel).join(' & '); + return `${formatAdvancementCondition({ + type: nextParticipationSource.resultCondition.type, + level: nextParticipationSource.resultCondition.value, + })} from dual rounds ${sourceRounds} to ${formatLongRoundLabel(nextRound.id)}`; + } + + if (hasLegacyAdvancementCondition(round)) { + const advancementText = formatAdvancementCondition(round.advancementCondition); + return advancementText ? `${advancementText} to ${formatLongRoundLabel(nextRound.id)}` : null; + } + + return null; +}; + +export const getDualRoundDetails = (event: Event, roundId: string): DualRoundDetails | null => { for (const candidate of event.rounds) { const participationSource = getParticipationRuleset(candidate)?.participationSource; From b25955a622ac2e9c55d47d785b8c6b027aed0fad Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Sat, 2 May 2026 08:27:01 -0700 Subject: [PATCH 10/13] Refine round participation metadata --- src/components/RoundLimitInfo.tsx | 136 +++++++++++++++----------- src/lib/wcif/rounds.test.ts | 155 +++++++++++++++++++++++++++++- src/lib/wcif/rounds.ts | 43 ++++++++- src/lib/wcif/v2-types.d.ts | 9 +- 4 files changed, 282 insertions(+), 61 deletions(-) diff --git a/src/components/RoundLimitInfo.tsx b/src/components/RoundLimitInfo.tsx index 5f703be..149b574 100644 --- a/src/components/RoundLimitInfo.tsx +++ b/src/components/RoundLimitInfo.tsx @@ -1,11 +1,17 @@ import { mayMakeCutoff, mayMakeTimeLimit } from '../lib/domain/persons'; import { - getParticipationConditionTextForRound, + getNextRoundParticipationTextForRound, getParticipationSourceTextForRound, } from '../lib/wcif/rounds'; import { renderResultByEventId } from '../lib/utils/utils'; -import { Box, Divider, Tooltip, Typography } from '@mui/material'; -import { type Event, type EventId, formatCentiseconds, type Person, type Round } from '@wca/helpers'; +import { Box, Tooltip, Typography } from '@mui/material'; +import { + type Event, + type EventId, + formatCentiseconds, + type Person, + type Round, +} from '@wca/helpers'; interface RoundLimitInfoProps { event: Event | null; @@ -14,62 +20,82 @@ interface RoundLimitInfoProps { personsShouldBeInRound: Person[]; } -export const RoundLimitInfo = ({ event, round, eventId, personsShouldBeInRound }: RoundLimitInfoProps) => { - const participationSourceText = event ? getParticipationSourceTextForRound(event, round.id) : null; - const participationConditionText = event - ? getParticipationConditionTextForRound(event, round.id) +export const RoundLimitInfo = ({ + event, + round, + eventId, + personsShouldBeInRound, +}: RoundLimitInfoProps) => { + const thisRoundParticipationText = event + ? getParticipationSourceTextForRound(event, round.id) : null; - - return ( - - {round.timeLimit && ( - - - Time Limit: {formatCentiseconds(round.timeLimit.centiseconds)} - {personsShouldBeInRound.length > 0 && ( - - May make TimeLimit:{' '} - {mayMakeTimeLimit(eventId as EventId, round, personsShouldBeInRound)?.length} - - )} - - - )} - {round.timeLimit && round.cutoff && } - {round.cutoff && ( - - + const nextRoundParticipationText = event + ? getNextRoundParticipationTextForRound(event, round.id) + : null; + const sections = [ + round.timeLimit ? ( + + + Time Limit: {formatCentiseconds(round.timeLimit.centiseconds)} + {personsShouldBeInRound.length > 0 && ( - Cutoff: {round.cutoff.numberOfAttempts} attempts to get {'< '} - {renderResultByEventId(eventId as EventId, 'average', round.cutoff.attemptResult)} + May make TimeLimit:{' '} + {mayMakeTimeLimit(eventId as EventId, round, personsShouldBeInRound)?.length} - {personsShouldBeInRound.length > 0 && ( - - May make cutoff:{' '} - {mayMakeCutoff(eventId as EventId, round, personsShouldBeInRound)?.length} - - )} - - - )} - {participationSourceText && ( - <> - {(round.timeLimit || round.cutoff) && } - - Participation Source: {participationSourceText} - - - )} - {participationConditionText && ( - <> - {(round.timeLimit || round.cutoff || participationSourceText) && ( - )} - - Next Round: {participationConditionText} - - - )} + + + ) : null, + round.cutoff ? ( + + + Cutoff: + + {round.cutoff.numberOfAttempts} attempts to get {'< '} + {renderResultByEventId(eventId as EventId, 'average', round.cutoff.attemptResult)} + + {personsShouldBeInRound.length > 0 && ( + + May make cutoff:{' '} + {mayMakeCutoff(eventId as EventId, round, personsShouldBeInRound)?.length} + + )} + + + ) : null, + thisRoundParticipationText ? ( + + Participation: {thisRoundParticipationText} + + ) : null, + nextRoundParticipationText ? ( + + Advancement: {nextRoundParticipationText} + + ) : null, + ].filter(Boolean); + + return ( + + {sections.map((section, index) => ( + + {section} + + ))} ); }; diff --git a/src/lib/wcif/rounds.test.ts b/src/lib/wcif/rounds.test.ts index 79c7e74..10299ac 100644 --- a/src/lib/wcif/rounds.test.ts +++ b/src/lib/wcif/rounds.test.ts @@ -4,6 +4,7 @@ import { getDisplayAdvancementConditionForRound, getDerivedAdvancementCondition, getDualRoundDetails, + getNextRoundParticipationTextForRound, getParticipationConditionTextForRound, getParticipationSourceTextForRound, usesRegistrationParticipation, @@ -242,7 +243,7 @@ describe('getParticipationConditionTextForRound', () => { ], }); - expect(getParticipationConditionTextForRound(event, '333-r1')).toBe('Top 14 to round 2'); + expect(getParticipationConditionTextForRound(event, '333-r1')).toBe('Top 14 to next round'); }); it('formats dual-round participation text for linked source and target rounds', () => { @@ -314,6 +315,40 @@ describe('getParticipationSourceTextForRound', () => { ); }); + it('formats v2 single-round participation source for downstream rounds', () => { + const event = buildEvent({ + id: 'sq1', + rounds: [ + buildRound({ + id: 'sq1-r1', + participationRuleset: { + participationSource: { + type: 'registrations', + }, + reservedPlaces: null, + }, + }), + buildRound({ + id: 'sq1-r2', + participationRuleset: { + participationSource: { + type: 'round', + roundId: 'sq1-r1', + resultCondition: { + type: 'ranking', + scope: 'average', + value: 6, + }, + }, + reservedPlaces: null, + }, + }), + ], + }); + + expect(getParticipationSourceTextForRound(event, 'sq1-r2')).toBe('Top 6 from previous round'); + }); + it('formats dual-round participation source for target rounds', () => { const event = buildEvent({ id: 'clock', @@ -354,7 +389,123 @@ describe('getParticipationSourceTextForRound', () => { ], }); - expect(getParticipationSourceTextForRound(event, 'clock-r3')).toBe( + expect(getParticipationSourceTextForRound(event, 'clock-r3')).toBe('Top 40% from dual rounds R1 & R2'); + }); + + it('formats legacy participation source for downstream rounds', () => { + const event = buildEvent({ + id: 'sq1', + rounds: [ + buildRound({ + id: 'sq1-r1', + advancementCondition: { type: 'ranking', level: 14 }, + }), + buildRound({ id: 'sq1-r2' }), + ], + }); + + expect(getParticipationSourceTextForRound(event, 'sq1-r2')).toBe('Top 14 from previous round'); + }); +}); + +describe('getNextRoundParticipationTextForRound', () => { + it('returns the downstream round participation for legacy rounds', () => { + const event = buildEvent({ + id: 'sq1', + rounds: [ + buildRound({ + id: 'sq1-r1', + advancementCondition: { type: 'ranking', level: 14 }, + }), + buildRound({ id: 'sq1-r2' }), + ], + }); + + expect(getNextRoundParticipationTextForRound(event, 'sq1-r1')).toBe( + 'Top 14 from previous round' + ); + }); + + it('returns the downstream round participation for v2 single-round progression', () => { + const event = buildEvent({ + id: 'sq1', + rounds: [ + buildRound({ + id: 'sq1-r1', + participationRuleset: { + participationSource: { + type: 'registrations', + }, + reservedPlaces: null, + }, + }), + buildRound({ + id: 'sq1-r2', + participationRuleset: { + participationSource: { + type: 'round', + roundId: 'sq1-r1', + resultCondition: { + type: 'ranking', + scope: 'average', + value: 6, + }, + }, + reservedPlaces: null, + }, + }), + ], + }); + + expect(getNextRoundParticipationTextForRound(event, 'sq1-r1')).toBe( + 'Top 6 from previous round' + ); + }); + + it('returns the shared seeded round participation for dual-round source rounds', () => { + const event = buildEvent({ + id: 'clock', + rounds: [ + buildRound({ + id: 'clock-r1', + participationRuleset: { + participationSource: { + type: 'registrations', + }, + reservedPlaces: null, + }, + }), + buildRound({ + id: 'clock-r2', + participationRuleset: { + participationSource: { + type: 'registrations', + }, + reservedPlaces: null, + }, + }), + buildRound({ + id: 'clock-r3', + participationRuleset: { + participationSource: { + type: 'linkedRounds', + roundIds: ['clock-r1', 'clock-r2'], + resultCondition: { + type: 'percent', + scope: 'average', + value: 40, + }, + }, + reservedPlaces: null, + }, + }), + ], + }); + + expect(getNextRoundParticipationTextForRound(event, 'clock-r1')).toBe( + 'Top 40% from dual rounds R1 & R2' + ); + expect(getNextRoundParticipationTextForRound(event, 'clock-r2')).toBe( 'Top 40% from dual rounds R1 & R2' ); }); diff --git a/src/lib/wcif/rounds.ts b/src/lib/wcif/rounds.ts index cfd6904..f222503 100644 --- a/src/lib/wcif/rounds.ts +++ b/src/lib/wcif/rounds.ts @@ -35,7 +35,7 @@ export const usesRegistrationParticipation = (round: Round): boolean => { export const getDerivedAdvancementCondition = (round: Round): AdvancementCondition | null => { const participationSource = getParticipationRuleset(round)?.participationSource; - if (participationSource?.type !== 'linkedRounds') { + if (participationSource?.type !== 'linkedRounds' && participationSource?.type !== 'round') { return null; } @@ -140,6 +140,9 @@ const findNextSequentialRound = (event: Event, roundId: string): Round | undefin return currentRoundIndex >= 0 ? event.rounds[currentRoundIndex + 1] : undefined; }; +const findNextSeededRound = (event: Event, roundId: string): Round | undefined => + findLinkedTargetRound(event, roundId) ?? findNextSequentialRound(event, roundId); + export const getParticipationSourceTextForRound = ( event: Event, roundId: string @@ -160,6 +163,13 @@ export const getParticipationSourceTextForRound = ( })} from dual rounds ${sourceRounds}`; } + if (participationSource?.type === 'round') { + return `${formatAdvancementCondition({ + type: participationSource.resultCondition.type, + level: participationSource.resultCondition.value, + })} from previous round`; + } + if (participationSource?.type === 'registrations') { return 'Open to all registered competitors'; } @@ -168,7 +178,7 @@ export const getParticipationSourceTextForRound = ( if (previousRound && hasLegacyAdvancementCondition(previousRound)) { const advancementText = formatAdvancementCondition(previousRound.advancementCondition); - return advancementText ? `${advancementText} from ${formatLongRoundLabel(previousRound.id)}` : null; + return advancementText ? `${advancementText} from previous round` : null; } const linkedTargetRound = findLinkedTargetRound(event, roundId); @@ -202,6 +212,13 @@ export const getParticipationConditionTextForRound = ( level: linkedTargetSource.resultCondition.value, })} from dual rounds ${sourceRounds} to ${formatLongRoundLabel(linkedTargetRound.id)}`; } + + if (linkedTargetSource?.type === 'round') { + return `${formatAdvancementCondition({ + type: linkedTargetSource.resultCondition.type, + level: linkedTargetSource.resultCondition.value, + })} to ${formatLongRoundLabel(linkedTargetRound.id)}`; + } } const nextRound = findNextSequentialRound(event, roundId); @@ -220,14 +237,34 @@ export const getParticipationConditionTextForRound = ( })} from dual rounds ${sourceRounds} to ${formatLongRoundLabel(nextRound.id)}`; } + if (nextParticipationSource?.type === 'round') { + return `${formatAdvancementCondition({ + type: nextParticipationSource.resultCondition.type, + level: nextParticipationSource.resultCondition.value, + })} to ${formatLongRoundLabel(nextRound.id)}`; + } + if (hasLegacyAdvancementCondition(round)) { const advancementText = formatAdvancementCondition(round.advancementCondition); - return advancementText ? `${advancementText} to ${formatLongRoundLabel(nextRound.id)}` : null; + return advancementText ? `${advancementText} to next round` : null; } return null; }; +export const getNextRoundParticipationTextForRound = ( + event: Event, + roundId: string +): string | null => { + const nextRound = findNextSeededRound(event, roundId); + + if (!nextRound) { + return null; + } + + return getParticipationSourceTextForRound(event, nextRound.id); +}; + export const getDualRoundDetails = (event: Event, roundId: string): DualRoundDetails | null => { for (const candidate of event.rounds) { const participationSource = getParticipationRuleset(candidate)?.participationSource; diff --git a/src/lib/wcif/v2-types.d.ts b/src/lib/wcif/v2-types.d.ts index 0a8025e..504b2a6 100644 --- a/src/lib/wcif/v2-types.d.ts +++ b/src/lib/wcif/v2-types.d.ts @@ -17,9 +17,16 @@ declare module '@wca/helpers' { resultCondition: ParticipationResultCondition; } + export interface RoundParticipationSource { + type: 'round'; + roundId: string; + resultCondition: ParticipationResultCondition; + } + export type ParticipationSource = | RegistrationsParticipationSource - | LinkedRoundsParticipationSource; + | LinkedRoundsParticipationSource + | RoundParticipationSource; export interface ParticipationRuleset { participationSource: ParticipationSource; From 0a64b4824b1eae4365514a30074da2937fa15f9f Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Mon, 6 Jul 2026 08:23:02 -0700 Subject: [PATCH 11/13] Fix WCIF patch endpoint Use the versioned WCIF route only for reads. PATCH requests must target the unversioned WCA update route to match the Rails API. --- src/lib/api/wcaAPI.test.ts | 8 ++++---- src/lib/api/wcaAPI.ts | 6 ++++-- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/lib/api/wcaAPI.test.ts b/src/lib/api/wcaAPI.test.ts index ecd9513..99b1558 100644 --- a/src/lib/api/wcaAPI.test.ts +++ b/src/lib/api/wcaAPI.test.ts @@ -85,7 +85,7 @@ describe('wcaAPI', () => { await saveWcifChanges(previousWcif, newWcif); expect(globalThis.fetch).toHaveBeenCalledWith( - 'https://wca.test/api/v0/competitions/Comp/wcif/version/2', + 'https://wca.test/api/v0/competitions/Comp/wcif', expect.objectContaining({ method: 'PATCH', body: JSON.stringify({ name: 'New' }), @@ -107,7 +107,7 @@ describe('wcaAPI', () => { await saveWcifChanges(wcif, wcif); expect(globalThis.fetch).not.toHaveBeenCalledWith( - 'https://wca.test/api/v0/competitions/Comp/wcif/version/2', + 'https://wca.test/api/v0/competitions/Comp/wcif', expect.objectContaining({ method: 'PATCH' }) ); }); @@ -125,13 +125,13 @@ describe('wcaAPI', () => { ); }); - it('patches WCIF to the version 2 endpoint', async () => { + it('patches WCIF to the update endpoint', async () => { mockFetch({ json: vi.fn().mockResolvedValue({ id: 'Comp' }) }); await patchWcif('Comp', { name: 'Updated' } as any); expect(globalThis.fetch).toHaveBeenCalledWith( - 'https://wca.test/api/v0/competitions/Comp/wcif/version/2', + 'https://wca.test/api/v0/competitions/Comp/wcif', expect.objectContaining({ method: 'PATCH', body: JSON.stringify({ name: 'Updated' }), diff --git a/src/lib/api/wcaAPI.ts b/src/lib/api/wcaAPI.ts index 25a6a78..d2054dd 100644 --- a/src/lib/api/wcaAPI.ts +++ b/src/lib/api/wcaAPI.ts @@ -11,7 +11,9 @@ import { pick } from 'lodash'; const wcaAccessToken = (): string | null => getLocalStorage('accessToken'); const WCIF_VERSION = '2'; -const wcifPath = (competitionId: string) => `/competitions/${competitionId}/wcif/version/${WCIF_VERSION}`; +const wcifPath = (competitionId: string) => `/competitions/${competitionId}/wcif`; +const versionedWcifPath = (competitionId: string) => + `${wcifPath(competitionId)}/version/${WCIF_VERSION}`; export const getMe = (): Promise<{ me: WcaUser }> => { return wcaApiFetch(`/me`); @@ -47,7 +49,7 @@ export const getPastManageableCompetitions = (): Promise => - wcaApiFetch(wcifPath(competitionId)); + wcaApiFetch(versionedWcifPath(competitionId)); export const patchWcif = ( competitionId: string, From 64ae526b6a793f62b8a9f5a3d0f40033260d96c0 Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Mon, 10 Aug 2026 08:37:04 -0700 Subject: [PATCH 12/13] Fix WCIF v2 test support Match the versioned WCIF read route in the browser mock and provide event data to distributed-round limits. --- e2e/mocks/wcaApi.ts | 2 +- src/pages/Competition/Round/DistributedAttemptRoundView.tsx | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/e2e/mocks/wcaApi.ts b/e2e/mocks/wcaApi.ts index 953cf35..78ad069 100644 --- a/e2e/mocks/wcaApi.ts +++ b/e2e/mocks/wcaApi.ts @@ -100,7 +100,7 @@ export const registerWcaApiRoutes = async ( return; } - const wcifMatch = apiPath.match(/^\/competitions\/([^/]+)\/wcif$/); + const wcifMatch = apiPath.match(/^\/competitions\/([^/]+)\/wcif(?:\/version\/2)?$/); if (wcifMatch && method === 'GET') { const competitionId = wcifMatch[1]; const wcif = state.wcifById[competitionId] ?? state.wcif; diff --git a/src/pages/Competition/Round/DistributedAttemptRoundView.tsx b/src/pages/Competition/Round/DistributedAttemptRoundView.tsx index f737f24..7966f2d 100644 --- a/src/pages/Competition/Round/DistributedAttemptRoundView.tsx +++ b/src/pages/Competition/Round/DistributedAttemptRoundView.tsx @@ -203,6 +203,7 @@ const DistributedAttemptRoundView = ({ candidate.id === eventId) ?? null} round={round} eventId={eventId} personsShouldBeInRound={personsShouldBeInRound} From c909d39aea7fdc2ba493b9645c64eb83adebdf86 Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Sun, 12 Jul 2026 10:09:28 -0700 Subject: [PATCH 13/13] Preflight WCIF saves Validate the complete WCIF with the WCA check endpoint before PATCHing changed fields, and surface API error details to delegates. --- .../CompetitionLayout/CompetitionLayout.tsx | 2 +- .../_tests_/CompetitionLayout.test.tsx | 4 ++- src/lib/api/wcaAPI.test.ts | 29 ++++++++++++++++ src/lib/api/wcaAPI.ts | 33 ++++++++++++++++++- src/store/actions.test.ts | 26 ++++++++++++++- src/store/actions.ts | 5 +-- 6 files changed, 93 insertions(+), 6 deletions(-) diff --git a/src/layout/CompetitionLayout/CompetitionLayout.tsx b/src/layout/CompetitionLayout/CompetitionLayout.tsx index 35e71ac..09eb90f 100644 --- a/src/layout/CompetitionLayout/CompetitionLayout.tsx +++ b/src/layout/CompetitionLayout/CompetitionLayout.tsx @@ -83,7 +83,7 @@ export const CompetitionLayout = () => { dispatch( uploadCurrentWCIFChanges((e) => { if (e) { - enqueueSnackbar('Error saving changes', { variant: 'error' }); + enqueueSnackbar(`Error saving changes: ${e.message}`, { variant: 'error' }); } else { enqueueSnackbar('Saved!', { variant: 'success' }); } diff --git a/src/layout/CompetitionLayout/_tests_/CompetitionLayout.test.tsx b/src/layout/CompetitionLayout/_tests_/CompetitionLayout.test.tsx index c355741..10b9f4a 100644 --- a/src/layout/CompetitionLayout/_tests_/CompetitionLayout.test.tsx +++ b/src/layout/CompetitionLayout/_tests_/CompetitionLayout.test.tsx @@ -155,6 +155,8 @@ describe('CompetitionLayout', () => { const errorCallback = uploadCurrentWCIFChangesMock.mock.calls[1][0]; errorCallback(new Error('save failed')); - expect(enqueueSnackbarMock).toHaveBeenCalledWith('Error saving changes', { variant: 'error' }); + expect(enqueueSnackbarMock).toHaveBeenCalledWith('Error saving changes: save failed', { + variant: 'error', + }); }); }); diff --git a/src/lib/api/wcaAPI.test.ts b/src/lib/api/wcaAPI.test.ts index 99b1558..b9ca953 100644 --- a/src/lib/api/wcaAPI.test.ts +++ b/src/lib/api/wcaAPI.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi, afterEach } from 'vitest'; import { + checkWcif, getMe, getPastManageableCompetitions, getUpcomingManageableCompetitions, @@ -54,6 +55,34 @@ describe('wcaAPI', () => { await expect(wcaApiFetch('/me')).rejects.toThrow('Something went wrong: Status code 418'); }); + it('uses an API error response when one is available', async () => { + mockFetch({ + ok: false, + status: 400, + statusText: 'Bad Request', + json: vi.fn().mockResolvedValue({ error: 'WCIF formatVersion is required' }), + }); + + await expect(wcaApiFetch('/me')).rejects.toThrow('WCIF formatVersion is required'); + }); + + it('checks a complete WCIF without parsing the empty success response', async () => { + const json = vi.fn(); + const wcif = { id: 'Comp', formatVersion: '1.1' } as any; + mockFetch({ json }); + + await checkWcif(wcif); + + expect(globalThis.fetch).toHaveBeenCalledWith( + 'https://wca.test/api/v0/competitions/wcif/check', + expect.objectContaining({ + method: 'PUT', + body: JSON.stringify(wcif), + }) + ); + expect(json).not.toHaveBeenCalled(); + }); + it('builds upcoming and past competition queries', async () => { vi.spyOn(Date, 'now').mockReturnValue(0); mockFetch({ json: vi.fn().mockResolvedValue([]) }); diff --git a/src/lib/api/wcaAPI.ts b/src/lib/api/wcaAPI.ts index d2054dd..8c19014 100644 --- a/src/lib/api/wcaAPI.ts +++ b/src/lib/api/wcaAPI.ts @@ -60,6 +60,16 @@ export const patchWcif = ( body: JSON.stringify(wcif), }); +export const checkWcif = (wcif: Competition): Promise => + wcaApiFetch( + '/competitions/wcif/check', + { + method: 'PUT', + body: JSON.stringify(wcif), + }, + false + ); + export const saveWcifChanges = ( previousWcif: Competition, newWcif: Competition @@ -86,7 +96,8 @@ export const getUser = (userId: number): Promise<{ user: WcaUser }> => export const wcaApiFetch = async ( path: string, - fetchOptions: RequestInit = {} + fetchOptions: RequestInit = {}, + parseJsonResponse = true ): Promise => { const baseApiUrl = `${WCA_ORIGIN}/api/v0`; @@ -101,6 +112,9 @@ export const wcaApiFetch = async ( ); if (!res.ok) { + const error = await errorFromResponse(res); + if (error) throw new Error(error); + if (res.statusText) { throw new Error(`${res.status}: ${res.statusText}`); } else { @@ -108,5 +122,22 @@ export const wcaApiFetch = async ( } } + if (!parseJsonResponse) return undefined as T; + return await res.json(); }; + +const errorFromResponse = async (res: Response): Promise => { + try { + const body: unknown = await res.json(); + if (Array.isArray(body)) return body.map(String).join('\n'); + + if (body && typeof body === 'object' && 'error' in body) { + const error = body.error; + if (Array.isArray(error)) return error.map(String).join('\n'); + if (typeof error === 'string') return error; + } + } catch { + // Fall back to the HTTP status when the API does not return JSON. + } +}; diff --git a/src/store/actions.test.ts b/src/store/actions.test.ts index 32b006a..2bdf9a7 100644 --- a/src/store/actions.test.ts +++ b/src/store/actions.test.ts @@ -26,7 +26,7 @@ import { import type { Assignment, Competition } from '@wca/helpers'; import type { Extension } from '@wca/helpers/lib/models/extension'; import { describe, expect, it, vi } from 'vitest'; -import { getUpcomingManageableCompetitions, getWcif, patchWcif } from '../lib/api'; +import { checkWcif, getUpcomingManageableCompetitions, getWcif, patchWcif } from '../lib/api'; import { sortWcifEvents } from '../lib/domain/events'; import { validateWcif } from '../lib/wcif/validation'; import type { AppState } from './initialState'; @@ -41,6 +41,7 @@ import { vi.mock('../lib/api', () => ({ getUpcomingManageableCompetitions: vi.fn(), getWcif: vi.fn(), + checkWcif: vi.fn(), patchWcif: vi.fn(), })); @@ -54,6 +55,7 @@ vi.mock('../lib/wcif/validation', () => ({ const getUpcomingManageableCompetitionsMock = vi.mocked(getUpcomingManageableCompetitions); const getWcifMock = vi.mocked(getWcif); +const checkWcifMock = vi.mocked(checkWcif); const patchWcifMock = vi.mocked(patchWcif); const sortWcifEventsMock = vi.mocked(sortWcifEvents); const validateWcifMock = vi.mocked(validateWcif); @@ -311,6 +313,7 @@ describe('store actions', () => { wcif, changedKeys: new Set(['events']), }) as unknown as AppState; + checkWcifMock.mockResolvedValueOnce(undefined); patchWcifMock.mockResolvedValueOnce(wcif); uploadCurrentWCIFChanges(cb)(dispatch, getState); @@ -320,6 +323,7 @@ describe('store actions', () => { type: ActionType.UPLOADING_WCIF, uploading: true, }); + expect(checkWcifMock).toHaveBeenCalledWith(wcif); expect(patchWcifMock).toHaveBeenCalledWith('Comp1', { formatVersion: wcif.formatVersion, events: wcif.events, @@ -343,6 +347,7 @@ describe('store actions', () => { changedKeys: new Set(['events']), }) as unknown as AppState; const error = new Error('Upload failed'); + checkWcifMock.mockResolvedValueOnce(undefined); patchWcifMock.mockRejectedValueOnce(error); const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); @@ -360,4 +365,23 @@ describe('store actions', () => { expect(cb).toHaveBeenCalledWith(error); consoleError.mockRestore(); }); + + it('does not patch when the WCIF schema check fails', async () => { + vi.clearAllMocks(); + const dispatch = vi.fn(); + const cb = vi.fn(); + const wcif = { ...buildWcif([], []), id: 'Comp1' }; + const error = new Error('WCIF formatVersion is required'); + const getState = () => + ({ wcif, changedKeys: new Set(['events']) }) as unknown as AppState; + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + checkWcifMock.mockRejectedValueOnce(error); + + uploadCurrentWCIFChanges(cb)(dispatch, getState); + await flushPromises(); + + expect(patchWcifMock).not.toHaveBeenCalled(); + expect(cb).toHaveBeenCalledWith(error); + consoleError.mockRestore(); + }); }); diff --git a/src/store/actions.ts b/src/store/actions.ts index bcedf93..fef6ee0 100644 --- a/src/store/actions.ts +++ b/src/store/actions.ts @@ -1,4 +1,4 @@ -import { getUpcomingManageableCompetitions, getWcif, patchWcif } from '../lib/api'; +import { checkWcif, getUpcomingManageableCompetitions, getWcif, patchWcif } from '../lib/api'; import { sortWcifEvents } from '../lib/domain/events'; import { type BulkInProgressAssignments } from '../lib/types'; import { validateWcif, type ValidationError } from '../lib/wcif/validation'; @@ -163,7 +163,8 @@ export const uploadCurrentWCIFChanges = const changes = pick(wcif, keysForPatch); dispatch(updateUploading(true)); - patchWcif(competitionId, changes) + checkWcif(wcif) + .then(() => patchWcif(competitionId, changes)) .then(() => { dispatch(updateUploading(false)); cb();