diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index f048fd8..172da2f 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -19,7 +19,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '18' + node-version-file: '.nvmrc' cache: 'yarn' - name: Install dependencies diff --git a/.github/workflows/type-check.yml b/.github/workflows/type-check.yml index 8dbde80..3f3816a 100644 --- a/.github/workflows/type-check.yml +++ b/.github/workflows/type-check.yml @@ -19,7 +19,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '18' + node-version-file: '.nvmrc' cache: 'yarn' - name: Install dependencies diff --git a/src/components/RoundLimitInfo.tsx b/src/components/RoundLimitInfo.tsx index 7d5d98e..149b574 100644 --- a/src/components/RoundLimitInfo.tsx +++ b/src/components/RoundLimitInfo.tsx @@ -1,47 +1,101 @@ import { mayMakeCutoff, mayMakeTimeLimit } from '../lib/domain/persons'; +import { + getNextRoundParticipationTextForRound, + getParticipationSourceTextForRound, +} 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 { Box, Tooltip, Typography } from '@mui/material'; +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) => { - return ( - - {round.timeLimit && ( - - - Time Limit: {formatCentiseconds(round.timeLimit.centiseconds)} - {personsShouldBeInRound.length > 0 && ( - - May make TimeLimit:{' '} - {mayMakeTimeLimit(eventId as EventId, round, personsShouldBeInRound)?.length} - - )} - - - )} - - {round.cutoff && ( - - +export const RoundLimitInfo = ({ + event, + round, + eventId, + personsShouldBeInRound, +}: RoundLimitInfoProps) => { + const thisRoundParticipationText = event + ? getParticipationSourceTextForRound(event, round.id) + : null; + const nextRoundParticipationText = event + ? getNextRoundParticipationTextForRound(event, round.id) + : null; + const sections = [ + round.timeLimit ? ( + + + Time Limit: {formatCentiseconds(round.timeLimit.centiseconds)} + {personsShouldBeInRound.length > 0 && ( + + May make TimeLimit:{' '} + {mayMakeTimeLimit(eventId as EventId, round, personsShouldBeInRound)?.length} + + )} + + + ) : null, + round.cutoff ? ( + + + Cutoff: + + {round.cutoff.numberOfAttempts} attempts to get {'< '} + {renderResultByEventId(eventId as EventId, 'average', round.cutoff.attemptResult)} + + {personsShouldBeInRound.length > 0 && ( - Cutoff: {round.cutoff.numberOfAttempts} attempts to get {'< '} - {renderResultByEventId(eventId as EventId, 'average', round.cutoff.attemptResult)} + May make cutoff:{' '} + {mayMakeCutoff(eventId as EventId, round, personsShouldBeInRound)?.length} - {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/components/RoundSelector/_tests_/RoundSelector.test.tsx b/src/components/RoundSelector/_tests_/RoundSelector.test.tsx index 2d6b5b6..90cd3b3 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, }; }); @@ -85,4 +88,74 @@ describe('RoundSelector', () => { expect(toggle).toBeChecked(); }); + + 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 e5e9efa..02c310b 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'; @@ -27,11 +27,15 @@ const RoundSelector = ({ onSelected }: RoundSelectorProps) => { const [showAllRounds, setShowAllRounds] = useState(false); const [selectedId, setSelectedId] = useState(wcif?.events[0]?.rounds[0]?.id || null); - 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; } @@ -49,9 +53,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/components/RoundStatisticsCard.tsx b/src/components/RoundStatisticsCard.tsx index 475accc..d130d9b 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,7 +62,11 @@ export const RoundStatisticsCard = ({ onOpenPersonsDialog, onOpenPersonsAssignmentsDialog, actionButtons, + linkedRounds = [], + competitionId, }: RoundStatisticsCardProps) => { + const event = wcif?.events.find((candidate) => candidate.id === eventId) ?? null; + return ( } /> + {linkedRounds.length > 0 && ( + <> + + + Linked Rounds + + + {linkedRounds.map(({ roundId, onCopyAssignments }) => ( + + + {onCopyAssignments && ( + + )} + + ))} + + + + + )} Stages}> {roundActivities.map(({ id, startTime, endTime, room }) => ( @@ -148,6 +200,7 @@ export const RoundStatisticsCard = ({ { 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/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 c4675bb..35cc1b1 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..10299ac --- /dev/null +++ b/src/lib/wcif/rounds.test.ts @@ -0,0 +1,512 @@ +import { + formatAdvancementCondition, + getAdvancementConditionForRound, + getDisplayAdvancementConditionForRound, + getDerivedAdvancementCondition, + getDualRoundDetails, + getNextRoundParticipationTextForRound, + getParticipationConditionTextForRound, + getParticipationSourceTextForRound, + 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('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%'); + }); + + it('returns an empty string for missing advancement conditions', () => { + expect(formatAdvancementCondition(null)).toBe(''); + expect(formatAdvancementCondition(undefined)).toBe(''); + }); +}); + +describe('getAdvancementConditionForRound', () => { + it('returns true when a round has a v2 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')).toBe(true); + expect(getAdvancementConditionForRound(event, 'clock-r2')).toBe(true); + expect(getAdvancementConditionForRound(event, 'clock-r3')).toBe(true); + }); +}); + +describe('getDisplayAdvancementConditionForRound', () => { + 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(getDisplayAdvancementConditionForRound(event, 'clock-r1')).toEqual({ + type: 'percent', + level: 75, + }); + expect(getDisplayAdvancementConditionForRound(event, 'clock-r2')).toEqual({ + type: 'percent', + level: 75, + }); + }); +}); + +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, + }); + }); +}); + +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 next round'); + }); + + 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')).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 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', + 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'); + }); + + 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 new file mode 100644 index 0000000..f222503 --- /dev/null +++ b/src/lib/wcif/rounds.ts @@ -0,0 +1,307 @@ +import { parseActivityCode } from '../domain/activities'; +import { + formatCentiseconds, + 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 } => + 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' && participationSource?.type !== 'round') { + return null; + } + + return { + type: participationSource.resultCondition.type, + level: participationSource.resultCondition.value, + }; +}; + +export const formatAdvancementCondition = ( + advancementCondition: AdvancementCondition | null | undefined +): string => { + if (!advancementCondition) { + return ''; + } + + const { type, level } = advancementCondition; + + 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): boolean => { + const round = event.rounds.find((candidate) => candidate.id === roundId); + + if (!round) { + return false; + } + + if (hasLegacyAdvancementCondition(round)) { + return true; + } + + return getParticipationRuleset(round) !== null; +}; + +export const getDisplayAdvancementConditionForRound = ( + 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; +}; + +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) + ); + }); + +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; +}; + +const findNextSeededRound = (event: Event, roundId: string): Round | undefined => + findLinkedTargetRound(event, roundId) ?? findNextSequentialRound(event, roundId); + +export const getParticipationSourceTextForRound = ( + 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}`; + } + + 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'; + } + + const previousRound = findPreviousRound(event, roundId); + + if (previousRound && hasLegacyAdvancementCondition(previousRound)) { + const advancementText = formatAdvancementCondition(previousRound.advancementCondition); + return advancementText ? `${advancementText} from previous round` : null; + } + + const linkedTargetRound = findLinkedTargetRound(event, roundId); + + if (!linkedTargetRound) { + return null; + } + + return getParticipationSourceTextForRound(event, linkedTargetRound.id); +}; + +export const getParticipationConditionTextForRound = ( + event: Event, + roundId: string +): 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)}`; + } + + if (linkedTargetSource?.type === 'round') { + return `${formatAdvancementCondition({ + type: linkedTargetSource.resultCondition.type, + level: linkedTargetSource.resultCondition.value, + })} 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 (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 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; + + 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; +}; + +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)); +}; diff --git a/src/lib/wcif/v2-types.d.ts b/src/lib/wcif/v2-types.d.ts new file mode 100644 index 0000000..504b2a6 --- /dev/null +++ b/src/lib/wcif/v2-types.d.ts @@ -0,0 +1,40 @@ +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 interface RoundParticipationSource { + type: 'round'; + roundId: string; + resultCondition: ParticipationResultCondition; + } + + export type ParticipationSource = + | RegistrationsParticipationSource + | LinkedRoundsParticipationSource + | RoundParticipationSource; + + 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..d86156f 100644 --- a/src/lib/wcif/validation/eventRoundValidation.test.ts +++ b/src/lib/wcif/validation/eventRoundValidation.test.ts @@ -155,6 +155,126 @@ 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 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', diff --git a/src/lib/wcif/validation/eventRoundValidation.ts b/src/lib/wcif/validation/eventRoundValidation.ts index 10f2572..f0ef436 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 80b102f..30ce556 100644 --- a/src/pages/Competition/Export/index.tsx +++ b/src/pages/Competition/Export/index.tsx @@ -22,33 +22,10 @@ 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 { formatAdvancementCondition, getDisplayAdvancementConditionForRound } from '../../../lib/wcif/rounds'; type AssignmentWithActivity = Assignment & { activity: ActivityWithParent }; -const advancementConditionToText = ({ type, level }: AdvancementConditionLike): 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, @@ -256,9 +233,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 = getDisplayAdvancementConditionForRound(event, round.id); + return advancementCondition ? formatAdvancementCondition(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; diff --git a/src/pages/Competition/Round/RoundContainer.tsx b/src/pages/Competition/Round/RoundContainer.tsx index 5326a56..b19b70c 100644 --- a/src/pages/Competition/Round/RoundContainer.tsx +++ b/src/pages/Competition/Round/RoundContainer.tsx @@ -37,13 +37,20 @@ const RoundContainer = ({ roundId, activityCode, eventId, round }: RoundContaine personsAssignedToCompete, personsAssignedWithCompetitorAssignmentCount, adamRoundConfig, + linkedRoundIds, } = useRoundData(activityCode, round); - const { handleGenerateAssignments, handleResetAll, handleResetNonScrambling } = useRoundActions({ + const { handleGenerateAssignments, handleResetAll, handleResetNonScrambling, handleCopyAssignments } = useRoundActions({ + wcif, round, groups, roundActivities, }); + const linkedRounds = linkedRoundIds.map((linkedRoundId) => ({ + roundId: linkedRoundId, + onCopyAssignments: + linkedRoundId === round.id ? undefined : () => handleCopyAssignments(linkedRoundId, round.id), + })); if (roundActivities.length === 0) { return ( @@ -90,6 +97,8 @@ const RoundContainer = ({ roundId, activityCode, eventId, round }: RoundContaine onOpenRawActivitiesData={() => dialogs.rawRoundActivitiesData.setOpen(true)} onOpenPersonsDialog={dialogs.personsDialog.open} onOpenPersonsAssignmentsDialog={() => dialogs.personsAssignments.setOpen(true)} + competitionId={wcif?.id} + linkedRounds={linkedRounds} actionButtons={ { +export const useRoundActions = ({ wcif, round, groups, roundActivities }: UseRoundActionsParams) => { const dispatch = useDispatch(); const confirm = useConfirm(); + const { enqueueSnackbar } = useSnackbar(); const handleGenerateAssignments = useCallback(() => { if (!round) return; @@ -75,9 +80,61 @@ export const useRoundActions = ({ round, groups, roundActivities }: UseRoundActi }); }, [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, handleResetAll, handleResetNonScrambling, + handleCopyAssignments, }; }; diff --git a/src/pages/Competition/Round/hooks/useRoundData.ts b/src/pages/Competition/Round/hooks/useRoundData.ts index c8da654..0751109 100644 --- a/src/pages/Competition/Round/hooks/useRoundData.ts +++ b/src/pages/Competition/Round/hooks/useRoundData.ts @@ -1,5 +1,7 @@ +import { parseActivityCode } from '../../../../lib/domain/activities'; 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, @@ -29,10 +31,16 @@ interface RoundDataResult { groupCount?: number; expectedRegistrations?: number; } | null; + 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) : [] @@ -96,5 +104,8 @@ export const useRoundData = (activityCode: string, round: Round | undefined): Ro personsAssignedToCompete, personsAssignedWithCompetitorAssignmentCount, adamRoundConfig, + 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, + }; +}; diff --git a/src/store/actions.test.ts b/src/store/actions.test.ts index 1a06c69..a74068a 100644 --- a/src/store/actions.test.ts +++ b/src/store/actions.test.ts @@ -320,7 +320,10 @@ describe('store actions', () => { type: ActionType.UPLOADING_WCIF, uploading: true, }); - expect(patchWcifMock).toHaveBeenCalledWith('Comp1', { events: wcif.events }); + expect(patchWcifMock).toHaveBeenCalledWith( + 'Comp1', + expect.objectContaining({ events: wcif.events }) + ); expect(dispatch.mock.calls[1][0]).toEqual({ type: ActionType.UPLOADING_WCIF, uploading: false,