Skip to content

Commit 321370f

Browse files
committed
Add competition sum of rankings page
1 parent acd8105 commit 321370f

7 files changed

Lines changed: 567 additions & 1 deletion

File tree

src/App.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
import CompetitionScramblerSchedule from './pages/Competition/ScramblerSchedule';
2929
import CompetitionStats from './pages/Competition/Stats';
3030
import CompetitionStreamSchedule from './pages/Competition/StreamSchedule';
31+
import CompetitionSumOfRanks from './pages/Competition/SumOfRanks';
3132
import Home from './pages/Home';
3233
import Settings from './pages/Settings';
3334
import Support from './pages/Support';
@@ -120,6 +121,7 @@ const Navigation = () => {
120121
<Route path="explore" element={<CompetitionGroupsOverview />} />
121122
<Route path="groups-schedule" element={<CompetitionGroupsSchedule />} />
122123
<Route path="stats" element={<CompetitionStats />} />
124+
<Route path="sum-of-ranks" element={<CompetitionSumOfRanks />} />
123125
<Route path="*" element={<p>Path not resolved</p>} />
124126
</Route>
125127
<Route path="/users/:userId" element={<UserLogin />} />

src/containers/CompetitionResults/resultSources.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ const compareRoundTypeIds = (a: string, b: string) => {
2323
return a.localeCompare(b);
2424
};
2525

26-
const findPersonForApiResult = (persons: Person[], result: WcaCompetitionResult) =>
26+
export const findPersonForApiResult = (persons: Person[], result: WcaCompetitionResult) =>
2727
persons.find(
2828
(person) =>
2929
(result.wca_id && person.wcaId === result.wca_id) ||
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import classNames from 'classnames';
2+
import { useEffect, useMemo, useState } from 'react';
3+
import { Popover } from 'react-tiny-popover';
4+
import { Container } from '@/components/Container';
5+
import { useWcaCompetitionResults } from '@/hooks/queries/useWcaResults';
6+
import { AnchorLink, LinkRenderer } from '@/lib/linkRenderer';
7+
import { useWCIF } from '@/providers/WCIFProvider';
8+
import { SumOfRanksPersonRanking, getSumOfRanks } from './rankings';
9+
10+
export interface CompetitionSumOfRanksContainerProps {
11+
competitionId: string;
12+
LinkComponent?: LinkRenderer;
13+
}
14+
15+
const formatKinch = (kinch: number) => (kinch * 100).toFixed(2);
16+
17+
const formatMedals = (ranking: SumOfRanksPersonRanking) =>
18+
ranking.medals.length > 0
19+
? `${ranking.medals.length} (${ranking.medals.map((medal) => medal.eventId).join(', ')})`
20+
: '';
21+
22+
function KinchTooltip() {
23+
const [open, setOpen] = useState(false);
24+
25+
return (
26+
<Popover
27+
isOpen={open}
28+
positions={['bottom', 'left']}
29+
padding={8}
30+
onClickOutside={() => setOpen(false)}
31+
content={
32+
<div
33+
className={classNames(
34+
'z-1000 max-w-xs space-y-2 rounded border border-tertiary-weak bg-panel p-3 text-left type-body-sm text-default shadow-md transition-opacity duration-300 opacity-0',
35+
{
36+
'opacity-100': open,
37+
},
38+
)}>
39+
<p>Kinch is averaged across counted rounds.</p>
40+
<p>
41+
Each round compares the winning result to the competitor&apos;s result. Missed or
42+
invalid results count as 0.
43+
</p>
44+
</div>
45+
}>
46+
<button
47+
type="button"
48+
className="inline-flex h-5 w-5 items-center justify-center rounded-full text-gray-500 hover:bg-gray-200 hover:text-gray-700"
49+
aria-label="Explain Kinch"
50+
onClick={() => setOpen((current) => !current)}>
51+
?
52+
</button>
53+
</Popover>
54+
);
55+
}
56+
57+
export function CompetitionSumOfRanksContainer({
58+
competitionId,
59+
LinkComponent = AnchorLink,
60+
}: CompetitionSumOfRanksContainerProps) {
61+
const { wcif, setTitle } = useWCIF();
62+
const { data: wcaApiResults, status: wcaApiResultsStatus } = useWcaCompetitionResults(
63+
competitionId,
64+
{
65+
enabled: Boolean(wcif),
66+
},
67+
);
68+
69+
useEffect(() => {
70+
setTitle(`${wcif?.name ?? competitionId} Sum of Rankings`);
71+
}, [competitionId, setTitle, wcif?.name]);
72+
73+
const rankings = useMemo(
74+
() => (wcif ? getSumOfRanks(wcif, wcaApiResults) : []),
75+
[wcaApiResults, wcif],
76+
);
77+
const isLoadingResults = wcaApiResultsStatus === 'pending' && rankings.length === 0;
78+
const roundsCounted = rankings[0]?.roundsCounted ?? 0;
79+
const summary =
80+
rankings.length > 0
81+
? `${rankings.length} competitors across ${roundsCounted} rounds`
82+
: 'No ranked rounds yet.';
83+
84+
return (
85+
<Container className="mx-auto max-w-screen-lg space-y-4 p-2" fullWidth>
86+
<h1 className="type-heading">Sum of Rankings</h1>
87+
88+
{!isLoadingResults && <div className="type-body-sm text-gray-600">{summary}</div>}
89+
90+
{rankings.length > 0 && (
91+
<div className="overflow-x-auto rounded border border-gray-200 shadow-sm">
92+
<table className="w-full table-auto whitespace-nowrap type-body">
93+
<thead className="bg-gray-100 text-left">
94+
<tr>
95+
<th className="px-3 py-2 text-right font-semibold">#</th>
96+
<th className="px-3 py-2 font-semibold">Competitor</th>
97+
<th className="px-3 py-2 text-right font-semibold">Sum</th>
98+
<th className="px-3 py-2 text-right font-semibold">
99+
<span className="inline-flex items-center justify-end gap-1">
100+
Kinch
101+
<KinchTooltip />
102+
</span>
103+
</th>
104+
<th className="px-3 py-2 text-right font-semibold">Medals</th>
105+
</tr>
106+
</thead>
107+
<tbody>
108+
{rankings.map((ranking) => (
109+
<tr key={ranking.person.registrantId} className="border-t border-gray-200">
110+
<td className="px-3 py-2 text-right">{ranking.position}</td>
111+
<td className="px-3 py-2">
112+
<LinkComponent
113+
className="text-blue-600 hover:underline"
114+
to={`/competitions/${competitionId}/persons/${ranking.person.registrantId}/results`}>
115+
{ranking.person.name}
116+
</LinkComponent>
117+
</td>
118+
<td className="px-3 py-2 text-right font-semibold">{ranking.sumOfRanks}</td>
119+
<td className="px-3 py-2 text-right">{formatKinch(ranking.kinch)}</td>
120+
<td className="px-3 py-2 text-right">{formatMedals(ranking)}</td>
121+
</tr>
122+
))}
123+
</tbody>
124+
</table>
125+
</div>
126+
)}
127+
128+
{isLoadingResults && <div className="type-body text-gray-600">Loading results...</div>}
129+
</Container>
130+
);
131+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './CompetitionSumOfRanks';
Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
import { Competition, Person } from '@wca/helpers';
2+
import { WcaCompetitionResult } from '@/lib/api';
3+
import { getSumOfRanks } from './rankings';
4+
5+
jest.mock('@/lib/events', () => ({
6+
getAllEvents: (wcif: Competition) => wcif.events,
7+
getEventName: (eventId: string) => (eventId === '333' ? '3x3x3 Cube' : eventId),
8+
}));
9+
10+
const persons: Person[] = [
11+
{
12+
registrantId: 1,
13+
name: 'Alice Solver',
14+
wcaUserId: 1001,
15+
wcaId: '2010ALIC01',
16+
countryIso2: 'US',
17+
registration: null,
18+
assignments: [],
19+
extensions: [],
20+
},
21+
{
22+
registrantId: 2,
23+
name: 'Bob Solver',
24+
wcaUserId: 1002,
25+
wcaId: '2010BOBS01',
26+
countryIso2: 'US',
27+
registration: null,
28+
assignments: [],
29+
extensions: [],
30+
},
31+
{
32+
registrantId: 3,
33+
name: 'Casey Solver',
34+
wcaUserId: 1003,
35+
wcaId: null,
36+
countryIso2: 'US',
37+
registration: null,
38+
assignments: [],
39+
extensions: [],
40+
},
41+
{
42+
registrantId: 4,
43+
name: 'No Results',
44+
wcaUserId: 1004,
45+
wcaId: null,
46+
countryIso2: 'US',
47+
registration: null,
48+
assignments: [],
49+
extensions: [],
50+
},
51+
];
52+
53+
const wcif = {
54+
formatVersion: '1.0',
55+
id: 'SumRanksTest2026',
56+
name: 'Sum Ranks Test 2026',
57+
shortName: 'Sum Ranks Test',
58+
persons,
59+
competitorLimit: null,
60+
extensions: [],
61+
events: [
62+
{
63+
id: '333',
64+
extensions: [],
65+
rounds: [
66+
{
67+
id: '333-r1',
68+
format: 'a',
69+
cutoff: null,
70+
timeLimit: null,
71+
advancementCondition: null,
72+
extensions: [],
73+
results: [
74+
{
75+
personId: 1,
76+
ranking: 1,
77+
attempts: [{ result: 1200, reconstruction: null }],
78+
best: 1200,
79+
average: 1200,
80+
},
81+
{
82+
personId: 2,
83+
ranking: 2,
84+
attempts: [{ result: 1300, reconstruction: null }],
85+
best: 1300,
86+
average: 1300,
87+
},
88+
],
89+
},
90+
{
91+
id: '333-r2',
92+
format: 'a',
93+
cutoff: null,
94+
timeLimit: null,
95+
advancementCondition: null,
96+
extensions: [],
97+
results: [
98+
{
99+
personId: 2,
100+
ranking: 1,
101+
attempts: [{ result: 1100, reconstruction: null }],
102+
best: 1100,
103+
average: 1100,
104+
},
105+
],
106+
},
107+
{
108+
id: '333-r3',
109+
format: 'a',
110+
cutoff: null,
111+
timeLimit: null,
112+
advancementCondition: null,
113+
extensions: [],
114+
results: [],
115+
},
116+
],
117+
},
118+
],
119+
schedule: { numberOfDays: 1, startDate: '2026-05-03', venues: [] },
120+
} as Competition;
121+
122+
const apiResult = (overrides: Partial<WcaCompetitionResult> = {}): WcaCompetitionResult => ({
123+
id: 9000,
124+
pos: 1,
125+
best: 1000,
126+
average: 1100,
127+
name: 'Casey Solver',
128+
country_iso2: 'US',
129+
competition_id: wcif.id,
130+
event_id: '333',
131+
round_type_id: '3',
132+
format_id: 'a',
133+
wca_id: null,
134+
attempts: [1000, 1100, 1200],
135+
...overrides,
136+
});
137+
138+
describe('getSumOfRanks', () => {
139+
it('sums rankings across ranked rounds and penalizes missed ranked rounds', () => {
140+
const rankings = getSumOfRanks(wcif, []);
141+
142+
expect(rankings.map((ranking) => [ranking.person.name, ranking.sumOfRanks])).toEqual([
143+
['Bob Solver', 3],
144+
['Alice Solver', 3],
145+
]);
146+
expect(rankings.find((ranking) => ranking.person.name === 'Alice Solver')?.missedRounds).toBe(
147+
1,
148+
);
149+
expect(rankings.find((ranking) => ranking.person.name === 'No Results')).toBeUndefined();
150+
});
151+
152+
it('uses WCA API fallback results when WCIF results are missing for a round', () => {
153+
const rankings = getSumOfRanks(wcif, [
154+
apiResult(),
155+
apiResult({
156+
id: 9001,
157+
pos: 2,
158+
name: 'Bob Solver',
159+
wca_id: '2010BOBS01',
160+
best: 1300,
161+
average: 1400,
162+
}),
163+
]);
164+
165+
expect(rankings.map((ranking) => [ranking.person.name, ranking.sumOfRanks])).toEqual([
166+
['Bob Solver', 5],
167+
['Alice Solver', 6],
168+
['Casey Solver', 6],
169+
]);
170+
expect(rankings.find((ranking) => ranking.person.name === 'Casey Solver')?.scores).toEqual(
171+
expect.arrayContaining([
172+
expect.objectContaining({
173+
roundId: '333-r3',
174+
ranking: 1,
175+
kinch: 1,
176+
medal: true,
177+
missed: false,
178+
}),
179+
]),
180+
);
181+
expect(rankings.find((ranking) => ranking.person.name === 'Bob Solver')?.medals).toEqual([
182+
expect.objectContaining({ eventId: '333', roundNumber: 3 }),
183+
]);
184+
});
185+
186+
it('ignores rounds that have no WCIF or fallback results', () => {
187+
const rankings = getSumOfRanks(wcif, []);
188+
189+
expect(rankings[0]?.roundsCounted).toBe(2);
190+
});
191+
192+
it('averages Kinch across counted rounds with missed rounds scored as zero', () => {
193+
const rankings = getSumOfRanks(wcif, []);
194+
195+
expect(rankings.find((ranking) => ranking.person.name === 'Alice Solver')?.kinch).toBe(0.5);
196+
expect(rankings.find((ranking) => ranking.person.name === 'Bob Solver')?.kinch).toBeCloseTo(
197+
0.9615,
198+
4,
199+
);
200+
});
201+
});

0 commit comments

Comments
 (0)