diff --git a/src/components/RoundActionButtons.tsx b/src/components/RoundActionButtons.tsx index ffa49e2..56c35f9 100644 --- a/src/components/RoundActionButtons.tsx +++ b/src/components/RoundActionButtons.tsx @@ -1,7 +1,12 @@ +import { Recipes } from '../lib/recipes'; import { hasDistributedAttempts, parseActivityCode } from '../lib/domain/activities'; import { type ActivityWithParent } from '../lib/domain/types'; import Box from '@mui/material/Box'; import Button from '@mui/material/Button'; +import FormControl from '@mui/material/FormControl'; +import InputLabel from '@mui/material/InputLabel'; +import MenuItem from '@mui/material/MenuItem'; +import Select from '@mui/material/Select'; import { type Person } from '@wca/helpers'; interface RoundActionButtonsProps { @@ -10,7 +15,9 @@ interface RoundActionButtonsProps { personsShouldBeInRound: Person[]; activityCode: string; onConfigureAssignments: () => void; - onGenerateAssignments: () => void; + recipeId: string; + onChangeRecipeId: (recipeId: string) => void; + onRunRecipe: () => void; onAssignToRoundAttempt: () => void; onResetAttemptAssignments: () => void; onConfigureStationNumbers: (activityCode: string) => void; @@ -27,7 +34,9 @@ export const RoundActionButtons = ({ personsShouldBeInRound, activityCode, onConfigureAssignments, - onGenerateAssignments, + recipeId, + onChangeRecipeId, + onRunRecipe, onAssignToRoundAttempt, onResetAttemptAssignments, onConfigureStationNumbers, @@ -86,7 +95,21 @@ export const RoundActionButtons = ({ return ( <> - + + Recipe + + +
+
+ ), +})); + +vi.mock('../../../dialogs/ConfigureGroupCountsDialog', () => ({ + default: ({ activityCode, onClose }: { activityCode: string; onClose: () => void }) => ( +
+ Configuring groups for {activityCode} + +
+ ), +})); + +const registration = (registrantId: number, eventIds: EventId[]) => ({ + status: 'accepted' as const, + eventIds, + isCompeting: true, + comments: undefined, + wcaRegistrationId: registrantId, +}); + +const buildCompetition = () => + buildWcifWithEvents( + [ + buildActivity({ + id: 1, + activityCode: '333-r1', + startTime: '2024-01-01T11:00:00Z', + endTime: '2024-01-01T11:30:00Z', + childActivities: [buildActivity({ id: 101, activityCode: '333-r1-g1' })], + }), + buildActivity({ + id: 2, + activityCode: '333-r2', + startTime: '2024-01-01T10:00:00Z', + endTime: '2024-01-01T10:30:00Z', + childActivities: [buildActivity({ id: 201, activityCode: '333-r2-g1' })], + }), + buildActivity({ + id: 3, + activityCode: '222-r1', + startTime: '2024-01-01T09:00:00Z', + endTime: '2024-01-01T09:30:00Z', + childActivities: [buildActivity({ id: 301, activityCode: '222-r1-g1' })], + }), + buildActivity({ + id: 4, + activityCode: '222-r2', + startTime: '2024-01-01T12:00:00Z', + endTime: '2024-01-01T12:30:00Z', + childActivities: [], + }), + buildActivity({ + id: 5, + activityCode: '333fm-r1', + childActivities: [], + }), + ], + [ + buildEvent({ + id: '333', + rounds: [ + buildRound({ id: '333-r1' }), + buildRound({ + id: '333-r2', + results: [{ personId: 1, ranking: 1, attempts: [], best: 0, average: 0 }], + }), + ], + }), + buildEvent({ + id: '222', + rounds: [buildRound({ id: '222-r1' }), buildRound({ id: '222-r2' })], + }), + buildEvent({ + id: '333fm' as EventId, + rounds: [buildRound({ id: '333fm-r1' })], + }), + ], + [ + buildPerson({ + registrantId: 1, + registration: registration(1, ['333' as EventId, '222' as EventId]), + }), + buildPerson({ + registrantId: 2, + registration: registration(2, ['333' as EventId, '222' as EventId]), + }), + ] + ); + +const renderPage = () => + renderWithProviders( + + + } /> + + , + { route: '/competitions/test-comp/bulk-generation' } + ); + +describe('BulkGenerationPage', () => { + beforeEach(() => { + vi.clearAllMocks(); + workerInstances.length = 0; + vi.stubGlobal('Worker', MockWorker); + getLocalStorageMock.mockReturnValue(null); + const state = { wcif: buildCompetition() } as AppState; + useAppSelectorMock.mockImplementation((selector: (state: AppState) => unknown) => + selector(state) + ); + }); + + it('renders normal rounds with Round 1 selected by default and distributed attempts excluded', () => { + renderPage(); + + expect(screen.getByRole('heading', { name: 'Bulk Generate' })).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: 'Size' })).toBeInTheDocument(); + expect(screen.queryByRole('columnheader', { name: 'Assigned' })).not.toBeInTheDocument(); + expect(screen.getByText('3x3 Round 1')).toBeInTheDocument(); + expect(screen.getByText('3x3 Round 2')).toBeInTheDocument(); + expect(screen.getByText('2x2 Round 1')).toBeInTheDocument(); + expect(screen.getByText('2x2 Round 2')).toBeInTheDocument(); + expect(screen.queryByText('333FM Round 1')).not.toBeInTheDocument(); + expect(screen.getByLabelText('Select 333-r1')).toBeChecked(); + expect(screen.getByLabelText('Select 222-r1')).toBeChecked(); + expect(screen.getByLabelText('Select 333-r2')).not.toBeChecked(); + expect(screen.getByLabelText('Select 333-r2')).not.toBeDisabled(); + expect(screen.getByLabelText('Select 222-r2')).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Configure' })).toBeInTheDocument(); + + const rowTexts = screen.getAllByRole('row').map((row) => row.textContent ?? ''); + expect(rowTexts[1]).toContain('2x2 Round 1'); + expect(rowTexts[1]).toContain('0 / 2'); + expect(rowTexts[2]).toContain('3x3 Round 2'); + expect(rowTexts[2]).toContain('0 / 1'); + expect(rowTexts[3]).toContain('3x3 Round 1'); + expect(rowTexts[3]).toContain('0 / 2'); + expect(rowTexts[4]).toContain('2x2 Round 2'); + expect(rowTexts[4]).toContain('0 / 0'); + expect(setLocalStorageMock).toHaveBeenCalledWith( + 'bulk-generation.round-order.test-comp', + JSON.stringify(['222-r1', '333-r2', '333-r1', '222-r2']) + ); + }); + + it('dispatches selected rounds in displayed order after manual reordering', async () => { + const user = userEvent.setup(); + renderPage(); + + await user.click(screen.getByLabelText('Select 333-r2')); + await user.click(screen.getByLabelText('Move 333-r2 up')); + await user.click(screen.getByRole('button', { name: 'Generate' })); + const worker = workerInstances[0]; + + expect(worker.request).toMatchObject({ + type: 'runBulkGeneration', + recipeId: 'pnw', + roundIds: ['333-r2', '222-r1', '333-r1'], + }); + act(() => { + worker.emit({ type: 'complete', wcif: buildCompetition() }); + }); + expect(dispatchMock).toHaveBeenCalledWith({ + type: ActionType.PARTIAL_UPDATE_WCIF, + wcif: expect.objectContaining({ + events: expect.any(Array), + persons: expect.any(Array), + schedule: expect.any(Object), + }), + }); + expect(setLocalStorageMock).toHaveBeenLastCalledWith( + 'bulk-generation.round-order.test-comp', + JSON.stringify(['333-r2', '222-r1', '333-r1', '222-r2']) + ); + }); + + it('does not dispatch later rounds with no competitors', async () => { + const user = userEvent.setup(); + renderPage(); + + await user.click(screen.getByRole('button', { name: 'Generate' })); + const worker = workerInstances[0]; + + expect(worker.request).toMatchObject({ + type: 'runBulkGeneration', + recipeId: 'pnw', + roundIds: ['222-r1', '333-r1'], + }); + }); + + it('uses persisted round order when it is available', async () => { + const user = userEvent.setup(); + getLocalStorageMock.mockReturnValue(JSON.stringify(['333-r1', '222-r1', '333-r2'])); + renderPage(); + + await user.click(screen.getByRole('button', { name: 'Generate' })); + const worker = workerInstances[0]; + + expect(worker.request).toMatchObject({ + type: 'runBulkGeneration', + recipeId: 'pnw', + roundIds: ['333-r1', '222-r1'], + }); + expect(setLocalStorageMock).toHaveBeenCalledWith( + 'bulk-generation.round-order.test-comp', + JSON.stringify(['333-r1', '222-r1', '333-r2', '222-r2']) + ); + }); + + it('resets persisted order back to schedule order', async () => { + const user = userEvent.setup(); + getLocalStorageMock.mockReturnValue(JSON.stringify(['333-r1', '222-r1', '333-r2'])); + renderPage(); + + await user.click(screen.getByRole('button', { name: 'Reset to Schedule Order' })); + await user.click(screen.getByRole('button', { name: 'Generate' })); + const worker = workerInstances[0]; + + expect(setLocalStorageMock).toHaveBeenLastCalledWith( + 'bulk-generation.round-order.test-comp', + JSON.stringify(['222-r1', '333-r2', '333-r1', '222-r2']) + ); + expect(worker.request).toMatchObject({ + type: 'runBulkGeneration', + recipeId: 'pnw', + roundIds: ['222-r1', '333-r1'], + }); + }); + + it('shows worker progress and disables controls while generation is running', async () => { + const user = userEvent.setup(); + renderPage(); + + await user.click(screen.getByRole('button', { name: 'Generate' })); + const worker = workerInstances[0]; + + expect(screen.getByText('Starting bulk generation')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Generate' })).toBeDisabled(); + expect(screen.getByLabelText('Select 333-r1')).toBeDisabled(); + expect(screen.getAllByRole('button', { name: /Preview/ })[0]).toBeDisabled(); + + act(() => { + worker.emit({ type: 'progress', phase: 'generating', roundId: '222-r1' }); + }); + expect(screen.getByText('Generating for 2x2 Round 1')).toBeInTheDocument(); + + act(() => { + worker.emit({ type: 'progress', phase: 'fixing' }); + }); + expect(screen.getByText('Fixing group assignments')).toBeInTheDocument(); + + act(() => { + worker.emit({ type: 'progress', phase: 'staff', roundId: '333-r1' }); + }); + expect(screen.getByText('Generating staff assignments for 3x3 Round 1')).toBeInTheDocument(); + }); + + it('shows worker errors and leaves WCIF unchanged', async () => { + const user = userEvent.setup(); + renderPage(); + + await user.click(screen.getByRole('button', { name: 'Generate' })); + act(() => { + workerInstances[0].emit({ type: 'error', message: 'Recipe failed' }); + }); + + expect(screen.getByText('Recipe failed')).toBeInTheDocument(); + expect(dispatchMock).not.toHaveBeenCalled(); + }); + + it('opens assignment preview for a round', async () => { + const user = userEvent.setup(); + renderPage(); + + const roundRow = screen + .getAllByRole('row') + .find((row) => row.textContent?.includes('3x3 Round 2')); + expect(roundRow).toBeDefined(); + + await user.click(within(roundRow as HTMLElement).getByRole('button', { name: /Preview/ })); + + expect(screen.getByRole('dialog', { name: 'Preview 333-r2' })).toBeInTheDocument(); + expect(screen.getByText('Show all: yes')).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Close Preview' })); + + expect(screen.queryByRole('dialog', { name: 'Preview 333-r2' })).not.toBeInTheDocument(); + }); + + it('opens group count configuration for rounds without groups', async () => { + const user = userEvent.setup(); + renderPage(); + + const roundRow = screen + .getAllByRole('row') + .find((row) => row.textContent?.includes('2x2 Round 2')); + expect(roundRow).toBeDefined(); + + await user.click(within(roundRow as HTMLElement).getByRole('button', { name: 'Configure' })); + + expect( + screen.getByRole('dialog', { name: 'Configure groups 222-r2' }) + ).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Close Configure Groups' })); + + expect( + screen.queryByRole('dialog', { name: 'Configure groups 222-r2' }) + ).not.toBeInTheDocument(); + }); +}); diff --git a/src/pages/Competition/BulkGeneration/index.tsx b/src/pages/Competition/BulkGeneration/index.tsx new file mode 100644 index 0000000..d5ca010 --- /dev/null +++ b/src/pages/Competition/BulkGeneration/index.tsx @@ -0,0 +1,311 @@ +import { BulkRoundTable } from './BulkRoundTable'; +import { BulkRoundGroupCountsDialog } from './BulkRoundGroupCountsDialog'; +import { BulkRoundPreviewDialog } from './BulkRoundPreviewDialog'; +import { + buildBulkRoundRows, + defaultSelectedRoundIds, + mergeRoundOrder, + scheduleOrderedRoundIds, +} from './bulkRoundRows'; +import { getLocalStorage, setLocalStorage } from '../../../lib/api'; +import { Recipes } from '../../../lib/recipes'; +import { useBreadcrumbs } from '../../../providers/BreadcrumbsProvider'; +import { useAppDispatch, useAppSelector } from '../../../store'; +import { partialUpdateWCIF } from '../../../store/actions'; +import RestartAltIcon from '@mui/icons-material/RestartAlt'; +import { + Alert, + Button, + FormControl, + InputLabel, + MenuItem, + Select, + Stack, + Typography, +} from '@mui/material'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import type { + BulkGenerationWorkerRequest, + BulkGenerationWorkerResponse, +} from './bulkGenerationWorkerTypes'; + +const moveRoundId = (roundIds: string[], roundId: string, direction: -1 | 1) => { + const fromIndex = roundIds.indexOf(roundId); + const toIndex = fromIndex + direction; + if (fromIndex === -1 || toIndex < 0 || toIndex >= roundIds.length) { + return roundIds; + } + + const nextRoundIds = [...roundIds]; + const [movedRoundId] = nextRoundIds.splice(fromIndex, 1); + nextRoundIds.splice(toIndex, 0, movedRoundId); + return nextRoundIds; +}; + +const roundOrderStorageKey = (competitionId: string) => + `bulk-generation.round-order.${competitionId}`; + +const parsePersistedRoundOrder = (value: string | null) => { + if (!value) { + return []; + } + + try { + const parsedValue = JSON.parse(value); + return Array.isArray(parsedValue) + ? parsedValue.filter((roundId): roundId is string => typeof roundId === 'string') + : []; + } catch { + return []; + } +}; + +const progressText = ( + progress: Extract, + labelForRound: (roundId: string) => string +) => { + if (progress.phase === 'fixing') { + return 'Fixing group assignments'; + } + + if (progress.roundId && progress.phase === 'staff') { + return `Generating staff assignments for ${labelForRound(progress.roundId)}`; + } + + if (progress.roundId) { + return `Generating for ${labelForRound(progress.roundId)}`; + } + + return 'Generating'; +}; + +const BulkGenerationPage = () => { + const dispatch = useAppDispatch(); + const wcif = useAppSelector((state) => state.wcif); + const workerRef = useRef(null); + const { setBreadcrumbs } = useBreadcrumbs(); + const rows = useMemo(() => (wcif ? buildBulkRoundRows(wcif) : []), [wcif]); + const [recipeId, setRecipeId] = useState('pnw'); + const [orderedRoundIds, setOrderedRoundIds] = useState([]); + const [selectedRoundIds, setSelectedRoundIds] = useState>(new Set()); + const [generationStatus, setGenerationStatus] = useState(null); + const [generationError, setGenerationError] = useState(null); + const [previewRoundId, setPreviewRoundId] = useState(null); + const [configureGroupsRoundId, setConfigureGroupsRoundId] = useState(null); + + useEffect(() => { + setBreadcrumbs([{ text: 'Bulk Generate' }]); + }, [setBreadcrumbs]); + + useEffect( + () => () => { + workerRef.current?.terminate(); + }, + [] + ); + + useEffect(() => { + if (!wcif?.id) { + return; + } + + const scheduleOrder = scheduleOrderedRoundIds(rows); + const persistedOrder = parsePersistedRoundOrder(getLocalStorage(roundOrderStorageKey(wcif.id))); + const nextOrder = mergeRoundOrder(scheduleOrder, persistedOrder); + + setLocalStorage(roundOrderStorageKey(wcif.id), JSON.stringify(nextOrder)); + setOrderedRoundIds(nextOrder); + setSelectedRoundIds(defaultSelectedRoundIds(rows)); + }, [rows, wcif?.id]); + + const orderedRows = useMemo( + () => + orderedRoundIds + .map((roundId) => rows.find((row) => row.roundId === roundId)) + .filter((row): row is NonNullable => Boolean(row)), + [orderedRoundIds, rows] + ); + + const selectedOrderedRoundIds = orderedRows + .filter((row) => row.selectable && selectedRoundIds.has(row.roundId)) + .map((row) => row.roundId); + const generating = Boolean(generationStatus); + const labelForRound = (roundId: string) => + rows.find((row) => row.roundId === roundId)?.label ?? roundId; + + const handleToggleRound = (roundId: string) => { + if (generating) { + return; + } + + if (!rows.find((row) => row.roundId === roundId)?.selectable) { + return; + } + + setSelectedRoundIds((currentRoundIds) => { + const nextRoundIds = new Set(currentRoundIds); + if (nextRoundIds.has(roundId)) { + nextRoundIds.delete(roundId); + } else { + nextRoundIds.add(roundId); + } + return nextRoundIds; + }); + }; + + const handleMoveRound = (roundId: string, direction: -1 | 1) => { + if (generating) { + return; + } + + setOrderedRoundIds((currentRoundIds) => { + const nextRoundIds = moveRoundId(currentRoundIds, roundId, direction); + if (wcif?.id) { + setLocalStorage(roundOrderStorageKey(wcif.id), JSON.stringify(nextRoundIds)); + } + return nextRoundIds; + }); + }; + + const handleResetOrder = () => { + if (generating) { + return; + } + + const scheduleOrder = scheduleOrderedRoundIds(rows); + if (wcif?.id) { + setLocalStorage(roundOrderStorageKey(wcif.id), JSON.stringify(scheduleOrder)); + } + setOrderedRoundIds(scheduleOrder); + }; + + const handleGenerate = () => { + if (!wcif || selectedOrderedRoundIds.length === 0 || generating) { + return; + } + + workerRef.current?.terminate(); + const worker = new Worker(new URL('./bulkGeneration.worker.ts', import.meta.url), { + type: 'module', + }); + workerRef.current = worker; + setGenerationError(null); + setGenerationStatus('Starting bulk generation'); + + worker.onmessage = (event: MessageEvent) => { + const message = event.data; + + if (message.type === 'progress') { + setGenerationStatus(progressText(message, labelForRound)); + return; + } + + if (message.type === 'complete') { + dispatch( + partialUpdateWCIF({ + events: message.wcif.events, + persons: message.wcif.persons, + schedule: message.wcif.schedule, + }) + ); + setGenerationStatus(null); + worker.terminate(); + workerRef.current = null; + return; + } + + setGenerationError(message.message); + setGenerationStatus(null); + worker.terminate(); + workerRef.current = null; + }; + + worker.onerror = () => { + setGenerationError('Bulk generation failed'); + setGenerationStatus(null); + worker.terminate(); + workerRef.current = null; + }; + + const message: BulkGenerationWorkerRequest = { + type: 'runBulkGeneration', + wcif, + recipeId, + roundIds: selectedOrderedRoundIds, + }; + worker.postMessage(message); + }; + + return ( + + setPreviewRoundId(null)} + /> + setConfigureGroupsRoundId(null)} + /> + + + + Bulk Generate + + + Recipe + + + + + + {generationStatus && {generationStatus}} + {generationError && {generationError}} + + {orderedRows.length === 0 ? ( + No normal non-distributed rounds found. + ) : ( + + )} + + + + + + ); +}; + +export default BulkGenerationPage; diff --git a/src/pages/Competition/Round/NormalRoundView.tsx b/src/pages/Competition/Round/NormalRoundView.tsx index c57f84c..ccb2544 100644 --- a/src/pages/Competition/Round/NormalRoundView.tsx +++ b/src/pages/Competition/Round/NormalRoundView.tsx @@ -3,7 +3,8 @@ import Grid from '@mui/material/GridLegacy'; import { type Round, type Activity } from '@wca/helpers'; import { RoundStatisticsCard } from '../../../components/RoundStatisticsCard'; import GroupCard from '../../../components/GroupCard'; -import { type ActivityWithRoom } from '../../../lib/domain/types'; +import { RoundAssignmentCountsTable } from '../../../components/RoundAssignmentCountsTable'; +import { type ActivityWithParent, type ActivityWithRoom } from '../../../lib/domain/types'; import { type AppState } from '../../../store/initialState'; import { type Person } from '@wca/helpers'; @@ -25,6 +26,7 @@ interface NormalRoundViewProps { groupCount?: number; expectedRegistrations?: number; } | null; + groups: ActivityWithParent[]; sortedGroups: Activity[]; } @@ -47,6 +49,7 @@ const NormalRoundView = ({ onOpenPersonsAssignmentsDialog, actionButtons, adamRoundConfig, + groups, sortedGroups, }: NormalRoundViewProps) => { const pluralizeWord = (count: number, singular: string, plural?: string) => @@ -100,6 +103,10 @@ const NormalRoundView = ({ ))} + + + + ); }; diff --git a/src/pages/Competition/Round/RoundContainer.tsx b/src/pages/Competition/Round/RoundContainer.tsx index 4ab6957..11f468c 100644 --- a/src/pages/Competition/Round/RoundContainer.tsx +++ b/src/pages/Competition/Round/RoundContainer.tsx @@ -12,8 +12,12 @@ import { useRoundData } from './hooks/useRoundData'; import { useRoundDialogs } from './hooks/useRoundDialogs'; import DistributedAttemptRoundView from './DistributedAttemptRoundView'; import NormalRoundView from './NormalRoundView'; +import { getRoundConfigExtensionData } from '../../../lib/wcif/extensions/delegateDashboard/delegateDashboard'; +import { useAppDispatch } from '../../../store'; +import { runRecipe as runRecipeAction, updateRoundExtensionData } from '../../../store/actions'; import { type Round } from '@wca/helpers'; import { ConfirmProvider } from 'material-ui-confirm'; +import { useState } from 'react'; interface RoundContainerProps { roundId: string; @@ -24,6 +28,7 @@ interface RoundContainerProps { const RoundContainer = ({ roundId, activityCode, eventId, round }: RoundContainerProps) => { const dialogs = useRoundDialogs(); + const dispatch = useAppDispatch(); const { wcif, @@ -40,7 +45,6 @@ const RoundContainer = ({ roundId, activityCode, eventId, round }: RoundContaine } = useRoundData(activityCode, round); const { - handleGenerateAssignments, handleAssignToRoundAttempt, handleResetAttemptAssignments, handleResetAll, @@ -52,6 +56,24 @@ const RoundContainer = ({ roundId, activityCode, eventId, round }: RoundContaine roundActivities, }); + const existingRoundConfig = getRoundConfigExtensionData(round); + const existingRecipe = existingRoundConfig?.recipe as { id?: string } | undefined; + const [recipeId, setRecipeId] = useState(existingRecipe?.id ?? 'pnw'); + + const handleChangeRecipeId = (nextId: string) => { + setRecipeId(nextId); + dispatch( + updateRoundExtensionData(round.id, { + ...(existingRoundConfig ?? {}), + recipe: { id: nextId }, + }) + ); + }; + + const handleRunRecipe = () => { + dispatch(runRecipeAction(round.id, recipeId)); + }; + if (roundActivities.length === 0) { return (
@@ -71,7 +93,9 @@ const RoundContainer = ({ roundId, activityCode, eventId, round }: RoundContaine personsShouldBeInRound={personsShouldBeInRound} activityCode={activityCode} onConfigureAssignments={() => dialogs.configureAssignments.setOpen(true)} - onGenerateAssignments={handleGenerateAssignments} + recipeId={recipeId} + onChangeRecipeId={handleChangeRecipeId} + onRunRecipe={handleRunRecipe} onAssignToRoundAttempt={handleAssignToRoundAttempt} onResetAttemptAssignments={handleResetAttemptAssignments} onConfigureStationNumbers={(code) => dialogs.configureStationNumbers.setActivityCode(code)} @@ -177,6 +201,7 @@ const RoundContainer = ({ roundId, activityCode, eventId, round }: RoundContaine onOpenPersonsAssignmentsDialog={() => dialogs.personsAssignments.setOpen(true)} actionButtons={actionButtons} adamRoundConfig={adamRoundConfig} + groups={groups} sortedGroups={sortedGroups} /> )} diff --git a/src/pages/Competition/index.tsx b/src/pages/Competition/index.tsx index 27341ed..fe8172d 100644 --- a/src/pages/Competition/index.tsx +++ b/src/pages/Competition/index.tsx @@ -7,3 +7,4 @@ export { default as Assignments } from './Assignments'; export { default as Import } from './Import'; export { default as Export } from './Export'; export { default as ScramblerSchedule } from './ScramblerSchedule'; +export { default as BulkGeneration } from './BulkGeneration'; diff --git a/src/store/actions.test.ts b/src/store/actions.test.ts index 32b006a..5691a76 100644 --- a/src/store/actions.test.ts +++ b/src/store/actions.test.ts @@ -12,6 +12,7 @@ import { partialUpdateWCIF, removePersonAssignments, resetAllGroupAssignments, + runRecipes, togglePersonRole, updateGlobalExtension, updateGroupCount, @@ -144,6 +145,11 @@ describe('store actions', () => { roundId: '333-r1', options: { sortOrganizationStaffInLastGroups: true }, }); + expect(runRecipes(['333-r1', '222-r1'], 'pnw')).toEqual({ + type: ActionType.RUN_RECIPES, + roundIds: ['333-r1', '222-r1'], + recipeId: 'pnw', + }); expect(editActivity({ id: 10 }, { name: 'Updated' })).toEqual({ type: ActionType.EDIT_ACTIVITY, where: { id: 10 }, diff --git a/src/store/actions.ts b/src/store/actions.ts index bcedf93..364cf19 100644 --- a/src/store/actions.ts +++ b/src/store/actions.ts @@ -46,6 +46,8 @@ export const ActionType = { PARTIAL_UPDATE_WCIF: 'partial_update_wcif', RESET_ALL_GROUP_ASSIGNMENTS: 'reset_all_group_assignments', GENERATE_ASSIGNMENTS: 'generate_assignments', + RUN_RECIPE: 'run_recipe', + RUN_RECIPES: 'run_recipes', GENERATE_ROUND_ATTEMPT_ASSIGNMENTS: 'generate_round_attempt_assignments', EDIT_ACTIVITY: 'edit_activity', UPDATE_GLOBAL_EXTENSION: 'update_global_extension', @@ -366,6 +368,32 @@ export type GenerateAssignmentsPayload = { * @param {ActivityCode} roundId * @returns */ +export type RunRecipePayload = { + roundId: string; + recipeId: string; +}; +export const runRecipe = ( + roundId: string, + recipeId: string +): ReduxAction => ({ + type: ActionType.RUN_RECIPE, + roundId, + recipeId, +}); + +export type RunRecipesPayload = { + roundIds: string[]; + recipeId: string; +}; +export const runRecipes = ( + roundIds: string[], + recipeId: string +): ReduxAction => ({ + type: ActionType.RUN_RECIPES, + roundIds, + recipeId, +}); + export const generateAssignments = ( roundId: string, options?: Partial diff --git a/src/store/reducerHandlers.ts b/src/store/reducerHandlers.ts index 7d77022..a589794 100644 --- a/src/store/reducerHandlers.ts +++ b/src/store/reducerHandlers.ts @@ -172,6 +172,8 @@ export const reducers: Record = { }; }, [ActionType.GENERATE_ASSIGNMENTS]: Reducers.generateAssignments, + [ActionType.RUN_RECIPE]: Reducers.runRecipe, + [ActionType.RUN_RECIPES]: Reducers.runRecipes, [ActionType.GENERATE_ROUND_ATTEMPT_ASSIGNMENTS]: ( state, action: GenerateRoundAttemptAssignmentsPayload diff --git a/src/store/reducers/index.ts b/src/store/reducers/index.ts index af3bc42..03b426a 100644 --- a/src/store/reducers/index.ts +++ b/src/store/reducers/index.ts @@ -1,3 +1,4 @@ export * from './generateAssignments'; +export * from './runRecipe'; export * from './competitorAssignments'; export * from './persons'; diff --git a/src/store/reducers/runRecipe.test.ts b/src/store/reducers/runRecipe.test.ts new file mode 100644 index 0000000..be4986e --- /dev/null +++ b/src/store/reducers/runRecipe.test.ts @@ -0,0 +1,730 @@ +import { runRecipe } from './runRecipe'; +import { runRecipes } from './runRecipe'; +import { + buildActivity, + buildEvent, + buildPerson, + buildRound, + buildState, + buildWcifWithEvents, +} from './_tests_/helpers'; +import { + parseActivityCode, + type Activity, + type Assignment, + type AssignmentCode, + type Competition, + type EventId, + type Person, + type Round, +} from '@wca/helpers'; +import { describe, expect, it } from 'vitest'; +import { getRoundConfigExtensionData } from '../../lib/wcif/extensions/delegateDashboard/delegateDashboard'; + +interface CompetitionOptions { + groupCount?: number; + stageCount?: number; + includeGroups?: boolean; +} + +const acceptedRegistration = (registrantId: number) => ({ + status: 'accepted' as const, + eventIds: ['333' as EventId], + isCompeting: true, + comments: undefined, + wcaRegistrationId: registrantId, +}); + +const acceptedRegistrationForEvents = (registrantId: number, eventIds: EventId[]) => ({ + ...acceptedRegistration(registrantId), + eventIds, +}); + +const personalBest = (worldRanking: number, best: number) => ({ + eventId: '333' as EventId, + type: 'average' as const, + best, + worldRanking, + continentalRanking: worldRanking, + nationalRanking: worldRanking, +}); + +const assignment = (activityId: number, assignmentCode: AssignmentCode): Assignment => ({ + activityId, + assignmentCode, + stationNumber: null, +}); + +const competitor = (registrantId: number, overrides: Partial = {}) => + buildPerson({ + registrantId, + name: `Competitor ${registrantId}`, + wcaUserId: registrantId, + registration: acceptedRegistration(registrantId), + personalBests: [personalBest(registrantId, 1000 + registrantId)], + ...overrides, + }); + +const buildGroup = (id: number, groupNumber: number, stageNumber: number): Activity => + buildActivity({ + id, + name: `Stage ${stageNumber} Group ${groupNumber}`, + activityCode: `333-r1-g${groupNumber}`, + startTime: new Date(Date.UTC(2024, 0, 1, 10, (groupNumber - 1) * 10)).toISOString(), + endTime: new Date(Date.UTC(2024, 0, 1, 10, groupNumber * 10)).toISOString(), + }); + +const buildTimedGroup = ( + id: number, + activityCode: string, + startTime: string, + endTime: string +): Activity => + buildActivity({ + id, + name: activityCode, + activityCode, + startTime, + endTime, + }); + +const buildTimedRoundActivity = (id: number, activityCode: string, childActivities: Activity[]) => + buildActivity({ + id, + name: activityCode, + activityCode, + childActivities, + }); + +const buildRoundActivity = ( + stageNumber: number, + groupCount: number, + includeGroups: boolean +): Activity => + buildActivity({ + id: stageNumber, + name: `3x3 Round 1 Stage ${stageNumber}`, + activityCode: '333-r1', + childActivities: includeGroups + ? Array.from({ length: groupCount }, (_, index) => + buildGroup(stageNumber * 100 + index + 1, index + 1, stageNumber) + ) + : [], + }); + +const buildCompetition = ( + persons: Person[], + { groupCount = 3, stageCount = 1, includeGroups = true }: CompetitionOptions = {} +): Competition => { + const round = buildRound({ id: '333-r1' }); + const event = buildEvent({ id: '333', rounds: [round] }); + const roundActivities = Array.from({ length: stageCount }, (_, index) => + buildRoundActivity(index + 1, groupCount, includeGroups) + ); + const wcif = buildWcifWithEvents(roundActivities, [event], persons); + + return { + ...wcif, + schedule: { + ...wcif.schedule, + venues: [ + { + ...wcif.schedule.venues[0], + rooms: roundActivities.map((roundActivity, index) => ({ + id: index + 1, + name: `Stage ${index + 1}`, + color: '#000', + extensions: [], + activities: [roundActivity], + })), + }, + ], + }, + }; +}; + +const runRecipeById = ( + recipeId: string, + persons: Person[], + options: CompetitionOptions = {} +): Competition => + runRecipe(buildState(buildCompetition(persons, options)), { + roundId: '333-r1', + recipeId, + }).wcif as Competition; + +const runPnwRecipe = (persons: Person[], options: CompetitionOptions = {}) => + runRecipeById('pnw', persons, options); + +const personById = (wcif: Competition, registrantId: number) => + wcif.persons.find((person) => person.registrantId === registrantId); + +const competitorAssignment = (person: Person | undefined) => + person?.assignments?.find((assignment) => assignment.assignmentCode === 'competitor'); + +const allActivities = (wcif: Competition) => + wcif.schedule.venues.flatMap((venue) => + venue.rooms.flatMap((room) => + room.activities.flatMap((activity) => [activity, ...(activity.childActivities ?? [])]) + ) + ); + +const roomForActivity = (wcif: Competition, activityId: number) => + wcif.schedule.venues + .flatMap((venue) => venue.rooms) + .find((room) => + room.activities.some( + (activity) => + activity.id === activityId || + activity.childActivities?.some((childActivity) => childActivity.id === activityId) + ) + ); + +const activityForAssignment = (wcif: Competition, person: Person | undefined) => { + const assignment = competitorAssignment(person); + if (!assignment) return undefined; + + return allActivities(wcif).find((activity) => activity.id === assignment.activityId); +}; + +const competitorAssignmentInRound = ( + wcif: Competition, + person: Person | undefined, + roundId: string +) => { + const activityIds = new Set( + allActivities(wcif) + .filter((activity) => activity.activityCode.startsWith(`${roundId}-g`)) + .map((activity) => activity.id) + ); + + return person?.assignments?.find( + (personAssignment) => + personAssignment.assignmentCode === 'competitor' && + activityIds.has(personAssignment.activityId) + ); +}; + +const roundById = (wcif: Competition, roundId: string) => + wcif.events.flatMap((event) => event.rounds).find((round) => round.id === roundId); + +const competitorGroupNumber = (wcif: Competition, person: Person | undefined) => { + const activity = activityForAssignment(wcif, person); + if (!activity) return undefined; + + return parseActivityCode(activity.activityCode).groupNumber; +}; + +const competitorGroupNumberInRound = ( + wcif: Competition, + person: Person | undefined, + roundId: string +) => { + const activityIds = new Set( + allActivities(wcif) + .filter((activity) => activity.activityCode.startsWith(`${roundId}-g`)) + .map((activity) => activity.id) + ); + const assignment = person?.assignments?.find( + (personAssignment) => + personAssignment.assignmentCode === 'competitor' && + activityIds.has(personAssignment.activityId) + ); + const activity = allActivities(wcif).find((candidate) => candidate.id === assignment?.activityId); + + return activity ? parseActivityCode(activity.activityCode).groupNumber : undefined; +}; + +const competitorStageNumber = (wcif: Competition, person: Person | undefined) => { + const assignment = competitorAssignment(person); + if (!assignment) return undefined; + + return roomForActivity(wcif, assignment.activityId)?.id; +}; + +const competitorCombo = (wcif: Competition, person: Person | undefined) => { + const stageNumber = competitorStageNumber(wcif, person); + const groupNumber = competitorGroupNumber(wcif, person); + + return stageNumber && groupNumber ? `${stageNumber}:${groupNumber}` : undefined; +}; + +const valuesById = (wcif: Competition, registrantIds: number[], value: (person: Person) => T) => + registrantIds.map((registrantId) => value(personById(wcif, registrantId) as Person)); + +const countBy = (values: T[]) => + values.reduce>( + (counts, value) => ({ + ...counts, + [value]: (counts[value] ?? 0) + 1, + }), + {} as Record + ); + +const maxMinusMin = (counts: Record) => { + const values = Object.values(counts); + return Math.max(...values) - Math.min(...values); +}; + +const firstTimerCountsByGroup = (wcif: Competition) => + wcif.persons.reduce>((counts, person) => { + if (person.wcaId) return counts; + + const groupNumber = competitorGroupNumber(wcif, person); + if (!groupNumber) return counts; + + return { + ...counts, + [groupNumber]: (counts[groupNumber] ?? 0) + 1, + }; + }, {}); + +const assignmentCount = (person: Person | undefined, assignmentCode: AssignmentCode) => + person?.assignments?.filter( + (personAssignment) => personAssignment.assignmentCode === assignmentCode + ).length ?? 0; + +describe('runRecipe', () => { + it('stores the selected recipe config on the generated round', () => { + const updatedState = runRecipe( + buildState(buildCompetition([competitor(1), competitor(2)], { includeGroups: false })), + { + roundId: '333-r1', + recipeId: 'balanced', + } + ); + + const updatedRound = roundById(updatedState.wcif as Competition, '333-r1'); + expect(getRoundConfigExtensionData(updatedRound as NonNullable)).toMatchObject({ + recipe: { id: 'balanced' }, + }); + }); + + it('assigns every finalist into the generated group for one-group finals', () => { + const wcif = runPnwRecipe( + Array.from({ length: 4 }, (_, index) => competitor(index + 1)), + { includeGroups: false } + ); + + expect(wcif.schedule.venues[0].rooms[0].activities[0].childActivities).toHaveLength(1); + expect(wcif.persons.map((person) => competitorGroupNumber(wcif, person))).toEqual([ + 1, 1, 1, 1, + ]); + expect(wcif.persons.map((person) => assignmentCount(person, 'staff-judge'))).toEqual([ + 0, 0, 0, 0, + ]); + }); + + it('assigns staff competitors before staff assignments so they help after competing', () => { + const wcif = runPnwRecipe([ + competitor(1, { + name: 'Staff In Middle Group', + assignments: [{ activityId: 102, assignmentCode: 'staff-judge', stationNumber: null }], + }), + competitor(2, { + name: 'Staff In First Group', + assignments: [{ activityId: 101, assignmentCode: 'staff-judge', stationNumber: null }], + }), + competitor(3), + competitor(4), + ]); + + expect(competitorGroupNumber(wcif, personById(wcif, 1))).toBe(1); + expect(competitorGroupNumber(wcif, personById(wcif, 2))).toBe(3); + }); + + it('balances five delegates across group numbers and stages in a two-group two-stage round', () => { + const wcif = runPnwRecipe( + Array.from({ length: 5 }, (_, index) => + competitor(index + 1, { + roles: [index === 1 ? 'trainee-delegate' : 'delegate'], + }) + ), + { groupCount: 2, stageCount: 2 } + ); + + const delegateIds = [1, 2, 3, 4, 5]; + const groupNumbers = valuesById(wcif, delegateIds, (person) => + competitorGroupNumber(wcif, person) + ).filter(Boolean) as number[]; + const stageNumbers = valuesById(wcif, delegateIds, (person) => + competitorStageNumber(wcif, person) + ).filter(Boolean) as number[]; + + expect(new Set(groupNumbers)).toEqual(new Set([1, 2])); + expect(new Set(stageNumbers)).toEqual(new Set([1, 2])); + expect(maxMinusMin(countBy(groupNumbers))).toBeLessThanOrEqual(1); + expect(maxMinusMin(countBy(stageNumbers))).toBeLessThanOrEqual(1); + }); + + it('puts three delegates in different group numbers in a five-group two-stage round', () => { + const wcif = runPnwRecipe( + Array.from({ length: 3 }, (_, index) => + competitor(index + 1, { + roles: [index === 2 ? 'organizer' : 'delegate'], + }) + ), + { groupCount: 5, stageCount: 2 } + ); + + const delegateIds = [1, 2, 3]; + const groupNumbers = valuesById(wcif, delegateIds, (person) => + competitorGroupNumber(wcif, person) + ); + const combos = valuesById(wcif, delegateIds, (person) => competitorCombo(wcif, person)); + + expect(new Set(groupNumbers).size).toBe(3); + expect(new Set(combos).size).toBe(3); + }); + + it('keeps first timers out of the last group and distributes them evenly across eligible groups', () => { + const firstTimers = Array.from({ length: 6 }, (_, index) => + competitor(index + 1, { + name: `First Timer ${index + 1}`, + wcaId: null, + personalBests: [], + }) + ); + const experiencedCompetitors = Array.from({ length: 6 }, (_, index) => + competitor(index + 7, { + name: `Experienced ${index + 1}`, + }) + ); + + const wcif = runPnwRecipe([...firstTimers, ...experiencedCompetitors]); + const firstTimerCounts = firstTimerCountsByGroup(wcif); + + expect(firstTimerCounts[3] ?? 0).toBe(0); + expect(Math.abs((firstTimerCounts[1] ?? 0) - (firstTimerCounts[2] ?? 0))).toBeLessThanOrEqual( + 1 + ); + }); + + it('fills missing assignments when scrambler, runner, and competitor assignments already exist', () => { + const preAssignedCompetitors = [ + competitor(1, { + assignments: [assignment(101, 'competitor')], + }), + competitor(2, { + assignments: [assignment(102, 'competitor')], + }), + competitor(3, { + assignments: [assignment(201, 'competitor')], + }), + ]; + const staffAssignments = [101, 102, 201, 202].flatMap((activityId, activityIndex) => + Array.from({ length: 4 }, (_, offset) => { + const registrantId = 4 + activityIndex * 4 + offset; + const assignmentCode = offset < 2 ? 'staff-scrambler' : 'staff-runner'; + + return competitor(registrantId, { + assignments: [assignment(activityId, assignmentCode)], + }); + }) + ); + const unassignedCompetitors = Array.from({ length: 5 }, (_, index) => + competitor(20 + index) + ); + const initialPersons = [ + ...preAssignedCompetitors, + ...staffAssignments, + ...unassignedCompetitors, + ]; + + const wcif = runPnwRecipe(initialPersons, { groupCount: 2, stageCount: 2 }); + + for (const initialPerson of initialPersons) { + const updatedPerson = personById(wcif, initialPerson.registrantId); + expect(assignmentCount(updatedPerson, 'competitor')).toBe(1); + + for (const initialAssignment of initialPerson.assignments ?? []) { + expect(updatedPerson?.assignments).toContainEqual(initialAssignment); + } + } + + for (const staffPerson of staffAssignments) { + const updatedPerson = personById(wcif, staffPerson.registrantId); + expect(assignmentCount(updatedPerson, 'staff-judge')).toBe(0); + } + + for (const person of [...preAssignedCompetitors, ...unassignedCompetitors]) { + const updatedPerson = personById(wcif, person.registrantId); + expect(assignmentCount(updatedPerson, 'staff-judge')).toBe(1); + } + }); + + it('spreads the fastest competitors across different groups', () => { + const wcif = runPnwRecipe( + Array.from({ length: 9 }, (_, index) => + competitor(index + 1, { + name: `Seeded Competitor ${index + 1}`, + personalBests: [personalBest(index + 1, 1000 + index)], + }) + ) + ); + + const fastestThreeGroups = valuesById(wcif, [1, 2, 3], (person) => + competitorGroupNumber(wcif, person) + ); + + expect(new Set(fastestThreeGroups).size).toBe(3); + }); + + it('keeps repeated Luke and Michael-name variants out of the same group/stage combo', () => { + const lukeNames = ['Luke Adams', 'Luke Baker', 'Luke Clark', 'Luke Davis', 'Luke Evans']; + const michaelNames = [ + 'Michael Frost', + 'Micheal Grant', + 'Mikel Harris', + 'Mykel Irwin', + 'Mikael Jones', + 'Mickel King', + ]; + const wcif = runPnwRecipe( + [...lukeNames, ...michaelNames].map((name, index) => competitor(index + 1, { name })), + { groupCount: 3, stageCount: 2 } + ); + + const lukeCombos = valuesById(wcif, [1, 2, 3, 4, 5], (person) => + competitorCombo(wcif, person) + ); + const michaelCombos = valuesById(wcif, [6, 7, 8, 9, 10, 11], (person) => + competitorCombo(wcif, person) + ); + + expect(new Set(lukeCombos).size).toBe(lukeCombos.length); + expect(new Set(michaelCombos).size).toBe(michaelCombos.length); + }); + + it('keeps duplicate first names apart even when one duplicate is in a smaller group', () => { + const groupOneEthan = competitor(1, { + name: 'Ethan Smith', + assignments: [assignment(101, 'competitor')], + }); + const fullOtherGroups = [102, 103].flatMap((activityId, activityIndex) => + Array.from({ length: 4 }, (_, offset) => + competitor(2 + activityIndex * 4 + offset, { + assignments: [assignment(activityId, 'competitor')], + }) + ) + ); + const unassignedEthan = competitor(20, { name: 'Ethan Jones' }); + + const wcif = runPnwRecipe([groupOneEthan, ...fullOtherGroups, unassignedEthan]); + + expect(competitorGroupNumber(wcif, personById(wcif, 20))).not.toBe(1); + }); + + it('still gives everyone a competitor assignment when timing options are poor', () => { + const wcif = runPnwRecipe([ + competitor(1, { + name: 'Alpha Staff', + assignments: [assignment(102, 'staff-judge')], + }), + competitor(2, { + name: 'Bravo Staff', + assignments: [assignment(102, 'staff-runner')], + }), + competitor(3, { name: 'Charlie Competitor' }), + ], { groupCount: 2 }); + + expect(wcif.persons.map((person) => assignmentCount(person, 'competitor'))).toEqual([ + 1, 1, 1, + ]); + }); + + it('uses cross-round competitor assignments as read-only spacing context', () => { + const nearActivity = buildTimedGroup( + 101, + '333-r1-g1', + '2024-01-01T10:10:00.000Z', + '2024-01-01T10:20:00.000Z' + ); + const farActivity = buildTimedGroup( + 102, + '333-r1-g2', + '2024-01-01T11:00:00.000Z', + '2024-01-01T11:10:00.000Z' + ); + const otherRoundActivity = buildTimedGroup( + 201, + '222-r1-g1', + '2024-01-01T10:00:00.000Z', + '2024-01-01T10:10:00.000Z' + ); + const roundActivity = buildActivity({ + id: 1, + activityCode: '333-r1', + childActivities: [nearActivity, farActivity], + }); + const otherRound = buildActivity({ + id: 2, + activityCode: '222-r1', + childActivities: [otherRoundActivity], + }); + const person = competitor(1, { + assignments: [assignment(201, 'competitor')], + }); + const wcif = buildWcifWithEvents( + [roundActivity, otherRound], + [ + buildEvent({ id: '333', rounds: [buildRound({ id: '333-r1' })] }), + buildEvent({ id: '222', rounds: [buildRound({ id: '222-r1' })] }), + ], + [person] + ); + + const updatedWcif = runRecipe(buildState(wcif), { + roundId: '333-r1', + recipeId: 'pnw', + }).wcif as Competition; + + expect(competitorGroupNumberInRound(updatedWcif, personById(updatedWcif, 1), '333-r1')).toBe( + 2 + ); + expect(personById(updatedWcif, 1)?.assignments).toContainEqual(assignment(201, 'competitor')); + }); + + it('does not change assignments from other rounds when generating a single round', () => { + const otherRoundAssignment = assignment(201, 'staff-judge'); + const roundActivity = buildActivity({ + id: 1, + activityCode: '333-r1', + childActivities: [buildGroup(101, 1, 1), buildGroup(102, 2, 1)], + }); + const otherRound = buildActivity({ + id: 2, + activityCode: '222-r1', + childActivities: [ + buildTimedGroup( + 201, + '222-r1-g1', + '2024-01-01T12:00:00.000Z', + '2024-01-01T12:10:00.000Z' + ), + ], + }); + const wcif = buildWcifWithEvents( + [roundActivity, otherRound], + [ + buildEvent({ id: '333', rounds: [buildRound({ id: '333-r1' })] }), + buildEvent({ id: '222', rounds: [buildRound({ id: '222-r1' })] }), + ], + [ + competitor(1, { + assignments: [otherRoundAssignment], + }), + competitor(2), + ] + ); + + const updatedWcif = runRecipe(buildState(wcif), { + roundId: '333-r1', + recipeId: 'pnw', + }).wcif as Competition; + + expect(personById(updatedWcif, 1)?.assignments).toContainEqual(otherRoundAssignment); + expect( + personById(updatedWcif, 1)?.assignments?.filter( + (personAssignment) => personAssignment.activityId === 201 + ) + ).toEqual([otherRoundAssignment]); + expect( + allActivities(updatedWcif).find((activity) => activity.activityCode === '222-r1') + ?.childActivities + ).toEqual(otherRound.childActivities); + }); +}); + +describe('runRecipes', () => { + const buildBulkCompetition = () => { + const persons = [1, 2, 3].map((registrantId) => + competitor(registrantId, { + registration: acceptedRegistrationForEvents(registrantId, [ + '222' as EventId, + '333' as EventId, + ]), + personalBests: [ + personalBest(registrantId, 1000 + registrantId), + { + ...personalBest(registrantId, 900 + registrantId), + eventId: '222' as EventId, + }, + ], + }) + ); + const round222 = buildTimedRoundActivity(2, '222-r1', [ + buildTimedGroup(201, '222-r1-g1', '2024-01-01T10:00:00.000Z', '2024-01-01T10:10:00.000Z'), + ]); + const round333 = buildTimedRoundActivity(1, '333-r1', [ + buildTimedGroup(101, '333-r1-g1', '2024-01-01T10:10:00.000Z', '2024-01-01T10:20:00.000Z'), + buildTimedGroup(102, '333-r1-g2', '2024-01-01T11:00:00.000Z', '2024-01-01T11:10:00.000Z'), + ]); + + return buildWcifWithEvents( + [round333, round222], + [ + buildEvent({ id: '333', rounds: [buildRound({ id: '333-r1' })] }), + buildEvent({ id: '222', rounds: [buildRound({ id: '222-r1' })] }), + ], + persons + ); + }; + + it('runs the same recipe across selected rounds in order with accumulating WCIF context', () => { + const updatedState = runRecipes(buildState(buildBulkCompetition()), { + recipeId: 'pnw', + roundIds: ['222-r1', '333-r1'], + }); + const updatedWcif = updatedState.wcif as Competition; + const person = personById(updatedWcif, 1); + + expect(competitorAssignmentInRound(updatedWcif, person, '222-r1')).toBeDefined(); + expect(competitorAssignmentInRound(updatedWcif, person, '333-r1')).toBeDefined(); + expect(competitorGroupNumberInRound(updatedWcif, person, '333-r1')).toBe(2); + expect(updatedState.needToSave).toBe(true); + expect(Array.from(updatedState.changedKeys).sort()).toEqual(['events', 'persons', 'schedule']); + }); + + it('preserves existing groups and assignments while filling missing round data', () => { + const existingAssignment = assignment(101, 'competitor'); + const wcif = { + ...buildBulkCompetition(), + persons: [ + competitor(1, { + registration: acceptedRegistrationForEvents(1, ['222' as EventId, '333' as EventId]), + assignments: [existingAssignment], + }), + competitor(2, { + registration: acceptedRegistrationForEvents(2, ['222' as EventId, '333' as EventId]), + }), + ], + }; + + const updatedWcif = runRecipes(buildState(wcif), { + recipeId: 'pnw', + roundIds: ['333-r1', '222-r1'], + }).wcif as Competition; + + expect(personById(updatedWcif, 1)?.assignments).toContainEqual(existingAssignment); + expect( + allActivities(updatedWcif).find((activity) => activity.activityCode === '333-r1') + ?.childActivities + ).toHaveLength(2); + expect(competitorAssignmentInRound(updatedWcif, personById(updatedWcif, 2), '333-r1')).toBeDefined(); + }); + + it('stores the selected recipe config on every generated round', () => { + const updatedWcif = runRecipes(buildState(buildBulkCompetition()), { + recipeId: 'mca', + roundIds: ['333-r1', '222-r1'], + }).wcif as Competition; + + expect(getRoundConfigExtensionData(roundById(updatedWcif, '333-r1') as Round)).toMatchObject({ + recipe: { id: 'mca' }, + }); + expect(getRoundConfigExtensionData(roundById(updatedWcif, '222-r1') as Round)).toMatchObject({ + recipe: { id: 'mca' }, + }); + }); +}); diff --git a/src/store/reducers/runRecipe.ts b/src/store/reducers/runRecipe.ts new file mode 100644 index 0000000..2a398ab --- /dev/null +++ b/src/store/reducers/runRecipe.ts @@ -0,0 +1,28 @@ +import { runBulkRecipesOnWcif, runRecipeOnWcif } from '../../lib/recipes/runRecipeOnWcif'; +import { type AppState } from '../initialState'; +import type { RunRecipePayload, RunRecipesPayload } from '../actions'; + +/** + * Run a built-in recipe to generate groups and/or assignments for a round. + */ +export function runRecipe(state: AppState, action: RunRecipePayload): AppState { + if (!state.wcif) return state; + + return { + ...state, + needToSave: true, + changedKeys: new Set([...state.changedKeys, 'schedule', 'persons', 'events']), + wcif: runRecipeOnWcif(state.wcif, action), + }; +} + +export function runRecipes(state: AppState, action: RunRecipesPayload): AppState { + if (!state.wcif) return state; + + return { + ...state, + needToSave: true, + changedKeys: new Set([...state.changedKeys, 'schedule', 'persons', 'events']), + wcif: runBulkRecipesOnWcif(state.wcif, action), + }; +}