Skip to content
2 changes: 1 addition & 1 deletion .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '18'
node-version-file: '.nvmrc'
cache: 'yarn'

- name: Install dependencies
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/type-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '18'
node-version-file: '.nvmrc'
cache: 'yarn'

- name: Install dependencies
Expand Down
120 changes: 87 additions & 33 deletions src/components/RoundLimitInfo.tsx
Original file line number Diff line number Diff line change
@@ -1,47 +1,101 @@
import { mayMakeCutoff, mayMakeTimeLimit } from '../lib/domain/persons';
import {
getNextRoundParticipationTextForRound,
getParticipationSourceTextForRound,
} from '../lib/wcif/rounds';
import { renderResultByEventId } from '../lib/utils/utils';
import { Box, Divider, Tooltip, Typography } from '@mui/material';
import { type EventId, formatCentiseconds, type Person, type Round } from '@wca/helpers';
import { Box, Tooltip, Typography } from '@mui/material';
import {
type Event,
type EventId,
formatCentiseconds,
type Person,
type Round,
} from '@wca/helpers';

interface RoundLimitInfoProps {
event: Event | null;
round: Round;
eventId: string;
personsShouldBeInRound: Person[];
}

export const RoundLimitInfo = ({ round, eventId, personsShouldBeInRound }: RoundLimitInfoProps) => {
return (
<Box sx={{ display: 'flex' }}>
{round.timeLimit && (
<Tooltip title="Defined by the number of people who have a PR single under the timelimit">
<Box sx={{ px: 3, py: 1 }}>
<Typography>Time Limit: {formatCentiseconds(round.timeLimit.centiseconds)}</Typography>
{personsShouldBeInRound.length > 0 && (
<Typography>
May make TimeLimit:{' '}
{mayMakeTimeLimit(eventId as EventId, round, personsShouldBeInRound)?.length}
</Typography>
)}
</Box>
</Tooltip>
)}
<Divider orientation="vertical" flexItem sx={{ mx: 1 }} />
{round.cutoff && (
<Tooltip title="Defined by the number of people who have a PR average under the cutoff">
<Box sx={{ px: 3, py: 1 }}>
export const RoundLimitInfo = ({
event,
round,
eventId,
personsShouldBeInRound,
}: RoundLimitInfoProps) => {
const thisRoundParticipationText = event
? getParticipationSourceTextForRound(event, round.id)
: null;
const nextRoundParticipationText = event
? getNextRoundParticipationTextForRound(event, round.id)
: null;
const sections = [
round.timeLimit ? (
<Tooltip
key="time-limit"
title="Defined by the number of people who have a PR single under the timelimit">
<Box sx={{ px: 3, py: 1, minWidth: 0 }}>
<Typography>Time Limit: {formatCentiseconds(round.timeLimit.centiseconds)}</Typography>
{personsShouldBeInRound.length > 0 && (
<Typography>
May make TimeLimit:{' '}
{mayMakeTimeLimit(eventId as EventId, round, personsShouldBeInRound)?.length}
</Typography>
)}
</Box>
</Tooltip>
) : null,
round.cutoff ? (
<Tooltip
key="cutoff"
title="Defined by the number of people who have a PR average under the cutoff">
<Box sx={{ px: 3, py: 1, minWidth: 0 }}>
<Typography>Cutoff:</Typography>
<Typography>
{round.cutoff.numberOfAttempts} attempts to get {'< '}
{renderResultByEventId(eventId as EventId, 'average', round.cutoff.attemptResult)}
</Typography>
{personsShouldBeInRound.length > 0 && (
<Typography>
Cutoff: {round.cutoff.numberOfAttempts} attempts to get {'< '}
{renderResultByEventId(eventId as EventId, 'average', round.cutoff.attemptResult)}
May make cutoff:{' '}
{mayMakeCutoff(eventId as EventId, round, personsShouldBeInRound)?.length}
</Typography>
{personsShouldBeInRound.length > 0 && (
<Typography>
May make cutoff:{' '}
{mayMakeCutoff(eventId as EventId, round, personsShouldBeInRound)?.length}
</Typography>
)}
</Box>
</Tooltip>
)}
)}
</Box>
</Tooltip>
) : null,
thisRoundParticipationText ? (
<Box key="this-round" sx={{ px: 3, py: 1, minWidth: 0 }}>
<Typography>Participation: {thisRoundParticipationText}</Typography>
</Box>
) : null,
nextRoundParticipationText ? (
<Box key="next-round" sx={{ px: 3, py: 1, minWidth: 0 }}>
<Typography>Advancement: {nextRoundParticipationText}</Typography>
</Box>
) : null,
].filter(Boolean);

return (
<Box
sx={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))',
alignItems: 'stretch',
}}>
{sections.map((section, index) => (
<Box
key={index}
sx={{
borderLeft: index === 0 ? 'none' : 1,
borderColor: 'divider',
}}>
{section}
</Box>
))}
</Box>
);
};
75 changes: 74 additions & 1 deletion src/components/RoundSelector/_tests_/RoundSelector.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
import type { AppState } from '../../../store/initialState';

