Skip to content

Commit 433c1e4

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

3 files changed

Lines changed: 648 additions & 33 deletions

File tree

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);

src/lib/wcif.test.ts

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
import { Competition } from '@wca/helpers';
2+
import {
3+
CompatibleRound,
4+
getAdvancementConditionForRound,
5+
getCutoffResultValue,
6+
getRoundParticipationRuleset,
7+
getSeedMapForRound,
8+
} from './wcif';
9+
10+
const baseCompetition = {
11+
formatVersion: '1.0',
12+
id: 'TestComp2026',
13+
name: 'Test Comp 2026',
14+
shortName: 'Test Comp',
15+
competitorLimit: 0,
16+
extensions: [],
17+
persons: [],
18+
schedule: {
19+
numberOfDays: 1,
20+
startDate: '2026-03-15',
21+
venues: [],
22+
},
23+
} as const;
24+
25+
describe('wcif compatibility helpers', () => {
26+
it('reads cutoff values from both stable and latest field names', () => {
27+
expect(getCutoffResultValue({ numberOfAttempts: 2, attemptResult: 1234 } as never)).toBe(1234);
28+
expect(getCutoffResultValue({ numberOfAttempts: 2, resultValue: 5678 } as never)).toBe(5678);
29+
});
30+
31+
it('backfills stable advancement conditions into a participation ruleset', () => {
32+
const rounds = [
33+
{
34+
id: '333-r1',
35+
format: 'a',
36+
results: [],
37+
},
38+
{
39+
id: '333-r2',
40+
format: 'a',
41+
advancementCondition: {
42+
type: 'ranking',
43+
level: 16,
44+
},
45+
results: [],
46+
},
47+
] as unknown as CompatibleRound[];
48+
49+
expect(getRoundParticipationRuleset(rounds, rounds[1])).toEqual({
50+
participationSource: {
51+
type: 'round',
52+
roundId: '333-r1',
53+
resultCondition: {
54+
type: 'ranking',
55+
value: 16,
56+
},
57+
},
58+
});
59+
});
60+
61+
it('derives current-round advancement from the next round participation ruleset', () => {
62+
const rounds = [
63+
{
64+
id: '333-r1',
65+
format: 'a',
66+
results: [],
67+
},
68+
{
69+
id: '333-r2',
70+
format: 'a',
71+
participationRuleset: {
72+
participationSource: {
73+
type: 'round',
74+
roundId: '333-r1',
75+
resultCondition: {
76+
type: 'percent',
77+
value: 75,
78+
},
79+
},
80+
},
81+
results: [],
82+
},
83+
] as unknown as CompatibleRound[];
84+
85+
expect(getAdvancementConditionForRound(rounds, rounds[0])).toEqual({
86+
sourceType: 'round',
87+
sourceRoundIds: ['333-r1'],
88+
resultCondition: {
89+
type: 'percent',
90+
value: 75,
91+
},
92+
reservedPlaces: null,
93+
});
94+
});
95+
96+
it('derives linked-round advancement from the next round participation ruleset', () => {
97+
const rounds = [
98+
{
99+
id: '333-r1',
100+
format: 'a',
101+
results: [],
102+
},
103+
{
104+
id: '333-r2',
105+
format: 'a',
106+
results: [],
107+
},
108+
{
109+
id: '333-r3',
110+
format: 'a',
111+
participationRuleset: {
112+
participationSource: {
113+
type: 'linkedRounds',
114+
roundIds: ['333-r1', '333-r2'],
115+
resultCondition: {
116+
type: 'ranking',
117+
value: 12,
118+
},
119+
},
120+
},
121+
results: [],
122+
},
123+
] as unknown as CompatibleRound[];
124+
125+
expect(getAdvancementConditionForRound(rounds, rounds[1])).toEqual({
126+
sourceType: 'linkedRounds',
127+
sourceRoundIds: ['333-r1', '333-r2'],
128+
resultCondition: {
129+
type: 'ranking',
130+
value: 12,
131+
},
132+
reservedPlaces: null,
133+
});
134+
});
135+
136+
it('merges linked-round seeds from the best source-round result', () => {
137+
const wcif = {
138+
...baseCompetition,
139+
events: [
140+
{
141+
id: '333bf',
142+
extensions: [],
143+
rounds: [
144+
{
145+
id: '333bf-r1',
146+
format: '1',
147+
results: [
148+
{ personId: 1, ranking: 1, best: 2000, average: 2000, attempts: [] },
149+
{ personId: 2, ranking: 2, best: 2500, average: 2500, attempts: [] },
150+
],
151+
},
152+
{
153+
id: '333bf-r2',
154+
format: '1',
155+
results: [
156+
{ personId: 1, ranking: 2, best: 2100, average: 2100, attempts: [] },
157+
{ personId: 2, ranking: 1, best: 1900, average: 1900, attempts: [] },
158+
],
159+
},
160+
{
161+
id: '333bf-r3',
162+
format: '1',
163+
participationRuleset: {
164+
participationSource: {
165+
type: 'linkedRounds',
166+
roundIds: ['333bf-r1', '333bf-r2'],
167+
resultCondition: {
168+
type: 'ranking',
169+
value: 8,
170+
},
171+
},
172+
},
173+
results: [],
174+
},
175+
],
176+
},
177+
],
178+
} as unknown as Competition;
179+
180+
const rounds = wcif.events[0].rounds as CompatibleRound[];
181+
const seedMap = getSeedMapForRound(wcif, rounds, rounds[2]);
182+
183+
expect(seedMap.get(2)).toEqual({
184+
ranking: 1,
185+
resultType: 'single',
186+
value: 1900,
187+
});
188+
expect(seedMap.get(1)).toEqual({
189+
ranking: 2,
190+
resultType: 'single',
191+
value: 2000,
192+
});
193+
});
194+
});

0 commit comments

Comments
 (0)