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;