Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -99,6 +102,76 @@
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',
]);
});

it('renders round-level entry plus attempts for distributed-attempt events', () => {
const wcif = {
id: 'TestComp',
Expand Down
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 @@ -29,11 +29,15 @@ const RoundSelector = ({ onSelected }: RoundSelectorProps) => {

const attemptCountForRound = (round: Round) => (round.format === 'm' ? 3 : +round.format);

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 @@ -51,9 +55,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
3 changes: 2 additions & 1 deletion src/lib/api/localStorage.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { getLocalStorage, localStorageKey, setLocalStorage } from './localStorage';

afterEach(() => {
localStorage.clear();
Expand All @@ -14,6 +13,7 @@ describe('localStorage helpers', () => {
writable: true,
});
await import('./wca-env');
const { localStorageKey } = await import('./localStorage');

expect(localStorageKey('token')).toContain('delegate-dashboard.example-application-id.token');
});
Expand All @@ -24,6 +24,7 @@ describe('localStorage helpers', () => {
writable: true,
});
await import('./wca-env');
const { getLocalStorage, setLocalStorage } = await import('./localStorage');

setLocalStorage('token', 'abc123');
expect(getLocalStorage('token')).toBe('abc123');
Expand Down
Loading
Loading