Skip to content

Commit f769448

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

14 files changed

Lines changed: 986 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: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
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+
TableHead,
11+
TableRow,
12+
} from '@mui/material';
13+
import { alpha } from '@mui/material/styles';
14+
import { parseActivityCode, type Person } from '@wca/helpers';
15+
16+
const trackedAssignments = [
17+
{ assignmentCode: 'competitor', label: 'Competitor' },
18+
{ assignmentCode: 'staff-scrambler', label: 'Scrambler' },
19+
{ assignmentCode: 'staff-runner', label: 'Runner' },
20+
{ assignmentCode: 'staff-judge', label: 'Judge' },
21+
] as const;
22+
23+
type TrackedAssignmentCode = (typeof trackedAssignments)[number]['assignmentCode'];
24+
25+
type AssignmentCountRow = {
26+
groupId: number;
27+
stageName: string;
28+
groupNumber: number | string;
29+
counts: Record<TrackedAssignmentCode, number>;
30+
};
31+
32+
type StageHeader = {
33+
id: number;
34+
name: string;
35+
groups: AssignmentCountRow[];
36+
};
37+
38+
const emptyCounts = () =>
39+
trackedAssignments.reduce(
40+
(counts, { assignmentCode }) => ({
41+
...counts,
42+
[assignmentCode]: 0,
43+
}),
44+
{} as Record<TrackedAssignmentCode, number>
45+
);
46+
47+
const buildAssignmentCountRows = (
48+
groups: ActivityWithParent[],
49+
persons: Person[]
50+
): AssignmentCountRow[] =>
51+
groups.map((group) => {
52+
const counts = emptyCounts();
53+
const groupAssignments = persons.flatMap((person) =>
54+
(person.assignments ?? []).filter((assignment) => assignment.activityId === group.id)
55+
);
56+
57+
for (const assignment of groupAssignments) {
58+
if (assignment.assignmentCode in counts) {
59+
counts[assignment.assignmentCode as TrackedAssignmentCode] += 1;
60+
}
61+
}
62+
63+
return {
64+
groupId: group.id,
65+
stageName: group.parent.room.name,
66+
groupNumber: parseActivityCode(group.activityCode).groupNumber ?? '-',
67+
counts,
68+
};
69+
});
70+
71+
const buildStageHeaders = (rows: AssignmentCountRow[]) =>
72+
rows.reduce<StageHeader[]>((headers, row) => {
73+
const header = headers.find((candidate) => candidate.name === row.stageName);
74+
if (header) {
75+
header.groups.push(row);
76+
return headers;
77+
}
78+
79+
return [
80+
...headers,
81+
{
82+
id: row.groupId,
83+
name: row.stageName,
84+
groups: [row],
85+
},
86+
];
87+
}, []);
88+
89+
interface RoundAssignmentCountsTableProps {
90+
groups: ActivityWithParent[];
91+
persons: Person[];
92+
}
93+
94+
export const RoundAssignmentCountsTable = ({
95+
groups,
96+
persons,
97+
}: RoundAssignmentCountsTableProps) => {
98+
const rows = buildAssignmentCountRows(groups, persons);
99+
const stageHeaders = buildStageHeaders(rows);
100+
const totalsByAssignment = rows.reduce((totalCounts, row) => {
101+
for (const { assignmentCode } of trackedAssignments) {
102+
totalCounts[assignmentCode] += row.counts[assignmentCode];
103+
}
104+
105+
return totalCounts;
106+
}, emptyCounts());
107+
108+
if (rows.length === 0) {
109+
return null;
110+
}
111+
112+
return (
113+
<Card>
114+
<CardHeader title="Assignment Counts" />
115+
<TableContainer>
116+
<Table size="small">
117+
<TableHead>
118+
<TableRow>
119+
<TableCell />
120+
{stageHeaders.map((stage) => (
121+
<TableCell
122+
key={stage.id}
123+
align="center"
124+
colSpan={stage.groups.length}
125+
sx={{ fontWeight: 600 }}>
126+
{stage.name}
127+
</TableCell>
128+
))}
129+
<TableCell />
130+
</TableRow>
131+
<TableRow>
132+
<TableCell sx={{ fontWeight: 600 }}>Assignment</TableCell>
133+
{rows.map((row) => (
134+
<TableCell key={row.groupId} align="center" sx={{ fontWeight: 600 }}>
135+
g{row.groupNumber}
136+
</TableCell>
137+
))}
138+
<TableCell align="right" sx={{ fontWeight: 600 }}>
139+
Total
140+
</TableCell>
141+
</TableRow>
142+
</TableHead>
143+
<TableBody>
144+
{trackedAssignments.map(({ assignmentCode, label }) => (
145+
<TableRow
146+
key={assignmentCode}
147+
sx={{ backgroundColor: alpha(AssignmentsMap[assignmentCode].color[200], 0.5) }}>
148+
<TableCell sx={{ fontWeight: 600 }}>{label}</TableCell>
149+
{rows.map((row) => (
150+
<TableCell key={row.groupId} align="center">
151+
{row.counts[assignmentCode]}
152+
</TableCell>
153+
))}
154+
<TableCell align="right" sx={{ fontWeight: 600 }}>
155+
{totalsByAssignment[assignmentCode]}
156+
</TableCell>
157+
</TableRow>
158+
))}
159+
</TableBody>
160+
</Table>
161+
</TableContainer>
162+
</Card>
163+
);
164+
};

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)