Skip to content

Commit 34548c3

Browse files
committed
Add PNW recipe constraints
1 parent 4cfa780 commit 34548c3

14 files changed

Lines changed: 1003 additions & 28 deletions

File tree

src/components/RoundActionButtons.tsx

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ interface RoundActionButtonsProps {
1313
personsShouldBeInRound: Person[];
1414
activityCode: string;
1515
onConfigureAssignments: () => void;
16-
onGenerateAssignments: () => void;
1716
recipeId: string;
1817
onChangeRecipeId: (recipeId: string) => void;
1918
onRunRecipe: () => void;
@@ -30,7 +29,6 @@ export const RoundActionButtons = ({
3029
personsShouldBeInRound,
3130
activityCode,
3231
onConfigureAssignments,
33-
onGenerateAssignments,
3432
recipeId,
3533
onChangeRecipeId,
3634
onRunRecipe,
@@ -52,7 +50,6 @@ export const RoundActionButtons = ({
5250
return (
5351
<>
5452
<Button onClick={onConfigureAssignments}>Configure Assignments</Button>
55-
<Button onClick={onGenerateAssignments}>Assign Competitor and Judging Assignments</Button>
5653
<FormControl size="small" sx={{ minWidth: 220, marginLeft: 2 }}>
5754
<InputLabel id="recipe-select-label">Recipe</InputLabel>
5855
<Select
@@ -68,7 +65,7 @@ export const RoundActionButtons = ({
6865
))}
6966
</Select>
7067
</FormControl>
71-
<Button onClick={onRunRecipe}>Run Recipe</Button>
68+
<Button onClick={onRunRecipe}>Generate</Button>
7269
<div style={{ display: 'flex', flex: 1 }} />
7370
<Button onClick={onConfigureGroups}>Configure Groups</Button>
7471
<Button color="error" onClick={onResetAll}>
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
import { AssignmentsMap } from '../config/assignments';
2+
import type { ActivityWithParent } from '../lib/domain/types';
3+
import {
4+
Card,
5+
CardHeader,
6+
Table,
7+
TableBody,
8+
TableCell,
9+
TableContainer,
10+
TableFooter,
11+
TableHead,
12+
TableRow,
13+
} from '@mui/material';
14+
import { alpha } from '@mui/material/styles';
15+
import { parseActivityCode, type Person } from '@wca/helpers';
16+
17+
const trackedAssignments = [
18+
{ assignmentCode: 'competitor', label: 'Competitor' },
19+
{ assignmentCode: 'staff-scrambler', label: 'Scrambler' },
20+
{ assignmentCode: 'staff-runner', label: 'Runner' },
21+
{ assignmentCode: 'staff-judge', label: 'Judge' },
22+
] as const;
23+
24+
type TrackedAssignmentCode = (typeof trackedAssignments)[number]['assignmentCode'];
25+
26+
type AssignmentCountRow = {
27+
groupId: number;
28+
stageName: string;
29+
groupNumber: number | string;
30+
counts: Record<TrackedAssignmentCode, number>;
31+
};
32+
33+
type StageHeader = {
34+
id: number;
35+
name: string;
36+
groups: AssignmentCountRow[];
37+
};
38+
39+
const emptyCounts = () =>
40+
trackedAssignments.reduce(
41+
(counts, { assignmentCode }) => ({
42+
...counts,
43+
[assignmentCode]: 0,
44+
}),
45+
{} as Record<TrackedAssignmentCode, number>
46+
);
47+
48+
const buildAssignmentCountRows = (
49+
groups: ActivityWithParent[],
50+
persons: Person[]
51+
): AssignmentCountRow[] =>
52+
groups.map((group) => {
53+
const counts = emptyCounts();
54+
const groupAssignments = persons.flatMap((person) =>
55+
(person.assignments ?? []).filter((assignment) => assignment.activityId === group.id)
56+
);
57+
58+
for (const assignment of groupAssignments) {
59+
if (assignment.assignmentCode in counts) {
60+
counts[assignment.assignmentCode as TrackedAssignmentCode] += 1;
61+
}
62+
}
63+
64+
return {
65+
groupId: group.id,
66+
stageName: group.parent.room.name,
67+
groupNumber: parseActivityCode(group.activityCode).groupNumber ?? '-',
68+
counts,
69+
};
70+
});
71+
72+
const buildStageHeaders = (rows: AssignmentCountRow[]) =>
73+
rows.reduce<StageHeader[]>((headers, row) => {
74+
const header = headers.find((candidate) => candidate.name === row.stageName);
75+
if (header) {
76+
header.groups.push(row);
77+
return headers;
78+
}
79+
80+
return [
81+
...headers,
82+
{
83+
id: row.groupId,
84+
name: row.stageName,
85+
groups: [row],
86+
},
87+
];
88+
}, []);
89+
90+
interface RoundAssignmentCountsTableProps {
91+
groups: ActivityWithParent[];
92+
persons: Person[];
93+
}
94+
95+
export const RoundAssignmentCountsTable = ({
96+
groups,
97+
persons,
98+
}: RoundAssignmentCountsTableProps) => {
99+
const rows = buildAssignmentCountRows(groups, persons);
100+
const stageHeaders = buildStageHeaders(rows);
101+
const totalsByAssignment = rows.reduce((totalCounts, row) => {
102+
for (const { assignmentCode } of trackedAssignments) {
103+
totalCounts[assignmentCode] += row.counts[assignmentCode];
104+
}
105+
106+
return totalCounts;
107+
}, emptyCounts());
108+
109+
if (rows.length === 0) {
110+
return null;
111+
}
112+
113+
return (
114+
<Card>
115+
<CardHeader title="Assignment Counts" />
116+
<TableContainer>
117+
<Table size="small">
118+
<TableHead>
119+
<TableRow>
120+
<TableCell />
121+
{stageHeaders.map((stage) => (
122+
<TableCell
123+
key={stage.id}
124+
align="center"
125+
colSpan={stage.groups.length}
126+
sx={{ fontWeight: 600 }}>
127+
{stage.name}
128+
</TableCell>
129+
))}
130+
<TableCell />
131+
</TableRow>
132+
<TableRow>
133+
<TableCell sx={{ fontWeight: 600 }}>Assignment</TableCell>
134+
{rows.map((row) => (
135+
<TableCell key={row.groupId} align="center" sx={{ fontWeight: 600 }}>
136+
g{row.groupNumber}
137+
</TableCell>
138+
))}
139+
<TableCell align="right" sx={{ fontWeight: 600 }}>
140+
Total
141+
</TableCell>
142+
</TableRow>
143+
</TableHead>
144+
<TableBody>
145+
{trackedAssignments.map(({ assignmentCode, label }) => (
146+
<TableRow
147+
key={assignmentCode}
148+
sx={{ backgroundColor: alpha(AssignmentsMap[assignmentCode].color[200], 0.5) }}>
149+
<TableCell sx={{ fontWeight: 600 }}>{label}</TableCell>
150+
{rows.map((row) => (
151+
<TableCell key={row.groupId} align="center">
152+
{row.counts[assignmentCode]}
153+
</TableCell>
154+
))}
155+
<TableCell align="right" sx={{ fontWeight: 600 }}>
156+
{totalsByAssignment[assignmentCode]}
157+
</TableCell>
158+
</TableRow>
159+
))}
160+
</TableBody>
161+
<TableFooter>
162+
<TableRow>
163+
<TableCell sx={{ fontWeight: 600 }}>Total</TableCell>
164+
{rows.map((row) => (
165+
<TableCell key={row.groupId} align="center" sx={{ fontWeight: 600 }}>
166+
{trackedAssignments.reduce(
167+
(total, { assignmentCode }) => total + row.counts[assignmentCode],
168+
0
169+
)}
170+
</TableCell>
171+
))}
172+
<TableCell align="right" sx={{ fontWeight: 600 }}>
173+
{Object.values(totalsByAssignment).reduce((total, count) => total + count, 0)}
174+
</TableCell>
175+
</TableRow>
176+
</TableFooter>
177+
</Table>
178+
</TableContainer>
179+
</Card>
180+
);
181+
};

src/lib/recipes/conditions.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import type { Activity, Competition } from '@wca/helpers';
2+
import type { GroupStep } from './types';
3+
4+
const isFinalRound = (wcif: Competition, roundId: string) => {
5+
const event = wcif.events.find((candidateEvent) =>
6+
candidateEvent.rounds.some((round) => round.id === roundId)
7+
);
8+
if (!event) return false;
9+
10+
return event.rounds[event.rounds.length - 1]?.id === roundId;
11+
};
12+
13+
const hasGroupActivities = (roundActivities: Activity[]) =>
14+
roundActivities.some((roundActivity) => (roundActivity.childActivities ?? []).length > 0);
15+
16+
export const shouldRunGroupStep = (
17+
wcif: Competition,
18+
roundId: string,
19+
roundActivities: Activity[],
20+
step: GroupStep
21+
) => {
22+
switch (step.props.condition) {
23+
case 'missingGroupsInFinalRound':
24+
return isFinalRound(wcif, roundId) && !hasGroupActivities(roundActivities);
25+
default:
26+
return true;
27+
}
28+
};
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import {
2+
avoidSimilarFirstNames,
3+
mustNotHaveRoles,
4+
onlyMultipleGroupRounds,
5+
preferLaterGroups,
6+
shouldHelpAfterCompeting,
7+
} from './constraints';
8+
import { buildActivity, buildPerson, buildWcif } from '../../store/reducers/_tests_/helpers';
9+
import type { Activity } from '@wca/helpers';
10+
import { describe, expect, it } from 'vitest';
11+
12+
const buildGroup = (id: number, groupNumber: number): Activity =>
13+
buildActivity({
14+
id,
15+
name: `Group ${groupNumber}`,
16+
activityCode: `333-r1-g${groupNumber}`,
17+
startTime: `2024-01-01T10:0${groupNumber}:00Z`,
18+
endTime: `2024-01-01T10:1${groupNumber}:00Z`,
19+
});
20+
21+
const scoreProps = (activity: Activity, activities: Activity[]) => ({
22+
wcif: buildWcif([
23+
buildActivity({
24+
id: 1,
25+
activityCode: '333-r1',
26+
childActivities: activities,
27+
}),
28+
]),
29+
activities,
30+
cluster: [],
31+
assignmentCode: 'competitor',
32+
person: buildPerson({
33+
assignments: [{ activityId: 12, assignmentCode: 'staff-judge', stationNumber: null }],
34+
}),
35+
activity,
36+
});
37+
38+
describe('recipe constraints', () => {
39+
it('prefers the group before a staff assignment so staff help after competing', () => {
40+
const activities = [buildGroup(11, 1), buildGroup(12, 2), buildGroup(13, 3)];
41+
42+
expect(shouldHelpAfterCompeting.score(scoreProps(activities[0], activities))).toBe(1);
43+
expect(shouldHelpAfterCompeting.score(scoreProps(activities[2], activities))).toBe(0);
44+
});
45+
46+
it('wraps staff competitor assignments from group one staff to the last competing group', () => {
47+
const activities = [buildGroup(11, 1), buildGroup(12, 2), buildGroup(13, 3)];
48+
const props = {
49+
...scoreProps(activities[2], activities),
50+
person: buildPerson({
51+
assignments: [{ activityId: 11, assignmentCode: 'staff-judge', stationNumber: null }],
52+
}),
53+
};
54+
55+
expect(shouldHelpAfterCompeting.score(props)).toBe(1);
56+
});
57+
58+
it('scores later groups higher', () => {
59+
const activities = [buildGroup(11, 1), buildGroup(12, 2), buildGroup(13, 3)];
60+
61+
const earlyScore = preferLaterGroups.score(scoreProps(activities[0], activities));
62+
const lateScore = preferLaterGroups.score(scoreProps(activities[2], activities));
63+
64+
expect(lateScore).toBeGreaterThan(earlyScore ?? 0);
65+
});
66+
67+
it('rejects people with blocked roles', () => {
68+
const activity = buildGroup(11, 1);
69+
const props = {
70+
...scoreProps(activity, [activity]),
71+
person: buildPerson({ roles: ['organizer'] }),
72+
options: { roles: ['delegate', 'trainee-delegate', 'organizer'] },
73+
};
74+
75+
expect(mustNotHaveRoles.score(props)).toBeNull();
76+
});
77+
78+
it('rejects judge assignments when only one group exists', () => {
79+
const activity = buildGroup(11, 1);
80+
81+
expect(onlyMultipleGroupRounds.score(scoreProps(activity, [activity]))).toBeNull();
82+
});
83+
84+
it('penalizes same-sounding first names in the same activity', () => {
85+
const activity = buildGroup(11, 1);
86+
const props = {
87+
...scoreProps(activity, [activity]),
88+
wcif: buildWcif([], [
89+
buildPerson({
90+
name: 'John Smith',
91+
assignments: [{ activityId: 11, assignmentCode: 'competitor', stationNumber: null }],
92+
}),
93+
]),
94+
person: buildPerson({ name: 'Jon Jones' }),
95+
};
96+
97+
expect(avoidSimilarFirstNames.score(props)).toBeLessThan(0);
98+
});
99+
});

0 commit comments

Comments
 (0)