Skip to content

Commit cb09e3e

Browse files
committed
Refine advancement copy and linked round UI
1 parent ee5398b commit cb09e3e

11 files changed

Lines changed: 458 additions & 99 deletions

File tree

src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.stories.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,15 @@ export const ParticipationConditionLinkedRounds: Story = {
7373
},
7474
};
7575

76+
export const ParticipationConditionLinkedRoundsStart: Story = {
77+
parameters: {
78+
competitionFixture: storybookParticipationConditionLinkedRoundsFixture,
79+
},
80+
args: {
81+
round: storybookParticipationConditionLinkedRoundsFixture.events[0].rounds[0],
82+
},
83+
};
84+
7685
export const CutoffAndTimeLimit: Story = {
7786
args: {
7887
round: getStorybookRoundFixture('222-r1'),

src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.test.tsx

Lines changed: 75 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -67,10 +67,40 @@ jest.mock('react-i18next', () => ({
6767
return `Round ${options?.roundNumber}`;
6868
}
6969

70+
if (key === 'common.wca.advancement.ranking') {
71+
return `Top ${options?.level} advance to ${options?.what}`;
72+
}
73+
74+
if (key === 'common.wca.advancement.percent') {
75+
return `Top ${options?.level}% advance to ${options?.what}`;
76+
}
77+
78+
if (key === 'common.wca.advancement.linkedRanking') {
79+
return `Top ${options?.level} in dual rounds ${options?.rounds} advance to ${options?.what}`;
80+
}
81+
82+
if (key === 'common.wca.advancement.linkedPercent') {
83+
return `Top ${options?.level}% in dual rounds ${options?.rounds} advance to ${options?.what}`;
84+
}
85+
86+
if (key === 'common.wca.advancement.nextRound') {
87+
return 'next round';
88+
}
89+
90+
if (key === 'common.wca.advancement.final') {
91+
return 'final';
92+
}
93+
94+
if (key === 'common.wca.advancement.resultThresholdUnknown') {
95+
return 'an unknown result';
96+
}
97+
7098
if (options?.defaultValue) {
7199
return String(options.defaultValue)
72100
.replace('{{level}}', String(options.level ?? ''))
73101
.replace('{{rounds}}', String(options.rounds ?? ''))
102+
.replace('{{round}}', String(options.round ?? ''))
103+
.replace('{{what}}', String(options.what ?? ''))
74104
.replace('{{scope}}', String(options.scope ?? ''))
75105
.replace('{{result}}', String(options.result ?? ''));
76106
}
@@ -134,6 +164,42 @@ const wcifMock = {
134164
},
135165
],
136166
},
167+
{
168+
id: '222',
169+
rounds: [
170+
{
171+
id: '222-r1',
172+
format: 'a',
173+
cutoff: null,
174+
timeLimit: null,
175+
results: [],
176+
},
177+
{
178+
id: '222-r2',
179+
format: 'a',
180+
cutoff: null,
181+
timeLimit: null,
182+
results: [],
183+
},
184+
{
185+
id: '222-r3',
186+
format: 'a',
187+
cutoff: null,
188+
timeLimit: null,
189+
participationRuleset: {
190+
participationSource: {
191+
type: 'linkedRounds',
192+
roundIds: ['222-r1', '222-r2'],
193+
resultCondition: {
194+
type: 'ranking',
195+
value: 8,
196+
},
197+
},
198+
},
199+
results: [],
200+
},
201+
],
202+
},
137203
],
138204
};
139205

@@ -181,7 +247,7 @@ describe('CutoffTimeLimitPanel', () => {
181247
it('shows the legacy advancement text for stable wcif rounds', () => {
182248
renderPanel(wcifMock.events[0].rounds[0] as unknown as Round);
183249

184-
expect(screen.getByText('Top 16 to next round')).toBeInTheDocument();
250+
expect(screen.getByText('Top 16 advance to next round')).toBeInTheDocument();
185251
});
186252

187253
it('shows advancement text derived from the next round participation ruleset', () => {
@@ -190,14 +256,18 @@ describe('CutoffTimeLimitPanel', () => {
190256
advancementCondition: null,
191257
} as Round);
192258

193-
expect(screen.getByText('Top 75% to next round')).toBeInTheDocument();
259+
expect(screen.getByText('Top 75% advance to next round')).toBeInTheDocument();
194260
});
195261

196262
it('shows linked-round advancement text when a later round depends on combined results', () => {
197263
renderPanel(wcifMock.events[0].rounds[1] as unknown as Round);
198264

199-
expect(
200-
screen.getByText('Top 12 combined across Round 1 and Round 2 advance to next round'),
201-
).toBeInTheDocument();
265+
expect(screen.getByText('Top 12 in dual rounds 1 & 2 advance to final')).toBeInTheDocument();
266+
});
267+
268+
it('shows the same dual-round advancement text for the first round in a linked-round set', () => {
269+
renderPanel(wcifMock.events[1].rounds[0] as unknown as Round);
270+
271+
expect(screen.getByText('Top 8 in dual rounds 1 & 2 advance to final')).toBeInTheDocument();
202272
});
203273
});

