From fb674063e465f18759ddbb4b5934da8dd279ba1c Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Sat, 1 Aug 2026 12:24:45 +1000 Subject: [PATCH] PM-5775: refresh checkpoint winner data What was broken A Review page opened while Checkpoint Review was active could retain a challenge response without checkpoint winners after the phase closed, so the existing winner star never received the data it needed. Root cause The Challenge API intentionally hides checkpoint winners until Checkpoint Review closes, while the Review app disabled automatic challenge refreshes and did not poll that transition. What was changed Poll challenge details every ten seconds only while an active Checkpoint Review is open and checkpoint winners are still unavailable. Polling stops once the phase closes, winner data arrives, the challenge completes, or another phase is active. Any added/updated tests Added refresh-interval coverage for active Checkpoint Review, closed review, populated winner data, completed challenges, and unrelated phases. --- .../lib/hooks/useFetchChallengeInfo.spec.ts | 96 +++++++++++++++++++ .../src/lib/hooks/useFetchChallengeInfo.ts | 31 +++++- 2 files changed, 126 insertions(+), 1 deletion(-) create mode 100644 src/apps/review/src/lib/hooks/useFetchChallengeInfo.spec.ts diff --git a/src/apps/review/src/lib/hooks/useFetchChallengeInfo.spec.ts b/src/apps/review/src/lib/hooks/useFetchChallengeInfo.spec.ts new file mode 100644 index 000000000..6c786c8d4 --- /dev/null +++ b/src/apps/review/src/lib/hooks/useFetchChallengeInfo.spec.ts @@ -0,0 +1,96 @@ +import type { BackendPhase, ChallengeInfo } from '../models' + +import { getChallengeInfoRefreshInterval } from './useFetchChallengeInfo' + +jest.mock('~/apps/admin/src/lib/utils', () => ({ + handleError: jest.fn(), +}), { virtual: true }) + +jest.mock('../services', () => ({ + fetchChallengeInfoById: jest.fn(), +})) + +const checkpointReviewPhase: BackendPhase = { + constraints: [], + description: 'Select checkpoint winners', + duration: 3_600, + id: 'checkpoint-review-phase', + isOpen: true, + name: 'Checkpoint Review', + phaseId: 'checkpoint-review-phase-type', + scheduledEndDate: '2026-08-01T01:00:00.000Z', + scheduledStartDate: '2026-08-01T00:00:00.000Z', +} + +/** + * Build the minimum challenge data needed to exercise refresh-interval selection. + * + * @param overrides challenge fields to replace for the current test case + * @returns a complete ChallengeInfo fixture + */ +const buildChallengeInfo = ( + overrides: Partial = {}, +): ChallengeInfo => ({ + checkpointWinners: [], + currentPhase: 'Checkpoint Review', + currentPhaseEndDate: '2026-08-01T01:00:00.000Z', + id: 'challenge-1', + name: 'Checkpoint challenge', + phases: [checkpointReviewPhase], + status: 'ACTIVE', + submissions: [], + track: { + id: 'track-1', + name: 'Design', + }, + type: { + id: 'type-1', + name: 'Challenge', + }, + typeId: 'type-1', + ...overrides, +} as ChallengeInfo) + +describe('getChallengeInfoRefreshInterval', () => { + it('polls while an active Checkpoint Review can still be hiding winners', () => { + expect(getChallengeInfoRefreshInterval(buildChallengeInfo())) + .toBe(10_000) + }) + + it('stops polling after Checkpoint Review closes', () => { + expect(getChallengeInfoRefreshInterval(buildChallengeInfo({ + currentPhase: 'Submission', + phases: [{ + ...checkpointReviewPhase, + isOpen: false, + }], + }))) + .toBe(0) + }) + + it('stops polling when checkpoint winners are already available', () => { + expect(getChallengeInfoRefreshInterval(buildChallengeInfo({ + checkpointWinners: [{ + handle: 'winner', + placement: 1, + userId: 123, + }], + }))) + .toBe(0) + }) + + it('does not poll completed challenges or unrelated phases', () => { + expect(getChallengeInfoRefreshInterval(buildChallengeInfo({ status: 'COMPLETED' }))) + .toBe(0) + expect(getChallengeInfoRefreshInterval(buildChallengeInfo({ + currentPhase: 'Review', + phases: [{ + ...checkpointReviewPhase, + id: 'review-phase', + name: 'Review', + phaseId: 'review-phase-type', + }], + }))) + .toBe(0) + }) +}) diff --git a/src/apps/review/src/lib/hooks/useFetchChallengeInfo.ts b/src/apps/review/src/lib/hooks/useFetchChallengeInfo.ts index 31956bf61..d4ad09137 100644 --- a/src/apps/review/src/lib/hooks/useFetchChallengeInfo.ts +++ b/src/apps/review/src/lib/hooks/useFetchChallengeInfo.ts @@ -9,11 +9,39 @@ import useSWR, { SWRResponse } from 'swr' import { handleError } from '~/apps/admin/src/lib/utils' -import { +import type { ChallengeInfo, } from '../models' import { fetchChallengeInfoById } from '../services' +const CHECKPOINT_REVIEW_PHASE_NAME = 'checkpoint review' +const CHECKPOINT_WINNER_REFRESH_INTERVAL_MS = 10_000 + +/** + * Select the challenge-info polling interval used while checkpoint winners are pending. + * The Challenge API intentionally hides checkpoint winners until Checkpoint Review closes, + * so active pages poll during that phase and stop as soon as the refreshed response changes. + * + * @param challengeInfo latest challenge response held by SWR + * @returns polling interval in milliseconds, or zero when no refresh is needed + */ +export function getChallengeInfoRefreshInterval( + challengeInfo?: ChallengeInfo, +): number { + const isActive = challengeInfo?.status?.trim() + .toUpperCase() === 'ACTIVE' + const hasCheckpointWinners = Boolean(challengeInfo?.checkpointWinners?.length) + const hasOpenCheckpointReview = challengeInfo?.phases?.some(phase => ( + phase.isOpen === true + && phase.name?.trim() + .toLowerCase() === CHECKPOINT_REVIEW_PHASE_NAME + )) ?? false + + return isActive && !hasCheckpointWinners && hasOpenCheckpointReview + ? CHECKPOINT_WINNER_REFRESH_INTERVAL_MS + : 0 +} + export interface useFetchChallengeInfoProps { challengeInfo: ChallengeInfo | undefined error: Error | undefined @@ -41,6 +69,7 @@ export function useFetchChallengeInfo( { fetcher: () => fetchChallengeInfoById(challengeId ?? ''), isPaused: () => !challengeId, + refreshInterval: getChallengeInfoRefreshInterval, }, )