Skip to content

Commit b808cca

Browse files
committed
fix: show advancement details for participation rounds
1 parent 5529fe2 commit b808cca

3 files changed

Lines changed: 259 additions & 37 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: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Competition } from '@wca/helpers';
22
import {
33
CompatibleRound,
4+
getAdvancementConditionForRound,
45
getCutoffResultValue,
56
getRoundParticipationRuleset,
67
getSeedMapForRound,
@@ -57,6 +58,81 @@ describe('wcif compatibility helpers', () => {
5758
});
5859
});
5960

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+
60136
it('merges linked-round seeds from the best source-round result', () => {
61137
const wcif = {
62138
...baseCompetition,

src/lib/wcif.ts

Lines changed: 63 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ type LegacyAdvancementCondition = {
55
level: number;
66
};
77

8-
type ResultCondition =
8+
export type ResultCondition =
99
| {
1010
type: 'ranking' | 'percent';
1111
value: number;
@@ -16,7 +16,7 @@ type ResultCondition =
1616
value: number | null;
1717
};
1818

19-
type ParticipationSource =
19+
export type ParticipationSource =
2020
| {
2121
type: 'registrations';
2222
}
@@ -31,17 +31,24 @@ type ParticipationSource =
3131
resultCondition: ResultCondition;
3232
};
3333

34-
type ReservedPlaces = {
34+
export type ReservedPlaces = {
3535
nationalities: string[];
3636
count?: number;
3737
reservations?: number;
3838
};
3939

40-
type ParticipationRuleset = {
40+
export type ParticipationRuleset = {
4141
participationSource: ParticipationSource;
4242
reservedPlaces?: ReservedPlaces | null;
4343
};
4444

45+
export interface RoundAdvancementCondition {
46+
sourceType: ParticipationSource['type'];
47+
sourceRoundIds: string[];
48+
resultCondition: ResultCondition;
49+
reservedPlaces?: ReservedPlaces | null;
50+
}
51+
4552
export type CompatibleCutoff = Cutoff & {
4653
attemptResult?: number;
4754
resultValue?: number;
@@ -143,6 +150,58 @@ export const getPreviousRound = (
143150
return eventRounds.find((candidate) => candidate.id === `${eventId}-r${roundNumber - 1}`);
144151
};
145152

153+
export const getAdvancementConditionForRound = (
154+
eventRounds: CompatibleRound[],
155+
round: CompatibleRound,
156+
): RoundAdvancementCondition | null => {
157+
if (round.advancementCondition) {
158+
return {
159+
sourceType: 'round',
160+
sourceRoundIds: [round.id],
161+
resultCondition: getLegacyResultCondition(round.advancementCondition, round),
162+
reservedPlaces: null,
163+
};
164+
}
165+
166+
const { roundNumber } = parseActivityCode(round.id);
167+
const futureRounds = eventRounds.filter((candidate) => {
168+
const parsedCandidate = parseActivityCode(candidate.id);
169+
return (parsedCandidate.roundNumber ?? 0) > (roundNumber ?? 0);
170+
});
171+
172+
const nextEligibleRound = futureRounds.find((candidate) => {
173+
const source = getRoundParticipationSource(eventRounds, candidate);
174+
175+
if (!source || source.type === 'registrations') {
176+
return false;
177+
}
178+
179+
if (source.type === 'round') {
180+
return source.roundId === round.id;
181+
}
182+
183+
return source.roundIds.includes(round.id);
184+
});
185+
186+
if (!nextEligibleRound) {
187+
return null;
188+
}
189+
190+
const ruleset = getRoundParticipationRuleset(eventRounds, nextEligibleRound);
191+
const source = ruleset?.participationSource;
192+
193+
if (!source || source.type === 'registrations') {
194+
return null;
195+
}
196+
197+
return {
198+
sourceType: source.type,
199+
sourceRoundIds: source.type === 'round' ? [source.roundId] : source.roundIds,
200+
resultCondition: source.resultCondition,
201+
reservedPlaces: ruleset?.reservedPlaces ?? null,
202+
};
203+
};
204+
146205
export const getSourceRoundIds = (
147206
eventRounds: CompatibleRound[],
148207
round: CompatibleRound,

0 commit comments

Comments
 (0)