diff --git a/src/components/RoundActionButtons.tsx b/src/components/RoundActionButtons.tsx
index 5fb6bc7..ffa49e2 100644
--- a/src/components/RoundActionButtons.tsx
+++ b/src/components/RoundActionButtons.tsx
@@ -1,4 +1,6 @@
+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 { type Person } from '@wca/helpers';
@@ -9,11 +11,14 @@ interface RoundActionButtonsProps {
activityCode: string;
onConfigureAssignments: () => void;
onGenerateAssignments: () => void;
+ onAssignToRoundAttempt: () => void;
+ onResetAttemptAssignments: () => void;
onConfigureStationNumbers: (activityCode: string) => void;
onConfigureGroups: () => void;
onResetAll: () => void;
onResetNonScrambling: () => void;
onConfigureGroupCounts: () => void;
+ isDistributedAttemptRoundLevel: boolean;
}
export const RoundActionButtons = ({
@@ -23,12 +28,52 @@ export const RoundActionButtons = ({
activityCode,
onConfigureAssignments,
onGenerateAssignments,
+ onAssignToRoundAttempt,
+ onResetAttemptAssignments,
onConfigureStationNumbers,
onConfigureGroups,
onResetAll,
onResetNonScrambling,
onConfigureGroupCounts,
+ isDistributedAttemptRoundLevel,
}: RoundActionButtonsProps) => {
+ const { attemptNumber } = parseActivityCode(activityCode);
+ const isAttemptActivity = hasDistributedAttempts(activityCode) && attemptNumber !== undefined;
+
+ if (isDistributedAttemptRoundLevel) {
+ return (
+ <>
+
+
+
+
+ >
+ );
+ }
+
+ if (groups.length === 0 && isAttemptActivity) {
+ if (personsAssignedToCompete.length > 0) {
+ return (
+ <>
+
+
+
+ >
+ );
+ }
+
+ return (
+ <>
+
+
+ >
+ );
+ }
+
if (groups.length === 0) {
return (
<>
diff --git a/src/components/RoundSelector/RoundListItem.tsx b/src/components/RoundSelector/RoundListItem.tsx
index 7203264..388cc3d 100644
--- a/src/components/RoundSelector/RoundListItem.tsx
+++ b/src/components/RoundSelector/RoundListItem.tsx
@@ -21,10 +21,17 @@ interface RoundListItemProps {
activityCode: string;
round: Round;
selected: boolean;
+ nestingLevel?: number;
in?: boolean;
}
-function RoundListItem({ activityCode, round, selected, ...props }: RoundListItemProps) {
+function RoundListItem({
+ activityCode,
+ round,
+ selected,
+ nestingLevel = 0,
+ ...props
+}: RoundListItemProps) {
const ref = useRef(null);
const wcif = useAppSelector((state) => state.wcif);
const realGroups = wcif ? findGroupActivitiesByRound(wcif, activityCode) : [];
@@ -75,6 +82,7 @@ function RoundListItem({ activityCode, round, selected, ...props }: RoundListIte
component={RouterLink}
to={`/competitions/${wcif?.id}/events/${activityCode}`}
selected={selected}
+ sx={{ pl: 2 + nestingLevel * 4 }}
ref={ref}>
diff --git a/src/components/RoundSelector/_tests_/RoundSelector.test.tsx b/src/components/RoundSelector/_tests_/RoundSelector.test.tsx
index 2d6b5b6..73811a5 100644
--- a/src/components/RoundSelector/_tests_/RoundSelector.test.tsx
+++ b/src/components/RoundSelector/_tests_/RoundSelector.test.tsx
@@ -26,8 +26,21 @@ vi.mock('../../../lib/domain/activities', async () => {
});
vi.mock('../RoundListItem', () => ({
- default: ({ activityCode, selected }: { activityCode: string; selected: boolean }) => (
-
+ default: ({
+ activityCode,
+ selected,
+ nestingLevel,
+ }: {
+ activityCode: string;
+ selected: boolean;
+ nestingLevel?: number;
+ }) => (
+
),
}));
@@ -85,4 +98,31 @@ describe('RoundSelector', () => {
expect(toggle).toBeChecked();
});
+
+ it('renders round-level entry plus attempts for distributed-attempt events', () => {
+ const wcif = {
+ id: 'TestComp',
+ events: [
+ {
+ id: '333fm',
+ rounds: [{ id: '333fm-r1', format: '3', results: [], timeLimit: null, cutoff: null }],
+ },
+ ],
+ };
+
+ const state = { wcif } as unknown as AppState;
+ useAppSelector.mockImplementation((selector: (state: AppState) => unknown) => selector(state));
+
+ const { getAllByTestId } = renderWithProviders(
+ undefined} />
+ );
+
+ const activityCodes = getAllByTestId('round-item').map((item) => item.getAttribute('data-code'));
+ const nestingLevels = getAllByTestId('round-item').map((item) =>
+ item.getAttribute('data-nesting')
+ );
+
+ expect(activityCodes).toEqual(['333fm-r1', '333fm-r1-a1', '333fm-r1-a2', '333fm-r1-a3']);
+ expect(nestingLevels).toEqual(['0', '1', '1', '1']);
+ });
});
diff --git a/src/components/RoundSelector/index.tsx b/src/components/RoundSelector/index.tsx
index e5e9efa..dc9c80a 100644
--- a/src/components/RoundSelector/index.tsx
+++ b/src/components/RoundSelector/index.tsx
@@ -27,6 +27,8 @@ const RoundSelector = ({ onSelected }: RoundSelectorProps) => {
const [showAllRounds, setShowAllRounds] = useState(false);
const [selectedId, setSelectedId] = useState(wcif?.events[0]?.rounds[0]?.id || null);
+ const attemptCountForRound = (round: Round) => (round.format === 'm' ? 3 : +round.format);
+
const shouldShowRound = (round: Round) => {
if (!wcif) return false;
@@ -54,13 +56,18 @@ const RoundSelector = ({ onSelected }: RoundSelectorProps) => {
.filter(shouldShowRound)
: [];
- const roundIds = rounds.flatMap((r) =>
- hasDistributedAttempts(r.id)
- ? new Array(r.format === 'm' ? 3 : +r.format)
- .fill(0)
- .map((_, index) => `${r.id}-a${index + 1}`)
- : r.id
- );
+ const roundIds = rounds.flatMap((r) => {
+ if (!hasDistributedAttempts(r.id)) {
+ return r.id;
+ }
+
+ return [
+ r.id,
+ ...new Array(attemptCountForRound(r))
+ .fill(0)
+ .map((_, index) => `${r.id}-a${index + 1}`),
+ ];
+ });
const handleKeyDown = (e: KeyboardEvent) => {
if (commandPromptOpen) {
@@ -124,19 +131,30 @@ const RoundSelector = ({ onSelected }: RoundSelectorProps) => {
{!hasDistributedAttempts(event.id)
? roundsForEvent.map((round) => (
-
+
))
: roundsForEvent.flatMap((round) => {
- const attempts = new Array(round.format === 'm' ? 3 : +round.format) // TODO: create helper function to calculate attempts
- .fill(0);
-
- return attempts.map((_, index) => {
+ const attempts = new Array(attemptCountForRound(round)).fill(0);
+
+ const roundListItem = (
+
+ );
+
+ const attemptListItems = attempts.map((_, index) => {
const attemptActivityCode = `${round.id}-a${index + 1}`;
return (
@@ -144,11 +162,14 @@ const RoundSelector = ({ onSelected }: RoundSelectorProps) => {
key={attemptActivityCode}
activityCode={attemptActivityCode}
round={round}
+ nestingLevel={1}
selected={attemptActivityCode === selectedId}
in
/>
);
});
+
+ return [roundListItem, ...attemptListItems];
})}
diff --git a/src/components/_tests_/RoundActionButtons.test.tsx b/src/components/_tests_/RoundActionButtons.test.tsx
new file mode 100644
index 0000000..18963fb
--- /dev/null
+++ b/src/components/_tests_/RoundActionButtons.test.tsx
@@ -0,0 +1,32 @@
+import { RoundActionButtons } from '../RoundActionButtons';
+import { renderWithProviders } from '../../test-utils';
+import { describe, expect, it, vi } from 'vitest';
+
+const baseProps = {
+ groups: [],
+ personsAssignedToCompete: [],
+ personsShouldBeInRound: [],
+ activityCode: '333fm-r1',
+ onConfigureAssignments: vi.fn(),
+ onGenerateAssignments: vi.fn(),
+ onAssignToRoundAttempt: vi.fn(),
+ onResetAttemptAssignments: vi.fn(),
+ onConfigureStationNumbers: vi.fn(),
+ onConfigureGroups: vi.fn(),
+ onResetAll: vi.fn(),
+ onResetNonScrambling: vi.fn(),
+ onConfigureGroupCounts: vi.fn(),
+};
+
+describe('RoundActionButtons', () => {
+ it('shows distributed round-level attempt actions', () => {
+ const { getByText, queryByText } = renderWithProviders(
+
+ );
+
+ expect(getByText('Configure Round Attempt Assignments')).toBeInTheDocument();
+ expect(getByText('Assign All')).toBeInTheDocument();
+ expect(getByText('Clear Round Attempt Assignments')).toBeInTheDocument();
+ expect(queryByText('Configure Group Counts')).not.toBeInTheDocument();
+ });
+});
diff --git a/src/dialogs/ConfigureAssignmentsDialog/ConfigureAssignmentsDialog.test.tsx b/src/dialogs/ConfigureAssignmentsDialog/ConfigureAssignmentsDialog.test.tsx
new file mode 100644
index 0000000..4b06eab
--- /dev/null
+++ b/src/dialogs/ConfigureAssignmentsDialog/ConfigureAssignmentsDialog.test.tsx
@@ -0,0 +1,189 @@
+import ConfigureAssignmentsDialog from './ConfigureAssignmentsDialog';
+import { buildActivity, buildEvent, buildPerson, buildRound } from '../../store/reducers/_tests_/helpers';
+import INITIAL_STATE, { type AppState } from '../../store/initialState';
+import reducer from '../../store/reducer';
+import { renderWithProviders } from '../../test-utils';
+import { fireEvent, screen } from '@testing-library/react';
+import type { Activity, Competition, Person, Round } from '@wca/helpers';
+import { ConfirmProvider } from 'material-ui-confirm';
+import type { ReactElement } from 'react';
+import { Provider } from 'react-redux';
+import { applyMiddleware, createStore, type AnyAction, type Reducer } from 'redux';
+import { thunk } from 'redux-thunk';
+import { describe, expect, it, vi } from 'vitest';
+
+const room = {
+ id: 10,
+ name: 'Main Room',
+ color: '#000',
+ extensions: [],
+ activities: [] as Activity[],
+};
+
+const roundActivity = {
+ ...buildActivity({
+ id: 100,
+ name: 'FMC Round 1',
+ activityCode: '333fm-r1',
+ }),
+ room,
+};
+
+const attemptOneActivity = {
+ ...buildActivity({
+ id: 101,
+ name: 'FMC Attempt 1',
+ activityCode: '333fm-r1-a1',
+ }),
+ room,
+ parent: roundActivity,
+};
+
+const attemptTwoActivity = {
+ ...buildActivity({
+ id: 102,
+ name: 'FMC Attempt 2',
+ activityCode: '333fm-r1-a2',
+ }),
+ room,
+ parent: roundActivity,
+};
+
+const round = buildRound({
+ id: '333fm-r1',
+ format: '3',
+});
+
+const person = buildPerson({
+ name: 'Alice Example',
+ registrantId: 1,
+ wcaUserId: 1,
+ registration: {
+ status: 'accepted',
+ eventIds: ['333fm'],
+ isCompeting: true,
+ comments: undefined,
+ wcaRegistrationId: 1,
+ },
+});
+
+const buildWcif = (persons: Person[]): Competition => ({
+ ...(INITIAL_STATE.wcif as Competition),
+ id: 'test-competition',
+ name: 'Test Competition',
+ shortName: 'Test',
+ formatVersion: '1.0',
+ persons,
+ events: [
+ buildEvent({
+ id: '333fm',
+ rounds: [round],
+ }),
+ ],
+ schedule: {
+ startDate: '2024-01-01',
+ numberOfDays: 1,
+ venues: [
+ {
+ id: 1,
+ name: 'Venue',
+ latitudeMicrodegrees: 0,
+ longitudeMicrodegrees: 0,
+ countryIso2: 'US',
+ timezone: 'America/New_York',
+ extensions: [],
+ rooms: [
+ {
+ ...room,
+ activities: [
+ {
+ ...roundActivity,
+ childActivities: [attemptOneActivity, attemptTwoActivity],
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+});
+
+const buildState = (persons: Person[] = [person]): AppState => ({
+ ...INITIAL_STATE,
+ wcif: buildWcif(persons),
+ changedKeys: new Set(),
+});
+
+const renderWithStore = (ui: ReactElement, state = buildState()) => {
+ const store = createStore(
+ reducer as Reducer,
+ state,
+ applyMiddleware(thunk)
+ );
+
+ return {
+ store,
+ ...renderWithProviders(
+
+ {ui}
+
+ ),
+ };
+};
+
+const renderDistributedDialog = (state = buildState()) =>
+ renderWithStore(
+ ,
+ state
+ );
+
+describe('ConfigureAssignmentsDialog distributed attempts', () => {
+ it('uses the normal assignments toolbar instead of per-attempt action buttons', () => {
+ renderDistributedDialog();
+
+ expect(screen.getByText('Assignment')).toBeInTheDocument();
+ expect(screen.getByText('Sort')).toBeInTheDocument();
+ expect(screen.getByText('Show All Competitors')).toBeInTheDocument();
+ expect(screen.getByText('Show Competitors Not In Round')).toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: 'Assign All' })).not.toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: 'Clear' })).not.toBeInTheDocument();
+ });
+
+ it('paints distributed attempt cells with the selected assignment type', () => {
+ const { baseElement, store } = renderDistributedDialog();
+
+ fireEvent.keyDown(window, { key: 'j' });
+
+ const firstAttemptCell = baseElement.querySelector('tbody tr td:nth-child(6)');
+ expect(firstAttemptCell).toBeInTheDocument();
+ if (!firstAttemptCell) {
+ throw new Error('Expected the first distributed attempt assignment cell to render');
+ }
+
+ fireEvent.mouseDown(firstAttemptCell);
+
+ expect(firstAttemptCell).toHaveTextContent('J');
+ expect(store.getState().wcif?.persons[0].assignments).toContainEqual({
+ activityId: 101,
+ assignmentCode: 'staff-judge',
+ stationNumber: null,
+ });
+ });
+});
diff --git a/src/dialogs/ConfigureAssignmentsDialog/ConfigureAssignmentsDialog.tsx b/src/dialogs/ConfigureAssignmentsDialog/ConfigureAssignmentsDialog.tsx
index 3f10766..d140f17 100644
--- a/src/dialogs/ConfigureAssignmentsDialog/ConfigureAssignmentsDialog.tsx
+++ b/src/dialogs/ConfigureAssignmentsDialog/ConfigureAssignmentsDialog.tsx
@@ -1,6 +1,7 @@
import Assignments from '../../config/assignments';
-import { parseActivityCode, activityCodeToName } from '../../lib/domain/activities';
-import type { ActivityWithParent } from '../../lib/domain/activities';
+import TableAssignmentCell from '../../components/TableAssignmentCell';
+import { activityCodeToName, parseActivityCode } from '../../lib/domain/activities';
+import type { ActivityWithParent, ActivityWithRoom } from '../../lib/domain/activities';
import { useAppDispatch, useAppSelector } from '../../store';
import { selectWcifRooms } from '../../store/selectors';
import AssignmentsTableHeader from './AssignmentsTableHeader';
@@ -19,6 +20,9 @@ import {
DialogTitle,
Table,
TableBody,
+ TableCell,
+ TableHead,
+ TableRow,
Typography,
useMediaQuery,
} from '@mui/material';
@@ -33,12 +37,19 @@ const ConfigureAssignmentsDialog = ({
round,
activityCode,
groups,
+ isDistributedAttemptRoundLevel,
+ distributedAttemptGroups,
}: {
open: boolean;
onClose: () => void;
round: Round;
activityCode: string;
groups: ActivityWithParent[];
+ isDistributedAttemptRoundLevel: boolean;
+ distributedAttemptGroups: Array<{
+ attemptNumber: number;
+ activities: ActivityWithRoom[];
+ }>;
}) => {
const wcif = useAppSelector((state) => state.wcif);
const { eventId, roundNumber } = parseActivityCode(activityCode) as {
@@ -52,7 +63,7 @@ const ConfigureAssignmentsDialog = ({
const theme = useTheme();
const fullScreen = useMediaQuery(theme.breakpoints.down('md'));
- const [showAllCompetitors, setShowAllCompetitors] = useState(false);
+ const [showAllCompetitors, setShowAllCompetitors] = useState(isDistributedAttemptRoundLevel);
const [paintingAssignmentCode, setPaintingAssignmentCode] = useState('staff-scrambler');
const [competitorSort, setCompetitorSort] = useState('speed');
const [showCompetitorsNotInRound, setShowCompetitorsNotInRound] = useState(false);
@@ -123,41 +134,130 @@ const ConfigureAssignmentsDialog = ({
Configuring Assignments For {activityCodeToName(activityCode)}
-
-
-
-
-
- {persons?.map((person) => (
-
+
+
+
+
+ #
+ Name
+ Age
+ Seed Result
+ Registered
+ {distributedAttemptGroups.map((attemptGroup) => (
+
+ Attempt {attemptGroup.attemptNumber}
+
+ ))}
+ Stream
+ Total Staff Assignments
+
+
+
+
+
+
+
+ {distributedAttemptGroups.flatMap((attemptGroup) =>
+ attemptGroup.activities.map((activity) => {
+ const roomName = activity.room.name;
+ return (
+
+ {roomName}
+
+ );
+ })
+ )}
+
+
+
+
+
+ {persons?.map((person) => (
+ undefined}
+ handleUpdateAssignmentForPerson={() => () => undefined}
+ toggleFeaturedCompetitor={toggleFeaturedCompetitor}
+ additionalAssignmentCells={distributedAttemptGroups.flatMap((attemptGroup) =>
+ attemptGroup.activities.map((activity) => (
+
+ ))
+ )}
+ />
+ ))}
+
+
+ >
+ ) : (
+ <>
+
+
+
+
+ {persons?.map((person) => (
+
+ ))}
+
+
+ >
+ )}
diff --git a/src/dialogs/ConfigureAssignmentsDialog/PersonAssignmentRow.tsx b/src/dialogs/ConfigureAssignmentsDialog/PersonAssignmentRow.tsx
index a517605..e8ddd5b 100644
--- a/src/dialogs/ConfigureAssignmentsDialog/PersonAssignmentRow.tsx
+++ b/src/dialogs/ConfigureAssignmentsDialog/PersonAssignmentRow.tsx
@@ -10,6 +10,7 @@ import CheckIcon from '@mui/icons-material/Check';
import { Checkbox, TableCell, TableRow, Tooltip } from '@mui/material';
import { grey, red, yellow } from '@mui/material/colors';
import type { EventId, Person, Round } from '@wca/helpers';
+import type { ReactNode } from 'react';
interface PersonAssignmentRowProps {
person: PersonWithSeedResult;
@@ -21,6 +22,7 @@ interface PersonAssignmentRowProps {
getAssignmentCodeForPersonGroup: (registrantId: number, activityId: number) => string | undefined;
handleUpdateAssignmentForPerson: (registrantId: number, activityId: number) => () => void;
toggleFeaturedCompetitor: (person: Person) => void;
+ additionalAssignmentCells?: ReactNode;
}
const PersonAssignmentRow = ({
@@ -33,6 +35,7 @@ const PersonAssignmentRow = ({
getAssignmentCodeForPersonGroup,
handleUpdateAssignmentForPerson,
toggleFeaturedCompetitor,
+ additionalAssignmentCells,
}: PersonAssignmentRowProps) => {
const roundFormat = roundFormatById(round.format)?.rankingResult || 'single';
@@ -101,6 +104,7 @@ const PersonAssignmentRow = ({
/>
))
)}
+ {additionalAssignmentCells}
toggleFeaturedCompetitor(person)} />
diff --git a/src/lib/assignmentGenerators/generateAssignmentsForRoundAttempt.test.ts b/src/lib/assignmentGenerators/generateAssignmentsForRoundAttempt.test.ts
new file mode 100644
index 0000000..8965c94
--- /dev/null
+++ b/src/lib/assignmentGenerators/generateAssignmentsForRoundAttempt.test.ts
@@ -0,0 +1,215 @@
+import { generateAssignmentsForRoundAttempt } from './generateAssignmentsForRoundAttempt';
+import type { Assignment } from '@wca/helpers';
+import { describe, expect, it } from 'vitest';
+import {
+ buildActivity,
+ buildEvent,
+ buildPerson,
+ buildRound,
+ buildWcifWithEvents,
+} from '../../store/reducers/_tests_/helpers';
+
+const buildAttemptActivity = (id: number, attemptNumber: number) =>
+ buildActivity({
+ id,
+ name: `Attempt ${attemptNumber}`,
+ activityCode: `333fm-r1-a${attemptNumber}`,
+ startTime: '2024-01-01T10:00:00Z',
+ endTime: '2024-01-01T11:00:00Z',
+ });
+
+const buildCompetition = (
+ attemptActivities: ReturnType[],
+ persons: ReturnType[]
+) => {
+ const round = buildRound({ id: '333fm-r1', format: '3' });
+ const event = buildEvent({ id: '333fm', rounds: [round] });
+ const roundActivity = buildActivity({
+ id: 1,
+ name: '3x3 Fewest Moves Round 1',
+ activityCode: '333fm-r1',
+ childActivities: attemptActivities,
+ });
+
+ return buildWcifWithEvents([roundActivity], [event], persons);
+};
+
+const competitor = (registrantId: number, assignments: Assignment[] = []) =>
+ buildPerson({
+ registrantId,
+ name: `Competitor ${registrantId}`,
+ wcaUserId: registrantId,
+ assignments,
+ registration: {
+ status: 'accepted',
+ eventIds: ['333fm'],
+ isCompeting: true,
+ comments: undefined,
+ wcaRegistrationId: registrantId,
+ },
+ });
+
+describe('generateAssignmentsForRoundAttempt', () => {
+ it('assigns all competitors directly to the attempt activity', () => {
+ const attemptActivities = [buildAttemptActivity(10, 1)];
+ const persons = [competitor(1), competitor(2), competitor(3)];
+ const wcif = buildCompetition(attemptActivities, persons);
+ const generator = generateAssignmentsForRoundAttempt(wcif, '333fm-r1-a1');
+
+ const assignments = generator ? generator([]) : [];
+
+ expect(assignments).toHaveLength(3);
+ expect(assignments.every((a) => a.assignment.activityId === 10)).toBe(true);
+ expect(assignments.every((a) => a.assignment.assignmentCode === 'competitor')).toBe(true);
+ expect(assignments.map((a) => a.registrantId).sort()).toEqual([1, 2, 3]);
+ });
+
+ it('distributes competitors across multiple attempt activities (multi-room)', () => {
+ // Two rooms, each with attempt 1
+ const round = buildRound({ id: '333fm-r1', format: '3' });
+ const event = buildEvent({ id: '333fm', rounds: [round] });
+ const attempt1Room1 = buildAttemptActivity(10, 1);
+ const attempt1Room2 = buildAttemptActivity(11, 1);
+ const roundActivity1 = buildActivity({
+ id: 1,
+ name: '3x3 Fewest Moves Round 1',
+ activityCode: '333fm-r1',
+ childActivities: [attempt1Room1],
+ });
+ const roundActivity2 = buildActivity({
+ id: 2,
+ name: '3x3 Fewest Moves Round 1',
+ activityCode: '333fm-r1',
+ childActivities: [attempt1Room2],
+ });
+ const persons = [competitor(1), competitor(2), competitor(3), competitor(4)];
+
+ const wcif = {
+ ...buildWcifWithEvents([roundActivity1], [event], persons),
+ schedule: {
+ startDate: '2024-01-01',
+ numberOfDays: 1,
+ venues: [
+ {
+ id: 1,
+ name: 'Main Venue',
+ latitudeMicrodegrees: 0,
+ longitudeMicrodegrees: 0,
+ countryIso2: 'US',
+ timezone: 'America/New_York',
+ extensions: [],
+ rooms: [
+ {
+ id: 10,
+ name: 'Room A',
+ color: '#000',
+ extensions: [],
+ activities: [roundActivity1],
+ },
+ {
+ id: 11,
+ name: 'Room B',
+ color: '#000',
+ extensions: [],
+ activities: [roundActivity2],
+ },
+ ],
+ },
+ ],
+ },
+ };
+
+ const generator = generateAssignmentsForRoundAttempt(wcif, '333fm-r1-a1');
+ const assignments = generator ? generator([]) : [];
+
+ expect(assignments).toHaveLength(4);
+ // Should be distributed: 2 to each room's attempt activity
+ const countRoom1 = assignments.filter((a) => a.assignment.activityId === 10).length;
+ const countRoom2 = assignments.filter((a) => a.assignment.activityId === 11).length;
+ expect(countRoom1).toBe(2);
+ expect(countRoom2).toBe(2);
+ });
+
+ it('skips competitors who already have a competitor assignment to the attempt', () => {
+ const attemptActivities = [buildAttemptActivity(10, 1)];
+ const persons = [
+ competitor(1),
+ competitor(2),
+ competitor(3, [{ activityId: 10, assignmentCode: 'competitor', stationNumber: null }]),
+ ];
+ const wcif = buildCompetition(attemptActivities, persons);
+ const generator = generateAssignmentsForRoundAttempt(wcif, '333fm-r1-a1');
+
+ const assignments = generator ? generator([]) : [];
+
+ expect(assignments).toHaveLength(2);
+ expect(assignments.map((a) => a.registrantId).sort()).toEqual([1, 2]);
+ });
+
+ it('returns undefined when the round is missing', () => {
+ const round = buildRound({ id: '333fm-r2', format: '3' });
+ const event = buildEvent({ id: '333fm', rounds: [round] });
+ const attemptActivity = buildAttemptActivity(10, 1);
+ const roundActivity = buildActivity({
+ id: 1,
+ activityCode: '333fm-r2',
+ childActivities: [attemptActivity],
+ });
+ const wcif = buildWcifWithEvents([roundActivity], [event], [competitor(1)]);
+
+ const generator = generateAssignmentsForRoundAttempt(wcif, '333fm-r1-a1');
+
+ expect(generator).toBeUndefined();
+ });
+
+ it('returns undefined when no attempt activities are found in the schedule', () => {
+ // The round exists but there are no attempt activities in the schedule
+ const round = buildRound({ id: '333fm-r1', format: '3' });
+ const event = buildEvent({ id: '333fm', rounds: [round] });
+ const roundActivity = buildActivity({
+ id: 1,
+ activityCode: '333fm-r1',
+ childActivities: [],
+ });
+ const wcif = buildWcifWithEvents([roundActivity], [event], [competitor(1)]);
+
+ const generator = generateAssignmentsForRoundAttempt(wcif, '333fm-r1-a1');
+
+ expect(generator).toBeUndefined();
+ });
+
+ it('works for 333mbf', () => {
+ const round = buildRound({ id: '333mbf-r1', format: '3' });
+ const event = buildEvent({ id: '333mbf', rounds: [round] });
+ const attemptActivity = buildActivity({
+ id: 20,
+ activityCode: '333mbf-r1-a1',
+ name: 'Multi-Blind Round 1 Attempt 1',
+ });
+ const roundActivity = buildActivity({
+ id: 1,
+ activityCode: '333mbf-r1',
+ childActivities: [attemptActivity],
+ });
+ const persons = [
+ buildPerson({
+ registrantId: 1,
+ registration: {
+ status: 'accepted',
+ eventIds: ['333mbf'],
+ isCompeting: true,
+ comments: undefined,
+ wcaRegistrationId: 1,
+ },
+ }),
+ ];
+ const wcif = buildWcifWithEvents([roundActivity], [event], persons);
+
+ const generator = generateAssignmentsForRoundAttempt(wcif, '333mbf-r1-a1');
+ const assignments = generator ? generator([]) : [];
+
+ expect(assignments).toHaveLength(1);
+ expect(assignments[0].assignment.activityId).toBe(20);
+ expect(assignments[0].assignment.assignmentCode).toBe('competitor');
+ });
+});
diff --git a/src/lib/assignmentGenerators/generateAssignmentsForRoundAttempt.ts b/src/lib/assignmentGenerators/generateAssignmentsForRoundAttempt.ts
new file mode 100644
index 0000000..735997f
--- /dev/null
+++ b/src/lib/assignmentGenerators/generateAssignmentsForRoundAttempt.ts
@@ -0,0 +1,92 @@
+import { byPROrResult, findRoundActivitiesById, parseActivityCode } from '../domain';
+import { isCompetitorAssignment, missingCompetitorAssignments } from '../domain';
+import { personsShouldBeInRound } from '../domain';
+import { type InProgressAssignmment } from '../types';
+import { byName } from '../utils';
+import { createGroupAssignment } from '../wcif';
+import { type Competition } from '@wca/helpers';
+
+/**
+ * Assigns all persons in a round directly to the attempt-level activity,
+ * without creating sub-groups. Used for 333fm and 333mbf events where
+ * Groupifier expects competitor assignments at the attempt level
+ * (e.g., 333fm-r1-a1) rather than at a sub-group level (e.g., 333fm-r1-a1-g1).
+ */
+export const generateAssignmentsForRoundAttempt = (
+ wcif: Competition,
+ attemptActivityCode: string
+) => {
+ const { eventId, roundNumber } = parseActivityCode(attemptActivityCode);
+ if (!eventId || !roundNumber) {
+ console.error('Unable to parse attempt activity code:', attemptActivityCode);
+ return;
+ }
+
+ const roundId = `${eventId}-r${roundNumber}`;
+
+ const event = wcif.events.find((e) => e.id === eventId);
+ const round = event?.rounds?.find((r) => r.id === roundId);
+
+ if (!event || !round) {
+ console.error('Unable to find event or round for attempt activity code:', attemptActivityCode);
+ return;
+ }
+
+ const attemptActivities = findRoundActivitiesById(wcif, attemptActivityCode);
+ const attemptActivityIds = attemptActivities.map((a) => a.id);
+
+ if (!attemptActivityIds.length) {
+ console.error(
+ 'No attempt activities found in schedule for attempt activity code:',
+ attemptActivityCode
+ );
+ return;
+ }
+
+ const allExistingCompetitorAssignments: Record = {};
+ for (const id of attemptActivityIds) {
+ allExistingCompetitorAssignments[id] = 0;
+ }
+
+ const nextAttemptActivityId = (assignments: InProgressAssignmment[]): number => {
+ const counts: Record = assignments
+ .filter(
+ (a) =>
+ isCompetitorAssignment(a.assignment) &&
+ attemptActivityIds.includes(a.assignment.activityId)
+ )
+ .reduce(
+ (sizes, { assignment }) => ({
+ ...sizes,
+ [assignment.activityId]: (sizes[assignment.activityId] || 0) + 1,
+ }),
+ { ...allExistingCompetitorAssignments }
+ );
+
+ const sorted = Object.keys(counts)
+ .map((id) => ({ id, size: counts[+id] }))
+ .sort((a, b) => +a.id - +b.id)
+ .sort((a, b) => a.size - b.size);
+
+ return +sorted[0].id;
+ };
+
+ return (assignments: InProgressAssignmment[]): InProgressAssignmment[] => {
+ const persons = personsShouldBeInRound(round)(wcif.persons)
+ .filter(missingCompetitorAssignments({ assignments, groupIds: attemptActivityIds }))
+ .sort(byName)
+ .sort(byPROrResult(event, roundNumber));
+
+ return persons.reduce(
+ (acc, person) => [
+ ...acc,
+ createGroupAssignment(
+ person.registrantId,
+ nextAttemptActivityId([...acc, ...assignments]),
+ 'competitor'
+ ),
+ ],
+ [] as InProgressAssignmment[]
+ );
+ };
+};
diff --git a/src/lib/assignmentGenerators/index.ts b/src/lib/assignmentGenerators/index.ts
index fdd83b6..517a210 100644
--- a/src/lib/assignmentGenerators/index.ts
+++ b/src/lib/assignmentGenerators/index.ts
@@ -1,3 +1,4 @@
+export * from './generateAssignmentsForRoundAttempt';
export * from './generateCompetingAssignmentsForStaff';
export * from './generateCompetingGroupActitivitesForEveryone';
export * from './generateGroupAssignmentsForDelegatesAndOrganizers';
diff --git a/src/lib/wcif/validation/eventRoundValidation.ts b/src/lib/wcif/validation/eventRoundValidation.ts
index 10f2572..e7a12a6 100644
--- a/src/lib/wcif/validation/eventRoundValidation.ts
+++ b/src/lib/wcif/validation/eventRoundValidation.ts
@@ -1,4 +1,4 @@
-import { activityCodeToName, findAllRoundActivities } from '../../domain';
+import { activityCodeToName, findAllRoundActivities, hasDistributedAttempts, activityCodeIsChild } from '../../domain';
import {
MISSING_ADVANCEMENT_CONDITION,
NO_ROUNDS_FOR_ACTIVITY,
@@ -56,7 +56,11 @@ export const validateRoundsHaveScheduleActivities = (
const allRoundActivities = findAllRoundActivities(wcif);
return flatMap(event.rounds, (round) => {
- const hasScheduleActivity = allRoundActivities.some((activity) => activity.activityCode === round.id);
+ // For distributed attempt events (333fm, 333mbf), check if there are any child activities
+ // (e.g., 333mbf-r1-a1) instead of looking for exact match (333mbf-r1)
+ const hasScheduleActivity = hasDistributedAttempts(round.id)
+ ? allRoundActivities.some((activity) => activityCodeIsChild(round.id, activity.activityCode))
+ : allRoundActivities.some((activity) => activity.activityCode === round.id);
return hasScheduleActivity
? []
diff --git a/src/pages/Competition/Round/DistributedAttemptRoundView.tsx b/src/pages/Competition/Round/DistributedAttemptRoundView.tsx
new file mode 100644
index 0000000..7280c1f
--- /dev/null
+++ b/src/pages/Competition/Round/DistributedAttemptRoundView.tsx
@@ -0,0 +1,230 @@
+import { Alert, Typography, Card, CardHeader, CardActions, Divider, List, ListItemButton, ListSubheader, Table, TableBody, TableCell, TableHead, TableRow } from '@mui/material';
+import Grid from '@mui/material/GridLegacy';
+import { type Round } from '@wca/helpers';
+import { type ActivityWithRoom } from '../../../lib/domain/types';
+import { type AppState } from '../../../store/initialState';
+import { type Person } from '@wca/helpers';
+import { activityCodeToName } from '../../../lib/domain/activities';
+import { formatTimeRange } from '../../../lib/utils/time';
+import { byName } from '../../../lib/utils/utils';
+import { cumulativeGroupCount } from '../../../lib/wcif/groups';
+import { RoundLimitInfo } from '../../../components/RoundLimitInfo';
+import ActionMenu from '../../../components/ActionMenu';
+
+interface DistributedAttemptRoundViewProps {
+ activityCode: string;
+ round: Round;
+ eventId: string;
+ personsShouldBeInRound: Person[];
+ personsAssigned: Person[];
+ personsAssignedWithCompetitorAssignmentCount: number;
+ wcif: AppState['wcif'];
+ onOpenRawRoundData: () => void;
+ onOpenRawActivitiesData: () => void;
+ onOpenPersonsDialog: (title: string, persons: Person[]) => void;
+ onOpenPersonsAssignmentsDialog: () => void;
+ actionButtons: React.ReactNode;
+ adamRoundConfig: {
+ groupCount?: number;
+ expectedRegistrations?: number;
+ } | null;
+ distributedAttemptGroups: Array<{
+ attemptNumber: number;
+ activities: ActivityWithRoom[];
+ }>;
+}
+
+/**
+ * View component for rounds with distributed attempts (333fm, 333mbf).
+ * These events require round-level competitor assignments rather than group-level assignments.
+ */
+const DistributedAttemptRoundView = ({
+ activityCode,
+ round,
+ eventId,
+ personsShouldBeInRound,
+ personsAssigned,
+ personsAssignedWithCompetitorAssignmentCount,
+ wcif,
+ onOpenRawRoundData,
+ onOpenRawActivitiesData,
+ onOpenPersonsDialog,
+ onOpenPersonsAssignmentsDialog,
+ actionButtons,
+ adamRoundConfig,
+ distributedAttemptGroups,
+}: DistributedAttemptRoundViewProps) => {
+ const pluralizeWord = (count: number, singular: string, plural?: string) =>
+ count === 1 ? singular : plural || singular + 's';
+
+ // Check if there are multiple stages
+ const uniqueStages = new Set(
+ distributedAttemptGroups.flatMap(({ activities }) =>
+ activities.map((activity) => activity.room.name)
+ )
+ );
+ const hasMultipleStages = uniqueStages.size > 1;
+
+ return (
+
+
+ {adamRoundConfig && (
+
+ The delegate team strongly recommends {adamRoundConfig.groupCount}{' '}
+ {pluralizeWord(adamRoundConfig.groupCount || 0, 'group', 'groups')} for this round. This
+ was based on an estimated number of competitors for this round of{' '}
+ {adamRoundConfig.expectedRegistrations}. Discuss with the delegates before
+ deviating from this number.
+
+ )}
+
+
+
+
+
+ }
+ />
+ Attempts}>
+ {distributedAttemptGroups.map(({ attemptNumber, activities }) => {
+ // For each attempt, show all activities (potentially across multiple stages)
+ if (hasMultipleStages) {
+ // Show stage info for each activity
+ return activities.map((activity) => {
+ const roomName = activity.room.name;
+
+ return (
+
+ Attempt {attemptNumber} - {roomName}:{' '}
+ {new Date(activity.startTime).toLocaleDateString()}{' '}
+ {formatTimeRange(activity.startTime, activity.endTime)} (
+ {(new Date(activity.endTime).getTime() - new Date(activity.startTime).getTime()) /
+ 1000 /
+ 60}{' '}
+ Minutes)
+
+ );
+ });
+ } else {
+ // Single stage - just show the attempt with timing
+ const firstActivity = activities[0];
+ if (!firstActivity) return null;
+
+ return (
+
+ Attempt {attemptNumber}: {new Date(firstActivity.startTime).toLocaleDateString()}{' '}
+ {formatTimeRange(firstActivity.startTime, firstActivity.endTime)} (
+ {(new Date(firstActivity.endTime).getTime() -
+ new Date(firstActivity.startTime).getTime()) /
+ 1000 /
+ 60}{' '}
+ Minutes)
+
+ );
+ }
+ })}
+
+
+
+
+
+
+ Round Size
+
+ Persons In Round
+
+ Based on WCA-Live data
+
+ Competitors assigned
+ Persons with any assignment
+
+ Groups Configured
+ (per stage)
+
+
+
+
+
+
+ onOpenPersonsDialog(
+ 'People who should be in the round',
+ personsShouldBeInRound?.sort(byName) || []
+ )
+ }>
+ {personsShouldBeInRound?.length || '???'}
+
+
+ onOpenPersonsDialog(
+ 'People in the round according to wca-live',
+ round.results.length > 0
+ ? round.results
+ .map(({ personId }) =>
+ wcif?.persons.find(({ registrantId }) => registrantId === personId)
+ )
+ .filter((p): p is Person => p !== undefined)
+ .sort(byName) || []
+ : []
+ )
+ }>
+ {round?.results?.length}
+
+
+ {personsAssignedWithCompetitorAssignmentCount}
+
+
+ {personsAssigned.length}
+
+ {cumulativeGroupCount(round)}
+
+
+
+
+
+
+ {actionButtons}
+
+
+
+ {personsShouldBeInRound.length === 0 && (
+
+
+
+ No one in round to automatically assign. Make sure the next round is opened on WCA-Live
+ to generate assignments
+
+
+
+ )}
+
+ );
+};
+
+export default DistributedAttemptRoundView;
diff --git a/src/pages/Competition/Round/NormalRoundView.tsx b/src/pages/Competition/Round/NormalRoundView.tsx
new file mode 100644
index 0000000..c57f84c
--- /dev/null
+++ b/src/pages/Competition/Round/NormalRoundView.tsx
@@ -0,0 +1,107 @@
+import { Alert, Typography } from '@mui/material';
+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 { type AppState } from '../../../store/initialState';
+import { type Person } from '@wca/helpers';
+
+interface NormalRoundViewProps {
+ activityCode: string;
+ roundActivities: ActivityWithRoom[];
+ round: Round;
+ eventId: string;
+ personsShouldBeInRound: Person[];
+ personsAssigned: Person[];
+ personsAssignedWithCompetitorAssignmentCount: number;
+ wcif: AppState['wcif'];
+ onOpenRawRoundData: () => void;
+ onOpenRawActivitiesData: () => void;
+ onOpenPersonsDialog: (title: string, persons: Person[]) => void;
+ onOpenPersonsAssignmentsDialog: () => void;
+ actionButtons: React.ReactNode;
+ adamRoundConfig: {
+ groupCount?: number;
+ expectedRegistrations?: number;
+ } | null;
+ sortedGroups: Activity[];
+}
+
+/**
+ * View component for regular rounds that use group activities.
+ * Allows creating groups and making assignments at the group level.
+ */
+const NormalRoundView = ({
+ activityCode,
+ roundActivities,
+ round,
+ eventId,
+ personsShouldBeInRound,
+ personsAssigned,
+ personsAssignedWithCompetitorAssignmentCount,
+ wcif,
+ onOpenRawRoundData,
+ onOpenRawActivitiesData,
+ onOpenPersonsDialog,
+ onOpenPersonsAssignmentsDialog,
+ actionButtons,
+ adamRoundConfig,
+ sortedGroups,
+}: NormalRoundViewProps) => {
+ const pluralizeWord = (count: number, singular: string, plural?: string) =>
+ count === 1 ? singular : plural || singular + 's';
+
+ return (
+
+
+ {adamRoundConfig && (
+
+ The delegate team strongly recommends {adamRoundConfig.groupCount}{' '}
+ {pluralizeWord(adamRoundConfig.groupCount || 0, 'group', 'groups')} for this round. This
+ was based on an estimated number of competitors for this round of{' '}
+ {adamRoundConfig.expectedRegistrations}. Discuss with the delegates before
+ deviating from this number.
+
+ )}
+
+
+
+
+
+
+ {personsShouldBeInRound.length === 0 && (
+
+
+
+ No one in round to automatically assign. Make sure the next round is opened on WCA-Live
+ to generate assignments
+
+
+
+ )}
+
+
+ {sortedGroups.map((group) => (
+
+ ))}
+
+
+ );
+};
+
+export default NormalRoundView;
diff --git a/src/pages/Competition/Round/RoundContainer.tsx b/src/pages/Competition/Round/RoundContainer.tsx
index 5326a56..4ab6957 100644
--- a/src/pages/Competition/Round/RoundContainer.tsx
+++ b/src/pages/Competition/Round/RoundContainer.tsx
@@ -4,16 +4,14 @@ import ConfigureAssignmentsDialog from '../../../dialogs/ConfigureAssignmentsDia
import ConfigureGroupCountsDialog from '../../../dialogs/ConfigureGroupCountsDialog';
import { ConfigureGroupsDialog } from '../../../dialogs/ConfigureGroupsDialog';
import ConfigureStationNumbersDialog from '../../../dialogs/ConfigureStationNumbersDialog';
-import GroupCard from '../../../components/GroupCard';
import { RawRoundActivitiesDataDialog } from '../../../dialogs/RawRoundActivitiesDataDialog';
import { RawRoundDataDialog } from '../../../dialogs/RawRoundDataDialog';
import { RoundActionButtons } from '../../../components/RoundActionButtons';
-import { RoundStatisticsCard } from '../../../components/RoundStatisticsCard';
import { useRoundActions } from './hooks/useRoundActions';
import { useRoundData } from './hooks/useRoundData';
import { useRoundDialogs } from './hooks/useRoundDialogs';
-import { Alert, Typography } from '@mui/material';
-import Grid from '@mui/material/GridLegacy';
+import DistributedAttemptRoundView from './DistributedAttemptRoundView';
+import NormalRoundView from './NormalRoundView';
import { type Round } from '@wca/helpers';
import { ConfirmProvider } from 'material-ui-confirm';
@@ -37,10 +35,19 @@ const RoundContainer = ({ roundId, activityCode, eventId, round }: RoundContaine
personsAssignedToCompete,
personsAssignedWithCompetitorAssignmentCount,
adamRoundConfig,
+ isDistributedAttemptRoundLevel,
+ distributedAttemptGroups,
} = useRoundData(activityCode, round);
- const { handleGenerateAssignments, handleResetAll, handleResetNonScrambling } = useRoundActions({
+ const {
+ handleGenerateAssignments,
+ handleAssignToRoundAttempt,
+ handleResetAttemptAssignments,
+ handleResetAll,
+ handleResetNonScrambling,
+ } = useRoundActions({
round,
+ activityCode,
groups,
roundActivities,
});
@@ -53,129 +60,127 @@ const RoundContainer = ({ roundId, activityCode, eventId, round }: RoundContaine
);
}
- const pluralizeWord = (count: number, singular: string, plural?: string) =>
- count === 1 ? singular : plural || singular + 's';
-
if (!round) {
return null;
}
- return (
-
-
-
- {adamRoundConfig && (
-
- The delegate team strongly recommends {adamRoundConfig.groupCount}{' '}
- {pluralizeWord(adamRoundConfig.groupCount || 0, 'group', 'groups')} for this round.
- This was based on an estimated number of competitors for this round of{' '}
- {adamRoundConfig.expectedRegistrations}. Discuss with the delegates before
- deviating from this number.
-
- )}
-
-
- dialogs.rawRoundData.setOpen(true)}
- onOpenRawActivitiesData={() => dialogs.rawRoundActivitiesData.setOpen(true)}
- onOpenPersonsDialog={dialogs.personsDialog.open}
- onOpenPersonsAssignmentsDialog={() => dialogs.personsAssignments.setOpen(true)}
- actionButtons={
- dialogs.configureAssignments.setOpen(true)}
- onGenerateAssignments={handleGenerateAssignments}
- onConfigureStationNumbers={(code) =>
- dialogs.configureStationNumbers.setActivityCode(code)
- }
- onConfigureGroups={() => dialogs.configureGroups.setOpen(true)}
- onResetAll={handleResetAll}
- onResetNonScrambling={handleResetNonScrambling}
- onConfigureGroupCounts={() => dialogs.configureGroupCounts.setOpen(true)}
- />
- }
- />
-
-
- {personsShouldBeInRound.length === 0 && (
-
-
-
- No one in round to automatically assign. Make sure the next round is opened on
- WCA-Live to generate assignments
-
-
-
- )}
-
-
- {sortedGroups.map((group) => (
-
- ))}
-
+ const actionButtons = (
+ dialogs.configureAssignments.setOpen(true)}
+ onGenerateAssignments={handleGenerateAssignments}
+ onAssignToRoundAttempt={handleAssignToRoundAttempt}
+ onResetAttemptAssignments={handleResetAttemptAssignments}
+ onConfigureStationNumbers={(code) => dialogs.configureStationNumbers.setActivityCode(code)}
+ onConfigureGroups={() => dialogs.configureGroups.setOpen(true)}
+ onResetAll={handleResetAll}
+ onResetNonScrambling={handleResetNonScrambling}
+ onConfigureGroupCounts={() => dialogs.configureGroupCounts.setOpen(true)}
+ isDistributedAttemptRoundLevel={isDistributedAttemptRoundLevel}
+ />
+ );
- dialogs.configureAssignments.setOpen(false)}
- round={round}
- activityCode={activityCode}
- groups={groups}
+ const commonDialogs = (
+ <>
+ dialogs.configureAssignments.setOpen(false)}
+ round={round}
+ activityCode={activityCode}
+ groups={groups}
+ isDistributedAttemptRoundLevel={isDistributedAttemptRoundLevel}
+ distributedAttemptGroups={distributedAttemptGroups}
+ />
+ dialogs.configureGroupCounts.setOpen(false)}
+ activityCode={activityCode}
+ round={round}
+ roundActivities={roundActivities}
+ />
+ {dialogs.configureStationNumbers.activityCode && (
+ dialogs.configureStationNumbers.setActivityCode(false)}
+ activityCode={dialogs.configureStationNumbers.activityCode}
/>
- dialogs.configureGroupCounts.setOpen(false)}
+ )}
+
+ dialogs.personsAssignments.setOpen(false)}
+ />
+ dialogs.configureGroups.setOpen(false)}
+ activityCode={activityCode}
+ />
+ dialogs.rawRoundData.setOpen(false)}
+ roundId={roundId}
+ />
+ dialogs.rawRoundActivitiesData.setOpen(false)}
+ activityCode={activityCode}
+ />
+ >
+ );
+
+ return (
+
+ {isDistributedAttemptRoundLevel ? (
+
- {dialogs.configureStationNumbers.activityCode && (
- dialogs.configureStationNumbers.setActivityCode(false)}
- activityCode={dialogs.configureStationNumbers.activityCode}
- />
- )}
-
- dialogs.personsAssignments.setOpen(false)}
+ eventId={eventId}
+ personsShouldBeInRound={personsShouldBeInRound}
+ personsAssigned={personsAssigned}
+ personsAssignedWithCompetitorAssignmentCount={
+ personsAssignedWithCompetitorAssignmentCount
+ }
+ wcif={wcif}
+ onOpenRawRoundData={() => dialogs.rawRoundData.setOpen(true)}
+ onOpenRawActivitiesData={() => dialogs.rawRoundActivitiesData.setOpen(true)}
+ onOpenPersonsDialog={dialogs.personsDialog.open}
+ onOpenPersonsAssignmentsDialog={() => dialogs.personsAssignments.setOpen(true)}
+ actionButtons={actionButtons}
+ adamRoundConfig={adamRoundConfig}
+ distributedAttemptGroups={distributedAttemptGroups}
/>
- dialogs.configureGroups.setOpen(false)}
- activityCode={activityCode}
- />
- dialogs.rawRoundData.setOpen(false)}
- roundId={roundId}
- />
- dialogs.rawRoundActivitiesData.setOpen(false)}
+ ) : (
+ dialogs.rawRoundData.setOpen(true)}
+ onOpenRawActivitiesData={() => dialogs.rawRoundActivitiesData.setOpen(true)}
+ onOpenPersonsDialog={dialogs.personsDialog.open}
+ onOpenPersonsAssignmentsDialog={() => dialogs.personsAssignments.setOpen(true)}
+ actionButtons={actionButtons}
+ adamRoundConfig={adamRoundConfig}
+ sortedGroups={sortedGroups}
/>
-
+ )}
+ {commonDialogs}
);
};
diff --git a/src/pages/Competition/Round/hooks/useRoundActions.ts b/src/pages/Competition/Round/hooks/useRoundActions.ts
index 77b49d9..72cf009 100644
--- a/src/pages/Competition/Round/hooks/useRoundActions.ts
+++ b/src/pages/Competition/Round/hooks/useRoundActions.ts
@@ -1,7 +1,9 @@
+import { parseActivityCode } from '../../../../lib/domain/activities';
import { type ActivityWithParent, type ActivityWithRoom } from '../../../../lib/domain/types';
import {
bulkRemovePersonAssignments,
generateAssignments,
+ generateRoundAttemptAssignments,
updateRoundChildActivities,
} from '../../../../store/actions';
import { type Round } from '@wca/helpers';
@@ -11,11 +13,17 @@ import { useDispatch } from 'react-redux';
interface UseRoundActionsParams {
round: Round | undefined;
+ activityCode: string;
groups: ActivityWithParent[];
roundActivities: ActivityWithRoom[];
}
-export const useRoundActions = ({ round, groups, roundActivities }: UseRoundActionsParams) => {
+export const useRoundActions = ({
+ round,
+ activityCode,
+ groups,
+ roundActivities,
+}: UseRoundActionsParams) => {
const dispatch = useDispatch();
const confirm = useConfirm();
@@ -24,6 +32,60 @@ export const useRoundActions = ({ round, groups, roundActivities }: UseRoundActi
dispatch(generateAssignments(round.id));
}, [dispatch, round]);
+ const handleAssignToRoundAttempt = useCallback(() => {
+ const { attemptNumber } = parseActivityCode(activityCode);
+
+ if (attemptNumber !== undefined) {
+ dispatch(generateRoundAttemptAssignments(activityCode));
+ return;
+ }
+
+ const attemptCodes = Array.from(
+ new Set(
+ [...groups, ...roundActivities]
+ .map((activity) => activity.activityCode)
+ .filter((code) => parseActivityCode(code).attemptNumber !== undefined)
+ )
+ );
+
+ attemptCodes.forEach((code) => {
+ dispatch(generateRoundAttemptAssignments(code));
+ });
+ }, [dispatch, activityCode, groups, roundActivities]);
+
+ const handleResetAttemptAssignments = useCallback(() => {
+ confirm({
+ description: 'Do you really want to reset all competitor assignments for this attempt?',
+ confirmationText: 'Yes',
+ cancellationText: 'No',
+ })
+ .then(() => {
+ const { attemptNumber } = parseActivityCode(activityCode);
+ const activityIdsToReset =
+ attemptNumber !== undefined
+ ? roundActivities.map((roundActivity) => roundActivity.id)
+ : [...groups, ...roundActivities]
+ .filter((group) => parseActivityCode(group.activityCode).attemptNumber !== undefined)
+ .map((group) => group.id);
+
+ if (activityIdsToReset.length === 0) {
+ return;
+ }
+
+ dispatch(
+ bulkRemovePersonAssignments(
+ activityIdsToReset.map((activityId) => ({
+ activityId,
+ assignmentCode: 'competitor',
+ }))
+ )
+ );
+ })
+ .catch((e) => {
+ console.error('Failed to reset attempt assignments:', e);
+ });
+ }, [activityCode, confirm, dispatch, groups, roundActivities]);
+
const handleResetAll = useCallback(() => {
confirm({
description: 'Do you really want to reset all group activities in this round?',
@@ -77,6 +139,8 @@ export const useRoundActions = ({ round, groups, roundActivities }: UseRoundActi
return {
handleGenerateAssignments,
+ handleAssignToRoundAttempt,
+ handleResetAttemptAssignments,
handleResetAll,
handleResetNonScrambling,
};
diff --git a/src/pages/Competition/Round/hooks/useRoundData.test.tsx b/src/pages/Competition/Round/hooks/useRoundData.test.tsx
new file mode 100644
index 0000000..118db16
--- /dev/null
+++ b/src/pages/Competition/Round/hooks/useRoundData.test.tsx
@@ -0,0 +1,135 @@
+import { buildActivity, buildEvent, buildPerson, buildRound } from '../../../../store/reducers/_tests_/helpers';
+import INITIAL_STATE, { type AppState } from '../../../../store/initialState';
+import reducer from '../../../../store/reducer';
+import { useRoundData } from './useRoundData';
+import { renderHook } from '@testing-library/react';
+import type { Activity, Competition } from '@wca/helpers';
+import type { ReactNode } from 'react';
+import { Provider } from 'react-redux';
+import { applyMiddleware, createStore, type AnyAction, type Reducer } from 'redux';
+import { thunk } from 'redux-thunk';
+import { describe, expect, it } from 'vitest';
+
+const round = buildRound({
+ id: '333fm-r1',
+ format: '3',
+});
+
+const roomA = {
+ id: 10,
+ name: 'Room A',
+ color: '#000',
+ extensions: [],
+ activities: [] as Activity[],
+};
+
+const roomB = {
+ id: 11,
+ name: 'Room B',
+ color: '#111',
+ extensions: [],
+ activities: [] as Activity[],
+};
+
+const buildWcif = (): Competition => ({
+ ...(INITIAL_STATE.wcif as Competition),
+ id: 'test-competition',
+ name: 'Test Competition',
+ shortName: 'Test',
+ formatVersion: '1.0',
+ persons: [
+ buildPerson({
+ registrantId: 1,
+ registration: {
+ status: 'accepted',
+ eventIds: ['333fm'],
+ isCompeting: true,
+ comments: undefined,
+ wcaRegistrationId: 1,
+ },
+ }),
+ ],
+ events: [
+ buildEvent({
+ id: '333fm',
+ rounds: [round],
+ }),
+ ],
+ schedule: {
+ startDate: '2024-01-01',
+ numberOfDays: 1,
+ venues: [
+ {
+ id: 1,
+ name: 'Venue',
+ latitudeMicrodegrees: 0,
+ longitudeMicrodegrees: 0,
+ countryIso2: 'US',
+ timezone: 'America/New_York',
+ extensions: [],
+ rooms: [
+ {
+ ...roomA,
+ activities: [
+ buildActivity({
+ id: 101,
+ name: 'FMC Attempt 1',
+ activityCode: '333fm-r1-a1',
+ childActivities: [],
+ }),
+ ],
+ },
+ {
+ ...roomB,
+ activities: [
+ buildActivity({
+ id: 102,
+ name: 'FMC Attempt 2',
+ activityCode: '333fm-r1-a2',
+ childActivities: [],
+ }),
+ ],
+ },
+ ],
+ },
+ ],
+ },
+});
+
+const renderUseRoundData = () => {
+ const state: AppState = {
+ ...INITIAL_STATE,
+ wcif: buildWcif(),
+ changedKeys: new Set(),
+ };
+ const store = createStore(
+ reducer as Reducer,
+ state,
+ applyMiddleware(thunk)
+ );
+
+ return renderHook(() => useRoundData('333fm-r1', round), {
+ wrapper: ({ children }: { children: ReactNode }) => (
+ {children}
+ ),
+ });
+};
+
+describe('useRoundData', () => {
+ it('groups distributed round-level attempt activities even when they have no children', () => {
+ const { result } = renderUseRoundData();
+
+ expect(result.current.isDistributedAttemptRoundLevel).toBe(true);
+ expect(result.current.groups).toHaveLength(0);
+ expect(result.current.distributedAttemptGroups).toEqual([
+ {
+ attemptNumber: 1,
+ activities: [expect.objectContaining({ id: 101, room: expect.objectContaining({ name: 'Room A' }) })],
+ },
+ {
+ attemptNumber: 2,
+ activities: [expect.objectContaining({ id: 102, room: expect.objectContaining({ name: 'Room B' }) })],
+ },
+ ]);
+ });
+});
diff --git a/src/pages/Competition/Round/hooks/useRoundData.ts b/src/pages/Competition/Round/hooks/useRoundData.ts
index c8da654..489efa0 100644
--- a/src/pages/Competition/Round/hooks/useRoundData.ts
+++ b/src/pages/Competition/Round/hooks/useRoundData.ts
@@ -1,3 +1,8 @@
+import {
+ activityCodeIsChild,
+ hasDistributedAttempts,
+ parseActivityCode,
+} from '../../../../lib/domain/activities/activityCode';
import { byGroupNumber } from '../../../../lib/domain/activities/activityUtils';
import { type ActivityWithParent, type ActivityWithRoom } from '../../../../lib/domain/types';
import {
@@ -29,6 +34,11 @@ interface RoundDataResult {
groupCount?: number;
expectedRegistrations?: number;
} | null;
+ isDistributedAttemptRoundLevel: boolean;
+ distributedAttemptGroups: Array<{
+ attemptNumber: number;
+ activities: ActivityWithRoom[];
+ }>;
}
export const useRoundData = (activityCode: string, round: Round | undefined): RoundDataResult => {
@@ -38,23 +48,73 @@ export const useRoundData = (activityCode: string, round: Round | undefined): Ro
round ? selectPersonsShouldBeInRound(state)(round) : []
);
+ const isDistributedAttemptRoundLevel = useMemo(() => {
+ const parsedActivityCode = parseActivityCode(activityCode);
+ return hasDistributedAttempts(activityCode) && parsedActivityCode.attemptNumber === undefined;
+ }, [activityCode]);
+
// list of each stage's round activity
- const roundActivities: ActivityWithRoom[] = wcif
- ? findAllActivities(wcif)
- .filter((activity) => activity.activityCode === activityCode)
- .map((activity) => {
- const room = roomByActivity(wcif, activity.id);
- if (!room) {
- throw new Error(`Could not find room for activity ${activity.id}`);
- }
- return {
- ...activity,
- room,
- };
- })
- : [];
-
- const groups = roundActivities.flatMap((roundActivity) => allChildActivities(roundActivity));
+ // For distributed attempt rounds at round level, find all attempt activities
+ const roundActivities: ActivityWithRoom[] = useMemo(
+ () =>
+ wcif
+ ? findAllActivities(wcif)
+ .filter((activity) =>
+ isDistributedAttemptRoundLevel
+ ? activityCodeIsChild(activityCode, activity.activityCode)
+ : activity.activityCode === activityCode
+ )
+ .map((activity) => {
+ const room = roomByActivity(wcif, activity.id);
+ if (!room) {
+ throw new Error(`Could not find room for activity ${activity.id}`);
+ }
+ return {
+ ...activity,
+ room,
+ };
+ })
+ : [],
+ [activityCode, isDistributedAttemptRoundLevel, wcif]
+ );
+
+ const groups = useMemo(
+ () => roundActivities.flatMap((roundActivity) => allChildActivities(roundActivity)),
+ [roundActivities]
+ );
+
+ const distributedAttemptGroups = useMemo(() => {
+ if (!isDistributedAttemptRoundLevel) {
+ return [];
+ }
+
+ const groupedByAttempt = roundActivities.reduce<
+ Record
+ >((acc, activity) => {
+ const { attemptNumber } = parseActivityCode(activity.activityCode);
+ if (!attemptNumber) {
+ return acc;
+ }
+
+ if (!acc[attemptNumber]) {
+ acc[attemptNumber] = { attemptNumber, activities: [] };
+ }
+
+ acc[attemptNumber].activities.push(activity);
+ return acc;
+ }, {});
+
+ return Object.values(groupedByAttempt)
+ .map((group) => ({
+ ...group,
+ activities: [...group.activities].sort((a, b) => {
+ const roomNameA = a.room.name;
+ const roomNameB = b.room.name;
+ return roomNameA.localeCompare(roomNameB) || a.id - b.id;
+ }),
+ }))
+ .sort((a, b) => a.attemptNumber - b.attemptNumber);
+ }, [isDistributedAttemptRoundLevel, roundActivities]);
const sortedGroups = useMemo(
() =>
@@ -96,5 +156,7 @@ export const useRoundData = (activityCode: string, round: Round | undefined): Ro
personsAssignedToCompete,
personsAssignedWithCompetitorAssignmentCount,
adamRoundConfig,
+ isDistributedAttemptRoundLevel,
+ distributedAttemptGroups,
};
};
diff --git a/src/store/actions.test.ts b/src/store/actions.test.ts
index 17601b9..40ee0e3 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, formatVersion: wcif.formatVersion });
+ expect(patchWcifMock).toHaveBeenCalledWith('Comp1', {
+ formatVersion: wcif.formatVersion,
+ events: wcif.events,
+ });
expect(dispatch.mock.calls[1][0]).toEqual({
type: ActionType.UPLOADING_WCIF,
uploading: false,
diff --git a/src/store/actions.ts b/src/store/actions.ts
index 24289d6..03b6c37 100644
--- a/src/store/actions.ts
+++ b/src/store/actions.ts
@@ -46,6 +46,7 @@ export const ActionType = {
PARTIAL_UPDATE_WCIF: 'partial_update_wcif',
RESET_ALL_GROUP_ASSIGNMENTS: 'reset_all_group_assignments',
GENERATE_ASSIGNMENTS: 'generate_assignments',
+ GENERATE_ROUND_ATTEMPT_ASSIGNMENTS: 'generate_round_attempt_assignments',
EDIT_ACTIVITY: 'edit_activity',
UPDATE_GLOBAL_EXTENSION: 'update_global_extension',
ADD_PERSON: 'add_person',
@@ -381,6 +382,24 @@ export type EditActivityPayload = {
where: Partial & Pick;
what: Partial & Partial>;
};
+
+export type GenerateRoundAttemptAssignmentsPayload = {
+ attemptActivityCode: string;
+};
+/**
+ * Assigns all persons in a round directly to an attempt-level activity (e.g., 333fm-r1-a1),
+ * without creating sub-groups. Needed for 333fm and 333mbf so Groupifier can print scorecards.
+ * @param {ActivityCode} attemptActivityCode - e.g., '333fm-r1-a1'
+ */
+export const generateRoundAttemptAssignments = (
+ attemptActivityCode: string
+): ReduxAction<
+ typeof ActionType.GENERATE_ROUND_ATTEMPT_ASSIGNMENTS,
+ GenerateRoundAttemptAssignmentsPayload
+> => ({
+ type: ActionType.GENERATE_ROUND_ATTEMPT_ASSIGNMENTS,
+ attemptActivityCode,
+});
/**
* Queries activity based on the where and replaces it with the what
* @param {*} where
diff --git a/src/store/reducerHandlers.ts b/src/store/reducerHandlers.ts
index 44168f7..7d77022 100644
--- a/src/store/reducerHandlers.ts
+++ b/src/store/reducerHandlers.ts
@@ -1,4 +1,5 @@
import { findAndReplaceActivity } from '../lib/domain/activities';
+import { generateAssignmentsForRoundAttempt } from '../lib/assignmentGenerators';
import { type ValidationError } from '../lib/wcif';
import {
setActivityConfigExtensionData,
@@ -9,6 +10,7 @@ import { ActionType } from './actions';
import type {
EditActivityPayload,
FetchingWcifPayload,
+ GenerateRoundAttemptAssignmentsPayload,
PartialUpdateWcifPayload,
SetCompetitionsPayload,
UpdateGlobalExtensionPayload,
@@ -170,6 +172,16 @@ export const reducers: Record = {
};
},
[ActionType.GENERATE_ASSIGNMENTS]: Reducers.generateAssignments,
+ [ActionType.GENERATE_ROUND_ATTEMPT_ASSIGNMENTS]: (
+ state,
+ action: GenerateRoundAttemptAssignmentsPayload
+ ) => {
+ if (!('attemptActivityCode' in action) || !state.wcif) return state;
+ const generator = generateAssignmentsForRoundAttempt(state.wcif, action.attemptActivityCode);
+ if (!generator) return state;
+ const newAssignments = generator([]);
+ return Reducers.bulkAddPersonAssignments(state, { assignments: newAssignments });
+ },
[ActionType.EDIT_ACTIVITY]: (state, action: EditActivityPayload) => {
if (!('where' in action && 'what' in action) || !state.wcif) return state;
const { where, what } = action;