From 4533e5e6fa46411e805ee9b7fd136ada6ac8d319 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 7 May 2026 15:00:35 +0000 Subject: [PATCH 1/9] Initial plan From dbda44b3dc2e39c5ea570e9fa4a1daccac18f692 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 7 May 2026 15:14:41 +0000 Subject: [PATCH 2/9] Add ability to assign people directly to round attempt for 333fm and 333mbf --- src/components/RoundActionButtons.tsx | 29 +++ ...generateAssignmentsForRoundAttempt.test.ts | 215 ++++++++++++++++++ .../generateAssignmentsForRoundAttempt.ts | 83 +++++++ src/lib/assignmentGenerators/index.ts | 1 + .../Competition/Round/RoundContainer.tsx | 5 +- .../Round/hooks/useRoundActions.ts | 36 ++- src/store/actions.ts | 19 ++ src/store/reducerHandlers.ts | 12 + 8 files changed, 398 insertions(+), 2 deletions(-) create mode 100644 src/lib/assignmentGenerators/generateAssignmentsForRoundAttempt.test.ts create mode 100644 src/lib/assignmentGenerators/generateAssignmentsForRoundAttempt.ts diff --git a/src/components/RoundActionButtons.tsx b/src/components/RoundActionButtons.tsx index 5fb6bc7..6febd76 100644 --- a/src/components/RoundActionButtons.tsx +++ b/src/components/RoundActionButtons.tsx @@ -1,3 +1,4 @@ +import { hasDistributedAttempts, parseActivityCode } from '../lib/domain/activities'; import { type ActivityWithParent } from '../lib/domain/types'; import Button from '@mui/material/Button'; import { type Person } from '@wca/helpers'; @@ -9,6 +10,8 @@ interface RoundActionButtonsProps { activityCode: string; onConfigureAssignments: () => void; onGenerateAssignments: () => void; + onAssignToRoundAttempt: () => void; + onResetAttemptAssignments: () => void; onConfigureStationNumbers: (activityCode: string) => void; onConfigureGroups: () => void; onResetAll: () => void; @@ -23,12 +26,38 @@ export const RoundActionButtons = ({ activityCode, onConfigureAssignments, onGenerateAssignments, + onAssignToRoundAttempt, + onResetAttemptAssignments, onConfigureStationNumbers, onConfigureGroups, onResetAll, onResetNonScrambling, onConfigureGroupCounts, }: RoundActionButtonsProps) => { + const { attemptNumber } = parseActivityCode(activityCode); + const isAttemptActivity = hasDistributedAttempts(activityCode) && attemptNumber !== undefined; + + if (groups.length === 0 && isAttemptActivity) { + if (personsAssignedToCompete.length > 0) { + return ( + <> + +
+ + + ); + } + + return ( + <> + + + + ); + } + if (groups.length === 0) { return ( <> 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..cc4fafb --- /dev/null +++ b/src/lib/assignmentGenerators/generateAssignmentsForRoundAttempt.ts @@ -0,0 +1,83 @@ +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, type Event } 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); + const roundId = eventId && roundNumber ? `${eventId}-r${roundNumber}` : undefined; + + const event = wcif.events.find((e) => e.id === eventId) as Event; + const round = event?.rounds?.find((r) => r.id === roundId); + + if (!event || !round || !roundNumber) { + console.error('Error finding round for attempt', attemptActivityCode); + return; + } + + const attemptActivities = findRoundActivitiesById(wcif, attemptActivityCode); + const attemptActivityIds = attemptActivities.map((a) => a.id); + + if (!attemptActivityIds.length) { + console.error('No attempt activities found for', 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)); + + // eslint-disable-next-line + console.log(`Generating attempt assignments for ${persons.length} competitors`, persons); + + 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/pages/Competition/Round/RoundContainer.tsx b/src/pages/Competition/Round/RoundContainer.tsx index 5326a56..61da9be 100644 --- a/src/pages/Competition/Round/RoundContainer.tsx +++ b/src/pages/Competition/Round/RoundContainer.tsx @@ -39,8 +39,9 @@ const RoundContainer = ({ roundId, activityCode, eventId, round }: RoundContaine adamRoundConfig, } = useRoundData(activityCode, round); - const { handleGenerateAssignments, handleResetAll, handleResetNonScrambling } = useRoundActions({ + const { handleGenerateAssignments, handleAssignToRoundAttempt, handleResetAttemptAssignments, handleResetAll, handleResetNonScrambling } = useRoundActions({ round, + activityCode, groups, roundActivities, }); @@ -98,6 +99,8 @@ const RoundContainer = ({ roundId, activityCode, eventId, round }: RoundContaine activityCode={activityCode} onConfigureAssignments={() => dialogs.configureAssignments.setOpen(true)} onGenerateAssignments={handleGenerateAssignments} + onAssignToRoundAttempt={handleAssignToRoundAttempt} + onResetAttemptAssignments={handleResetAttemptAssignments} onConfigureStationNumbers={(code) => dialogs.configureStationNumbers.setActivityCode(code) } diff --git a/src/pages/Competition/Round/hooks/useRoundActions.ts b/src/pages/Competition/Round/hooks/useRoundActions.ts index 77b49d9..2f253a6 100644 --- a/src/pages/Competition/Round/hooks/useRoundActions.ts +++ b/src/pages/Competition/Round/hooks/useRoundActions.ts @@ -2,6 +2,7 @@ import { type ActivityWithParent, type ActivityWithRoom } from '../../../../lib/ import { bulkRemovePersonAssignments, generateAssignments, + generateRoundAttemptAssignments, updateRoundChildActivities, } from '../../../../store/actions'; import { type Round } from '@wca/helpers'; @@ -11,11 +12,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 +31,31 @@ export const useRoundActions = ({ round, groups, roundActivities }: UseRoundActi dispatch(generateAssignments(round.id)); }, [dispatch, round]); + const handleAssignToRoundAttempt = useCallback(() => { + dispatch(generateRoundAttemptAssignments(activityCode)); + }, [dispatch, activityCode]); + + const handleResetAttemptAssignments = useCallback(() => { + confirm({ + description: 'Do you really want to reset all competitor assignments for this attempt?', + confirmationText: 'Yes', + cancellationText: 'No', + }) + .then(() => { + dispatch( + bulkRemovePersonAssignments( + roundActivities.map((roundActivity) => ({ + activityId: roundActivity.id, + assignmentCode: 'competitor', + })) + ) + ); + }) + .catch((e) => { + console.error(e); + }); + }, [confirm, dispatch, roundActivities]); + const handleResetAll = useCallback(() => { confirm({ description: 'Do you really want to reset all group activities in this round?', @@ -77,6 +109,8 @@ export const useRoundActions = ({ round, groups, roundActivities }: UseRoundActi return { handleGenerateAssignments, + handleAssignToRoundAttempt, + handleResetAttemptAssignments, handleResetAll, handleResetNonScrambling, }; 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; From fa6e358dd971d09f3f86ee05ee693df585168e70 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 7 May 2026 15:17:35 +0000 Subject: [PATCH 3/9] Fix line length in RoundContainer.tsx --- src/pages/Competition/Round/RoundContainer.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/pages/Competition/Round/RoundContainer.tsx b/src/pages/Competition/Round/RoundContainer.tsx index 61da9be..3798b09 100644 --- a/src/pages/Competition/Round/RoundContainer.tsx +++ b/src/pages/Competition/Round/RoundContainer.tsx @@ -39,7 +39,13 @@ const RoundContainer = ({ roundId, activityCode, eventId, round }: RoundContaine adamRoundConfig, } = useRoundData(activityCode, round); - const { handleGenerateAssignments, handleAssignToRoundAttempt, handleResetAttemptAssignments, handleResetAll, handleResetNonScrambling } = useRoundActions({ + const { + handleGenerateAssignments, + handleAssignToRoundAttempt, + handleResetAttemptAssignments, + handleResetAll, + handleResetNonScrambling, + } = useRoundActions({ round, activityCode, groups, From 555a42c51f0b6a763a955ba4a2941d3774db6b8d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 7 May 2026 17:07:59 +0000 Subject: [PATCH 4/9] Add round-level distributed attempt assignment view and controls --- src/components/RoundActionButtons.tsx | 15 + .../_tests_/RoundSelector.test.tsx | 23 ++ src/components/RoundSelector/index.tsx | 33 ++- .../_tests_/RoundActionButtons.test.tsx | 32 +++ .../ConfigureAssignmentsDialog.tsx | 266 +++++++++++++++--- .../PersonAssignmentRow.tsx | 4 + .../Competition/Round/RoundContainer.tsx | 5 + .../Round/hooks/useRoundActions.ts | 40 ++- .../Competition/Round/hooks/useRoundData.ts | 49 ++++ 9 files changed, 420 insertions(+), 47 deletions(-) create mode 100644 src/components/_tests_/RoundActionButtons.test.tsx diff --git a/src/components/RoundActionButtons.tsx b/src/components/RoundActionButtons.tsx index 6febd76..c220ffa 100644 --- a/src/components/RoundActionButtons.tsx +++ b/src/components/RoundActionButtons.tsx @@ -17,6 +17,7 @@ interface RoundActionButtonsProps { onResetAll: () => void; onResetNonScrambling: () => void; onConfigureGroupCounts: () => void; + isDistributedAttemptRoundLevel: boolean; } export const RoundActionButtons = ({ @@ -33,10 +34,24 @@ export const RoundActionButtons = ({ 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 ( diff --git a/src/components/RoundSelector/_tests_/RoundSelector.test.tsx b/src/components/RoundSelector/_tests_/RoundSelector.test.tsx index 2d6b5b6..2e1bbdb 100644 --- a/src/components/RoundSelector/_tests_/RoundSelector.test.tsx +++ b/src/components/RoundSelector/_tests_/RoundSelector.test.tsx @@ -85,4 +85,27 @@ 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')); + + expect(activityCodes).toEqual(['333fm-r1', '333fm-r1-a1', '333fm-r1-a2', '333fm-r1-a3']); + }); }); diff --git a/src/components/RoundSelector/index.tsx b/src/components/RoundSelector/index.tsx index e5e9efa..79ddc06 100644 --- a/src/components/RoundSelector/index.tsx +++ b/src/components/RoundSelector/index.tsx @@ -54,13 +54,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(r.format === 'm' ? 3 : +r.format) + .fill(0) + .map((_, index) => `${r.id}-a${index + 1}`), + ]; + }); const handleKeyDown = (e: KeyboardEvent) => { if (commandPromptOpen) { @@ -136,7 +141,17 @@ const RoundSelector = ({ onSelected }: RoundSelectorProps) => { const attempts = new Array(round.format === 'm' ? 3 : +round.format) // TODO: create helper function to calculate attempts .fill(0); - return attempts.map((_, index) => { + const roundListItem = ( + + ); + + const attemptListItems = attempts.map((_, index) => { const attemptActivityCode = `${round.id}-a${index + 1}`; return ( @@ -149,6 +164,8 @@ const RoundSelector = ({ onSelected }: RoundSelectorProps) => { /> ); }); + + 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..8f9e52e --- /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 Attempt Assignments')).toBeInTheDocument(); + expect(getByText('Generate Attempt Assignments (All Attempts)')).toBeInTheDocument(); + expect(getByText('Reset Attempt Assignments')).toBeInTheDocument(); + expect(queryByText('Configure Group Counts')).not.toBeInTheDocument(); + }); +}); diff --git a/src/dialogs/ConfigureAssignmentsDialog/ConfigureAssignmentsDialog.tsx b/src/dialogs/ConfigureAssignmentsDialog/ConfigureAssignmentsDialog.tsx index 3f10766..0f08af0 100644 --- a/src/dialogs/ConfigureAssignmentsDialog/ConfigureAssignmentsDialog.tsx +++ b/src/dialogs/ConfigureAssignmentsDialog/ConfigureAssignmentsDialog.tsx @@ -1,7 +1,17 @@ import Assignments from '../../config/assignments'; -import { parseActivityCode, activityCodeToName } from '../../lib/domain/activities'; +import TableAssignmentCell from '../../components/TableAssignmentCell'; +import { + activityCodeToName, + hasDistributedAttempts, + parseActivityCode, +} from '../../lib/domain/activities'; import type { ActivityWithParent } from '../../lib/domain/activities'; import { useAppDispatch, useAppSelector } from '../../store'; +import { + bulkRemovePersonAssignments, + generateRoundAttemptAssignments, + upsertPersonAssignments, +} from '../../store/actions'; import { selectWcifRooms } from '../../store/selectors'; import AssignmentsTableHeader from './AssignmentsTableHeader'; import AssignmentsToolbar from './AssignmentsToolbar'; @@ -13,12 +23,17 @@ import type { CompetitorSort } from './types'; import { Box, Button, + Chip, Dialog, DialogActions, DialogContent, DialogTitle, + Stack, Table, TableBody, + TableCell, + TableHead, + TableRow, Typography, useMediaQuery, } from '@mui/material'; @@ -33,12 +48,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: ActivityWithParent[]; + }>; }) => { const wcif = useAppSelector((state) => state.wcif); const { eventId, roundNumber } = parseActivityCode(activityCode) as { @@ -90,6 +112,78 @@ const ConfigureAssignmentsDialog = ({ handleResetAssignments, } = useAssignmentHandlers(persons, groups, paintingAssignmentCode, dispatch); + const isRoundLevelDistributedAttempts = + isDistributedAttemptRoundLevel && + hasDistributedAttempts(activityCode) && + parseActivityCode(activityCode).attemptNumber === undefined; + + const getCompetitorAssignmentForPersonActivity = useCallback( + (registrantId: number, activityId: number) => + persons + .find((p) => p.registrantId === registrantId) + ?.assignments?.find((a) => a.activityId === activityId && a.assignmentCode === 'competitor'), + [persons] + ); + + const toggleCompetitorAssignmentForPersonAttemptActivity = useCallback( + (registrantId: number, activityId: number) => () => { + const existing = getCompetitorAssignmentForPersonActivity(registrantId, activityId); + + if (existing) { + dispatch( + bulkRemovePersonAssignments([ + { + registrantId, + activityId, + assignmentCode: 'competitor', + }, + ]) + ); + return; + } + + dispatch( + upsertPersonAssignments(registrantId, [ + { + activityId, + assignmentCode: 'competitor', + stationNumber: null, + }, + ]) + ); + }, + [dispatch, getCompetitorAssignmentForPersonActivity] + ); + + const assignAllToAttempt = useCallback( + (attemptNumber: number) => { + dispatch(generateRoundAttemptAssignments(`${round.id}-a${attemptNumber}`)); + }, + [dispatch, round.id] + ); + + const clearAttemptAssignments = useCallback( + (attemptNumber: number) => { + const activityIds = distributedAttemptGroups + .find((group) => group.attemptNumber === attemptNumber) + ?.activities.map((activity) => activity.id); + + if (!activityIds?.length) { + return; + } + + dispatch( + bulkRemovePersonAssignments( + activityIds.map((activityId) => ({ + activityId, + assignmentCode: 'competitor', + })) + ) + ); + }, + [dispatch, distributedAttemptGroups] + ); + const handleKeyDown = useCallback((e: KeyboardEvent) => { if (e.ctrlKey) { return; @@ -123,41 +217,145 @@ const ConfigureAssignmentsDialog = ({ Configuring Assignments For {activityCodeToName(activityCode)} - - - - - - {persons?.map((person) => ( - + + {distributedAttemptGroups.map((attemptGroup) => ( + + + + + + ))} + +
+ + + # + Name + Age + Seed Result + Registered + {distributedAttemptGroups.map((attemptGroup) => ( + + Attempt {attemptGroup.attemptNumber} + + ))} + Stream + Total Staff Assignments + + + + + + + + {distributedAttemptGroups.flatMap((attemptGroup) => + attemptGroup.activities.map((activity) => { + const parentRoomName = + typeof activity.parent === 'object' && 'room' in activity.parent + ? activity.parent.room.name + : ''; + return ( + + {parentRoomName} + + ); + }) + )} + + + + + + {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/pages/Competition/Round/RoundContainer.tsx b/src/pages/Competition/Round/RoundContainer.tsx index 3798b09..864e41f 100644 --- a/src/pages/Competition/Round/RoundContainer.tsx +++ b/src/pages/Competition/Round/RoundContainer.tsx @@ -37,6 +37,8 @@ const RoundContainer = ({ roundId, activityCode, eventId, round }: RoundContaine personsAssignedToCompete, personsAssignedWithCompetitorAssignmentCount, adamRoundConfig, + isDistributedAttemptRoundLevel, + distributedAttemptGroups, } = useRoundData(activityCode, round); const { @@ -114,6 +116,7 @@ const RoundContainer = ({ roundId, activityCode, eventId, round }: RoundContaine onResetAll={handleResetAll} onResetNonScrambling={handleResetNonScrambling} onConfigureGroupCounts={() => dialogs.configureGroupCounts.setOpen(true)} + isDistributedAttemptRoundLevel={isDistributedAttemptRoundLevel} /> } /> @@ -142,6 +145,8 @@ const RoundContainer = ({ roundId, activityCode, eventId, round }: RoundContaine round={round} activityCode={activityCode} groups={groups} + isDistributedAttemptRoundLevel={isDistributedAttemptRoundLevel} + distributedAttemptGroups={distributedAttemptGroups} /> { - dispatch(generateRoundAttemptAssignments(activityCode)); - }, [dispatch, activityCode]); + const { attemptNumber } = parseActivityCode(activityCode); + + if (attemptNumber !== undefined) { + dispatch(generateRoundAttemptAssignments(activityCode)); + return; + } + + const attemptCodes = Array.from( + new Set( + groups + .map((activity) => activity.activityCode) + .filter((code) => parseActivityCode(code).attemptNumber !== undefined) + ) + ); + + attemptCodes.forEach((code) => { + dispatch(generateRoundAttemptAssignments(code)); + }); + }, [dispatch, activityCode, groups]); const handleResetAttemptAssignments = useCallback(() => { confirm({ @@ -42,10 +60,22 @@ export const useRoundActions = ({ cancellationText: 'No', }) .then(() => { + const { attemptNumber } = parseActivityCode(activityCode); + const activityIdsToReset = + attemptNumber !== undefined + ? roundActivities.map((roundActivity) => roundActivity.id) + : groups + .filter((group) => parseActivityCode(group.activityCode).attemptNumber !== undefined) + .map((group) => group.id); + + if (activityIdsToReset.length === 0) { + return; + } + dispatch( bulkRemovePersonAssignments( - roundActivities.map((roundActivity) => ({ - activityId: roundActivity.id, + activityIdsToReset.map((activityId) => ({ + activityId, assignmentCode: 'competitor', })) ) @@ -54,7 +84,7 @@ export const useRoundActions = ({ .catch((e) => { console.error(e); }); - }, [confirm, dispatch, roundActivities]); + }, [activityCode, confirm, dispatch, groups, roundActivities]); const handleResetAll = useCallback(() => { confirm({ diff --git a/src/pages/Competition/Round/hooks/useRoundData.ts b/src/pages/Competition/Round/hooks/useRoundData.ts index c8da654..aaf6c34 100644 --- a/src/pages/Competition/Round/hooks/useRoundData.ts +++ b/src/pages/Competition/Round/hooks/useRoundData.ts @@ -1,3 +1,7 @@ +import { + 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 +33,11 @@ interface RoundDataResult { groupCount?: number; expectedRegistrations?: number; } | null; + isDistributedAttemptRoundLevel: boolean; + distributedAttemptGroups: Array<{ + attemptNumber: number; + activities: ActivityWithParent[]; + }>; } export const useRoundData = (activityCode: string, round: Round | undefined): RoundDataResult => { @@ -55,6 +64,44 @@ export const useRoundData = (activityCode: string, round: Round | undefined): Ro : []; const groups = roundActivities.flatMap((roundActivity) => allChildActivities(roundActivity)); + const parsedActivityCode = parseActivityCode(activityCode); + const isDistributedAttemptRoundLevel = + hasDistributedAttempts(activityCode) && parsedActivityCode.attemptNumber === undefined; + + const distributedAttemptGroups = useMemo(() => { + if (!isDistributedAttemptRoundLevel) { + return []; + } + + const groupedByAttempt = groups.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 = + typeof a.parent === 'object' && 'room' in a.parent ? a.parent.room.name : ''; + const roomNameB = + typeof b.parent === 'object' && 'room' in b.parent ? b.parent.room.name : ''; + return roomNameA.localeCompare(roomNameB) || a.id - b.id; + }), + })) + .sort((a, b) => a.attemptNumber - b.attemptNumber); + }, [groups, isDistributedAttemptRoundLevel]); const sortedGroups = useMemo( () => @@ -96,5 +143,7 @@ export const useRoundData = (activityCode: string, round: Round | undefined): Ro personsAssignedToCompete, personsAssignedWithCompetitorAssignmentCount, adamRoundConfig, + isDistributedAttemptRoundLevel, + distributedAttemptGroups, }; }; From 48a43291efa90369b5ede5c988f2db469c0dda40 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 7 May 2026 17:16:58 +0000 Subject: [PATCH 5/9] Refine distributed attempt assignment flow and tests --- src/components/RoundActionButtons.tsx | 5 ++-- src/components/RoundSelector/index.tsx | 7 +++-- .../ConfigureAssignmentsDialog.tsx | 13 ++------- .../generateAssignmentsForRoundAttempt.ts | 29 ++++++++++++------- .../Round/hooks/useRoundActions.ts | 2 +- .../Competition/Round/hooks/useRoundData.ts | 7 +++-- 6 files changed, 33 insertions(+), 30 deletions(-) diff --git a/src/components/RoundActionButtons.tsx b/src/components/RoundActionButtons.tsx index c220ffa..d849381 100644 --- a/src/components/RoundActionButtons.tsx +++ b/src/components/RoundActionButtons.tsx @@ -1,5 +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'; @@ -44,7 +45,7 @@ export const RoundActionButtons = ({ <> -
+ @@ -57,7 +58,7 @@ export const RoundActionButtons = ({ return ( <> -
+ diff --git a/src/components/RoundSelector/index.tsx b/src/components/RoundSelector/index.tsx index 79ddc06..5d461b3 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; @@ -61,7 +63,7 @@ const RoundSelector = ({ onSelected }: RoundSelectorProps) => { return [ r.id, - ...new Array(r.format === 'm' ? 3 : +r.format) + ...new Array(attemptCountForRound(r)) .fill(0) .map((_, index) => `${r.id}-a${index + 1}`), ]; @@ -138,8 +140,7 @@ const RoundSelector = ({ onSelected }: RoundSelectorProps) => { /> )) : roundsForEvent.flatMap((round) => { - const attempts = new Array(round.format === 'm' ? 3 : +round.format) // TODO: create helper function to calculate attempts - .fill(0); + const attempts = new Array(attemptCountForRound(round)).fill(0); const roundListItem = ( persons @@ -217,7 +208,7 @@ const ConfigureAssignmentsDialog = ({ Configuring Assignments For {activityCodeToName(activityCode)} - {isRoundLevelDistributedAttempts ? ( + {isDistributedAttemptRoundLevel ? ( <> {distributedAttemptGroups.map((attemptGroup) => ( diff --git a/src/lib/assignmentGenerators/generateAssignmentsForRoundAttempt.ts b/src/lib/assignmentGenerators/generateAssignmentsForRoundAttempt.ts index cc4fafb..735997f 100644 --- a/src/lib/assignmentGenerators/generateAssignmentsForRoundAttempt.ts +++ b/src/lib/assignmentGenerators/generateAssignmentsForRoundAttempt.ts @@ -4,7 +4,7 @@ import { personsShouldBeInRound } from '../domain'; import { type InProgressAssignmment } from '../types'; import { byName } from '../utils'; import { createGroupAssignment } from '../wcif'; -import { type Competition, type Event } from '@wca/helpers'; +import { type Competition } from '@wca/helpers'; /** * Assigns all persons in a round directly to the attempt-level activity, @@ -17,13 +17,18 @@ export const generateAssignmentsForRoundAttempt = ( attemptActivityCode: string ) => { const { eventId, roundNumber } = parseActivityCode(attemptActivityCode); - const roundId = eventId && roundNumber ? `${eventId}-r${roundNumber}` : undefined; + 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) as Event; + const event = wcif.events.find((e) => e.id === eventId); const round = event?.rounds?.find((r) => r.id === roundId); - if (!event || !round || !roundNumber) { - console.error('Error finding round for attempt', attemptActivityCode); + if (!event || !round) { + console.error('Unable to find event or round for attempt activity code:', attemptActivityCode); return; } @@ -31,7 +36,10 @@ export const generateAssignmentsForRoundAttempt = ( const attemptActivityIds = attemptActivities.map((a) => a.id); if (!attemptActivityIds.length) { - console.error('No attempt activities found for', attemptActivityCode); + console.error( + 'No attempt activities found in schedule for attempt activity code:', + attemptActivityCode + ); return; } @@ -42,7 +50,11 @@ export const generateAssignmentsForRoundAttempt = ( const nextAttemptActivityId = (assignments: InProgressAssignmment[]): number => { const counts: Record = assignments - .filter((a) => isCompetitorAssignment(a.assignment) && attemptActivityIds.includes(+a.assignment.activityId)) + .filter( + (a) => + isCompetitorAssignment(a.assignment) && + attemptActivityIds.includes(a.assignment.activityId) + ) .reduce( (sizes, { assignment }) => ({ ...sizes, @@ -65,9 +77,6 @@ export const generateAssignmentsForRoundAttempt = ( .sort(byName) .sort(byPROrResult(event, roundNumber)); - // eslint-disable-next-line - console.log(`Generating attempt assignments for ${persons.length} competitors`, persons); - return persons.reduce( (acc, person) => [ ...acc, diff --git a/src/pages/Competition/Round/hooks/useRoundActions.ts b/src/pages/Competition/Round/hooks/useRoundActions.ts index ad99d3a..e931e18 100644 --- a/src/pages/Competition/Round/hooks/useRoundActions.ts +++ b/src/pages/Competition/Round/hooks/useRoundActions.ts @@ -82,7 +82,7 @@ export const useRoundActions = ({ ); }) .catch((e) => { - console.error(e); + console.error('Failed to reset attempt assignments:', e); }); }, [activityCode, confirm, dispatch, groups, roundActivities]); diff --git a/src/pages/Competition/Round/hooks/useRoundData.ts b/src/pages/Competition/Round/hooks/useRoundData.ts index aaf6c34..f68f107 100644 --- a/src/pages/Competition/Round/hooks/useRoundData.ts +++ b/src/pages/Competition/Round/hooks/useRoundData.ts @@ -64,9 +64,10 @@ export const useRoundData = (activityCode: string, round: Round | undefined): Ro : []; const groups = roundActivities.flatMap((roundActivity) => allChildActivities(roundActivity)); - const parsedActivityCode = parseActivityCode(activityCode); - const isDistributedAttemptRoundLevel = - hasDistributedAttempts(activityCode) && parsedActivityCode.attemptNumber === undefined; + const isDistributedAttemptRoundLevel = useMemo(() => { + const parsedActivityCode = parseActivityCode(activityCode); + return hasDistributedAttempts(activityCode) && parsedActivityCode.attemptNumber === undefined; + }, [activityCode]); const distributedAttemptGroups = useMemo(() => { if (!isDistributedAttemptRoundLevel) { From 984899fb1808378cb1d97a27b99187ffe91d84db Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 7 May 2026 17:22:36 +0000 Subject: [PATCH 6/9] Use .nvmrc Node version in lint and type-check workflows --- .github/workflows/lint.yml | 2 +- .github/workflows/type-check.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index f048fd8..172da2f 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -19,7 +19,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '18' + node-version-file: '.nvmrc' cache: 'yarn' - name: Install dependencies diff --git a/.github/workflows/type-check.yml b/.github/workflows/type-check.yml index 8dbde80..3f3816a 100644 --- a/.github/workflows/type-check.yml +++ b/.github/workflows/type-check.yml @@ -19,7 +19,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '18' + node-version-file: '.nvmrc' cache: 'yarn' - name: Install dependencies From 2ce569e4c9459b98eb5ca753b6ab24a468e86d43 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 7 May 2026 23:36:26 +0000 Subject: [PATCH 7/9] Fix round-attempt nesting UI and round-level assignment visibility Agent-Logs-Url: https://github.com/coder13/delegateDashboard/sessions/66224d91-3a78-483b-95ba-f38e7f6af6d3 --- src/components/RoundActionButtons.tsx | 6 ++-- .../RoundSelector/RoundListItem.tsx | 10 +++++- .../_tests_/RoundSelector.test.tsx | 21 +++++++++++-- src/components/RoundSelector/index.tsx | 31 ++++++++++--------- .../_tests_/RoundActionButtons.test.tsx | 6 ++-- .../ConfigureAssignmentsDialog.tsx | 8 ++++- src/store/actions.test.ts | 5 ++- 7 files changed, 62 insertions(+), 25 deletions(-) diff --git a/src/components/RoundActionButtons.tsx b/src/components/RoundActionButtons.tsx index d849381..f0e8132 100644 --- a/src/components/RoundActionButtons.tsx +++ b/src/components/RoundActionButtons.tsx @@ -43,11 +43,11 @@ export const RoundActionButtons = ({ if (isDistributedAttemptRoundLevel) { 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 2e1bbdb..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; + }) => ( +
), })); @@ -105,7 +118,11 @@ describe('RoundSelector', () => { ); 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 5d461b3..dc9c80a 100644 --- a/src/components/RoundSelector/index.tsx +++ b/src/components/RoundSelector/index.tsx @@ -131,25 +131,27 @@ const RoundSelector = ({ onSelected }: RoundSelectorProps) => { {!hasDistributedAttempts(event.id) ? roundsForEvent.map((round) => ( - + )) : roundsForEvent.flatMap((round) => { const attempts = new Array(attemptCountForRound(round)).fill(0); const roundListItem = ( - + ); const attemptListItems = attempts.map((_, index) => { @@ -160,6 +162,7 @@ const RoundSelector = ({ onSelected }: RoundSelectorProps) => { key={attemptActivityCode} activityCode={attemptActivityCode} round={round} + nestingLevel={1} selected={attemptActivityCode === selectedId} in /> diff --git a/src/components/_tests_/RoundActionButtons.test.tsx b/src/components/_tests_/RoundActionButtons.test.tsx index 8f9e52e..87497e6 100644 --- a/src/components/_tests_/RoundActionButtons.test.tsx +++ b/src/components/_tests_/RoundActionButtons.test.tsx @@ -24,9 +24,9 @@ describe('RoundActionButtons', () => { ); - expect(getByText('Configure Attempt Assignments')).toBeInTheDocument(); - expect(getByText('Generate Attempt Assignments (All Attempts)')).toBeInTheDocument(); - expect(getByText('Reset Attempt Assignments')).toBeInTheDocument(); + expect(getByText('Configure Round Attempt Assignments')).toBeInTheDocument(); + expect(getByText('Assign Competitors to Round (All Attempts)')).toBeInTheDocument(); + expect(getByText('Clear Round Attempt Assignments')).toBeInTheDocument(); expect(queryByText('Configure Group Counts')).not.toBeInTheDocument(); }); }); diff --git a/src/dialogs/ConfigureAssignmentsDialog/ConfigureAssignmentsDialog.tsx b/src/dialogs/ConfigureAssignmentsDialog/ConfigureAssignmentsDialog.tsx index ecb62a7..d67013c 100644 --- a/src/dialogs/ConfigureAssignmentsDialog/ConfigureAssignmentsDialog.tsx +++ b/src/dialogs/ConfigureAssignmentsDialog/ConfigureAssignmentsDialog.tsx @@ -70,7 +70,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); @@ -175,6 +175,12 @@ const ConfigureAssignmentsDialog = ({ [dispatch, distributedAttemptGroups] ); + useEffect(() => { + if (isDistributedAttemptRoundLevel) { + setShowAllCompetitors(true); + } + }, [isDistributedAttemptRoundLevel]); + const handleKeyDown = useCallback((e: KeyboardEvent) => { if (e.ctrlKey) { return; diff --git a/src/store/actions.test.ts b/src/store/actions.test.ts index 1a06c69..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 }); + expect(patchWcifMock).toHaveBeenCalledWith('Comp1', { + formatVersion: wcif.formatVersion, + events: wcif.events, + }); expect(dispatch.mock.calls[1][0]).toEqual({ type: ActionType.UPLOADING_WCIF, uploading: false, From f92e1146524e3357bc25c78b56f8ffa3a0d1acd3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 7 May 2026 23:39:09 +0000 Subject: [PATCH 8/9] Remove redundant distributed assignment state effect Agent-Logs-Url: https://github.com/coder13/delegateDashboard/sessions/66224d91-3a78-483b-95ba-f38e7f6af6d3 --- .../ConfigureAssignmentsDialog.tsx | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/dialogs/ConfigureAssignmentsDialog/ConfigureAssignmentsDialog.tsx b/src/dialogs/ConfigureAssignmentsDialog/ConfigureAssignmentsDialog.tsx index d67013c..b0eadbd 100644 --- a/src/dialogs/ConfigureAssignmentsDialog/ConfigureAssignmentsDialog.tsx +++ b/src/dialogs/ConfigureAssignmentsDialog/ConfigureAssignmentsDialog.tsx @@ -175,12 +175,6 @@ const ConfigureAssignmentsDialog = ({ [dispatch, distributedAttemptGroups] ); - useEffect(() => { - if (isDistributedAttemptRoundLevel) { - setShowAllCompetitors(true); - } - }, [isDistributedAttemptRoundLevel]); - const handleKeyDown = useCallback((e: KeyboardEvent) => { if (e.ctrlKey) { return; From 5b71ad262f7075582cc8d49e760498231a82e229 Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Thu, 7 May 2026 18:07:43 -0700 Subject: [PATCH 9/9] Fix distributed round assignment attempts --- src/components/RoundActionButtons.tsx | 2 +- .../_tests_/RoundActionButtons.test.tsx | 2 +- .../ConfigureAssignmentsDialog.test.tsx | 189 ++++++++++++++ .../ConfigureAssignmentsDialog.tsx | 129 ++-------- .../wcif/validation/eventRoundValidation.ts | 8 +- .../Round/DistributedAttemptRoundView.tsx | 230 +++++++++++++++++ .../Competition/Round/NormalRoundView.tsx | 107 ++++++++ .../Competition/Round/RoundContainer.tsx | 231 +++++++++--------- .../Round/hooks/useRoundActions.ts | 6 +- .../Round/hooks/useRoundData.test.tsx | 135 ++++++++++ .../Competition/Round/hooks/useRoundData.ts | 62 +++-- 11 files changed, 840 insertions(+), 261 deletions(-) create mode 100644 src/dialogs/ConfigureAssignmentsDialog/ConfigureAssignmentsDialog.test.tsx create mode 100644 src/pages/Competition/Round/DistributedAttemptRoundView.tsx create mode 100644 src/pages/Competition/Round/NormalRoundView.tsx create mode 100644 src/pages/Competition/Round/hooks/useRoundData.test.tsx diff --git a/src/components/RoundActionButtons.tsx b/src/components/RoundActionButtons.tsx index f0e8132..ffa49e2 100644 --- a/src/components/RoundActionButtons.tsx +++ b/src/components/RoundActionButtons.tsx @@ -44,7 +44,7 @@ export const RoundActionButtons = ({ return ( <> - + - - - ))} - + @@ -259,13 +174,10 @@ const ConfigureAssignmentsDialog = ({ {distributedAttemptGroups.flatMap((attemptGroup) => attemptGroup.activities.map((activity) => { - const parentRoomName = - typeof activity.parent === 'object' && 'room' in activity.parent - ? activity.parent.room.name - : ''; + const roomName = activity.room.name; return ( - {parentRoomName} + {roomName} ); }) @@ -291,12 +203,11 @@ const ConfigureAssignmentsDialog = ({ attemptGroup.activities.map((activity) => ( { - 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 864e41f..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'; @@ -62,134 +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} - 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} - /> - } - /> - - - {personsShouldBeInRound.length === 0 && ( - - - - No one in round to automatically assign. Make sure the next round is opened on - WCA-Live to generate assignments - - - - )} + 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} + /> + ); - - {sortedGroups.map((group) => ( - - ))} - + 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.personsAssignments.setOpen(false)} + /> + dialogs.configureGroups.setOpen(false)} + activityCode={activityCode} + /> + dialogs.rawRoundData.setOpen(false)} + roundId={roundId} + /> + dialogs.rawRoundActivitiesData.setOpen(false)} + activityCode={activityCode} + /> + + ); - dialogs.configureAssignments.setOpen(false)} - round={round} + return ( + + {isDistributedAttemptRoundLevel ? ( + dialogs.rawRoundData.setOpen(true)} + onOpenRawActivitiesData={() => dialogs.rawRoundActivitiesData.setOpen(true)} + onOpenPersonsDialog={dialogs.personsDialog.open} + onOpenPersonsAssignmentsDialog={() => dialogs.personsAssignments.setOpen(true)} + actionButtons={actionButtons} + adamRoundConfig={adamRoundConfig} distributedAttemptGroups={distributedAttemptGroups} /> - dialogs.configureGroupCounts.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} /> - {dialogs.configureStationNumbers.activityCode && ( - dialogs.configureStationNumbers.setActivityCode(false)} - activityCode={dialogs.configureStationNumbers.activityCode} - /> - )} - - dialogs.personsAssignments.setOpen(false)} - /> - dialogs.configureGroups.setOpen(false)} - activityCode={activityCode} - /> - dialogs.rawRoundData.setOpen(false)} - roundId={roundId} - /> - dialogs.rawRoundActivitiesData.setOpen(false)} - activityCode={activityCode} - /> - + )} + {commonDialogs} ); }; diff --git a/src/pages/Competition/Round/hooks/useRoundActions.ts b/src/pages/Competition/Round/hooks/useRoundActions.ts index e931e18..72cf009 100644 --- a/src/pages/Competition/Round/hooks/useRoundActions.ts +++ b/src/pages/Competition/Round/hooks/useRoundActions.ts @@ -42,7 +42,7 @@ export const useRoundActions = ({ const attemptCodes = Array.from( new Set( - groups + [...groups, ...roundActivities] .map((activity) => activity.activityCode) .filter((code) => parseActivityCode(code).attemptNumber !== undefined) ) @@ -51,7 +51,7 @@ export const useRoundActions = ({ attemptCodes.forEach((code) => { dispatch(generateRoundAttemptAssignments(code)); }); - }, [dispatch, activityCode, groups]); + }, [dispatch, activityCode, groups, roundActivities]); const handleResetAttemptAssignments = useCallback(() => { confirm({ @@ -64,7 +64,7 @@ export const useRoundActions = ({ const activityIdsToReset = attemptNumber !== undefined ? roundActivities.map((roundActivity) => roundActivity.id) - : groups + : [...groups, ...roundActivities] .filter((group) => parseActivityCode(group.activityCode).attemptNumber !== undefined) .map((group) => group.id); 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 f68f107..489efa0 100644 --- a/src/pages/Competition/Round/hooks/useRoundData.ts +++ b/src/pages/Competition/Round/hooks/useRoundData.ts @@ -1,4 +1,5 @@ import { + activityCodeIsChild, hasDistributedAttempts, parseActivityCode, } from '../../../../lib/domain/activities/activityCode'; @@ -36,7 +37,7 @@ interface RoundDataResult { isDistributedAttemptRoundLevel: boolean; distributedAttemptGroups: Array<{ attemptNumber: number; - activities: ActivityWithParent[]; + activities: ActivityWithRoom[]; }>; } @@ -47,35 +48,48 @@ export const useRoundData = (activityCode: string, round: Round | undefined): Ro round ? selectPersonsShouldBeInRound(state)(round) : [] ); - // 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)); const isDistributedAttemptRoundLevel = useMemo(() => { const parsedActivityCode = parseActivityCode(activityCode); return hasDistributedAttempts(activityCode) && parsedActivityCode.attemptNumber === undefined; }, [activityCode]); + // list of each stage's round activity + // 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 = groups.reduce< - Record + const groupedByAttempt = roundActivities.reduce< + Record >((acc, activity) => { const { attemptNumber } = parseActivityCode(activity.activityCode); if (!attemptNumber) { @@ -94,15 +108,13 @@ export const useRoundData = (activityCode: string, round: Round | undefined): Ro .map((group) => ({ ...group, activities: [...group.activities].sort((a, b) => { - const roomNameA = - typeof a.parent === 'object' && 'room' in a.parent ? a.parent.room.name : ''; - const roomNameB = - typeof b.parent === 'object' && 'room' in b.parent ? b.parent.room.name : ''; + 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); - }, [groups, isDistributedAttemptRoundLevel]); + }, [isDistributedAttemptRoundLevel, roundActivities]); const sortedGroups = useMemo( () =>