From 71010336599e0a39b577bfa58e94c7aac4a97e7a Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Sat, 23 May 2026 15:53:24 -0700 Subject: [PATCH 01/11] Add PNW recipe constraints --- src/components/RoundActionButtons.tsx | 5 +- src/components/RoundAssignmentCountsTable.tsx | 164 +++++++ src/lib/recipes/conditions.ts | 28 ++ src/lib/recipes/constraints.test.ts | 99 +++++ src/lib/recipes/constraints.ts | 148 +++++++ src/lib/recipes/index.ts | 1 + src/lib/recipes/recipes.ts | 25 +- src/lib/recipes/steps.ts | 3 + src/lib/recipes/steps/core.ts | 13 + src/lib/recipes/steps/pnw.ts | 105 ++++- src/lib/recipes/types.ts | 1 + .../Competition/Round/RoundContainer.tsx | 8 +- src/store/reducers/runRecipe.test.ts | 412 ++++++++++++++++++ src/store/reducers/runRecipe.ts | 12 +- 14 files changed, 994 insertions(+), 30 deletions(-) create mode 100644 src/components/RoundAssignmentCountsTable.tsx create mode 100644 src/lib/recipes/conditions.ts create mode 100644 src/lib/recipes/constraints.test.ts create mode 100644 src/lib/recipes/constraints.ts create mode 100644 src/store/reducers/runRecipe.test.ts diff --git a/src/components/RoundActionButtons.tsx b/src/components/RoundActionButtons.tsx index d765315..dbb3c10 100644 --- a/src/components/RoundActionButtons.tsx +++ b/src/components/RoundActionButtons.tsx @@ -13,7 +13,6 @@ interface RoundActionButtonsProps { personsShouldBeInRound: Person[]; activityCode: string; onConfigureAssignments: () => void; - onGenerateAssignments: () => void; recipeId: string; onChangeRecipeId: (recipeId: string) => void; onRunRecipe: () => void; @@ -30,7 +29,6 @@ export const RoundActionButtons = ({ personsShouldBeInRound, activityCode, onConfigureAssignments, - onGenerateAssignments, recipeId, onChangeRecipeId, onRunRecipe, @@ -52,7 +50,6 @@ export const RoundActionButtons = ({ return ( <> - Recipe setRecipeId(String(event.target.value))}> + {Recipes.map((recipe) => ( + + {recipe.name} + + ))} + + + + + + + Only rounds with competitors can be selected. Selected rounds run in the displayed order. + Existing groups and assignments are preserved. + + + {orderedRows.length === 0 ? ( + No normal non-distributed rounds found. + ) : ( + + )} + + + + + + ); +}; + +export default BulkGenerationPage; diff --git a/src/pages/Competition/index.tsx b/src/pages/Competition/index.tsx index 27341ed..fe8172d 100644 --- a/src/pages/Competition/index.tsx +++ b/src/pages/Competition/index.tsx @@ -7,3 +7,4 @@ export { default as Assignments } from './Assignments'; export { default as Import } from './Import'; export { default as Export } from './Export'; export { default as ScramblerSchedule } from './ScramblerSchedule'; +export { default as BulkGeneration } from './BulkGeneration'; diff --git a/src/store/actions.test.ts b/src/store/actions.test.ts index 32b006a..5691a76 100644 --- a/src/store/actions.test.ts +++ b/src/store/actions.test.ts @@ -12,6 +12,7 @@ import { partialUpdateWCIF, removePersonAssignments, resetAllGroupAssignments, + runRecipes, togglePersonRole, updateGlobalExtension, updateGroupCount, @@ -144,6 +145,11 @@ describe('store actions', () => { roundId: '333-r1', options: { sortOrganizationStaffInLastGroups: true }, }); + expect(runRecipes(['333-r1', '222-r1'], 'pnw')).toEqual({ + type: ActionType.RUN_RECIPES, + roundIds: ['333-r1', '222-r1'], + recipeId: 'pnw', + }); expect(editActivity({ id: 10 }, { name: 'Updated' })).toEqual({ type: ActionType.EDIT_ACTIVITY, where: { id: 10 }, diff --git a/src/store/actions.ts b/src/store/actions.ts index aa16428..364cf19 100644 --- a/src/store/actions.ts +++ b/src/store/actions.ts @@ -47,6 +47,7 @@ export const ActionType = { RESET_ALL_GROUP_ASSIGNMENTS: 'reset_all_group_assignments', GENERATE_ASSIGNMENTS: 'generate_assignments', RUN_RECIPE: 'run_recipe', + RUN_RECIPES: 'run_recipes', GENERATE_ROUND_ATTEMPT_ASSIGNMENTS: 'generate_round_attempt_assignments', EDIT_ACTIVITY: 'edit_activity', UPDATE_GLOBAL_EXTENSION: 'update_global_extension', @@ -380,6 +381,19 @@ export const runRecipe = ( recipeId, }); +export type RunRecipesPayload = { + roundIds: string[]; + recipeId: string; +}; +export const runRecipes = ( + roundIds: string[], + recipeId: string +): ReduxAction => ({ + type: ActionType.RUN_RECIPES, + roundIds, + recipeId, +}); + export const generateAssignments = ( roundId: string, options?: Partial diff --git a/src/store/reducerHandlers.ts b/src/store/reducerHandlers.ts index 7168ca3..a589794 100644 --- a/src/store/reducerHandlers.ts +++ b/src/store/reducerHandlers.ts @@ -173,6 +173,7 @@ export const reducers: Record = { }, [ActionType.GENERATE_ASSIGNMENTS]: Reducers.generateAssignments, [ActionType.RUN_RECIPE]: Reducers.runRecipe, + [ActionType.RUN_RECIPES]: Reducers.runRecipes, [ActionType.GENERATE_ROUND_ATTEMPT_ASSIGNMENTS]: ( state, action: GenerateRoundAttemptAssignmentsPayload diff --git a/src/store/reducers/runRecipe.test.ts b/src/store/reducers/runRecipe.test.ts index c8b1b4f..be4986e 100644 --- a/src/store/reducers/runRecipe.test.ts +++ b/src/store/reducers/runRecipe.test.ts @@ -1,4 +1,5 @@ import { runRecipe } from './runRecipe'; +import { runRecipes } from './runRecipe'; import { buildActivity, buildEvent, @@ -15,8 +16,10 @@ import { type Competition, type EventId, type Person, + type Round, } from '@wca/helpers'; import { describe, expect, it } from 'vitest'; +import { getRoundConfigExtensionData } from '../../lib/wcif/extensions/delegateDashboard/delegateDashboard'; interface CompetitionOptions { groupCount?: number; @@ -32,6 +35,11 @@ const acceptedRegistration = (registrantId: number) => ({ wcaRegistrationId: registrantId, }); +const acceptedRegistrationForEvents = (registrantId: number, eventIds: EventId[]) => ({ + ...acceptedRegistration(registrantId), + eventIds, +}); + const personalBest = (worldRanking: number, best: number) => ({ eventId: '333' as EventId, type: 'average' as const, @@ -80,6 +88,14 @@ const buildTimedGroup = ( endTime, }); +const buildTimedRoundActivity = (id: number, activityCode: string, childActivities: Activity[]) => + buildActivity({ + id, + name: activityCode, + activityCode, + childActivities, + }); + const buildRoundActivity = ( stageNumber: number, groupCount: number, @@ -171,6 +187,27 @@ const activityForAssignment = (wcif: Competition, person: Person | undefined) => return allActivities(wcif).find((activity) => activity.id === assignment.activityId); }; +const competitorAssignmentInRound = ( + wcif: Competition, + person: Person | undefined, + roundId: string +) => { + const activityIds = new Set( + allActivities(wcif) + .filter((activity) => activity.activityCode.startsWith(`${roundId}-g`)) + .map((activity) => activity.id) + ); + + return person?.assignments?.find( + (personAssignment) => + personAssignment.assignmentCode === 'competitor' && + activityIds.has(personAssignment.activityId) + ); +}; + +const roundById = (wcif: Competition, roundId: string) => + wcif.events.flatMap((event) => event.rounds).find((round) => round.id === roundId); + const competitorGroupNumber = (wcif: Competition, person: Person | undefined) => { const activity = activityForAssignment(wcif, person); if (!activity) return undefined; @@ -248,6 +285,21 @@ const assignmentCount = (person: Person | undefined, assignmentCode: AssignmentC ).length ?? 0; describe('runRecipe', () => { + it('stores the selected recipe config on the generated round', () => { + const updatedState = runRecipe( + buildState(buildCompetition([competitor(1), competitor(2)], { includeGroups: false })), + { + roundId: '333-r1', + recipeId: 'balanced', + } + ); + + const updatedRound = roundById(updatedState.wcif as Competition, '333-r1'); + expect(getRoundConfigExtensionData(updatedRound as NonNullable)).toMatchObject({ + recipe: { id: 'balanced' }, + }); + }); + it('assigns every finalist into the generated group for one-group finals', () => { const wcif = runPnwRecipe( Array.from({ length: 4 }, (_, index) => competitor(index + 1)), @@ -583,3 +635,96 @@ describe('runRecipe', () => { ).toEqual(otherRound.childActivities); }); }); + +describe('runRecipes', () => { + const buildBulkCompetition = () => { + const persons = [1, 2, 3].map((registrantId) => + competitor(registrantId, { + registration: acceptedRegistrationForEvents(registrantId, [ + '222' as EventId, + '333' as EventId, + ]), + personalBests: [ + personalBest(registrantId, 1000 + registrantId), + { + ...personalBest(registrantId, 900 + registrantId), + eventId: '222' as EventId, + }, + ], + }) + ); + const round222 = buildTimedRoundActivity(2, '222-r1', [ + buildTimedGroup(201, '222-r1-g1', '2024-01-01T10:00:00.000Z', '2024-01-01T10:10:00.000Z'), + ]); + const round333 = buildTimedRoundActivity(1, '333-r1', [ + buildTimedGroup(101, '333-r1-g1', '2024-01-01T10:10:00.000Z', '2024-01-01T10:20:00.000Z'), + buildTimedGroup(102, '333-r1-g2', '2024-01-01T11:00:00.000Z', '2024-01-01T11:10:00.000Z'), + ]); + + return buildWcifWithEvents( + [round333, round222], + [ + buildEvent({ id: '333', rounds: [buildRound({ id: '333-r1' })] }), + buildEvent({ id: '222', rounds: [buildRound({ id: '222-r1' })] }), + ], + persons + ); + }; + + it('runs the same recipe across selected rounds in order with accumulating WCIF context', () => { + const updatedState = runRecipes(buildState(buildBulkCompetition()), { + recipeId: 'pnw', + roundIds: ['222-r1', '333-r1'], + }); + const updatedWcif = updatedState.wcif as Competition; + const person = personById(updatedWcif, 1); + + expect(competitorAssignmentInRound(updatedWcif, person, '222-r1')).toBeDefined(); + expect(competitorAssignmentInRound(updatedWcif, person, '333-r1')).toBeDefined(); + expect(competitorGroupNumberInRound(updatedWcif, person, '333-r1')).toBe(2); + expect(updatedState.needToSave).toBe(true); + expect(Array.from(updatedState.changedKeys).sort()).toEqual(['events', 'persons', 'schedule']); + }); + + it('preserves existing groups and assignments while filling missing round data', () => { + const existingAssignment = assignment(101, 'competitor'); + const wcif = { + ...buildBulkCompetition(), + persons: [ + competitor(1, { + registration: acceptedRegistrationForEvents(1, ['222' as EventId, '333' as EventId]), + assignments: [existingAssignment], + }), + competitor(2, { + registration: acceptedRegistrationForEvents(2, ['222' as EventId, '333' as EventId]), + }), + ], + }; + + const updatedWcif = runRecipes(buildState(wcif), { + recipeId: 'pnw', + roundIds: ['333-r1', '222-r1'], + }).wcif as Competition; + + expect(personById(updatedWcif, 1)?.assignments).toContainEqual(existingAssignment); + expect( + allActivities(updatedWcif).find((activity) => activity.activityCode === '333-r1') + ?.childActivities + ).toHaveLength(2); + expect(competitorAssignmentInRound(updatedWcif, personById(updatedWcif, 2), '333-r1')).toBeDefined(); + }); + + it('stores the selected recipe config on every generated round', () => { + const updatedWcif = runRecipes(buildState(buildBulkCompetition()), { + recipeId: 'mca', + roundIds: ['333-r1', '222-r1'], + }).wcif as Competition; + + expect(getRoundConfigExtensionData(roundById(updatedWcif, '333-r1') as Round)).toMatchObject({ + recipe: { id: 'mca' }, + }); + expect(getRoundConfigExtensionData(roundById(updatedWcif, '222-r1') as Round)).toMatchObject({ + recipe: { id: 'mca' }, + }); + }); +}); diff --git a/src/store/reducers/runRecipe.ts b/src/store/reducers/runRecipe.ts index a704e6c..3797f3e 100644 --- a/src/store/reducers/runRecipe.ts +++ b/src/store/reducers/runRecipe.ts @@ -11,8 +11,33 @@ import { } from '../../lib/recipes'; import { shouldRunGroupStep } from '../../lib/recipes/conditions'; import { mapIn } from '../../lib/utils/utils'; +import { + getRoundConfigExtensionData, + setRoundConfigExtensionData, +} from '../../lib/wcif/extensions/delegateDashboard/delegateDashboard'; import { type AppState } from '../initialState'; -import type { RunRecipePayload } from '../actions'; +import type { RunRecipePayload, RunRecipesPayload } from '../actions'; + +const setRoundRecipeConfig = ( + wcif: Competition, + roundId: string, + recipeId: string +): Competition => ({ + ...wcif, + events: wcif.events.map((event) => ({ + ...event, + rounds: event.rounds.map((round) => { + if (round.id !== roundId) { + return round; + } + + return setRoundConfigExtensionData(round, { + ...(getRoundConfigExtensionData(round) ?? {}), + recipe: { id: recipeId }, + }); + }), + })), +}); /** * Run a built-in recipe to generate groups and/or assignments for a round. @@ -29,7 +54,7 @@ export function runRecipe(state: AppState, action: RunRecipePayload): AppState { const recipe = fromRecipeDefinition(recipeDef, { wcif, activityCode: action.roundId }); - const updatedWcif = recipe.steps.reduce((accWcif, step) => { + const generatedWcif = recipe.steps.reduce((accWcif, step) => { if (step.type === 'assignments') { const generator = (Generators as Record)[step.props.generator]; if (!generator) { @@ -112,6 +137,8 @@ export function runRecipe(state: AppState, action: RunRecipePayload): AppState { return accWcif; }, wcif); + const updatedWcif = setRoundRecipeConfig(generatedWcif, action.roundId, action.recipeId); + return { ...state, needToSave: true, @@ -119,3 +146,10 @@ export function runRecipe(state: AppState, action: RunRecipePayload): AppState { wcif: updatedWcif, }; } + +export function runRecipes(state: AppState, action: RunRecipesPayload): AppState { + return action.roundIds.reduce( + (accState, roundId) => runRecipe(accState, { roundId, recipeId: action.recipeId }), + state + ); +} From c66643b92b7814e64b52a2445084196c1fa89b62 Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Sat, 23 May 2026 17:42:03 -0700 Subject: [PATCH 09/11] Move bulk generation into worker --- src/lib/recipes/runRecipeOnWcif.ts | 309 ++++++++++++++++++ .../BulkGeneration/BulkRoundTable.tsx | 8 +- .../BulkGeneration/bulkGeneration.worker.ts | 37 +++ .../bulkGenerationWorkerTypes.ts | 31 ++ .../Competition/BulkGeneration/index.test.tsx | 99 +++++- .../Competition/BulkGeneration/index.tsx | 116 ++++++- src/store/reducers/runRecipe.ts | 147 +-------- 7 files changed, 593 insertions(+), 154 deletions(-) create mode 100644 src/lib/recipes/runRecipeOnWcif.ts create mode 100644 src/pages/Competition/BulkGeneration/bulkGeneration.worker.ts create mode 100644 src/pages/Competition/BulkGeneration/bulkGenerationWorkerTypes.ts diff --git a/src/lib/recipes/runRecipeOnWcif.ts b/src/lib/recipes/runRecipeOnWcif.ts new file mode 100644 index 0000000..d4b154f --- /dev/null +++ b/src/lib/recipes/runRecipeOnWcif.ts @@ -0,0 +1,309 @@ +import type { Activity, AssignmentCode, Competition, Person } from '@wca/helpers'; +import { + Constraints, + Generators, + type Constraint, + type ConstraintAndWeight, + type Generator, +} from 'wca-group-generators'; +import { findRoundActivitiesById } from '../wcif/activities'; +import { createGroupsAcrossStages } from '../wcif/groups'; +import { + getRoundConfigExtensionData, + setRoundConfigExtensionData, +} from '../wcif/extensions/delegateDashboard/delegateDashboard'; +import { mapIn } from '../utils/utils'; +import { shouldRunGroupStep } from './conditions'; +import { RecipeConstraints } from './constraints'; +import { optimizeAssignmentsGlobally } from './globalScoring'; +import { Recipes, fromRecipeDefinition } from './recipes'; +import { hydrateStep } from './steps'; +import type { AssignmentStep, ConstraintProps, Step } from './types'; + +export interface RunRecipeOnWcifPayload { + roundId: string; + recipeId: string; +} + +export interface RunRecipesOnWcifPayload { + roundIds: string[]; + recipeId: string; +} + +export interface BulkGenerationProgress { + phase: 'generating' | 'fixing' | 'staff'; + roundId?: string; +} + +interface OptimizationContext { + roundId: string; + stepId: string; + cluster: Person[]; + activities: Activity[]; + assignmentCode: AssignmentCode; + constraints: ConstraintAndWeight[]; + options?: Record; + maxPasses?: number; + maxEvaluations?: number; + maxClusterSize?: number; +} + +interface RunRecipeStepsOnWcifPayload extends RunRecipeOnWcifPayload { + stepFilter?: (step: Step) => boolean; + onOptimizationContext?: (context: OptimizationContext) => void; +} + +const isCompetitorAssignmentStep = (step: Step) => + step.type === 'assignments' && step.props.assignmentCode === 'competitor'; + +const isStaffAssignmentStep = (step: Step) => + step.type === 'assignments' && step.props.assignmentCode !== 'competitor'; + +const setRoundRecipeConfig = ( + wcif: Competition, + roundId: string, + recipeId: string +): Competition => ({ + ...wcif, + events: wcif.events.map((event) => ({ + ...event, + rounds: event.rounds.map((round) => { + if (round.id !== roundId) { + return round; + } + + return setRoundConfigExtensionData(round, { + ...(getRoundConfigExtensionData(round) ?? {}), + recipe: { id: recipeId }, + }); + }), + })), +}); + +const setRoundRecipeConfigs = (wcif: Competition, roundIds: string[], recipeId: string) => + roundIds.reduce( + (accWcif, roundId) => setRoundRecipeConfig(accWcif, roundId, recipeId), + wcif + ); + +const resolveConstraints = (constraints: ConstraintProps[] | undefined): ConstraintAndWeight[] => + constraints?.map((constraintConfig) => { + const constraintFn = + (RecipeConstraints as Record)[constraintConfig.constraint] ?? + (Constraints as unknown as Record)[ + constraintConfig.constraint + ]; + if (!constraintFn) { + throw new Error(`Constraint ${constraintConfig.constraint} not found`); + } + return { + constraint: constraintFn, + weight: constraintConfig.weight, + options: constraintConfig.options, + }; + }) ?? []; + +const runAssignmentStepOnWcif = ( + wcif: Competition, + roundId: string, + step: AssignmentStep, + onOptimizationContext?: (context: OptimizationContext) => void +) => { + const generator = (Generators as unknown as Record)[ + step.props.generator + ]; + if (!generator) { + throw new Error(`Generator ${step.props.generator} not found`); + } + + const hydratedStep = hydrateStep(wcif, roundId, step); + const constraints = resolveConstraints(hydratedStep.props.constraints); + + if (hydratedStep.props.globalScore) { + onOptimizationContext?.({ + roundId, + stepId: hydratedStep.id, + cluster: hydratedStep.props.cluster, + activities: hydratedStep.props.activities, + assignmentCode: hydratedStep.props.assignmentCode, + constraints, + options: hydratedStep.props.options, + maxPasses: hydratedStep.props.globalScore.maxPasses, + maxEvaluations: hydratedStep.props.globalScore.maxEvaluations, + maxClusterSize: hydratedStep.props.globalScore.maxClusterSize, + }); + } + + const generatedWcif = generator.execute({ + wcif, + roundId, + ...hydratedStep.props, + constraints, + }) as Competition; + + if (!hydratedStep.props.globalScore) { + return generatedWcif; + } + + if ( + hydratedStep.props.globalScore.maxClusterSize && + hydratedStep.props.cluster.length > hydratedStep.props.globalScore.maxClusterSize + ) { + return generatedWcif; + } + + return optimizeAssignmentsGlobally({ + beforeWcif: wcif, + wcif: generatedWcif, + cluster: hydratedStep.props.cluster, + activities: hydratedStep.props.activities, + assignmentCode: hydratedStep.props.assignmentCode, + constraints, + options: hydratedStep.props.options, + maxPasses: hydratedStep.props.globalScore.maxPasses, + maxEvaluations: hydratedStep.props.globalScore.maxEvaluations, + }); +}; + +const runGroupStepOnWcif = (wcif: Competition, roundId: string, step: Step) => { + if (step.type !== 'groups') { + return wcif; + } + + const roundActivities = findRoundActivitiesById(wcif, roundId); + if (!shouldRunGroupStep(wcif, roundId, roundActivities, step)) { + return wcif; + } + + const roundActivitiesWithGroups = createGroupsAcrossStages(wcif, roundActivities, { + spreadGroupsAcrossAllStages: true, + groups: step.props.count, + }); + const roundActivitiesWithGroupsById = new Map( + roundActivitiesWithGroups.map((activity) => [activity.id, activity]) + ); + + return { + ...wcif, + schedule: mapIn(wcif.schedule, 'venues', (venue) => + mapIn(venue, 'rooms', (room) => + mapIn(room, 'activities', (activity) => + roundActivitiesWithGroupsById.get(activity.id) ?? activity + ) + ) + ), + } as Competition; +}; + +const recipeStepsFor = (wcif: Competition, recipeId: string, roundId: string) => { + const recipeDef = Recipes.find((recipe) => recipe.id === recipeId); + if (!recipeDef) { + throw new Error(`Recipe ${recipeId} not found`); + } + + return fromRecipeDefinition(recipeDef, { wcif, activityCode: roundId }).steps; +}; + +const runRecipeStepsOnWcif = ( + wcif: Competition, + { roundId, recipeId, stepFilter, onOptimizationContext }: RunRecipeStepsOnWcifPayload +): Competition => + recipeStepsFor(wcif, recipeId, roundId) + .filter(stepFilter ?? (() => true)) + .reduce((accWcif, step) => { + if (step.type === 'assignments') { + return runAssignmentStepOnWcif(accWcif, roundId, step, onOptimizationContext); + } + + return runGroupStepOnWcif(accWcif, roundId, step); + }, wcif); + +const optimizeCapturedContexts = ( + wcif: Competition, + baseWcif: Competition, + contexts: OptimizationContext[] +) => { + let optimizedWcif = wcif; + + for (let pass = 0; pass < 2; pass += 1) { + for (const context of contexts) { + if (context.maxClusterSize && context.cluster.length > context.maxClusterSize) { + continue; + } + + optimizedWcif = optimizeAssignmentsGlobally({ + beforeWcif: baseWcif, + wcif: optimizedWcif, + cluster: context.cluster, + activities: context.activities, + assignmentCode: context.assignmentCode, + constraints: context.constraints, + options: context.options, + maxPasses: context.maxPasses, + maxEvaluations: context.maxEvaluations, + }); + } + } + + return optimizedWcif; +}; + +export const runRecipeOnWcif = (wcif: Competition, action: RunRecipeOnWcifPayload): Competition => + setRoundRecipeConfig( + runRecipeStepsOnWcif(wcif, action), + action.roundId, + action.recipeId + ); + +export const runRecipesOnWcif = ( + wcif: Competition, + action: RunRecipesOnWcifPayload +): Competition => + action.roundIds.reduce( + (accWcif, roundId) => runRecipeOnWcif(accWcif, { roundId, recipeId: action.recipeId }), + wcif + ); + +export const runBulkRecipesOnWcif = ( + wcif: Competition, + { + recipeId, + roundIds, + onProgress, + }: RunRecipesOnWcifPayload & { + onProgress?: (progress: BulkGenerationProgress) => void; + } +): Competition => { + const optimizationContexts: OptimizationContext[] = []; + let generatedWcif = wcif; + + for (const roundId of roundIds) { + onProgress?.({ phase: 'generating', roundId }); + generatedWcif = runRecipeStepsOnWcif(generatedWcif, { + roundId, + recipeId, + stepFilter: (step) => step.type === 'groups' || isCompetitorAssignmentStep(step), + onOptimizationContext: (context) => { + if (context.assignmentCode === 'competitor') { + optimizationContexts.push(context); + } + }, + }); + } + + if (optimizationContexts.length) { + onProgress?.({ phase: 'fixing' }); + generatedWcif = optimizeCapturedContexts(generatedWcif, wcif, optimizationContexts); + } + + for (const roundId of roundIds) { + onProgress?.({ phase: 'staff', roundId }); + generatedWcif = runRecipeStepsOnWcif(generatedWcif, { + roundId, + recipeId, + stepFilter: isStaffAssignmentStep, + }); + } + + return setRoundRecipeConfigs(generatedWcif, roundIds, recipeId); +}; diff --git a/src/pages/Competition/BulkGeneration/BulkRoundTable.tsx b/src/pages/Competition/BulkGeneration/BulkRoundTable.tsx index 598379b..ecd0a2a 100644 --- a/src/pages/Competition/BulkGeneration/BulkRoundTable.tsx +++ b/src/pages/Competition/BulkGeneration/BulkRoundTable.tsx @@ -17,6 +17,7 @@ import { interface BulkRoundTableProps { rows: BulkRoundRow[]; selectedRoundIds: Set; + disabled?: boolean; onToggleRound: (roundId: string) => void; onMoveRound: (roundId: string, direction: -1 | 1) => void; } @@ -24,6 +25,7 @@ interface BulkRoundTableProps { export const BulkRoundTable = ({ rows, selectedRoundIds, + disabled = false, onToggleRound, onMoveRound, }: BulkRoundTableProps) => ( @@ -47,7 +49,7 @@ export const BulkRoundTable = ({ onToggleRound(row.roundId)} slotProps={{ input: { 'aria-label': `Select ${row.roundId}` } }} /> @@ -59,7 +61,7 @@ export const BulkRoundTable = ({ onMoveRound(row.roundId, -1)}> @@ -70,7 +72,7 @@ export const BulkRoundTable = ({ onMoveRound(row.roundId, 1)}> diff --git a/src/pages/Competition/BulkGeneration/bulkGeneration.worker.ts b/src/pages/Competition/BulkGeneration/bulkGeneration.worker.ts new file mode 100644 index 0000000..dc2cb47 --- /dev/null +++ b/src/pages/Competition/BulkGeneration/bulkGeneration.worker.ts @@ -0,0 +1,37 @@ +import { runBulkRecipesOnWcif } from '../../../lib/recipes/runRecipeOnWcif'; +import type { + BulkGenerationWorkerRequest, + BulkGenerationWorkerResponse, +} from './bulkGenerationWorkerTypes'; + +const workerScope = self as unknown as { + postMessage: (message: BulkGenerationWorkerResponse) => void; + addEventListener: ( + type: 'message', + listener: (event: MessageEvent) => void + ) => void; +}; + +workerScope.addEventListener('message', (event) => { + const message = event.data; + if (message.type !== 'runBulkGeneration') { + return; + } + + try { + const wcif = runBulkRecipesOnWcif(message.wcif, { + recipeId: message.recipeId, + roundIds: message.roundIds, + onProgress: (progress) => { + workerScope.postMessage({ type: 'progress', ...progress }); + }, + }); + + workerScope.postMessage({ type: 'complete', wcif }); + } catch (error) { + workerScope.postMessage({ + type: 'error', + message: error instanceof Error ? error.message : 'Bulk generation failed', + }); + } +}); diff --git a/src/pages/Competition/BulkGeneration/bulkGenerationWorkerTypes.ts b/src/pages/Competition/BulkGeneration/bulkGenerationWorkerTypes.ts new file mode 100644 index 0000000..29d391c --- /dev/null +++ b/src/pages/Competition/BulkGeneration/bulkGenerationWorkerTypes.ts @@ -0,0 +1,31 @@ +import type { Competition } from '@wca/helpers'; + +export type BulkGenerationProgressPhase = 'generating' | 'fixing' | 'staff'; + +export interface BulkGenerationWorkerRequest { + type: 'runBulkGeneration'; + wcif: Competition; + recipeId: string; + roundIds: string[]; +} + +export interface BulkGenerationWorkerProgress { + type: 'progress'; + phase: BulkGenerationProgressPhase; + roundId?: string; +} + +export interface BulkGenerationWorkerComplete { + type: 'complete'; + wcif: Competition; +} + +export interface BulkGenerationWorkerError { + type: 'error'; + message: string; +} + +export type BulkGenerationWorkerResponse = + | BulkGenerationWorkerProgress + | BulkGenerationWorkerComplete + | BulkGenerationWorkerError; diff --git a/src/pages/Competition/BulkGeneration/index.test.tsx b/src/pages/Competition/BulkGeneration/index.test.tsx index 4aebf18..edf3fab 100644 --- a/src/pages/Competition/BulkGeneration/index.test.tsx +++ b/src/pages/Competition/BulkGeneration/index.test.tsx @@ -10,8 +10,12 @@ import { } from '../../../store/reducers/_tests_/helpers'; import type { AppState } from '../../../store/initialState'; import { ActionType } from '../../../store/actions'; +import type { + BulkGenerationWorkerRequest, + BulkGenerationWorkerResponse, +} from './bulkGenerationWorkerTypes'; import type { EventId } from '@wca/helpers'; -import { screen } from '@testing-library/react'; +import { act, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { Route, Routes } from 'react-router-dom'; import { beforeEach, describe, expect, it, vi } from 'vitest'; @@ -20,6 +24,26 @@ const dispatchMock = vi.fn(); const useAppSelectorMock = vi.fn(); const getLocalStorageMock = vi.fn(); const setLocalStorageMock = vi.fn(); +const workerInstances: MockWorker[] = []; + +class MockWorker { + onmessage: ((event: MessageEvent) => void) | null = null; + onerror: (() => void) | null = null; + postMessage = vi.fn(); + terminate = vi.fn(); + + constructor() { + workerInstances.push(this); + } + + get request() { + return this.postMessage.mock.calls[0]?.[0] as BulkGenerationWorkerRequest | undefined; + } + + emit(message: BulkGenerationWorkerResponse) { + this.onmessage?.({ data: message } as MessageEvent); + } +} vi.mock('../../../store', () => ({ useAppDispatch: () => dispatchMock, @@ -121,6 +145,8 @@ const renderPage = () => describe('BulkGenerationPage', () => { beforeEach(() => { vi.clearAllMocks(); + workerInstances.length = 0; + vi.stubGlobal('Worker', MockWorker); getLocalStorageMock.mockReturnValue(null); const state = { wcif: buildCompetition() } as AppState; useAppSelectorMock.mockImplementation((selector: (state: AppState) => unknown) => @@ -167,12 +193,24 @@ describe('BulkGenerationPage', () => { await user.click(screen.getByLabelText('Select 333-r2')); await user.click(screen.getByLabelText('Move 333-r2 up')); await user.click(screen.getByRole('button', { name: 'Generate' })); + const worker = workerInstances[0]; - expect(dispatchMock).toHaveBeenCalledWith({ - type: ActionType.RUN_RECIPES, + expect(worker.request).toMatchObject({ + type: 'runBulkGeneration', recipeId: 'pnw', roundIds: ['333-r2', '222-r1', '333-r1'], }); + act(() => { + worker.emit({ type: 'complete', wcif: buildCompetition() }); + }); + expect(dispatchMock).toHaveBeenCalledWith({ + type: ActionType.PARTIAL_UPDATE_WCIF, + wcif: expect.objectContaining({ + events: expect.any(Array), + persons: expect.any(Array), + schedule: expect.any(Object), + }), + }); expect(setLocalStorageMock).toHaveBeenLastCalledWith( 'bulk-generation.round-order.test-comp', JSON.stringify(['333-r2', '222-r1', '333-r1', '222-r2']) @@ -184,9 +222,10 @@ describe('BulkGenerationPage', () => { renderPage(); await user.click(screen.getByRole('button', { name: 'Generate' })); + const worker = workerInstances[0]; - expect(dispatchMock).toHaveBeenCalledWith({ - type: ActionType.RUN_RECIPES, + expect(worker.request).toMatchObject({ + type: 'runBulkGeneration', recipeId: 'pnw', roundIds: ['222-r1', '333-r1'], }); @@ -198,9 +237,10 @@ describe('BulkGenerationPage', () => { renderPage(); await user.click(screen.getByRole('button', { name: 'Generate' })); + const worker = workerInstances[0]; - expect(dispatchMock).toHaveBeenCalledWith({ - type: ActionType.RUN_RECIPES, + expect(worker.request).toMatchObject({ + type: 'runBulkGeneration', recipeId: 'pnw', roundIds: ['333-r1', '222-r1'], }); @@ -217,15 +257,56 @@ describe('BulkGenerationPage', () => { await user.click(screen.getByRole('button', { name: 'Reset to Schedule Order' })); await user.click(screen.getByRole('button', { name: 'Generate' })); + const worker = workerInstances[0]; expect(setLocalStorageMock).toHaveBeenLastCalledWith( 'bulk-generation.round-order.test-comp', JSON.stringify(['222-r1', '333-r2', '333-r1', '222-r2']) ); - expect(dispatchMock).toHaveBeenCalledWith({ - type: ActionType.RUN_RECIPES, + expect(worker.request).toMatchObject({ + type: 'runBulkGeneration', recipeId: 'pnw', roundIds: ['222-r1', '333-r1'], }); }); + + it('shows worker progress and disables controls while generation is running', async () => { + const user = userEvent.setup(); + renderPage(); + + await user.click(screen.getByRole('button', { name: 'Generate' })); + const worker = workerInstances[0]; + + expect(screen.getByText('Starting bulk generation')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Generate' })).toBeDisabled(); + expect(screen.getByLabelText('Select 333-r1')).toBeDisabled(); + + act(() => { + worker.emit({ type: 'progress', phase: 'generating', roundId: '222-r1' }); + }); + expect(screen.getByText('Generating for 222 Round 1')).toBeInTheDocument(); + + act(() => { + worker.emit({ type: 'progress', phase: 'fixing' }); + }); + expect(screen.getByText('Fixing group assignments')).toBeInTheDocument(); + + act(() => { + worker.emit({ type: 'progress', phase: 'staff', roundId: '333-r1' }); + }); + expect(screen.getByText('Generating staff assignments for 333 Round 1')).toBeInTheDocument(); + }); + + it('shows worker errors and leaves WCIF unchanged', async () => { + const user = userEvent.setup(); + renderPage(); + + await user.click(screen.getByRole('button', { name: 'Generate' })); + act(() => { + workerInstances[0].emit({ type: 'error', message: 'Recipe failed' }); + }); + + expect(screen.getByText('Recipe failed')).toBeInTheDocument(); + expect(dispatchMock).not.toHaveBeenCalled(); + }); }); diff --git a/src/pages/Competition/BulkGeneration/index.tsx b/src/pages/Competition/BulkGeneration/index.tsx index 1d3a43d..145bb49 100644 --- a/src/pages/Competition/BulkGeneration/index.tsx +++ b/src/pages/Competition/BulkGeneration/index.tsx @@ -9,7 +9,7 @@ import { getLocalStorage, setLocalStorage } from '../../../lib/api'; import { Recipes } from '../../../lib/recipes'; import { useBreadcrumbs } from '../../../providers/BreadcrumbsProvider'; import { useAppDispatch, useAppSelector } from '../../../store'; -import { runRecipes } from '../../../store/actions'; +import { partialUpdateWCIF } from '../../../store/actions'; import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh'; import RestartAltIcon from '@mui/icons-material/RestartAlt'; import { @@ -22,7 +22,11 @@ import { Stack, Typography, } from '@mui/material'; -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import type { + BulkGenerationWorkerRequest, + BulkGenerationWorkerResponse, +} from './bulkGenerationWorkerTypes'; const moveRoundId = (roundIds: string[], roundId: string, direction: -1 | 1) => { const fromIndex = roundIds.indexOf(roundId); @@ -55,19 +59,48 @@ const parsePersistedRoundOrder = (value: string | null) => { } }; +const progressText = ( + progress: Extract, + labelForRound: (roundId: string) => string +) => { + if (progress.phase === 'fixing') { + return 'Fixing group assignments'; + } + + if (progress.roundId && progress.phase === 'staff') { + return `Generating staff assignments for ${labelForRound(progress.roundId)}`; + } + + if (progress.roundId) { + return `Generating for ${labelForRound(progress.roundId)}`; + } + + return 'Generating'; +}; + const BulkGenerationPage = () => { const dispatch = useAppDispatch(); const wcif = useAppSelector((state) => state.wcif); + const workerRef = useRef(null); const { setBreadcrumbs } = useBreadcrumbs(); const rows = useMemo(() => (wcif ? buildBulkRoundRows(wcif) : []), [wcif]); const [recipeId, setRecipeId] = useState('pnw'); const [orderedRoundIds, setOrderedRoundIds] = useState([]); const [selectedRoundIds, setSelectedRoundIds] = useState>(new Set()); + const [generationStatus, setGenerationStatus] = useState(null); + const [generationError, setGenerationError] = useState(null); useEffect(() => { setBreadcrumbs([{ text: 'Bulk Generate' }]); }, [setBreadcrumbs]); + useEffect( + () => () => { + workerRef.current?.terminate(); + }, + [] + ); + useEffect(() => { if (!wcif?.id) { return; @@ -93,8 +126,15 @@ const BulkGenerationPage = () => { const selectedOrderedRoundIds = orderedRows .filter((row) => row.selectable && selectedRoundIds.has(row.roundId)) .map((row) => row.roundId); + const generating = Boolean(generationStatus); + const labelForRound = (roundId: string) => + rows.find((row) => row.roundId === roundId)?.label ?? roundId; const handleToggleRound = (roundId: string) => { + if (generating) { + return; + } + if (!rows.find((row) => row.roundId === roundId)?.selectable) { return; } @@ -111,6 +151,10 @@ const BulkGenerationPage = () => { }; const handleMoveRound = (roundId: string, direction: -1 | 1) => { + if (generating) { + return; + } + setOrderedRoundIds((currentRoundIds) => { const nextRoundIds = moveRoundId(currentRoundIds, roundId, direction); if (wcif?.id) { @@ -121,6 +165,10 @@ const BulkGenerationPage = () => { }; const handleResetOrder = () => { + if (generating) { + return; + } + const scheduleOrder = scheduleOrderedRoundIds(rows); if (wcif?.id) { setLocalStorage(roundOrderStorageKey(wcif.id), JSON.stringify(scheduleOrder)); @@ -129,7 +177,60 @@ const BulkGenerationPage = () => { }; const handleGenerate = () => { - dispatch(runRecipes(selectedOrderedRoundIds, recipeId)); + if (!wcif || selectedOrderedRoundIds.length === 0 || generating) { + return; + } + + workerRef.current?.terminate(); + const worker = new Worker(new URL('./bulkGeneration.worker.ts', import.meta.url), { + type: 'module', + }); + workerRef.current = worker; + setGenerationError(null); + setGenerationStatus('Starting bulk generation'); + + worker.onmessage = (event: MessageEvent) => { + const message = event.data; + + if (message.type === 'progress') { + setGenerationStatus(progressText(message, labelForRound)); + return; + } + + if (message.type === 'complete') { + dispatch( + partialUpdateWCIF({ + events: message.wcif.events, + persons: message.wcif.persons, + schedule: message.wcif.schedule, + }) + ); + setGenerationStatus(null); + worker.terminate(); + workerRef.current = null; + return; + } + + setGenerationError(message.message); + setGenerationStatus(null); + worker.terminate(); + workerRef.current = null; + }; + + worker.onerror = () => { + setGenerationError('Bulk generation failed'); + setGenerationStatus(null); + worker.terminate(); + workerRef.current = null; + }; + + const message: BulkGenerationWorkerRequest = { + type: 'runBulkGeneration', + wcif, + recipeId, + roundIds: selectedOrderedRoundIds, + }; + worker.postMessage(message); }; return ( @@ -147,6 +248,7 @@ const BulkGenerationPage = () => { labelId="bulk-recipe-select-label" label="Recipe" value={recipeId} + disabled={generating} onChange={(event) => setRecipeId(String(event.target.value))}> {Recipes.map((recipe) => ( @@ -158,7 +260,7 @@ const BulkGenerationPage = () => { @@ -169,12 +271,16 @@ const BulkGenerationPage = () => { Existing groups and assignments are preserved. + {generationStatus && {generationStatus}} + {generationError && {generationError}} + {orderedRows.length === 0 ? ( No normal non-distributed rounds found. ) : ( @@ -183,7 +289,7 @@ const BulkGenerationPage = () => { diff --git a/src/store/reducers/runRecipe.ts b/src/store/reducers/runRecipe.ts index 3797f3e..2a398ab 100644 --- a/src/store/reducers/runRecipe.ts +++ b/src/store/reducers/runRecipe.ts @@ -1,155 +1,28 @@ -import { type Competition } from '@wca/helpers'; -import { Constraints, Generators } from 'wca-group-generators'; -import { findRoundActivitiesById } from '../../lib/wcif/activities'; -import { createGroupsAcrossStages } from '../../lib/wcif/groups'; -import { - RecipeConstraints, - Recipes, - fromRecipeDefinition, - hydrateStep, - optimizeAssignmentsGlobally, -} from '../../lib/recipes'; -import { shouldRunGroupStep } from '../../lib/recipes/conditions'; -import { mapIn } from '../../lib/utils/utils'; -import { - getRoundConfigExtensionData, - setRoundConfigExtensionData, -} from '../../lib/wcif/extensions/delegateDashboard/delegateDashboard'; +import { runBulkRecipesOnWcif, runRecipeOnWcif } from '../../lib/recipes/runRecipeOnWcif'; import { type AppState } from '../initialState'; import type { RunRecipePayload, RunRecipesPayload } from '../actions'; -const setRoundRecipeConfig = ( - wcif: Competition, - roundId: string, - recipeId: string -): Competition => ({ - ...wcif, - events: wcif.events.map((event) => ({ - ...event, - rounds: event.rounds.map((round) => { - if (round.id !== roundId) { - return round; - } - - return setRoundConfigExtensionData(round, { - ...(getRoundConfigExtensionData(round) ?? {}), - recipe: { id: recipeId }, - }); - }), - })), -}); - /** * Run a built-in recipe to generate groups and/or assignments for a round. - * Restores the legacy "recipe" workflow based on wca-group-generators. */ export function runRecipe(state: AppState, action: RunRecipePayload): AppState { if (!state.wcif) return state; - const wcif = state.wcif as unknown as Competition; - const recipeDef = Recipes.find((r) => r.id === action.recipeId); - if (!recipeDef) { - throw new Error(`Recipe ${action.recipeId} not found`); - } - - const recipe = fromRecipeDefinition(recipeDef, { wcif, activityCode: action.roundId }); - - const generatedWcif = recipe.steps.reduce((accWcif, step) => { - if (step.type === 'assignments') { - const generator = (Generators as Record)[step.props.generator]; - if (!generator) { - throw new Error(`Generator ${step.props.generator} not found`); - } - - const hydratedStep = hydrateStep(accWcif, action.roundId, step); - - const constraints = - hydratedStep.props.constraints?.map((c) => { - const constraintFn = - (RecipeConstraints as Record)[c.constraint] ?? - (Constraints as Record)[c.constraint]; - if (!constraintFn) { - throw new Error(`Constraint ${c.constraint} not found`); - } - return { - constraint: constraintFn, - weight: c.weight, - options: c.options, - }; - }) ?? []; - - const generatedWcif = generator.execute({ - wcif: accWcif, - roundId: action.roundId, - ...hydratedStep.props, - constraints, - }) as Competition; - - if (!hydratedStep.props.globalScore) { - return generatedWcif; - } - - if ( - hydratedStep.props.globalScore.maxClusterSize && - hydratedStep.props.cluster.length > hydratedStep.props.globalScore.maxClusterSize - ) { - return generatedWcif; - } - - return optimizeAssignmentsGlobally({ - beforeWcif: accWcif, - wcif: generatedWcif, - cluster: hydratedStep.props.cluster, - activities: hydratedStep.props.activities, - assignmentCode: hydratedStep.props.assignmentCode, - constraints, - options: hydratedStep.props.options, - maxPasses: hydratedStep.props.globalScore.maxPasses, - maxEvaluations: hydratedStep.props.globalScore.maxEvaluations, - }); - } - - if (step.type === 'groups') { - const roundActivities = findRoundActivitiesById(accWcif, action.roundId); - if (!shouldRunGroupStep(accWcif, action.roundId, roundActivities, step)) { - return accWcif; - } - - const roundActivitiesWithGroups = createGroupsAcrossStages(accWcif, roundActivities, { - spreadGroupsAcrossAllStages: true, - groups: step.props.count, - }); - - return { - ...accWcif, - schedule: mapIn(accWcif.schedule, 'venues', (venue) => - mapIn(venue, 'rooms', (room) => - mapIn(room, 'activities', (activity) => - roundActivitiesWithGroups.find((ra) => ra.id === activity.id) - ? (roundActivitiesWithGroups.find((ra) => ra.id === activity.id) as any) - : activity - ) - ) - ), - } as Competition; - } - - return accWcif; - }, wcif); - - const updatedWcif = setRoundRecipeConfig(generatedWcif, action.roundId, action.recipeId); - return { ...state, needToSave: true, changedKeys: new Set([...state.changedKeys, 'schedule', 'persons', 'events']), - wcif: updatedWcif, + wcif: runRecipeOnWcif(state.wcif, action), }; } export function runRecipes(state: AppState, action: RunRecipesPayload): AppState { - return action.roundIds.reduce( - (accState, roundId) => runRecipe(accState, { roundId, recipeId: action.recipeId }), - state - ); + if (!state.wcif) return state; + + return { + ...state, + needToSave: true, + changedKeys: new Set([...state.changedKeys, 'schedule', 'persons', 'events']), + wcif: runBulkRecipesOnWcif(state.wcif, action), + }; } From 0752fa761fc6316e52a0f8d3dffb421d449234a2 Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Sat, 23 May 2026 18:01:36 -0700 Subject: [PATCH 10/11] Improve bulk generation workflow --- src/components/_tests_/Header.test.tsx | 45 ++++++- .../ConfigureAssignmentsDialog.tsx | 6 +- src/dialogs/ConfigureGroupCountsDialog.tsx | 5 +- .../CompetitionLayout/CompetitionHeader.tsx | 34 ++++-- .../CompetitionLayout/CompetitionLayout.tsx | 2 + .../CompetitionLayout/EventRoundTree.tsx | 114 ++++++++++++++++++ .../recipes/bulkGenerationDiagnostics.test.ts | 63 ++++++++++ src/lib/recipes/bulkGenerationDiagnostics.ts | 57 +++++++++ src/lib/recipes/runRecipeOnWcif.ts | 11 +- .../BulkRoundGroupCountsDialog.tsx | 46 +++++++ .../BulkGeneration/BulkRoundPreviewDialog.tsx | 51 ++++++++ .../BulkGeneration/BulkRoundTable.tsx | 19 +++ .../BulkGeneration/bulkRoundRows.test.ts | 23 +++- .../BulkGeneration/bulkRoundRows.ts | 5 +- .../Competition/BulkGeneration/index.test.tsx | 94 +++++++++++++-- .../Competition/BulkGeneration/index.tsx | 24 ++-- 16 files changed, 561 insertions(+), 38 deletions(-) create mode 100644 src/layout/CompetitionLayout/EventRoundTree.tsx create mode 100644 src/lib/recipes/bulkGenerationDiagnostics.test.ts create mode 100644 src/lib/recipes/bulkGenerationDiagnostics.ts create mode 100644 src/pages/Competition/BulkGeneration/BulkRoundGroupCountsDialog.tsx create mode 100644 src/pages/Competition/BulkGeneration/BulkRoundPreviewDialog.tsx diff --git a/src/components/_tests_/Header.test.tsx b/src/components/_tests_/Header.test.tsx index 2e9fa19..664bb88 100644 --- a/src/components/_tests_/Header.test.tsx +++ b/src/components/_tests_/Header.test.tsx @@ -3,6 +3,7 @@ import { DrawerLinks, CompetitionHeader } from '../../layout/CompetitionLayout/C import { renderWithProviders } from '../../test-utils'; import userEvent from '@testing-library/user-event'; import type { AppState } from '../../store/initialState'; +import { buildEvent, buildRound } from '../../store/reducers/_tests_/helpers'; const useAppSelector = vi.fn(); @@ -27,8 +28,23 @@ describe('Header', () => { }); describe('DrawerLinks', () => { + const state = { + wcif: { + id: 'TestComp', + events: [ + buildEvent({ + id: '333', + rounds: [buildRound({ id: '333-r1' }), buildRound({ id: '333-r2' })], + }), + buildEvent({ + id: '222', + rounds: [buildRound({ id: '222-r1' })], + }), + ], + }, + } as unknown as AppState; + it('renders menu links for the current competition', () => { - const state = { wcif: { id: 'TestComp' } } as unknown as AppState; useAppSelector.mockImplementation((selector: (state: AppState) => unknown) => selector(state)); const { getByText, getByRole } = renderWithProviders(); @@ -41,4 +57,31 @@ describe('DrawerLinks', () => { '/competitions/TestComp/assignments' ); }); + + it('renders event accordions with round links', async () => { + useAppSelector.mockImplementation((selector: (state: AppState) => unknown) => selector(state)); + + const { getByRole, getAllByRole } = renderWithProviders(); + + expect(getByRole('button', { name: /3x3x3 Cube/ })).toHaveAttribute( + 'aria-expanded', + 'true' + ); + expect(getByRole('button', { name: /2x2x2 Cube/ })).toHaveAttribute( + 'aria-expanded', + 'false' + ); + + await userEvent.click(getByRole('button', { name: /2x2x2 Cube/ })); + + expect(getByRole('button', { name: /2x2x2 Cube/ })).toHaveAttribute( + 'aria-expanded', + 'true' + ); + expect( + getAllByRole('link', { name: 'Round 1' }).some( + (link) => link.getAttribute('href') === '/competitions/TestComp/events/222-r1' + ) + ).toBe(true); + }); }); diff --git a/src/dialogs/ConfigureAssignmentsDialog/ConfigureAssignmentsDialog.tsx b/src/dialogs/ConfigureAssignmentsDialog/ConfigureAssignmentsDialog.tsx index d140f17..695fbe6 100644 --- a/src/dialogs/ConfigureAssignmentsDialog/ConfigureAssignmentsDialog.tsx +++ b/src/dialogs/ConfigureAssignmentsDialog/ConfigureAssignmentsDialog.tsx @@ -39,6 +39,7 @@ const ConfigureAssignmentsDialog = ({ groups, isDistributedAttemptRoundLevel, distributedAttemptGroups, + defaultShowAllCompetitors, }: { open: boolean; onClose: () => void; @@ -50,6 +51,7 @@ const ConfigureAssignmentsDialog = ({ attemptNumber: number; activities: ActivityWithRoom[]; }>; + defaultShowAllCompetitors?: boolean; }) => { const wcif = useAppSelector((state) => state.wcif); const { eventId, roundNumber } = parseActivityCode(activityCode) as { @@ -63,7 +65,9 @@ const ConfigureAssignmentsDialog = ({ const theme = useTheme(); const fullScreen = useMediaQuery(theme.breakpoints.down('md')); - const [showAllCompetitors, setShowAllCompetitors] = useState(isDistributedAttemptRoundLevel); + const [showAllCompetitors, setShowAllCompetitors] = useState( + defaultShowAllCompetitors ?? isDistributedAttemptRoundLevel + ); const [paintingAssignmentCode, setPaintingAssignmentCode] = useState('staff-scrambler'); const [competitorSort, setCompetitorSort] = useState('speed'); const [showCompetitorsNotInRound, setShowCompetitorsNotInRound] = useState(false); diff --git a/src/dialogs/ConfigureGroupCountsDialog.tsx b/src/dialogs/ConfigureGroupCountsDialog.tsx index 3d21417..8524434 100644 --- a/src/dialogs/ConfigureGroupCountsDialog.tsx +++ b/src/dialogs/ConfigureGroupCountsDialog.tsx @@ -23,7 +23,7 @@ import { Typography, } from '@mui/material'; import { type Activity, type Round, type Room } from '@wca/helpers'; -import { useState } from 'react'; +import { useRef, useState } from 'react'; import { useDispatch } from 'react-redux'; export interface ConfigureGroupCountsDialogProps { @@ -48,6 +48,7 @@ const ConfigureGroupCountsDialog = ({ .filter((room) => room.activities.find((a) => a.activityCode === activityCode)) ); const dispatch = useDispatch(); + const groupCountInputRef = useRef(null); const groupsExtData = getGroupsExtensionData(round); const [groupsData, setGroupsData] = useState<{ groups: number | Record; @@ -178,6 +179,8 @@ const ConfigureGroupCountsDialog = ({ diff --git a/src/layout/CompetitionLayout/CompetitionHeader.tsx b/src/layout/CompetitionLayout/CompetitionHeader.tsx index fe23b9f..ad337dc 100644 --- a/src/layout/CompetitionLayout/CompetitionHeader.tsx +++ b/src/layout/CompetitionLayout/CompetitionHeader.tsx @@ -1,4 +1,5 @@ import { useAppSelector } from '../../store'; +import { EventRoundTree } from './EventRoundTree'; import { Tune } from '@mui/icons-material'; import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh'; import FileDownloadIcon from '@mui/icons-material/FileDownload'; @@ -12,6 +13,7 @@ import SearchIcon from '@mui/icons-material/Search'; import ViewListIcon from '@mui/icons-material/ViewList'; import { AppBar as MuiAppBar, + Box, Divider, IconButton, List, @@ -109,9 +111,8 @@ export const DrawerLinks = () => { icon: , text: 'Bulk Generate', }, - { - type: 'divider' as const, - }, + ] as MenuItemOrDivider[], + data: [ { url: `/competitions/${competitionId}/import`, icon: , @@ -122,9 +123,6 @@ export const DrawerLinks = () => { icon: , text: 'Export Data', }, - { - type: 'divider' as const, - }, { url: `/competitions/${competitionId}/checks/first-timers`, icon: , @@ -160,14 +158,26 @@ export const DrawerLinks = () => { ); return ( - - {menuLinks.top.map(renderLinkOrDivider)} + + + {menuLinks.top.map(renderLinkOrDivider)} + + -
+ + + + + - Debug - {menuLinks.debug.map(renderLinkOrDivider)} - + + + {menuLinks.data.map(renderLinkOrDivider)} + + Debug + {menuLinks.debug.map(renderLinkOrDivider)} + + ); }; diff --git a/src/layout/CompetitionLayout/CompetitionLayout.tsx b/src/layout/CompetitionLayout/CompetitionLayout.tsx index 35e71ac..76689c6 100644 --- a/src/layout/CompetitionLayout/CompetitionLayout.tsx +++ b/src/layout/CompetitionLayout/CompetitionLayout.tsx @@ -142,6 +142,8 @@ export const CompetitionLayout = () => { '& .MuiDrawer-paper': { width: drawerWidth, boxSizing: 'border-box', + display: 'flex', + flexDirection: 'column', }, }}> diff --git a/src/layout/CompetitionLayout/EventRoundTree.tsx b/src/layout/CompetitionLayout/EventRoundTree.tsx new file mode 100644 index 0000000..b201b37 --- /dev/null +++ b/src/layout/CompetitionLayout/EventRoundTree.tsx @@ -0,0 +1,114 @@ +import { activityCodeToName, parseActivityCode } from '../../lib/domain/activities'; +import { eventNameById } from '../../lib/domain/events'; +import { useAppSelector } from '../../store'; +import '@cubing/icons'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import { + Accordion, + AccordionDetails, + AccordionSummary, + List, + ListItemButton, + ListItemIcon, + ListItemText, + Typography, +} from '@mui/material'; +import { useEffect, useState } from 'react'; +import { Link as RouterLink, useParams } from 'react-router-dom'; + +const roundNameFor = (roundId: string) => { + const { roundNumber } = parseActivityCode(roundId); + return roundNumber ? `Round ${roundNumber}` : activityCodeToName(roundId); +}; + +export const EventRoundTree = () => { + const wcif = useAppSelector((state) => state.wcif); + const { competitionId, roundId } = useParams<{ + competitionId: string; + roundId?: string; + }>(); + const events = wcif?.events ?? []; + + const selectedActivity = roundId ? parseActivityCode(roundId) : undefined; + const selectedEventId = selectedActivity?.eventId; + const firstEventId = events[0]?.id; + const [expandedEventId, setExpandedEventId] = useState( + selectedEventId ?? firstEventId ?? false + ); + + useEffect(() => { + if (selectedEventId) { + setExpandedEventId(selectedEventId); + return; + } + + if (firstEventId) { + setExpandedEventId((currentEventId) => currentEventId || firstEventId); + } + }, [firstEventId, selectedEventId]); + + const eventsWithRounds = events.filter((event) => event.rounds.length > 0); + + if (!wcif || eventsWithRounds.length === 0) { + return null; + } + + return ( + + {eventsWithRounds.map((event) => ( + setExpandedEventId(expanded ? event.id : false)}> + } + aria-controls={`${event.id}-rounds-content`} + id={`${event.id}-rounds-header`} + sx={{ + minHeight: 40, + px: 2, + '& .MuiAccordionSummary-content': { + alignItems: 'center', + gap: 1.5, + my: 0.75, + minWidth: 0, + }, + }}> + + + {eventNameById(event.id)} + + + + + {event.rounds.map((round) => ( + + + + + + + ))} + + + + ))} + + ); +}; diff --git a/src/lib/recipes/bulkGenerationDiagnostics.test.ts b/src/lib/recipes/bulkGenerationDiagnostics.test.ts new file mode 100644 index 0000000..0a63de5 --- /dev/null +++ b/src/lib/recipes/bulkGenerationDiagnostics.test.ts @@ -0,0 +1,63 @@ +import { buildActivity, buildPerson, buildWcif } from '../../store/reducers/_tests_/helpers'; +import { countPeopleWithImmediateHelpingThenCompetingAssignments } from './bulkGenerationDiagnostics'; +import { describe, expect, it } from 'vitest'; + +describe('bulkGenerationDiagnostics', () => { + it('counts people with immediate helping then competing assignments', () => { + const wcif = buildWcif( + [ + buildActivity({ + id: 1, + activityCode: '333-r1', + startTime: '2024-01-01T10:00:00Z', + endTime: '2024-01-01T11:00:00Z', + childActivities: [ + buildActivity({ + id: 101, + activityCode: '333-r1-g1', + startTime: '2024-01-01T10:00:00Z', + endTime: '2024-01-01T10:15:00Z', + }), + buildActivity({ + id: 102, + activityCode: '333-r1-g2', + startTime: '2024-01-01T10:15:00Z', + endTime: '2024-01-01T10:30:00Z', + }), + buildActivity({ + id: 103, + activityCode: '333-r1-g3', + startTime: '2024-01-01T10:45:00Z', + endTime: '2024-01-01T11:00:00Z', + }), + ], + }), + ], + [ + buildPerson({ + registrantId: 1, + assignments: [ + { activityId: 101, assignmentCode: 'staff-judge', stationNumber: null }, + { activityId: 102, assignmentCode: 'competitor', stationNumber: null }, + ], + }), + buildPerson({ + registrantId: 2, + assignments: [ + { activityId: 101, assignmentCode: 'staff-runner', stationNumber: null }, + { activityId: 103, assignmentCode: 'competitor', stationNumber: null }, + ], + }), + buildPerson({ + registrantId: 3, + assignments: [ + { activityId: 101, assignmentCode: 'competitor', stationNumber: null }, + { activityId: 102, assignmentCode: 'staff-judge', stationNumber: null }, + ], + }), + ] + ); + + expect(countPeopleWithImmediateHelpingThenCompetingAssignments(wcif)).toBe(1); + }); +}); diff --git a/src/lib/recipes/bulkGenerationDiagnostics.ts b/src/lib/recipes/bulkGenerationDiagnostics.ts new file mode 100644 index 0000000..22cf32d --- /dev/null +++ b/src/lib/recipes/bulkGenerationDiagnostics.ts @@ -0,0 +1,57 @@ +import { isCompetitorAssignment, isStaffAssignment } from '../domain/assignments'; +import { findAllActivities } from '../wcif/activities'; +import type { Activity, Assignment, Competition } from '@wca/helpers'; + +interface ScheduledAssignment { + assignment: Assignment; + activity: Activity; +} + +const activityStartTime = (activity: Activity) => + activity.startTime ? new Date(activity.startTime).getTime() : Number.POSITIVE_INFINITY; + +const activityEndTime = (activity: Activity) => + activity.endTime ? new Date(activity.endTime).getTime() : Number.POSITIVE_INFINITY; + +const assignmentsBySchedule = ( + assignments: Assignment[] | undefined, + activityById: Map +): ScheduledAssignment[] => + (assignments ?? []) + .map((assignment) => { + const activity = activityById.get(assignment.activityId); + return activity ? { assignment, activity } : null; + }) + .filter((assignment): assignment is ScheduledAssignment => Boolean(assignment)) + .sort( + (assignmentA, assignmentB) => + activityStartTime(assignmentA.activity) - activityStartTime(assignmentB.activity) || + activityEndTime(assignmentA.activity) - activityEndTime(assignmentB.activity) || + assignmentA.activity.id - assignmentB.activity.id + ); + +const isImmediateHelpingThenCompeting = ( + helpingAssignment: ScheduledAssignment, + competingAssignment: ScheduledAssignment +) => + isStaffAssignment(helpingAssignment.assignment) && + isCompetitorAssignment(competingAssignment.assignment) && + helpingAssignment.activity.endTime && + competingAssignment.activity.startTime && + new Date(helpingAssignment.activity.endTime).getTime() === + new Date(competingAssignment.activity.startTime).getTime(); + +export const countPeopleWithImmediateHelpingThenCompetingAssignments = (wcif: Competition) => { + const activityById = new Map(findAllActivities(wcif).map((activity) => [activity.id, activity])); + + return wcif.persons.filter((person) => { + const assignments = assignmentsBySchedule(person.assignments, activityById); + + return assignments.some((assignment, index) => { + const nextAssignment = assignments[index + 1]; + return nextAssignment + ? isImmediateHelpingThenCompeting(assignment, nextAssignment) + : false; + }); + }).length; +}; diff --git a/src/lib/recipes/runRecipeOnWcif.ts b/src/lib/recipes/runRecipeOnWcif.ts index d4b154f..f5b8488 100644 --- a/src/lib/recipes/runRecipeOnWcif.ts +++ b/src/lib/recipes/runRecipeOnWcif.ts @@ -13,6 +13,7 @@ import { setRoundConfigExtensionData, } from '../wcif/extensions/delegateDashboard/delegateDashboard'; import { mapIn } from '../utils/utils'; +import { countPeopleWithImmediateHelpingThenCompetingAssignments } from './bulkGenerationDiagnostics'; import { shouldRunGroupStep } from './conditions'; import { RecipeConstraints } from './constraints'; import { optimizeAssignmentsGlobally } from './globalScoring'; @@ -305,5 +306,13 @@ export const runBulkRecipesOnWcif = ( }); } - return setRoundRecipeConfigs(generatedWcif, roundIds, recipeId); + const completedWcif = setRoundRecipeConfigs(generatedWcif, roundIds, recipeId); + + // eslint-disable-next-line no-console + console.debug( + '[BulkGeneration] competitors with immediate helping -> competing assignments:', + countPeopleWithImmediateHelpingThenCompetingAssignments(completedWcif) + ); + + return completedWcif; }; diff --git a/src/pages/Competition/BulkGeneration/BulkRoundGroupCountsDialog.tsx b/src/pages/Competition/BulkGeneration/BulkRoundGroupCountsDialog.tsx new file mode 100644 index 0000000..a1985f9 --- /dev/null +++ b/src/pages/Competition/BulkGeneration/BulkRoundGroupCountsDialog.tsx @@ -0,0 +1,46 @@ +import ConfigureGroupCountsDialog from '../../../dialogs/ConfigureGroupCountsDialog'; +import { findAllRoundActivities } from '../../../lib/wcif/activities'; +import type { Competition } from '@wca/helpers'; +import { useMemo } from 'react'; + +interface BulkRoundGroupCountsDialogProps { + wcif: Competition | null; + roundId: string | null; + onClose: () => void; +} + +export const BulkRoundGroupCountsDialog = ({ + wcif, + roundId, + onClose, +}: BulkRoundGroupCountsDialogProps) => { + const round = useMemo( + () => + wcif && roundId + ? wcif.events.flatMap((event) => event.rounds).find((candidate) => candidate.id === roundId) + : undefined, + [roundId, wcif] + ); + + const roundActivities = useMemo( + () => + wcif && roundId + ? findAllRoundActivities(wcif).filter((activity) => activity.activityCode === roundId) + : [], + [roundId, wcif] + ); + + if (!wcif || !roundId || !round) { + return null; + } + + return ( + + ); +}; diff --git a/src/pages/Competition/BulkGeneration/BulkRoundPreviewDialog.tsx b/src/pages/Competition/BulkGeneration/BulkRoundPreviewDialog.tsx new file mode 100644 index 0000000..f0ce687 --- /dev/null +++ b/src/pages/Competition/BulkGeneration/BulkRoundPreviewDialog.tsx @@ -0,0 +1,51 @@ +import ConfigureAssignmentsDialog from '../../../dialogs/ConfigureAssignmentsDialog/ConfigureAssignmentsDialog'; +import { allChildActivities, findAllRoundActivities } from '../../../lib/wcif/activities'; +import type { Competition } from '@wca/helpers'; +import { useMemo } from 'react'; + +interface BulkRoundPreviewDialogProps { + wcif: Competition | null; + roundId: string | null; + onClose: () => void; +} + +export const BulkRoundPreviewDialog = ({ + wcif, + roundId, + onClose, +}: BulkRoundPreviewDialogProps) => { + const round = useMemo( + () => + wcif && roundId + ? wcif.events.flatMap((event) => event.rounds).find((candidate) => candidate.id === roundId) + : undefined, + [roundId, wcif] + ); + + const groups = useMemo( + () => + wcif && roundId + ? findAllRoundActivities(wcif) + .filter((activity) => activity.activityCode === roundId) + .flatMap((activity) => allChildActivities(activity)) + : [], + [roundId, wcif] + ); + + if (!wcif || !roundId || !round) { + return null; + } + + return ( + + ); +}; diff --git a/src/pages/Competition/BulkGeneration/BulkRoundTable.tsx b/src/pages/Competition/BulkGeneration/BulkRoundTable.tsx index ecd0a2a..87bc91a 100644 --- a/src/pages/Competition/BulkGeneration/BulkRoundTable.tsx +++ b/src/pages/Competition/BulkGeneration/BulkRoundTable.tsx @@ -2,6 +2,7 @@ import type { BulkRoundRow } from './bulkRoundRows'; import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward'; import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward'; import { + Button, Checkbox, IconButton, Stack, @@ -20,6 +21,8 @@ interface BulkRoundTableProps { disabled?: boolean; onToggleRound: (roundId: string) => void; onMoveRound: (roundId: string, direction: -1 | 1) => void; + onPreviewRound: (roundId: string) => void; + onConfigureGroups: (roundId: string) => void; } export const BulkRoundTable = ({ @@ -28,6 +31,8 @@ export const BulkRoundTable = ({ disabled = false, onToggleRound, onMoveRound, + onPreviewRound, + onConfigureGroups, }: BulkRoundTableProps) => ( @@ -41,6 +46,7 @@ export const BulkRoundTable = ({ GroupsCompetitorsStaff + Actions @@ -88,6 +94,19 @@ export const BulkRoundTable = ({ {row.competitorAssignmentCount} / {row.roundSize} {row.staffAssignmentCount} + + + ))} diff --git a/src/pages/Competition/BulkGeneration/bulkRoundRows.test.ts b/src/pages/Competition/BulkGeneration/bulkRoundRows.test.ts index 119e8d4..377ef9c 100644 --- a/src/pages/Competition/BulkGeneration/bulkRoundRows.test.ts +++ b/src/pages/Competition/BulkGeneration/bulkRoundRows.test.ts @@ -91,7 +91,7 @@ describe('bulkRoundRows', () => { expect(rows.map((row) => row.roundId)).toEqual(['333-r1', '333-r2']); expect(rows[0]).toMatchObject({ - label: '333 Round 1', + label: '3x3 Round 1', roundSize: 2, existingGroupCount: 2, competitorAssignmentCount: 1, @@ -106,6 +106,27 @@ describe('bulkRoundRows', () => { expect(defaultSelectedRoundIds(rows)).toEqual(new Set(['333-r1'])); }); + it('requires groups before a round is selectable', () => { + const competition = buildCompetition(); + const roundActivity = competition.schedule.venues[0].rooms[0].activities.find( + (activity) => activity.activityCode === '333-r1' + ); + if (!roundActivity) { + throw new Error('Expected 333-r1 activity'); + } + roundActivity.childActivities = []; + + const rows = buildBulkRoundRows(competition); + + expect(rows[0]).toMatchObject({ + roundId: '333-r1', + roundSize: 2, + existingGroupCount: 0, + selectable: false, + }); + expect(defaultSelectedRoundIds(rows)).toEqual(new Set()); + }); + it('orders rows by schedule and merges persisted order with current rounds', () => { const rows = buildBulkRoundRows(buildCompetition()); diff --git a/src/pages/Competition/BulkGeneration/bulkRoundRows.ts b/src/pages/Competition/BulkGeneration/bulkRoundRows.ts index 32b7d92..57e8c84 100644 --- a/src/pages/Competition/BulkGeneration/bulkRoundRows.ts +++ b/src/pages/Competition/BulkGeneration/bulkRoundRows.ts @@ -3,6 +3,7 @@ import { hasDistributedAttempts, parseActivityCode, } from '../../../lib/domain/activities'; +import { shortEventNameById } from '../../../lib/domain/events'; import { personsShouldBeInRound } from '../../../lib/domain/persons'; import { findAllActivities } from '../../../lib/wcif/activities'; import { formatDateTimeRange } from '../../../lib/utils/time'; @@ -26,7 +27,7 @@ export interface BulkRoundRow { const eventRoundLabel = (event: Event, round: Round) => { const { roundNumber } = parseActivityCode(round.id); - return `${event.id.toUpperCase()} Round ${roundNumber ?? '?'}`; + return `${shortEventNameById(event.id)} Round ${roundNumber ?? '?'}`; }; const scheduledTimeForActivities = (activities: Activity[]) => { @@ -121,7 +122,7 @@ export const buildBulkRoundRows = (wcif: Competition): BulkRoundRow[] => { competitorAssignmentCount, staffAssignmentCount, warnings, - selectable: roundSize > 0, + selectable: roundSize > 0 && existingGroupCount > 0, }; }) ); diff --git a/src/pages/Competition/BulkGeneration/index.test.tsx b/src/pages/Competition/BulkGeneration/index.test.tsx index edf3fab..839f5e6 100644 --- a/src/pages/Competition/BulkGeneration/index.test.tsx +++ b/src/pages/Competition/BulkGeneration/index.test.tsx @@ -15,7 +15,7 @@ import type { BulkGenerationWorkerResponse, } from './bulkGenerationWorkerTypes'; import type { EventId } from '@wca/helpers'; -import { act, screen } from '@testing-library/react'; +import { act, screen, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { Route, Routes } from 'react-router-dom'; import { beforeEach, describe, expect, it, vi } from 'vitest'; @@ -55,6 +55,33 @@ vi.mock('../../../lib/api', () => ({ setLocalStorage: (key: string, value: string) => setLocalStorageMock(key, value), })); +vi.mock('../../../dialogs/ConfigureAssignmentsDialog/ConfigureAssignmentsDialog', () => ({ + default: ({ + activityCode, + defaultShowAllCompetitors, + onClose, + }: { + activityCode: string; + defaultShowAllCompetitors?: boolean; + onClose: () => void; + }) => ( +
+ Previewing {activityCode} + Show all: {defaultShowAllCompetitors ? 'yes' : 'no'} + +
+ ), +})); + +vi.mock('../../../dialogs/ConfigureGroupCountsDialog', () => ({ + default: ({ activityCode, onClose }: { activityCode: string; onClose: () => void }) => ( +
+ Configuring groups for {activityCode} + +
+ ), +})); + const registration = (registrantId: number, eventIds: EventId[]) => ({ status: 'accepted' as const, eventIds, @@ -92,7 +119,7 @@ const buildCompetition = () => activityCode: '222-r2', startTime: '2024-01-01T12:00:00Z', endTime: '2024-01-01T12:30:00Z', - childActivities: [buildActivity({ id: 401, activityCode: '222-r2-g1' })], + childActivities: [], }), buildActivity({ id: 5, @@ -160,25 +187,26 @@ describe('BulkGenerationPage', () => { expect(screen.getByRole('heading', { name: 'Bulk Generate' })).toBeInTheDocument(); expect(screen.getByRole('columnheader', { name: 'Size' })).toBeInTheDocument(); expect(screen.queryByRole('columnheader', { name: 'Assigned' })).not.toBeInTheDocument(); - expect(screen.getByText('333 Round 1')).toBeInTheDocument(); - expect(screen.getByText('333 Round 2')).toBeInTheDocument(); - expect(screen.getByText('222 Round 1')).toBeInTheDocument(); - expect(screen.getByText('222 Round 2')).toBeInTheDocument(); + expect(screen.getByText('3x3 Round 1')).toBeInTheDocument(); + expect(screen.getByText('3x3 Round 2')).toBeInTheDocument(); + expect(screen.getByText('2x2 Round 1')).toBeInTheDocument(); + expect(screen.getByText('2x2 Round 2')).toBeInTheDocument(); expect(screen.queryByText('333FM Round 1')).not.toBeInTheDocument(); expect(screen.getByLabelText('Select 333-r1')).toBeChecked(); expect(screen.getByLabelText('Select 222-r1')).toBeChecked(); expect(screen.getByLabelText('Select 333-r2')).not.toBeChecked(); expect(screen.getByLabelText('Select 333-r2')).not.toBeDisabled(); expect(screen.getByLabelText('Select 222-r2')).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Configure' })).toBeInTheDocument(); const rowTexts = screen.getAllByRole('row').map((row) => row.textContent ?? ''); - expect(rowTexts[1]).toContain('222 Round 1'); + expect(rowTexts[1]).toContain('2x2 Round 1'); expect(rowTexts[1]).toContain('0 / 2'); - expect(rowTexts[2]).toContain('333 Round 2'); + expect(rowTexts[2]).toContain('3x3 Round 2'); expect(rowTexts[2]).toContain('0 / 1'); - expect(rowTexts[3]).toContain('333 Round 1'); + expect(rowTexts[3]).toContain('3x3 Round 1'); expect(rowTexts[3]).toContain('0 / 2'); - expect(rowTexts[4]).toContain('222 Round 2'); + expect(rowTexts[4]).toContain('2x2 Round 2'); expect(rowTexts[4]).toContain('0 / 0'); expect(setLocalStorageMock).toHaveBeenCalledWith( 'bulk-generation.round-order.test-comp', @@ -280,11 +308,12 @@ describe('BulkGenerationPage', () => { expect(screen.getByText('Starting bulk generation')).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Generate' })).toBeDisabled(); expect(screen.getByLabelText('Select 333-r1')).toBeDisabled(); + expect(screen.getAllByRole('button', { name: /Preview/ })[0]).toBeDisabled(); act(() => { worker.emit({ type: 'progress', phase: 'generating', roundId: '222-r1' }); }); - expect(screen.getByText('Generating for 222 Round 1')).toBeInTheDocument(); + expect(screen.getByText('Generating for 2x2 Round 1')).toBeInTheDocument(); act(() => { worker.emit({ type: 'progress', phase: 'fixing' }); @@ -294,7 +323,7 @@ describe('BulkGenerationPage', () => { act(() => { worker.emit({ type: 'progress', phase: 'staff', roundId: '333-r1' }); }); - expect(screen.getByText('Generating staff assignments for 333 Round 1')).toBeInTheDocument(); + expect(screen.getByText('Generating staff assignments for 3x3 Round 1')).toBeInTheDocument(); }); it('shows worker errors and leaves WCIF unchanged', async () => { @@ -309,4 +338,45 @@ describe('BulkGenerationPage', () => { expect(screen.getByText('Recipe failed')).toBeInTheDocument(); expect(dispatchMock).not.toHaveBeenCalled(); }); + + it('opens assignment preview for a round', async () => { + const user = userEvent.setup(); + renderPage(); + + const roundRow = screen + .getAllByRole('row') + .find((row) => row.textContent?.includes('3x3 Round 2')); + expect(roundRow).toBeDefined(); + + await user.click(within(roundRow as HTMLElement).getByRole('button', { name: /Preview/ })); + + expect(screen.getByRole('dialog', { name: 'Preview 333-r2' })).toBeInTheDocument(); + expect(screen.getByText('Show all: yes')).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Close Preview' })); + + expect(screen.queryByRole('dialog', { name: 'Preview 333-r2' })).not.toBeInTheDocument(); + }); + + it('opens group count configuration for rounds without groups', async () => { + const user = userEvent.setup(); + renderPage(); + + const roundRow = screen + .getAllByRole('row') + .find((row) => row.textContent?.includes('2x2 Round 2')); + expect(roundRow).toBeDefined(); + + await user.click(within(roundRow as HTMLElement).getByRole('button', { name: 'Configure' })); + + expect( + screen.getByRole('dialog', { name: 'Configure groups 222-r2' }) + ).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Close Configure Groups' })); + + expect( + screen.queryByRole('dialog', { name: 'Configure groups 222-r2' }) + ).not.toBeInTheDocument(); + }); }); diff --git a/src/pages/Competition/BulkGeneration/index.tsx b/src/pages/Competition/BulkGeneration/index.tsx index 145bb49..d5ca010 100644 --- a/src/pages/Competition/BulkGeneration/index.tsx +++ b/src/pages/Competition/BulkGeneration/index.tsx @@ -1,4 +1,6 @@ import { BulkRoundTable } from './BulkRoundTable'; +import { BulkRoundGroupCountsDialog } from './BulkRoundGroupCountsDialog'; +import { BulkRoundPreviewDialog } from './BulkRoundPreviewDialog'; import { buildBulkRoundRows, defaultSelectedRoundIds, @@ -10,7 +12,6 @@ import { Recipes } from '../../../lib/recipes'; import { useBreadcrumbs } from '../../../providers/BreadcrumbsProvider'; import { useAppDispatch, useAppSelector } from '../../../store'; import { partialUpdateWCIF } from '../../../store/actions'; -import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh'; import RestartAltIcon from '@mui/icons-material/RestartAlt'; import { Alert, @@ -89,6 +90,8 @@ const BulkGenerationPage = () => { const [selectedRoundIds, setSelectedRoundIds] = useState>(new Set()); const [generationStatus, setGenerationStatus] = useState(null); const [generationError, setGenerationError] = useState(null); + const [previewRoundId, setPreviewRoundId] = useState(null); + const [configureGroupsRoundId, setConfigureGroupsRoundId] = useState(null); useEffect(() => { setBreadcrumbs([{ text: 'Bulk Generate' }]); @@ -235,6 +238,17 @@ const BulkGenerationPage = () => { return ( + setPreviewRoundId(null)} + /> + setConfigureGroupsRoundId(null)} + /> + { - - Only rounds with competitors can be selected. Selected rounds run in the displayed order. - Existing groups and assignments are preserved. - - {generationStatus && {generationStatus}} {generationError && {generationError}} @@ -283,6 +291,8 @@ const BulkGenerationPage = () => { disabled={generating} onToggleRound={handleToggleRound} onMoveRound={handleMoveRound} + onPreviewRound={setPreviewRoundId} + onConfigureGroups={setConfigureGroupsRoundId} /> )} From 67f5c9f056c62484e8d1507e9f4f5c3946def9e7 Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Sat, 23 May 2026 18:28:10 -0700 Subject: [PATCH 11/11] Optimize bulk assignment gaps --- .../recipes/bulkGenerationDiagnostics.test.ts | 129 ++++++++++++++++++ src/lib/recipes/bulkGenerationDiagnostics.ts | 56 +++++++- src/lib/recipes/constraints.test.ts | 51 +++++++ src/lib/recipes/constraints.ts | 117 ++++++++++++++-- src/lib/recipes/runRecipeOnWcif.ts | 23 +++- src/lib/recipes/steps/pnw.ts | 15 ++ 6 files changed, 375 insertions(+), 16 deletions(-) diff --git a/src/lib/recipes/bulkGenerationDiagnostics.test.ts b/src/lib/recipes/bulkGenerationDiagnostics.test.ts index 0a63de5..23e980d 100644 --- a/src/lib/recipes/bulkGenerationDiagnostics.test.ts +++ b/src/lib/recipes/bulkGenerationDiagnostics.test.ts @@ -60,4 +60,133 @@ describe('bulkGenerationDiagnostics', () => { expect(countPeopleWithImmediateHelpingThenCompetingAssignments(wcif)).toBe(1); }); + + it('excludes immediate help then compete assignments within two-group rounds', () => { + const wcif = buildWcif( + [ + buildActivity({ + id: 1, + activityCode: '333-r1', + startTime: '2024-01-01T10:00:00Z', + endTime: '2024-01-01T10:30:00Z', + childActivities: [ + buildActivity({ + id: 101, + activityCode: '333-r1-g1', + startTime: '2024-01-01T10:00:00Z', + endTime: '2024-01-01T10:15:00Z', + }), + buildActivity({ + id: 102, + activityCode: '333-r1-g2', + startTime: '2024-01-01T10:15:00Z', + endTime: '2024-01-01T10:30:00Z', + }), + ], + }), + buildActivity({ + id: 2, + activityCode: '222-r1', + startTime: '2024-01-01T11:00:00Z', + endTime: '2024-01-01T11:45:00Z', + childActivities: [ + buildActivity({ + id: 201, + activityCode: '222-r1-g1', + startTime: '2024-01-01T11:00:00Z', + endTime: '2024-01-01T11:15:00Z', + }), + buildActivity({ + id: 202, + activityCode: '222-r1-g2', + startTime: '2024-01-01T11:15:00Z', + endTime: '2024-01-01T11:30:00Z', + }), + buildActivity({ + id: 203, + activityCode: '222-r1-g3', + startTime: '2024-01-01T11:30:00Z', + endTime: '2024-01-01T11:45:00Z', + }), + ], + }), + ], + [ + buildPerson({ + registrantId: 1, + assignments: [ + { activityId: 101, assignmentCode: 'staff-judge', stationNumber: null }, + { activityId: 102, assignmentCode: 'competitor', stationNumber: null }, + ], + }), + buildPerson({ + registrantId: 2, + assignments: [ + { activityId: 201, assignmentCode: 'staff-judge', stationNumber: null }, + { activityId: 202, assignmentCode: 'competitor', stationNumber: null }, + ], + }), + ] + ); + + expect(countPeopleWithImmediateHelpingThenCompetingAssignments(wcif)).toBe(1); + }); + + it('counts unique group numbers when excluding two-group rounds across multiple stages', () => { + const wcif = buildWcif( + [ + buildActivity({ + id: 1, + activityCode: 'clock-r1', + startTime: '2024-01-01T10:00:00Z', + endTime: '2024-01-01T10:30:00Z', + childActivities: [ + buildActivity({ + id: 101, + activityCode: 'clock-r1-g1', + startTime: '2024-01-01T10:00:00Z', + endTime: '2024-01-01T10:15:00Z', + }), + buildActivity({ + id: 102, + activityCode: 'clock-r1-g2', + startTime: '2024-01-01T10:15:00Z', + endTime: '2024-01-01T10:30:00Z', + }), + ], + }), + buildActivity({ + id: 2, + activityCode: 'clock-r1', + startTime: '2024-01-01T10:00:00Z', + endTime: '2024-01-01T10:30:00Z', + childActivities: [ + buildActivity({ + id: 201, + activityCode: 'clock-r1-g1', + startTime: '2024-01-01T10:00:00Z', + endTime: '2024-01-01T10:15:00Z', + }), + buildActivity({ + id: 202, + activityCode: 'clock-r1-g2', + startTime: '2024-01-01T10:15:00Z', + endTime: '2024-01-01T10:30:00Z', + }), + ], + }), + ], + [ + buildPerson({ + registrantId: 1, + assignments: [ + { activityId: 101, assignmentCode: 'staff-judge', stationNumber: null }, + { activityId: 102, assignmentCode: 'competitor', stationNumber: null }, + ], + }), + ] + ); + + expect(countPeopleWithImmediateHelpingThenCompetingAssignments(wcif)).toBe(0); + }); }); diff --git a/src/lib/recipes/bulkGenerationDiagnostics.ts b/src/lib/recipes/bulkGenerationDiagnostics.ts index 22cf32d..2f5e716 100644 --- a/src/lib/recipes/bulkGenerationDiagnostics.ts +++ b/src/lib/recipes/bulkGenerationDiagnostics.ts @@ -1,4 +1,5 @@ import { isCompetitorAssignment, isStaffAssignment } from '../domain/assignments'; +import { parseActivityCode } from '../domain/activities'; import { findAllActivities } from '../wcif/activities'; import type { Activity, Assignment, Competition } from '@wca/helpers'; @@ -30,6 +31,46 @@ const assignmentsBySchedule = ( assignmentA.activity.id - assignmentB.activity.id ); +const roundKeyFor = (activity: Activity) => { + const { eventId, roundNumber } = parseActivityCode(activity.activityCode); + return roundNumber ? `${eventId}-r${roundNumber}` : null; +}; + +const groupNumberCountsByRound = (activities: Activity[]) => { + const groupNumbersByRound = activities.reduce((groups, activity) => { + const { groupNumber } = parseActivityCode(activity.activityCode); + const roundKey = roundKeyFor(activity); + + if (roundKey && groupNumber) { + groups.set(roundKey, (groups.get(roundKey) ?? new Set()).add(groupNumber)); + } + + return groups; + }, new Map>()); + + return new Map( + [...groupNumbersByRound.entries()].map(([roundKey, groupNumbers]) => [ + roundKey, + groupNumbers.size, + ]) + ); +}; + +const isTwoGroupSameRoundHelpingThenCompeting = ( + helpingAssignment: ScheduledAssignment, + competingAssignment: ScheduledAssignment, + roundGroupCounts: Map +) => { + const helpingRoundKey = roundKeyFor(helpingAssignment.activity); + const competingRoundKey = roundKeyFor(competingAssignment.activity); + + return ( + helpingRoundKey !== null && + helpingRoundKey === competingRoundKey && + roundGroupCounts.get(helpingRoundKey) === 2 + ); +}; + const isImmediateHelpingThenCompeting = ( helpingAssignment: ScheduledAssignment, competingAssignment: ScheduledAssignment @@ -42,16 +83,23 @@ const isImmediateHelpingThenCompeting = ( new Date(competingAssignment.activity.startTime).getTime(); export const countPeopleWithImmediateHelpingThenCompetingAssignments = (wcif: Competition) => { - const activityById = new Map(findAllActivities(wcif).map((activity) => [activity.id, activity])); + const activities = findAllActivities(wcif); + const activityById = new Map(activities.map((activity) => [activity.id, activity])); + const roundGroupCounts = groupNumberCountsByRound(activities); return wcif.persons.filter((person) => { const assignments = assignmentsBySchedule(person.assignments, activityById); return assignments.some((assignment, index) => { const nextAssignment = assignments[index + 1]; - return nextAssignment - ? isImmediateHelpingThenCompeting(assignment, nextAssignment) - : false; + if (!nextAssignment) { + return false; + } + + return ( + isImmediateHelpingThenCompeting(assignment, nextAssignment) && + !isTwoGroupSameRoundHelpingThenCompeting(assignment, nextAssignment, roundGroupCounts) + ); }); }).length; }; diff --git a/src/lib/recipes/constraints.test.ts b/src/lib/recipes/constraints.test.ts index 4c5dc25..12c7408 100644 --- a/src/lib/recipes/constraints.test.ts +++ b/src/lib/recipes/constraints.test.ts @@ -1,4 +1,5 @@ import { + avoidImmediateHelpingThenCompeting, avoidSimilarFirstNames, maximizeAssignmentGaps, mustNotHaveRoles, @@ -245,4 +246,54 @@ describe('recipe constraints', () => { expect(maximizeAssignmentGaps.score(props)).toBe(-100); }); + + it('rejects staff assignments immediately before a future competitor assignment', () => { + const immediateStaffActivity = buildTimedActivity( + 51, + '333-r1-g1', + '2024-01-01T10:00:00.000Z', + '2024-01-01T10:10:00.000Z' + ); + const futureCompetitorActivity = buildTimedActivity( + 61, + '222-r1-g1', + '2024-01-01T10:10:00.000Z', + '2024-01-01T10:20:00.000Z' + ); + const props = { + ...scoreProps(immediateStaffActivity, [immediateStaffActivity]), + assignmentCode: 'staff-judge', + wcif: buildWcif([immediateStaffActivity, futureCompetitorActivity]), + person: buildPerson({ + assignments: [{ activityId: 61, assignmentCode: 'competitor', stationNumber: null }], + }), + }; + + expect(avoidImmediateHelpingThenCompeting.score(props)).toBeNull(); + }); + + it('allows immediate help then compete assignments within two-group rounds', () => { + const firstGroup = buildTimedActivity( + 51, + '333-r1-g1', + '2024-01-01T10:00:00.000Z', + '2024-01-01T10:10:00.000Z' + ); + const secondGroup = buildTimedActivity( + 52, + '333-r1-g2', + '2024-01-01T10:10:00.000Z', + '2024-01-01T10:20:00.000Z' + ); + const props = { + ...scoreProps(firstGroup, [firstGroup, secondGroup]), + assignmentCode: 'staff-judge', + wcif: buildWcif([firstGroup, secondGroup]), + person: buildPerson({ + assignments: [{ activityId: 52, assignmentCode: 'competitor', stationNumber: null }], + }), + }; + + expect(avoidImmediateHelpingThenCompeting.score(props)).toBe(0); + }); }); diff --git a/src/lib/recipes/constraints.ts b/src/lib/recipes/constraints.ts index 4d076e6..d2db665 100644 --- a/src/lib/recipes/constraints.ts +++ b/src/lib/recipes/constraints.ts @@ -1,7 +1,8 @@ -import { parseActivityCode } from '@wca/helpers'; +import { parseActivityCode } from '../domain/activities'; import { roomByActivity, type Constraint } from 'wca-group-generators'; type Activity = Parameters[0]['activity']; +type Wcif = Parameters[0]['wcif']; const DEFAULT_KEY_STAFF_ROLES = ['delegate', 'trainee-delegate', 'organizer']; const DEFAULT_GAP_CAP_MINUTES = 120; @@ -68,21 +69,65 @@ const activitiesOverlap = (first: Activity, second: Activity) => new Date(first.startTime) < new Date(second.endTime) && new Date(second.startTime) < new Date(first.endTime); -const assignmentActivitiesForPerson = ( - wcif: Parameters[0]['wcif'], - person: Parameters[0]['person'], - assignmentTest: (assignmentCode: string) => boolean -) => { - const allActivities = wcif.schedule.venues.flatMap((venue) => +const groupNumberCountsByRound = (activities: Activity[]) => { + const groupNumbersByRound = activities.reduce((groups, activity) => { + const groupNumber = groupNumberFor(activity); + const roundKey = roundKeyFor(activity); + + if (groupNumber) { + groups.set(roundKey, (groups.get(roundKey) ?? new Set()).add(groupNumber)); + } + + return groups; + }, new Map>()); + + return new Map( + [...groupNumbersByRound.entries()].map(([roundKey, groupNumbers]) => [ + roundKey, + groupNumbers.size, + ]) + ); +}; + +const scheduleContextCache = new WeakMap< + Wcif['schedule'], + { + activityById: Map; + groupNumberCountsByRound: Map; + } +>(); + +const scheduleContextFor = (wcif: Wcif) => { + const cached = scheduleContextCache.get(wcif.schedule); + if (cached) { + return cached; + } + + const activities = wcif.schedule.venues.flatMap((venue) => venue.rooms.flatMap((room) => room.activities.flatMap((activity) => [activity, ...(activity.childActivities ?? [])]) ) ); + const context = { + activityById: new Map(activities.map((activity) => [activity.id, activity])), + groupNumberCountsByRound: groupNumberCountsByRound(activities), + }; + + scheduleContextCache.set(wcif.schedule, context); + return context; +}; + +const assignmentActivitiesForPerson = ( + wcif: Wcif, + person: Parameters[0]['person'], + assignmentTest: (assignmentCode: string) => boolean +) => { + const { activityById } = scheduleContextFor(wcif); return ( person.assignments ?.filter((assignment) => assignmentTest(assignment.assignmentCode)) - .map((assignment) => allActivities.find((activity) => activity.id === assignment.activityId)) + .map((assignment) => activityById.get(assignment.activityId)) .filter(Boolean) as Activity[] ).sort((a, b) => a.startTime.localeCompare(b.startTime)); }; @@ -155,6 +200,19 @@ const staffBeforeFutureCompetitorGapScore = ( ); }; +const isTwoGroupSameRoundTransition = ( + firstActivity: Activity, + secondActivity: Activity, + roundGroupCounts: Map +) => + roundKeyFor(firstActivity) === roundKeyFor(secondActivity) && + roundGroupCounts.get(roundKeyFor(firstActivity)) === 2; + +const isImmediateTransition = (firstActivity: Activity, secondActivity: Activity) => + firstActivity.endTime && + secondActivity.startTime && + new Date(firstActivity.endTime).getTime() === new Date(secondActivity.startTime).getTime(); + export const shouldHelpAfterCompeting: Constraint = { name: 'Should Help After Competing', score: ({ wcif, activities, activity, person }) => { @@ -292,6 +350,48 @@ export const maximizeAssignmentGaps: Constraint = { }, }; +export const avoidImmediateHelpingThenCompeting: Constraint = { + name: 'Avoid Immediate Helping Then Competing', + score: ({ wcif, activity, assignmentCode, person }) => { + const scoredPerson = wcif.persons.find( + (candidate) => candidate.registrantId === person.registrantId + ) ?? person; + const { groupNumberCountsByRound: roundGroupCounts } = scheduleContextFor(wcif); + const staffActivities = assignmentActivitiesForPerson( + wcif, + scoredPerson, + isStaffAssignmentCode + ); + const competitorActivities = assignmentActivitiesForPerson( + wcif, + scoredPerson, + isCompetitorAssignmentCode + ); + + if (isCompetitorAssignmentCode(assignmentCode)) { + const hasImmediatePriorStaffActivity = staffActivities.some( + (staffActivity) => + isImmediateTransition(staffActivity, activity) && + !isTwoGroupSameRoundTransition(staffActivity, activity, roundGroupCounts) + ); + + return hasImmediatePriorStaffActivity ? null : 0; + } + + if (isStaffAssignmentCode(assignmentCode)) { + const hasImmediateFutureCompetitorActivity = competitorActivities.some( + (competitorActivity) => + isImmediateTransition(activity, competitorActivity) && + !isTwoGroupSameRoundTransition(activity, competitorActivity, roundGroupCounts) + ); + + return hasImmediateFutureCompetitorActivity ? null : 0; + } + + return 0; + }, +}; + export const RecipeConstraints: Record = { shouldHelpAfterCompeting, preferLaterGroups, @@ -299,4 +399,5 @@ export const RecipeConstraints: Record = { onlyMultipleGroupRounds, avoidSimilarFirstNames, maximizeAssignmentGaps, + avoidImmediateHelpingThenCompeting, }; diff --git a/src/lib/recipes/runRecipeOnWcif.ts b/src/lib/recipes/runRecipeOnWcif.ts index f5b8488..ffc3994 100644 --- a/src/lib/recipes/runRecipeOnWcif.ts +++ b/src/lib/recipes/runRecipeOnWcif.ts @@ -275,7 +275,8 @@ export const runBulkRecipesOnWcif = ( onProgress?: (progress: BulkGenerationProgress) => void; } ): Competition => { - const optimizationContexts: OptimizationContext[] = []; + const competitorOptimizationContexts: OptimizationContext[] = []; + const staffOptimizationContexts: OptimizationContext[] = []; let generatedWcif = wcif; for (const roundId of roundIds) { @@ -286,15 +287,19 @@ export const runBulkRecipesOnWcif = ( stepFilter: (step) => step.type === 'groups' || isCompetitorAssignmentStep(step), onOptimizationContext: (context) => { if (context.assignmentCode === 'competitor') { - optimizationContexts.push(context); + competitorOptimizationContexts.push(context); } }, }); } - if (optimizationContexts.length) { + if (competitorOptimizationContexts.length) { onProgress?.({ phase: 'fixing' }); - generatedWcif = optimizeCapturedContexts(generatedWcif, wcif, optimizationContexts); + generatedWcif = optimizeCapturedContexts( + generatedWcif, + wcif, + competitorOptimizationContexts + ); } for (const roundId of roundIds) { @@ -303,9 +308,19 @@ export const runBulkRecipesOnWcif = ( roundId, recipeId, stepFilter: isStaffAssignmentStep, + onOptimizationContext: (context) => { + if (context.assignmentCode !== 'competitor') { + staffOptimizationContexts.push(context); + } + }, }); } + if (staffOptimizationContexts.length) { + onProgress?.({ phase: 'fixing' }); + generatedWcif = optimizeCapturedContexts(generatedWcif, wcif, staffOptimizationContexts); + } + const completedWcif = setRoundRecipeConfigs(generatedWcif, roundIds, recipeId); // eslint-disable-next-line no-console diff --git a/src/lib/recipes/steps/pnw.ts b/src/lib/recipes/steps/pnw.ts index 2e60122..49f3ef0 100644 --- a/src/lib/recipes/steps/pnw.ts +++ b/src/lib/recipes/steps/pnw.ts @@ -11,6 +11,15 @@ const PNW_GLOBAL_SCORE = { maxEvaluations: 250, maxClusterSize: 80, }; +const PNW_STAFF_GLOBAL_SCORE = { + maxPasses: 1, + maxEvaluations: 300, + maxClusterSize: 90, +}; +const avoidImmediateHelpingThenCompetingConstraint = () => ({ + constraint: 'avoidImmediateHelpingThenCompeting', + weight: 1, +}); export const GenerateCompetitorAssignmentsForStaff: StepDefinition = { id: 'GenerateCompetitorAssignmentsForStaff', @@ -49,6 +58,7 @@ export const GenerateCompetitorAssignmentsForStaff: StepDefinition = { constraint: 'mustNotHaveOtherAssignments', weight: 1, }, + avoidImmediateHelpingThenCompetingConstraint(), { constraint: 'shouldHelpAfterCompeting', weight: 20, @@ -128,6 +138,7 @@ export const GenerateCompetitorAssignmentsForDelegatesAndOrganizers: StepDefinit constraint: 'mustNotHaveOtherAssignments', weight: 1, }, + avoidImmediateHelpingThenCompetingConstraint(), { constraint: 'preferLaterGroups', weight: 20, @@ -197,6 +208,7 @@ export const GenerateCompetitorAssignmentsForFirstTimers: StepDefinition = { constraint: 'mustNotHaveOtherAssignments', weight: 1, }, + avoidImmediateHelpingThenCompetingConstraint(), { constraint: 'maximizeAssignmentGaps', weight: PNW_ASSIGNMENT_GAP_WEIGHT, @@ -255,6 +267,7 @@ export const GenerateCompetitorAssignments: StepDefinition = { constraint: 'mustNotHaveOtherAssignments', weight: 1, }, + avoidImmediateHelpingThenCompetingConstraint(), { constraint: 'maximizeAssignmentGaps', weight: PNW_ASSIGNMENT_GAP_WEIGHT, @@ -312,6 +325,7 @@ export const GenerateJudgeAssignmentsForCompetitors: StepDefinition = { options: { mode: 'symmetric', }, + globalScore: PNW_STAFF_GLOBAL_SCORE, constraints: [ { constraint: 'uniqueAssignment', @@ -321,6 +335,7 @@ export const GenerateJudgeAssignmentsForCompetitors: StepDefinition = { constraint: 'mustNotHaveOtherAssignments', weight: 1, }, + avoidImmediateHelpingThenCompetingConstraint(), { constraint: 'onlyMultipleGroupRounds', weight: 1,