From 02921a4b1724476f6bdfcc8b0417bbbed428fe17 Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Fri, 14 Aug 2026 20:51:47 -0700 Subject: [PATCH] Fix WCIF v2 personal best handling Omit read-only personal bests from v2 validation and save payloads. Read both v1 best and v2 value fields so production WCIF data remains usable. --- src/components/GroupCard.tsx | 4 ++-- src/lib/api/wcaAPI.test.ts | 40 ++++++++++++++++++++++++++++++++++ src/lib/api/wcaAPI.ts | 19 ++++++++++++++-- src/lib/domain/persons.test.ts | 29 ++++++++++++++++++++++++ src/lib/domain/persons.ts | 13 +++++++++-- src/lib/wcif/persons.ts | 6 ++--- 6 files changed, 102 insertions(+), 9 deletions(-) diff --git a/src/components/GroupCard.tsx b/src/components/GroupCard.tsx index a24eaf3..dfa1e76 100644 --- a/src/components/GroupCard.tsx +++ b/src/components/GroupCard.tsx @@ -11,7 +11,7 @@ import { activityDurationString, type ActivityWithParent, } from '../lib/domain/activities'; -import { mayMakeCutoff, mayMakeTimeLimit } from '../lib/domain/persons'; +import { getPersonalBestValue, mayMakeCutoff, mayMakeTimeLimit } from '../lib/domain/persons'; import { useAppSelector } from '../store'; import { selectPersonsAssignedToActivitiyId } from '../store/selectors'; import ConfigureGroupDialog from '../dialogs/ConfigureGroupDialog'; @@ -124,7 +124,7 @@ const GroupCard = ({ groupActivity }: GroupCardProps) => { const pr = person.personalBests?.find( (pb) => pb.eventId === eventId && pb.type === 'average' ); - return pr?.best; + return pr ? getPersonalBestValue(pr) : undefined; }) .filter((pr) => !!pr) as number[], [competitors, eventId] diff --git a/src/lib/api/wcaAPI.test.ts b/src/lib/api/wcaAPI.test.ts index 03d656d..3351f62 100644 --- a/src/lib/api/wcaAPI.test.ts +++ b/src/lib/api/wcaAPI.test.ts @@ -83,6 +83,46 @@ describe('wcaAPI', () => { expect(json).not.toHaveBeenCalled(); }); + it('omits read-only v2 personal bests from the WCIF check payload', async () => { + const wcif = { + formatVersion: '2.1.1', + persons: [{ registrantId: 1, personalBests: [{ eventId: '333', value: 1000 }] }], + } as any; + mockFetch({}); + + await checkWcif(wcif); + + expect(globalThis.fetch).toHaveBeenCalledWith( + 'https://wca.test/api/v0/competitions/wcif/check', + expect.objectContaining({ + body: JSON.stringify({ + formatVersion: '2.1.1', + persons: [{ registrantId: 1 }], + }), + }) + ); + }); + + it('omits read-only v2 personal bests from patch payloads', async () => { + const wcif = { + formatVersion: '2.1.1', + persons: [{ registrantId: 1, personalBests: [{ eventId: '333', value: 1000 }] }], + } as any; + mockFetch({ json: vi.fn().mockResolvedValue({}) }); + + await patchWcif('Comp', wcif); + + expect(globalThis.fetch).toHaveBeenCalledWith( + 'https://wca.test/api/v0/competitions/Comp/wcif', + expect.objectContaining({ + body: JSON.stringify({ + formatVersion: '2.1.1', + persons: [{ registrantId: 1 }], + }), + }) + ); + }); + it('builds upcoming and past competition queries', async () => { vi.spyOn(Date, 'now').mockReturnValue(0); mockFetch({ json: vi.fn().mockResolvedValue([]) }); diff --git a/src/lib/api/wcaAPI.ts b/src/lib/api/wcaAPI.ts index 8c19014..f86187e 100644 --- a/src/lib/api/wcaAPI.ts +++ b/src/lib/api/wcaAPI.ts @@ -15,6 +15,21 @@ const wcifPath = (competitionId: string) => `/competitions/${competitionId}/wcif const versionedWcifPath = (competitionId: string) => `${wcifPath(competitionId)}/version/${WCIF_VERSION}`; +/** + * Personal bests are read-only data. The v2 endpoint returns them, but the + * current WCIF checker does not accept them in a submitted v2 payload. + */ +const withoutV2PersonalBests = >(wcif: T): T => { + if (!wcif.formatVersion?.startsWith('2.') || !wcif.persons) { + return wcif; + } + + return { + ...wcif, + persons: wcif.persons.map(({ personalBests: _personalBests, ...person }) => person), + } as T; +}; + export const getMe = (): Promise<{ me: WcaUser }> => { return wcaApiFetch(`/me`); }; @@ -57,7 +72,7 @@ export const patchWcif = ( ): Promise => wcaApiFetch(wcifPath(competitionId), { method: 'PATCH', - body: JSON.stringify(wcif), + body: JSON.stringify(withoutV2PersonalBests(wcif)), }); export const checkWcif = (wcif: Competition): Promise => @@ -65,7 +80,7 @@ export const checkWcif = (wcif: Competition): Promise => '/competitions/wcif/check', { method: 'PUT', - body: JSON.stringify(wcif), + body: JSON.stringify(withoutV2PersonalBests(wcif)), }, false ); diff --git a/src/lib/domain/persons.test.ts b/src/lib/domain/persons.test.ts index c8179cd..b26ce69 100644 --- a/src/lib/domain/persons.test.ts +++ b/src/lib/domain/persons.test.ts @@ -7,6 +7,7 @@ import { shouldBeInRound, personsShouldBeInRound, findPR, + getPersonalBestValue, byPsychsheet, byResult, addAssignmentsToPerson, @@ -308,6 +309,34 @@ describe('findPR', () => { }); }); +describe('getPersonalBestValue', () => { + it('reads the v1 best field', () => { + expect( + getPersonalBestValue({ + eventId: '333', + type: 'single', + best: 1000, + worldRanking: 1, + continentalRanking: 1, + nationalRanking: 1, + }) + ).toBe(1000); + }); + + it('reads the v2 value field', () => { + expect( + getPersonalBestValue({ + eventId: '333', + type: 'single', + value: 1000, + worldRanking: 1, + continentalRanking: 1, + nationalRanking: 1, + } as never) + ).toBe(1000); + }); +}); + describe('byPsychsheet', () => { it('sorts people with WCA IDs before those without', () => { const personWithId = createMockPerson({ wcaId: 'TEST2025' }); diff --git a/src/lib/domain/persons.ts b/src/lib/domain/persons.ts index 61be176..06decff 100644 --- a/src/lib/domain/persons.ts +++ b/src/lib/domain/persons.ts @@ -2,6 +2,7 @@ import { parseActivityCode } from './activities'; import { type Activity, type Assignment, + type AttemptResult, type Event, type EventId, type Person, @@ -77,6 +78,14 @@ export const assignedInGroupsForRoles = export const findPR = (personalBests: PersonalBest[], eventId: EventId, type: RankingType) => personalBests.find((pr) => pr.eventId === eventId && pr.type === type); +type V2PersonalBest = Omit & { value: AttemptResult }; + +/** + * WCIF v1 calls this field `best`. WCIF v2 calls it `value`. + */ +export const getPersonalBestValue = (personalBest: PersonalBest | V2PersonalBest) => + 'value' in personalBest ? personalBest.value : personalBest.best; + /** * Comparator for array.sort * TODO: cleanup @@ -215,7 +224,7 @@ export const mayMakeTimeLimit = (eventId: EventId, round?: Round, persons?: Pers return false; } - return PR.best <= timeLimit.centiseconds; + return getPersonalBestValue(PR) <= timeLimit.centiseconds; }) || [] ); }; @@ -233,7 +242,7 @@ export const mayMakeCutoff = (eventId: EventId, round?: Round, persons?: Person[ return false; } - return PR.best <= cutoff.attemptResult; + return getPersonalBestValue(PR) <= cutoff.attemptResult; }) || [] ); }; diff --git a/src/lib/wcif/persons.ts b/src/lib/wcif/persons.ts index 8a2cf2e..4f874d0 100644 --- a/src/lib/wcif/persons.ts +++ b/src/lib/wcif/persons.ts @@ -1,6 +1,6 @@ import { parseActivityCode } from '../domain/activities/activityCode'; import { roundFormatById } from '../domain/events'; -import { findPR } from '../domain/persons'; +import { findPR, getPersonalBestValue } from '../domain/persons'; import { type Competition, type Person, type AttemptResult } from '@wca/helpers'; /** WCIF Person Lookup Functions */ @@ -79,8 +79,8 @@ export const getSeedResult = ( const single = findPR(person.personalBests || [], eventId, 'single'); return { - average: average?.best, - single: single?.best, + average: average ? getPersonalBestValue(average) : undefined, + single: single ? getPersonalBestValue(single) : undefined, }; }