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, }, )