diff --git a/README.md b/README.md index 6ab7c79..5e43e5f 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/package.json b/package.json index fdcfd33..0eb5b87 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "competition-groups", - "version": "0.1.0", + "version": "0.1.1", "private": true, "scripts": { "dev": "vite", diff --git a/src/lib/api.ts b/src/lib/api.ts index 1ec9fc1..e437516 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -1,5 +1,5 @@ -import { Competition } from '@wca/helpers'; import { WCA_ORIGIN } from './wca-env'; +import { normalizeWcifV2, WcifV2 } from './wcifV2'; export const wcaApiFetch = async (path: string, fetchOptions: RequestInit = {}) => { const res = await fetch(`${WCA_ORIGIN}${path}`, fetchOptions); @@ -53,7 +53,7 @@ export const fetchUserWithCompetitions = async (userId: string) => wcaApiFetch(`/users/${userId}?${fetchUserWithCompetitionsParams.toString()}`); export const fetchWcif = async (competitionId: string) => - wcaApiFetch(`/competitions/${competitionId}/wcif/public`); + normalizeWcifV2(await wcaApiFetch(`/competitions/${competitionId}/wcif/version/2`)); export interface WcaCompetitionResult { id: number; diff --git a/src/lib/wcifV2.test.ts b/src/lib/wcifV2.test.ts new file mode 100644 index 0000000..a82680a --- /dev/null +++ b/src/lib/wcifV2.test.ts @@ -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; + } + }); +}); diff --git a/src/lib/wcifV2.ts b/src/lib/wcifV2.ts new file mode 100644 index 0000000..077a59c --- /dev/null +++ b/src/lib/wcifV2.ts @@ -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 & { value: PersonalBest['best'] }; +type V2Result = Omit & { + attempts: Array< + Omit & { + 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 & { + persons: Array & { personalBests?: V2PersonalBest[] }>; + events: Array< + Omit & { + qualification?: V2Qualification | null; + rounds: Array & { 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), + })), + })), + })), + })), + }; +}; diff --git a/src/types/wca-helpers.d.ts b/src/types/wca-helpers.d.ts index bb76a48..7b3c8b5 100644 --- a/src/types/wca-helpers.d.ts +++ b/src/types/wca-helpers.d.ts @@ -4,6 +4,7 @@ declare module '@wca/helpers' { export type ParticipationResultCondition = | { type: 'ranking' | 'percent'; + scope?: 'single' | 'average'; value: number; } | { @@ -39,6 +40,7 @@ declare module '@wca/helpers' { } export interface Round { + linkedRounds?: string[] | null; participationRuleset?: ParticipationRuleset | null; } }