Skip to content
29 changes: 26 additions & 3 deletions src/components/RoundActionButtons.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { Recipes } from '../lib/recipes';
import { hasDistributedAttempts, parseActivityCode } from '../lib/domain/activities';
import { type ActivityWithParent } from '../lib/domain/types';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import FormControl from '@mui/material/FormControl';
import InputLabel from '@mui/material/InputLabel';
import MenuItem from '@mui/material/MenuItem';
import Select from '@mui/material/Select';
import { type Person } from '@wca/helpers';

interface RoundActionButtonsProps {
Expand All @@ -10,7 +15,9 @@ interface RoundActionButtonsProps {
personsShouldBeInRound: Person[];
activityCode: string;
onConfigureAssignments: () => void;
onGenerateAssignments: () => void;
recipeId: string;
onChangeRecipeId: (recipeId: string) => void;
onRunRecipe: () => void;
onAssignToRoundAttempt: () => void;
onResetAttemptAssignments: () => void;
onConfigureStationNumbers: (activityCode: string) => void;
Expand All @@ -27,7 +34,9 @@ export const RoundActionButtons = ({
personsShouldBeInRound,
activityCode,
onConfigureAssignments,
onGenerateAssignments,
recipeId,
onChangeRecipeId,
onRunRecipe,
onAssignToRoundAttempt,
onResetAttemptAssignments,
onConfigureStationNumbers,
Expand Down Expand Up @@ -86,7 +95,21 @@ export const RoundActionButtons = ({
return (
<>
<Button onClick={onConfigureAssignments}>Configure Assignments</Button>
<Button onClick={onGenerateAssignments}>Assign Competitor and Judging Assignments</Button>
<FormControl size="small" sx={{ minWidth: 220, marginLeft: 2 }}>
<InputLabel id="recipe-select-label">Recipe</InputLabel>
<Select
labelId="recipe-select-label"
label="Recipe"
value={recipeId}
onChange={(e) => onChangeRecipeId(String(e.target.value))}>
{Recipes.map((r) => (
<MenuItem key={r.id} value={r.id}>
{r.name}
</MenuItem>
))}
</Select>
</FormControl>
<Button onClick={onRunRecipe}>Generate</Button>
<div style={{ display: 'flex', flex: 1 }} />
<Button onClick={onConfigureGroups}>Configure Groups</Button>
<Button color="error" onClick={onResetAll}>
Expand Down
164 changes: 164 additions & 0 deletions src/components/RoundAssignmentCountsTable.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import { AssignmentsMap } from '../config/assignments';
import type { ActivityWithParent } from '../lib/domain/types';
import {
Card,
CardHeader,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
} from '@mui/material';
import { alpha } from '@mui/material/styles';
import { parseActivityCode, type Person } from '@wca/helpers';

const trackedAssignments = [
{ assignmentCode: 'competitor', label: 'Competitor' },
{ assignmentCode: 'staff-scrambler', label: 'Scrambler' },
{ assignmentCode: 'staff-runner', label: 'Runner' },
{ assignmentCode: 'staff-judge', label: 'Judge' },
] as const;

type TrackedAssignmentCode = (typeof trackedAssignments)[number]['assignmentCode'];

type AssignmentCountRow = {
groupId: number;
stageName: string;
groupNumber: number | string;
counts: Record<TrackedAssignmentCode, number>;
};

type StageHeader = {
id: number;
name: string;
groups: AssignmentCountRow[];
};

const emptyCounts = () =>
trackedAssignments.reduce(
(counts, { assignmentCode }) => ({
...counts,
[assignmentCode]: 0,
}),
{} as Record<TrackedAssignmentCode, number>
);

const buildAssignmentCountRows = (
groups: ActivityWithParent[],
persons: Person[]
): AssignmentCountRow[] =>
groups.map((group) => {
const counts = emptyCounts();
const groupAssignments = persons.flatMap((person) =>
(person.assignments ?? []).filter((assignment) => assignment.activityId === group.id)
);

for (const assignment of groupAssignments) {
if (assignment.assignmentCode in counts) {
counts[assignment.assignmentCode as TrackedAssignmentCode] += 1;
}
}

return {
groupId: group.id,
stageName: group.parent.room.name,
groupNumber: parseActivityCode(group.activityCode).groupNumber ?? '-',
counts,
};
});

const buildStageHeaders = (rows: AssignmentCountRow[]) =>
rows.reduce<StageHeader[]>((headers, row) => {
const header = headers.find((candidate) => candidate.name === row.stageName);
if (header) {
header.groups.push(row);
return headers;
}

return [
...headers,
{
id: row.groupId,
name: row.stageName,
groups: [row],
},
];
}, []);

interface RoundAssignmentCountsTableProps {
groups: ActivityWithParent[];
persons: Person[];
}

export const RoundAssignmentCountsTable = ({
groups,
persons,
}: RoundAssignmentCountsTableProps) => {
const rows = buildAssignmentCountRows(groups, persons);
const stageHeaders = buildStageHeaders(rows);
const totalsByAssignment = rows.reduce((totalCounts, row) => {
for (const { assignmentCode } of trackedAssignments) {
totalCounts[assignmentCode] += row.counts[assignmentCode];
}

return totalCounts;
}, emptyCounts());

if (rows.length === 0) {
return null;
}

return (
<Card>
<CardHeader title="Assignment Counts" />
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TableCell />
{stageHeaders.map((stage) => (
<TableCell
key={stage.id}
align="center"
colSpan={stage.groups.length}
sx={{ fontWeight: 600 }}>
{stage.name}
</TableCell>
))}
<TableCell />
</TableRow>
<TableRow>
<TableCell sx={{ fontWeight: 600 }}>Assignment</TableCell>
{rows.map((row) => (
<TableCell key={row.groupId} align="center" sx={{ fontWeight: 600 }}>
g{row.groupNumber}
</TableCell>
))}
<TableCell align="right" sx={{ fontWeight: 600 }}>
Total
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{trackedAssignments.map(({ assignmentCode, label }) => (
<TableRow
key={assignmentCode}
sx={{ backgroundColor: alpha(AssignmentsMap[assignmentCode].color[200], 0.5) }}>
<TableCell sx={{ fontWeight: 600 }}>{label}</TableCell>
{rows.map((row) => (
<TableCell key={row.groupId} align="center">
{row.counts[assignmentCode]}
</TableCell>
))}
<TableCell align="right" sx={{ fontWeight: 600 }}>
{totalsByAssignment[assignmentCode]}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</Card>
);
};
45 changes: 44 additions & 1 deletion src/components/_tests_/Header.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -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(<DrawerLinks />);
Expand All @@ -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(<DrawerLinks />);

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);
});
});
4 changes: 3 additions & 1 deletion src/components/_tests_/RoundActionButtons.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ const baseProps = {
personsShouldBeInRound: [],
activityCode: '333fm-r1',
onConfigureAssignments: vi.fn(),
onGenerateAssignments: vi.fn(),
recipeId: 'pnw',
onChangeRecipeId: vi.fn(),
onRunRecipe: vi.fn(),
onAssignToRoundAttempt: vi.fn(),
onResetAttemptAssignments: vi.fn(),
onConfigureStationNumbers: vi.fn(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ const ConfigureAssignmentsDialog = ({
groups,
isDistributedAttemptRoundLevel,
distributedAttemptGroups,
defaultShowAllCompetitors,
}: {
open: boolean;
onClose: () => void;
Expand All @@ -50,6 +51,7 @@ const ConfigureAssignmentsDialog = ({
attemptNumber: number;
activities: ActivityWithRoom[];
}>;
defaultShowAllCompetitors?: boolean;
}) => {
const wcif = useAppSelector((state) => state.wcif);
const { eventId, roundNumber } = parseActivityCode(activityCode) as {
Expand All @@ -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<CompetitorSort>('speed');
const [showCompetitorsNotInRound, setShowCompetitorsNotInRound] = useState(false);
Expand Down
5 changes: 4 additions & 1 deletion src/dialogs/ConfigureGroupCountsDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -48,6 +48,7 @@ const ConfigureGroupCountsDialog = ({
.filter((room) => room.activities.find((a) => a.activityCode === activityCode))
);
const dispatch = useDispatch();
const groupCountInputRef = useRef<HTMLInputElement>(null);
const groupsExtData = getGroupsExtensionData(round);
const [groupsData, setGroupsData] = useState<{
groups: number | Record<number, number>;
Expand Down Expand Up @@ -178,6 +179,8 @@ const ConfigureGroupCountsDialog = ({
<Input
id="groups"
type="number"
autoFocus
inputRef={groupCountInputRef}
value={groupCount || 1}
onChange={handleGroupsChange}
/>
Expand Down
Loading
Loading