Skip to content

Commit ef9d104

Browse files
committed
fix: show advancement details for participation rounds
1 parent a475d4c commit ef9d104

4 files changed

Lines changed: 543 additions & 42 deletions

File tree

src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.test.tsx

Lines changed: 130 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -22,24 +22,122 @@ jest.mock('react-tiny-popover', () => ({
2222
}));
2323

2424
jest.mock('react-i18next', () => ({
25-
Trans: ({ i18nKey }: { i18nKey: string }) => i18nKey,
25+
Trans: ({ i18nKey, values }: { i18nKey: string; values?: Record<string, unknown> }) => {
26+
if (i18nKey === 'common.wca.advancement.ranking') {
27+
return `Top ${values?.level} to next round`;
28+
}
29+
30+
if (i18nKey === 'common.wca.advancement.percent') {
31+
return `Top ${values?.level}% to next round`;
32+
}
33+
34+
if (i18nKey === 'common.wca.cumulativeTimelimit') {
35+
return `Time Limit: ${values?.time} Cumulative`;
36+
}
37+
38+
if (i18nKey === 'common.wca.cumulativeTimelimitWithrounds') {
39+
return `Time Limit: ${values?.time} Total with:`;
40+
}
41+
42+
return i18nKey;
43+
},
2644
useTranslation: () => ({
27-
t: (key: string) => key,
45+
t: (key: string, options?: Record<string, unknown>) => {
46+
if (key === 'common.help') {
47+
return 'help';
48+
}
49+
50+
if (key === 'common.wca.cutoff') {
51+
return 'Cutoff';
52+
}
53+
54+
if (key === 'common.wca.timeLimit') {
55+
return 'Time Limit';
56+
}
57+
58+
if (key === 'common.activityCodeToName.round') {
59+
return `Round ${options?.roundNumber}`;
60+
}
61+
62+
if (options?.defaultValue) {
63+
return String(options.defaultValue)
64+
.replace('{{level}}', String(options.level ?? ''))
65+
.replace('{{rounds}}', String(options.rounds ?? ''))
66+
.replace('{{scope}}', String(options.scope ?? ''))
67+
.replace('{{result}}', String(options.result ?? ''));
68+
}
69+
70+
return key;
71+
},
2872
}),
2973
}));
3074

75+
const wcifMock = {
76+
id: 'TestComp2026',
77+
schedule: { venues: [] },
78+
events: [
79+
{
80+
id: '333',
81+
rounds: [
82+
{
83+
id: '333-r1',
84+
format: 'a',
85+
cutoff: null,
86+
timeLimit: null,
87+
advancementCondition: {
88+
type: 'ranking',
89+
level: 16,
90+
},
91+
results: [],
92+
},
93+
{
94+
id: '333-r2',
95+
format: 'a',
96+
cutoff: null,
97+
timeLimit: null,
98+
participationRuleset: {
99+
participationSource: {
100+
type: 'round',
101+
roundId: '333-r1',
102+
resultCondition: {
103+
type: 'percent',
104+
value: 75,
105+
},
106+
},
107+
},
108+
results: [],
109+
},
110+
{
111+
id: '333-r3',
112+
format: 'a',
113+
cutoff: null,
114+
timeLimit: null,
115+
participationRuleset: {
116+
participationSource: {
117+
type: 'linkedRounds',
118+
roundIds: ['333-r1', '333-r2'],
119+
resultCondition: {
120+
type: 'ranking',
121+
value: 12,
122+
},
123+
},
124+
},
125+
results: [],
126+
},
127+
],
128+
},
129+
],
130+
};
131+
31132
jest.mock('@/providers/WCIFProvider', () => ({
32133
useWCIF: () => ({
33134
competitionId: 'TestComp2026',
34-
wcif: {
35-
id: 'TestComp2026',
36-
schedule: { venues: [] },
37-
},
135+
wcif: wcifMock,
38136
setTitle: () => {},
39137
}),
40138
}));
41139