src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.tsx

Lines changed: 80 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1-
import { Competition, Cutoff, Round, parseActivityCode } from '@wca/helpers';
1+
import { Cutoff, Round, parseActivityCode } from '@wca/helpers';
22
import classNames from 'classnames';
33
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 { getEventRoundsForRound, joinLabels } from '@/lib/roundLabels';
89
import { CompatibleRound, getAdvancementConditionForRound, ResultCondition } from '@/lib/wcif';
910
import { useWCIF } from '@/providers/WCIFProvider';
1011

@@ -92,7 +93,7 @@ export function CutoffTimeLimitPanel({
9293
</div>
9394
{advancement && (
9495
<div className="px-2">
95-
{renderAdvancementText(t, advancement.sourceType, advancement)}
96+
{renderAdvancementText(t, eventRounds, advancement.sourceType, advancement)}
9697
</div>
9798
)}
9899
</div>
@@ -103,117 +104,108 @@ export function CutoffTimeLimitPanel({
103104
);
104105
}
105106

106-
function getEventRoundsForRound(events: Competition['events'] | undefined, roundId: string) {
107-
const { eventId } = parseActivityCode(roundId);
108-
109-
return (
110-
events
111-
?.find((event) => event.id === eventId)
112-
?.rounds?.map((candidate) => candidate as CompatibleRound) || []
113-
);
114-
}
115-
116107
function getCumulativeRoundIds(timeLimit: Round['timeLimit'], roundId: string) {
117108
return timeLimit?.cumulativeRoundIds.filter((activityCode) => activityCode !== roundId) || [];
118109
}
119110

120111
function renderAdvancementText(
121112
t: ReturnType<typeof useTranslation>['t'],
113+
eventRounds: CompatibleRound[],
122114
sourceType: 'registrations' | 'round' | 'linkedRounds',
123115
advancement: NonNullable<ReturnType<typeof getAdvancementConditionForRound>>,
124116
) {
125-
const isLinkedRounds = sourceType === 'linkedRounds';
117+
if (sourceType === 'linkedRounds') {
118+
return renderLinkedRoundsAdvancementText(t, eventRounds, advancement);
119+
}
120+
121+
return renderSingleRoundAdvancementText(t, eventRounds, advancement);
122+
}
123+
124+
function renderLinkedRoundsAdvancementText(
125+
t: ReturnType<typeof useTranslation>['t'],
126+
eventRounds: CompatibleRound[],
127+
advancement: NonNullable<ReturnType<typeof getAdvancementConditionForRound>>,
128+
) {
126129
const { resultCondition } = advancement;
127-
const sourceRoundNames = advancement.sourceRoundIds.map((roundId) =>
128-
activityCodeToRoundName(t, roundId),
129-
);
130+
const sourceRoundNames = advancement.sourceRoundIds.map((roundId) => {
131+
const roundNumber = parseActivityCode(roundId).roundNumber;
132+
return roundNumber ? roundNumber.toString() : '';
133+
});
130134
const sourceRoundsLabel = joinLabels(sourceRoundNames);
135+
const targetLabel = getAdvancementTargetLabel(t, eventRounds, advancement.targetRoundId);
131136

132137
switch (resultCondition.type) {
133138
case 'ranking':
134-
return isLinkedRounds ? (
135-
<>
136-
{t('common.wca.advancement.linkedRanking', {
137-
defaultValue: 'Top {{level}} combined across {{rounds}} advance to next round',
138-
level: resultCondition.value,
139-
rounds: sourceRoundsLabel,
140-
})}
141-
</>
142-
) : (
143-
<Trans
144-
i18nKey={'common.wca.advancement.ranking'}
145-
values={{ level: resultCondition.value }}
146-
components={{ b: <span className="font-semibold" /> }}
147-
/>
148-
);
139+
return t('common.wca.advancement.linkedRanking', {
140+
level: resultCondition.value,
141+
rounds: sourceRoundsLabel,
142+
what: targetLabel,
143+
});
149144
case 'percent':
150-
return isLinkedRounds ? (
151-
<>
152-
{t('common.wca.advancement.linkedPercent', {
153-
defaultValue: 'Top {{level}}% combined across {{rounds}} advance to next round',
154-
level: resultCondition.value,
155-
rounds: sourceRoundsLabel,
156-
})}
157-
</>
158-
) : (
159-
<Trans
160-
i18nKey={'common.wca.advancement.percent'}
161-
values={{ level: resultCondition.value }}
162-
components={{ b: <span className="font-semibold" /> }}
163-
/>
164-
);
165-
case 'resultAchieved': {
166-
const thresholdCondition = resultCondition as Extract<
167-
ResultCondition,
168-
{ type: 'resultAchieved' }
169-
>;
170-
const scopeLabel = t(`common.wca.resultType.${thresholdCondition.scope}`, {
171-
defaultValue: thresholdCondition.scope,
172-
}).toLowerCase();
173-
const resultValue =
174-
thresholdCondition.value === null
175-
? t('common.wca.advancement.resultThresholdUnknown', {
176-
defaultValue: 'an unknown result',
177-
})
178-
: renderCentiseconds(thresholdCondition.value);
179-
180-
return (
181-
<>
182-
{t(
183-
isLinkedRounds
184-
? 'common.wca.advancement.linkedResultAchieved'
185-
: 'common.wca.advancement.resultAchieved',
186-
{
187-
scope: scopeLabel,
188-
result: resultValue,
189-
rounds: sourceRoundsLabel,
190-
},
191-
)}
192-
</>
193-
);
194-
}
145+
return t('common.wca.advancement.linkedPercent', {
146+
level: resultCondition.value,
147+
rounds: sourceRoundsLabel,
148+
what: targetLabel,
149+
});
150+
case 'resultAchieved':
151+
return t('common.wca.advancement.linkedResultAchieved', {
152+
scope: t(`common.wca.advancement.scope.${resultCondition.scope}`),
153+
result:
154+
resultCondition.value === null
155+
? t('common.wca.advancement.resultThresholdUnknown')
156+
: renderCentiseconds(resultCondition.value),
157+
rounds: sourceRoundsLabel,
158+
what: targetLabel,
159+
});
195160
}
196161
}
197162

198-
function activityCodeToRoundName(t: ReturnType<typeof useTranslation>['t'], roundId: string) {
199-
const { roundNumber } = parseActivityCode(roundId);
163+
function renderSingleRoundAdvancementText(
164+
t: ReturnType<typeof useTranslation>['t'],
165+
eventRounds: CompatibleRound[],
166+
advancement: NonNullable<ReturnType<typeof getAdvancementConditionForRound>>,
167+
) {
168+
const { resultCondition } = advancement;
169+
const targetLabel = getAdvancementTargetLabel(t, eventRounds, advancement.targetRoundId);
200170

201-
return t('common.activityCodeToName.round', {
202-
defaultValue: `Round ${roundNumber}`,
203-
roundNumber,
204-
});
171+
switch (resultCondition.type) {
172+
case 'ranking':
173+
return t('common.wca.advancement.ranking', {
174+
level: resultCondition.value,
175+
what: targetLabel,
176+
});
177+
case 'percent':
178+
return t('common.wca.advancement.percent', {
179+
level: resultCondition.value,
180+
what: targetLabel,
181+
});
182+
case 'resultAchieved':
183+
return t('common.wca.advancement.resultAchieved', {
184+
scope: t(`common.wca.advancement.scope.${resultCondition.scope}`),
185+
result:
186+
resultCondition.value === null
187+
? t('common.wca.advancement.resultThresholdUnknown')
188+
: renderCentiseconds(resultCondition.value),
189+
what: targetLabel,
190+
});
191+
}
205192
}
206193

207-
function joinLabels(labels: string[]) {
208-
if (labels.length <= 1) {
209-
return labels[0] || '';
194+
function getAdvancementTargetLabel(
195+
t: ReturnType<typeof useTranslation>['t'],
196+
eventRounds: CompatibleRound[],
197+
targetRoundId: string | null | undefined,
198+
) {
199+
if (!targetRoundId) {
200+
return t('common.wca.advancement.unknown');
210201
}
211202

212-
if (labels.length === 2) {
213-
return `${labels[0]} and ${labels[1]}`;
203+
const targetRoundIndex = eventRounds.findIndex((candidate) => candidate.id === targetRoundId);
204+
if (targetRoundIndex === eventRounds.length - 1) {
205+
return t('common.wca.advancement.final');
214206
}
215207

216-
return `${labels.slice(0, -1).join(', ')}, and ${labels[labels.length - 1]}`;
208+
return t('common.wca.advancement.nextRound');
217209
}
218210

219211
function CutoffTimeLimitPopover({ cutoff }: { cutoff: Cutoff | null }) {

src/containers/CompetitionRound/CompetitionRound.stories.tsx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,26 @@ export const ParticipationConditionLinkedRounds: Story = {
5353
},
5454
};
5555

56+
export const DualRoundWithPreviousRound: Story = {
57+
parameters: {
58+
competitionFixture: storybookParticipationConditionLinkedRoundsFixture,
59+
},
60+
args: {
61+
competitionId: 'SeattleSummerOpen2026',
62+
roundId: '333-r2',
63+
},
64+
};
65+
66+
export const DualRoundWithNextRound: Story = {
67+
parameters: {
68+
competitionFixture: storybookParticipationConditionLinkedRoundsFixture,
69+
},
70+
args: {
71+
competitionId: 'SeattleSummerOpen2026',
72+
roundId: '333-r1',
73+
},
74+
};
75+
5676
export const FinalRound: Story = {
5777
parameters: {
5878
competitionFixture: makeStorybookCompetitionFixtureWithRound('333-r3', (round) => ({

0 commit comments

Comments
 (0)