const useAppSelector = vi.fn();
const { earliestStartTimeForRoundMock } = vi.hoisted(() => ({
earliestStartTimeForRoundMock: vi.fn(() => new Date('2020-01-01T00:00:00Z')),
}));

vi.mock('../../../store', () => ({
useAppSelector: (...args: unknown[]) => useAppSelector(...args),
Expand All @@ -15,13 +18,13 @@
}));

vi.mock('../../../lib/domain/activities', async () => {
const actual = await vi.importActual<typeof import('../../../lib/domain/activities')>(

Check warning on line 21 in src/components/RoundSelector/_tests_/RoundSelector.test.tsx

View workflow job for this annotation

GitHub Actions / lint

`import()` type annotations are forbidden
'../../../lib/domain/activities'
);

return {
...actual,
earliestStartTimeForRound: vi.fn(() => new Date('2020-01-01T00:00:00Z')),
earliestStartTimeForRound: earliestStartTimeForRoundMock,
};
});

Expand Down Expand Up @@ -85,4 +88,74 @@

expect(toggle).toBeChecked();
});

it('shows linked dual-round source rounds even before they start', () => {
earliestStartTimeForRoundMock.mockReturnValueOnce(new Date('3020-01-01T00:00:00Z'));
earliestStartTimeForRoundMock.mockReturnValueOnce(new Date('3020-01-01T00:00:00Z'));
earliestStartTimeForRoundMock.mockReturnValueOnce(new Date('3020-01-01T00:00:00Z'));

const wcif = {
id: 'TestComp',
events: [
{
id: 'clock',
rounds: [
{
id: 'clock-r1',
format: 'a',
results: [],
timeLimit: null,
cutoff: null,
participationRuleset: {
participationSource: { type: 'registrations' },
reservedPlaces: null,
},
extensions: [],
},
{
id: 'clock-r2',
format: 'a',
results: [],
timeLimit: null,
cutoff: null,
participationRuleset: {
participationSource: { type: 'registrations' },
reservedPlaces: null,
},
extensions: [],
},
{
id: 'clock-r3',
format: 'a',
results: [],
timeLimit: null,
cutoff: null,
participationRuleset: {
participationSource: {
type: 'linkedRounds',
roundIds: ['clock-r1', 'clock-r2'],
resultCondition: { type: 'percent', scope: 'average', value: 75 },
},
reservedPlaces: null,
},
extensions: [],
},
],
},
],
};

const state = { wcif } as unknown as AppState;
useAppSelector.mockImplementation((selector: (state: AppState) => unknown) => selector(state));

const { getAllByTestId } = renderWithProviders(
<RoundSelector competitionId="TestComp" onSelected={() => undefined} />
);

expect(getAllByTestId('round-item')).toHaveLength(2);
expect(getAllByTestId('round-item').map((item) => item.getAttribute('data-code'))).toEqual([
'clock-r1',
'clock-r2',
]);
});
});
15 changes: 9 additions & 6 deletions src/components/RoundSelector/index.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import {
earliestStartTimeForRound,
hasDistributedAttempts,
parseActivityCode,
} from '../../lib/domain/activities';
import { eventNameById } from '../../lib/domain/events';
import { isAlwaysVisibleRound } from '../../lib/wcif/rounds';
import { useCommandPrompt } from '../../providers/CommandPromptProvider';
import { useAppSelector } from '../../store';
import RoundListItem from './RoundListItem';
Expand All @@ -27,11 +27,15 @@ const RoundSelector = ({ onSelected }: RoundSelectorProps) => {
const [showAllRounds, setShowAllRounds] = useState(false);
const [selectedId, setSelectedId] = useState(wcif?.events[0]?.rounds[0]?.id || null);

const shouldShowRound = (round: Round) => {
const shouldShowRound = (eventId: string, round: Round) => {
if (!wcif) return false;

const { roundNumber } = parseActivityCode(round.id);
if (roundNumber === 1 || showAllRounds) {
const event = wcif.events.find((candidate) => candidate.id === eventId);
if (!event) {
return false;
}

if (showAllRounds || isAlwaysVisibleRound(event, round)) {
return true;
}

Expand All @@ -49,9 +53,8 @@ const RoundSelector = ({ onSelected }: RoundSelectorProps) => {

const rounds = wcif
? wcif.events
.map((e) => e.rounds)
.map((e) => e.rounds.filter((round) => shouldShowRound(e.id, round)))
.flat()
.filter(shouldShowRound)
: [];

const roundIds = rounds.flatMap((r) =>
Expand Down
53 changes: 53 additions & 0 deletions src/components/RoundStatisticsCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,15 @@ import { byName } from '../lib/utils/utils';
import { cumulativeGroupCount } from '../lib/wcif/groups';
import { RoundLimitInfo } from './RoundLimitInfo';
import {
Button,
Card,
CardContent,
CardActions,
CardHeader,
Divider,
List,
ListItemButton,
ListItemText,
ListSubheader,
Table,
TableBody,
Expand All @@ -22,6 +25,7 @@ import {
} from '@mui/material';
import { type Competition, type Person, type Round } from '@wca/helpers';
import { type ReactNode } from 'react';
import { Link as RouterLink } from 'react-router-dom';

interface RoundStatisticsCardProps {
activityCode: string;
Expand All @@ -37,6 +41,11 @@ interface RoundStatisticsCardProps {
onOpenPersonsDialog: (title: string, persons: Person[]) => void;
onOpenPersonsAssignmentsDialog: () => void;
actionButtons: ReactNode;
linkedRounds?: Array<{
roundId: string;
onCopyAssignments?: () => void;
}>;
competitionId?: string;
}

export const RoundStatisticsCard = ({
Expand All @@ -53,7 +62,11 @@ export const RoundStatisticsCard = ({
onOpenPersonsDialog,
onOpenPersonsAssignmentsDialog,
actionButtons,
linkedRounds = [],
competitionId,
}: RoundStatisticsCardProps) => {
const event = wcif?.events.find((candidate) => candidate.id === eventId) ?? null;

return (
<Card>
<CardHeader
Expand All @@ -73,6 +86,45 @@ export const RoundStatisticsCard = ({
/>
}
/>
{linkedRounds.length > 0 && (
<>
<CardContent sx={{ pt: 0 }}>
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
Linked Rounds
</Typography>
<List dense disablePadding>
{linkedRounds.map(({ roundId, onCopyAssignments }) => (
<ListItemButton
key={roundId}
selected={roundId === activityCode}
disabled={!competitionId || roundId === activityCode}
component={competitionId && roundId !== activityCode ? RouterLink : 'div'}
to={
competitionId && roundId !== activityCode
? `/competitions/${competitionId}/events/${roundId}`
: undefined
}
sx={{ borderRadius: 1 }}>
<ListItemText primary={activityCodeToName(roundId)} />
{onCopyAssignments && (
<Button
size="small"
variant="text"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
onCopyAssignments();
}}>
Copy assignments to this round
</Button>
)}
</ListItemButton>
))}
</List>
</CardContent>
<Divider />
</>
)}
<List dense subheader={<ListSubheader id="stages">Stages</ListSubheader>}>
{roundActivities.map(({ id, startTime, endTime, room }) => (
<ListItemButton key={id}>
Expand Down Expand Up @@ -148,6 +200,7 @@ export const RoundStatisticsCard = ({
</TableBody>
</Table>
<RoundLimitInfo
event={event}
round={round}
eventId={eventId}
personsShouldBeInRound={personsShouldBeInRound}
Expand Down
Loading
Loading