42-
const round = {
140+
const cutoffOnlyRound = {
43141
id: '333-r1',
44142
cutoff: {
45143
numberOfAttempts: 2,
@@ -49,7 +147,7 @@ const round = {
49147
advancementCondition: null,
50148
} as unknown as Round;
51149

52-
function renderPanel() {
150+
function renderPanel(round: Round) {
53151
return render(
54152
<MemoryRouter>
55153
<CutoffTimeLimitPanel round={round} />
@@ -59,7 +157,7 @@ function renderPanel() {
59157

60158
describe('CutoffTimeLimitPanel', () => {
61159
it('uses theme-aware popover classes for help content', () => {
62-
renderPanel();
160+
renderPanel(cutoffOnlyRound);
63161

64162
fireEvent.click(screen.getByRole('button', { name: /help/i }));
65163

@@ -71,4 +169,27 @@ describe('CutoffTimeLimitPanel', () => {
71169
expect(popoverContent).toHaveClass('text-default');
72170
expect(popoverContent).not.toHaveClass('bg-white');
73171
});
172+
173+
it('shows the legacy advancement text for stable wcif rounds', () => {
174+
renderPanel(wcifMock.events[0].rounds[0] as unknown as Round);
175+
176+
expect(screen.getByText('Top 16 to next round')).toBeInTheDocument();
177+
});
178+
179+
it('shows advancement text derived from the next round participation ruleset', () => {
180+
renderPanel({
181+
...(wcifMock.events[0].rounds[0] as object),
182+
advancementCondition: null,
183+
} as Round);
184+
185+
expect(screen.getByText('Top 75% to next round')).toBeInTheDocument();
186+
});
187+
188+
it('shows linked-round advancement text when a later round depends on combined results', () => {
189+
renderPanel(wcifMock.events[0].rounds[1] as unknown as Round);
190+
191+
expect(
192+
screen.getByText('Top 12 combined across Round 1 and Round 2 advance to next round'),
193+
).toBeInTheDocument();
194+
});
74195
});

src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.tsx

Lines changed: 120 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import { Cutoff, Round, parseActivityCode } from '@wca/helpers';
22
import classNames from 'classnames';
3-
import { useState } from 'react';
3+
import { useMemo, useState } from 'react';
44
import { Trans, useTranslation } from 'react-i18next';
55
import { Link } from 'react-router-dom';
66
import { Popover } from 'react-tiny-popover';
77
import { renderCentiseconds, renderCutoff } from '@/lib/results';
8+
import { CompatibleRound, getAdvancementConditionForRound, ResultCondition } from '@/lib/wcif';
89
import { useWCIF } from '@/providers/WCIFProvider';
910

1011
export function CutoffTimeLimitPanel({
@@ -20,10 +21,20 @@ export function CutoffTimeLimitPanel({
2021
const cutoff = round.cutoff;
2122
const timeLimit = round.timeLimit;
2223
const timelimitTime = timeLimit && renderCentiseconds(timeLimit?.centiseconds);
24+
const eventRounds = useMemo(() => {
25+
const { eventId } = parseActivityCode(round.id);
26+
return (
27+
wcif?.events
28+
?.find((event) => event.id === eventId)
29+
?.rounds?.map((candidate) => candidate as CompatibleRound) || []
30+
);
31+
}, [round.id, wcif?.events]);
32+
const advancement = useMemo(
33+
() => getAdvancementConditionForRound(eventRounds, round as CompatibleRound),
34+
[eventRounds, round],
35+
);
2336

24-
if (!timeLimit && !cutoff && !round.advancementCondition) return null;
25-
26-
const level = round.advancementCondition?.level;
37+
if (!timeLimit && !cutoff && !advancement) return null;
2738

2839
return (
2940
<div className={classNames('flex w-full', className)}>
@@ -86,35 +97,9 @@ export function CutoffTimeLimitPanel({
8697
</div>
8798
)}
8899
</div>
89-
{round.advancementCondition && (
90-
<div>
91-
{round.advancementCondition.type === 'ranking' && (
92-
<div className="px-2">
93-
<Trans
94-
i18nKey={'common.wca.advancement.ranking'}
95-
values={{ level }}
96-
components={{ b: <span className="font-semibold" /> }}
97-
/>
98-
</div>
99-
)}
100-
{round.advancementCondition.type === 'percent' && (
101-
<div className="px-2">
102-
<Trans
103-
i18nKey={'common.wca.advancement.percent'}
104-
values={{ level }}
105-
components={{ b: <span className="font-semibold" /> }}
106-
/>
107-
</div>
108-
)}
109-
{round.advancementCondition.type === 'attemptResult' && (
110-
<div className="px-2">
111-
<Trans
112-
i18nKey={'common.wca.advancement.attemptResult'}
113-
values={{ level }}
114-
components={{ b: <span className="font-semibold" /> }}
115-
/>
116-
</div>
117-
)}
100+
{advancement && (
101+
<div className="px-2">
102+
{renderAdvancementText(t, advancement.sourceType, advancement)}
118103
</div>
119104
)}
120105
</div>
@@ -125,6 +110,108 @@ export function CutoffTimeLimitPanel({
125110
);
126111
}
127112

113+
function renderAdvancementText(
114+
t: ReturnType<typeof useTranslation>['t'],
115+
sourceType: 'registrations' | 'round' | 'linkedRounds',
116+
advancement: NonNullable<ReturnType<typeof getAdvancementConditionForRound>>,
117+
) {
118+
const isLinkedRounds = sourceType === 'linkedRounds';
119+
const { resultCondition } = advancement;
120+
const sourceRoundNames = advancement.sourceRoundIds.map((roundId) =>
121+
activityCodeToRoundName(t, roundId),
122+
);
123+
const sourceRoundsLabel = joinLabels(sourceRoundNames);
124+
125+
switch (resultCondition.type) {
126+
case 'ranking':
127+
return isLinkedRounds ? (
128+
<>
129+
{t('common.wca.advancement.linkedRanking', {
130+
defaultValue: 'Top {{level}} combined across {{rounds}} advance to next round',
131+
level: resultCondition.value,
132+
rounds: sourceRoundsLabel,
133+
})}
134+
</>
135+
) : (
136+
<Trans
137+
i18nKey={'common.wca.advancement.ranking'}
138+
values={{ level: resultCondition.value }}
139+
components={{ b: <span className="font-semibold" /> }}
140+
/>
141+
);
142+
case 'percent':
143+
return isLinkedRounds ? (
144+
<>
145+
{t('common.wca.advancement.linkedPercent', {
146+
defaultValue: 'Top {{level}}% combined across {{rounds}} advance to next round',
147+
level: resultCondition.value,
148+
rounds: sourceRoundsLabel,
149+
})}
150+
</>
151+
) : (
152+
<Trans
153+
i18nKey={'common.wca.advancement.percent'}
154+
values={{ level: resultCondition.value }}
155+
components={{ b: <span className="font-semibold" /> }}
156+
/>
157+
);
158+
case 'resultAchieved': {
159+
const thresholdCondition = resultCondition as Extract<
160+
ResultCondition,
161+
{ type: 'resultAchieved' }
162+
>;
163+
const scopeLabel = t(`common.wca.resultType.${thresholdCondition.scope}`, {
164+
defaultValue: thresholdCondition.scope,
165+
}).toLowerCase();
166+
const resultValue =
167+
thresholdCondition.value === null
168+
? t('common.wca.advancement.resultThresholdUnknown', {
169+
defaultValue: 'an unknown result',
170+
})
171+
: renderCentiseconds(thresholdCondition.value);
172+
173+
return (
174+
<>
175+
{t(
176+
isLinkedRounds
177+
? 'common.wca.advancement.linkedResultAchieved'
178+
: 'common.wca.advancement.resultAchieved',
179+
{
180+
defaultValue: isLinkedRounds
181+
? 'Competitors with a {{scope}} better than {{result}} combined across {{rounds}} advance to next round. Minimum of 25% of competitors must be eliminated.'
182+
: 'Competitors with a {{scope}} better than {{result}} advance to next round. Minimum of 25% of competitors must be eliminated.',
183+
scope: scopeLabel,
184+
result: resultValue,
185+
rounds: sourceRoundsLabel,
186+
},
187+
)}
188+
</>
189+
);
190+
}
191+
}
192+
}
193+
194+
function activityCodeToRoundName(t: ReturnType<typeof useTranslation>['t'], roundId: string) {
195+
const { roundNumber } = parseActivityCode(roundId);
196+
197+
return t('common.activityCodeToName.round', {
198+
defaultValue: `Round ${roundNumber}`,
199+
roundNumber,
200+
});
201+
}
202+
203+
function joinLabels(labels: string[]) {
204+
if (labels.length <= 1) {
205+
return labels[0] || '';
206+
}
207+
208+
if (labels.length === 2) {
209+
return `${labels[0]} and ${labels[1]}`;
210+
}
211+
212+
return `${labels.slice(0, -1).join(', ')}, and ${labels[labels.length - 1]}`;
213+
}
214+
128215
function CutoffTimeLimitPopover({ cutoff }: { cutoff: Cutoff | null }) {
129216
const { t } = useTranslation();
130217
const [open, setOpen] = useState(false);

0 commit comments

Comments
 (0)