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 (
<>
-
+