Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,17 @@ yarn test
yarn storybook
yarn build-storybook
```

## WCIF v2 migration (0.1.1)

Public competition downloads pin `/competitions/{id}/wcif/version/2`. The API boundary
normalizes v2 personal-best `value` and attempt `value` to the existing helper/UI aliases
`best` and `result`, preserving the original fields, participation rules, linked rounds,
qualification dates/conditions, assignments and extensions. Round result `best` and
`average`, WCA Live, and the separate results API retain their existing contracts.
Persisted older UI data remains readable; new network requests always use v2.

Regression coverage lives in `src/lib/wcifV2.test.ts`; run `yarn test --runInBand`,
`yarn check:type` and `yarn build`. On 2026-11-02, both public endpoints still responded
for SlovenianNationals2026 (v1.1 / v2.2); the migration does not assume a confirmed shutdown.
See [WCA's migration notice](https://github.com/coder13/Competitor-groups/issues/92).
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "competition-groups",
"version": "0.1.0",
"version": "0.1.1",
"private": true,
"scripts": {
"dev": "vite",
Expand Down
4 changes: 2 additions & 2 deletions src/lib/api.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Competition } from '@wca/helpers';
import { WCA_ORIGIN } from './wca-env';
import { normalizeWcifV2, WcifV2 } from './wcifV2';

export const wcaApiFetch = async <T>(path: string, fetchOptions: RequestInit = {}) => {
const res = await fetch(`${WCA_ORIGIN}${path}`, fetchOptions);
Expand Down Expand Up @@ -53,7 +53,7 @@ export const fetchUserWithCompetitions = async (userId: string) =>
wcaApiFetch<UserCompsResponse>(`/users/${userId}?${fetchUserWithCompetitionsParams.toString()}`);

export const fetchWcif = async (competitionId: string) =>
wcaApiFetch<Competition>(`/competitions/${competitionId}/wcif/public`);
normalizeWcifV2(await wcaApiFetch<WcifV2>(`/competitions/${competitionId}/wcif/version/2`));

export interface WcaCompetitionResult {
id: number;
Expand Down
125 changes: 125 additions & 0 deletions src/lib/wcifV2.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { fetchWcif } from './api';
import { getAdvancementConditionForRound } from './wcif';
import { normalizeWcifV2, WcifV2 } from './wcifV2';

jest.mock('./wca-env', () => ({ WCA_ORIGIN: 'https://api.worldcubeassociation.org' }));

const fixture = () =>
({
id: 'Synthetic2026',
formatVersion: '2.2',
persons: [
{
registrantId: 1,
name: 'Synthetic',
assignments: [{ activityId: 4, assignmentCode: 'competitor' }],
personalBests: [{ eventId: '333', type: 'single', value: 789, worldRanking: 100 }],
},
],
events: [
{
id: '333',
qualification: {
latestResultDate: '2026-10-01',
earliestResultDate: null,
resultCondition: { type: 'resultAchieved', scope: 'single', value: 1000 },
},
rounds: [
{
id: '333-r1',
format: 'a',
linkedRounds: ['333-r1', '333-r2'],
participationRuleset: null,
results: [
{
personId: 1,
ranking: 1,
best: 789,
average: -1,
attempts: [789, -1, -2, 0, 990123401].map((value) => ({
value,
reconstruction: null,
})),
},
],
},
{
id: '333-r2',
format: 'h',
linkedRounds: ['333-r1', '333-r2'],
participationRuleset: null,
results: [],
},
{
id: '333-r3',
format: 'a',
participationRuleset: {
participationSource: {
type: 'linkedRounds',
roundIds: ['333-r1', '333-r2'],
resultCondition: { type: 'ranking', scope: 'average', value: 12 },
},
reservedPlaces: { count: 1 },
},
results: [],
},
],
},
],
schedule: { venues: [] },
extensions: [{ id: 'synthetic', data: { retained: true } }],
}) as unknown as WcifV2;

describe('WCIF v2 boundary', () => {
it('preserves results, assignments and v2 rules while supplying helper aliases', () => {
const source = fixture();
const original = JSON.stringify(source);
const normalized = normalizeWcifV2(source);
expect(normalized.persons[0].personalBests![0].best).toBe(789);
expect(normalized.events[0].rounds[0].results[0].attempts.map((a) => a.result)).toEqual([
789, -1, -2, 0, 990123401,
]);
expect(normalized.events[0].rounds[0].results[0].best).toBe(789);
expect(normalized.persons[0].assignments).toEqual(source.persons[0].assignments);
expect(normalized.schedule).toEqual(source.schedule);
expect(normalized.extensions).toEqual(source.extensions);
expect(normalized.events[0].qualification).toMatchObject({
latestResultDate: '2026-10-01',
level: 1000,
resultType: 'single',
});
expect(
getAdvancementConditionForRound(normalized.events[0].rounds, normalized.events[0].rounds[0]),
).toMatchObject({
sourceType: 'linkedRounds',
sourceRoundIds: ['333-r1', '333-r2'],
targetRoundId: '333-r3',
resultCondition: { type: 'ranking', scope: 'average', value: 12 },
reservedPlaces: { count: 1 },
});
expect(JSON.stringify(source)).toBe(original);
});
it.each(['1.2', '3.0', ''])('rejects unexpected schema %s', (version) => {
expect(() => normalizeWcifV2({ ...fixture(), formatVersion: version })).toThrow(
'major version 2',
);
});
it('does not turn missing or malformed values into results', () => {
const source = fixture();
delete (source.persons[0].personalBests![0] as Partial<{ value: number }>).value;
expect(() => normalizeWcifV2(source)).toThrow('result value');
});
it('pins the public request to v2 and normalizes the response', async () => {
const previous = global.fetch;
global.fetch = jest.fn().mockResolvedValue({ ok: true, json: async () => fixture() });
try {
expect((await fetchWcif('Synthetic2026')).persons[0].personalBests![0].best).toBe(789);
expect(global.fetch).toHaveBeenCalledWith(
'https://api.worldcubeassociation.org/competitions/Synthetic2026/wcif/version/2',
{},
);
} finally {
global.fetch = previous;
}
});
});
94 changes: 94 additions & 0 deletions src/lib/wcifV2.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { Competition, Person, PersonalBest, Round, Result, Qualification } from '@wca/helpers';

// Keep the application's established helper model at this single API boundary.
// These aliases are not a v1 downgrade: v2 participation rules, linked rounds,
// qualification and extension data are retained verbatim.
type V2PersonalBest = Omit<PersonalBest, 'best'> & { value: PersonalBest['best'] };
type V2Result = Omit<Result, 'attempts'> & {
attempts: Array<
Omit<Result['attempts'][number], 'result'> & {
value: Result['attempts'][number]['result'];
}
>;
};
type V2Qualification = {
earliestResultDate?: string | null;
latestResultDate: string;
resultCondition: {
type: 'ranking' | 'resultAchieved';
scope: 'single' | 'average';
value: number | null;
};
};

export type WcifV2 = Omit<Competition, 'persons' | 'events'> & {
persons: Array<Omit<Person, 'personalBests'> & { personalBests?: V2PersonalBest[] }>;
events: Array<
Omit<Competition['events'][number], 'rounds' | 'qualification'> & {
qualification?: V2Qualification | null;
rounds: Array<Omit<Round, 'results' | 'advancementCondition'> & { results: V2Result[] }>;
}
>;
};

const resultValue = (value: number): number => {
// Zero (unattempted), -1 (DNF), -2 (DNS) and encoded multi-blind values
// must survive unchanged. Never manufacture a result from a missing field.
if (!Number.isSafeInteger(value) || value < -2) {
throw new Error('Invalid WCIF v2 result value');
}
return value;
};

const qualificationAlias = (qualification: V2Qualification): Qualification => {
const { resultCondition } = qualification;
const common = { ...qualification, resultType: resultCondition.scope };
if (resultCondition.type === 'ranking') {
if (resultCondition.value == null) throw new Error('Invalid WCIF v2 qualification');
return {
...common,
type: 'ranking',
whenDate: qualification.latestResultDate,
level: resultCondition.value,
};
}
return {
...common,
type: resultCondition.value == null ? 'anyResult' : 'attemptResult',
when: qualification.latestResultDate,
level: resultCondition.value ?? 0,
};
};

export const normalizeWcifV2 = (wcif: WcifV2): Competition => {
if (!/^2\.\d+(?:\.\d+)?$/.test(wcif.formatVersion)) {
throw new Error('Expected WCIF major version 2');
}
return {
...wcif,
persons: wcif.persons.map((person) => ({
...person,
personalBests: person.personalBests?.map((best) => ({
...best,
best: resultValue(best.value),
})),
})),
events: wcif.events.map((event) => ({
...event,
qualification: event.qualification
? qualificationAlias(event.qualification)
: event.qualification,
rounds: event.rounds.map((round) => ({
...round,
advancementCondition: null,
results: round.results.map((result) => ({
...result,
attempts: result.attempts.map((attempt) => ({
...attempt,
result: resultValue(attempt.value),
})),
})),
})),
})),
};
};
2 changes: 2 additions & 0 deletions src/types/wca-helpers.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ declare module '@wca/helpers' {
export type ParticipationResultCondition =
| {
type: 'ranking' | 'percent';
scope?: 'single' | 'average';
value: number;
}
| {
Expand Down Expand Up @@ -39,6 +40,7 @@ declare module '@wca/helpers' {
}

export interface Round {
linkedRounds?: string[] | null;
participationRuleset?: ParticipationRuleset | null;
}
}