diff --git a/src/apps/review/src/lib/hooks/useFetchScreeningReview.ts b/src/apps/review/src/lib/hooks/useFetchScreeningReview.ts index 080c00a0c..94adc9930 100644 --- a/src/apps/review/src/lib/hooks/useFetchScreeningReview.ts +++ b/src/apps/review/src/lib/hooks/useFetchScreeningReview.ts @@ -1993,12 +1993,19 @@ export function useFetchScreeningReview(): useFetchScreeningReviewProps { // get review progress from challenge review const reviewProgress = useMemo(() => calculateReviewProgress({ challengePhases: challengeInfo?.phases, + checkpointReviewRows: checkpointReview, + checkpointScreeningRows: checkpoint, + currentPhaseName: challengeInfo?.currentPhaseObject?.name || challengeInfo?.currentPhase, isDesignChallenge: challengeInfo?.track?.name === DESIGN, reviewRows: review, screeningRows: screening, }), [ challengeInfo?.phases, + challengeInfo?.currentPhase, + challengeInfo?.currentPhaseObject?.name, challengeInfo?.track?.name, + checkpoint, + checkpointReview, review, screening, ]) diff --git a/src/apps/review/src/lib/utils/reviewProgress.spec.ts b/src/apps/review/src/lib/utils/reviewProgress.spec.ts index e696400ca..9bd2a92ca 100644 --- a/src/apps/review/src/lib/utils/reviewProgress.spec.ts +++ b/src/apps/review/src/lib/utils/reviewProgress.spec.ts @@ -53,6 +53,7 @@ const createReviewSubmission = ( const createScreeningRow = ( submissionId: string, result: Screening['result'], + overrides: Partial = {}, ): Screening => ({ challengeId: 'challenge-id', createdAt: '2025-01-01T00:00:00.000Z', @@ -60,6 +61,7 @@ const createScreeningRow = ( result, score: '100', submissionId, + ...overrides, }) describe('calculateReviewProgress', () => { @@ -80,6 +82,9 @@ describe('calculateReviewProgress', () => { const progress = calculateReviewProgress({ challengePhases: reviewPhases, + checkpointReviewRows: [], + checkpointScreeningRows: [], + currentPhaseName: 'Review', isDesignChallenge: false, reviewRows, screeningRows, @@ -101,6 +106,9 @@ describe('calculateReviewProgress', () => { const progress = calculateReviewProgress({ challengePhases: reviewPhases, + checkpointReviewRows: [], + checkpointScreeningRows: [], + currentPhaseName: 'Review', isDesignChallenge: false, reviewRows, screeningRows, @@ -122,6 +130,9 @@ describe('calculateReviewProgress', () => { const progress = calculateReviewProgress({ challengePhases: reviewPhases, + checkpointReviewRows: [], + checkpointScreeningRows: [], + currentPhaseName: 'Review', isDesignChallenge: true, reviewRows, screeningRows, @@ -130,4 +141,75 @@ describe('calculateReviewProgress', () => { expect(progress) .toBe(50) }) + + it('uses completed checkpoint review rows for an open checkpoint review phase', () => { + const checkpointReviewPhase = createPhase('Checkpoint Review') + const checkpointReviewRows: Screening[] = [ + createScreeningRow('submission-one', 'PASS', { reviewStatus: 'COMPLETED' }), + createScreeningRow('submission-two', 'PASS', { reviewStatus: 'SUBMITTED' }), + ] + + const progress = calculateReviewProgress({ + challengePhases: [checkpointReviewPhase], + checkpointReviewRows, + checkpointScreeningRows: [], + currentPhaseName: '', + isDesignChallenge: true, + reviewRows: [createReviewSubmission('final-review', 'PENDING')], + screeningRows: [], + }) + + expect(progress) + .toBe(100) + }) + + it('uses checkpoint screening rows for partial checkpoint screening progress', () => { + const progress = calculateReviewProgress({ + challengePhases: [createPhase('Checkpoint Screening')], + checkpointReviewRows: [], + checkpointScreeningRows: [ + createScreeningRow('submission-one', 'PASS', { reviewStatus: 'COMPLETED' }), + createScreeningRow('submission-two', '-', { reviewStatus: 'IN_PROGRESS' }), + ], + currentPhaseName: 'Checkpoint Screening', + isDesignChallenge: true, + reviewRows: [], + screeningRows: [], + }) + + expect(progress) + .toBe(50) + }) + + it('counts each reviewer assignment in multi-screener phase progress', () => { + const screeningRows: Screening[] = [ + createScreeningRow('submission-one', '-', { + screeningReviews: [ + { + result: 'PASS', + reviewStatus: 'COMPLETED', + score: '100', + }, + { + result: '-', + reviewStatus: 'PENDING', + score: 'Pending', + }, + ], + }), + ] + + const progress = calculateReviewProgress({ + challengePhases: [createPhase('Screening')], + checkpointReviewRows: [], + checkpointScreeningRows: [], + currentPhaseName: 'Screening', + isDesignChallenge: false, + reviewRows: [], + screeningRows, + }) + + expect(progress) + .toBe(50) + }) }) diff --git a/src/apps/review/src/lib/utils/reviewProgress.ts b/src/apps/review/src/lib/utils/reviewProgress.ts index 07db797e5..7682e010e 100644 --- a/src/apps/review/src/lib/utils/reviewProgress.ts +++ b/src/apps/review/src/lib/utils/reviewProgress.ts @@ -8,10 +8,87 @@ import type { import { shouldIncludeInReviewPhase } from './reviewPhaseGuards' +const COMPLETED_REVIEW_STATUSES = new Set(['COMPLETED', 'SUBMITTED']) + +const REVIEW_PROGRESS_PHASES = new Set([ + 'checkpointreview', + 'checkpointscreening', + 'review', + 'screening', +]) + const normalizeScreeningResult = (result?: string | null): string => (result ?? '') .trim() .toUpperCase() +/** + * Normalizes a phase name for phase-aware progress comparisons. + * + * @param phaseName - Human-readable challenge phase name. + * @returns Lower-case alphabetic phase key, or an empty string when absent. + * Used internally by review progress phase selection. This function does not throw. + */ +const normalizeProgressPhaseName = (phaseName?: string | null): string => (phaseName ?? '') + .trim() + .toLowerCase() + .replace(/[^a-z]/g, '') + +/** + * Resolves the review-like phase whose rows should drive the progress bar. + * + * @param currentPhaseName - Current phase reported by the challenge response. + * @param challengePhases - Challenge phase metadata used when the current phase is absent. + * @returns Normalized supported phase name, or `undefined` when no review-like phase is active. + * Used by `calculateReviewProgress` and does not throw. + */ +const resolveReviewProgressPhase = ( + currentPhaseName: string | undefined, + challengePhases?: BackendPhase[], +): string | undefined => { + const normalizedCurrentPhase = normalizeProgressPhaseName(currentPhaseName) + if (REVIEW_PROGRESS_PHASES.has(normalizedCurrentPhase)) { + return normalizedCurrentPhase + } + + const openReviewPhase = (challengePhases ?? []).find(phase => ( + phase.isOpen + && REVIEW_PROGRESS_PHASES.has(normalizeProgressPhaseName(phase.name)) + )) + + return openReviewPhase + ? normalizeProgressPhaseName(openReviewPhase.name) + : undefined +} + +/** + * Calculates completion across screening-style phase rows. + * + * @param rows - Screening, Checkpoint Screening, or Checkpoint Review rows. + * @returns Rounded completion percentage in the inclusive range [0, 100]. + * Multi-screener rows count each assignment. Rows without assignments remain pending. + * Used by `calculateReviewProgress` and does not throw. + */ +const calculateScreeningRowsProgress = (rows: Screening[]): number => { + let completedReviewCount = 0 + let totalReviewCount = 0 + + rows.forEach(row => { + const reviewStatuses = row.screeningReviews?.length + ? row.screeningReviews.map(review => review.reviewStatus) + : [row.reviewStatus ?? row.myReviewStatus] + + totalReviewCount += reviewStatuses.length + completedReviewCount += reviewStatuses.filter(status => ( + COMPLETED_REVIEW_STATUSES.has((status ?? '').trim() + .toUpperCase()) + )).length + }) + + return totalReviewCount + ? Math.round((completedReviewCount * 100) / totalReviewCount) + : 0 +} + const resolveReviewSubmissionIds = (submission: SubmissionInfo): string[] => { const candidateIds = new Set() const submissionId = submission.id?.trim() @@ -78,24 +155,46 @@ const isCompletedReviewSubmission = (submission: SubmissionInfo): boolean => { type CalculateReviewProgressArgs = { challengePhases?: BackendPhase[] + checkpointReviewRows: Screening[] + checkpointScreeningRows: Screening[] + currentPhaseName: string | undefined isDesignChallenge: boolean reviewRows: SubmissionInfo[] screeningRows: Screening[] } /** - * Calculates review phase completion progress as a percentage. - * Screening-failed submissions are excluded whenever screening outcomes are available. + * Calculates completion progress for the current review-like challenge phase. + * Final Review preserves screening outcome and submission-history filtering, while + * Screening and checkpoint phases use their phase-specific assignment rows. * * @param args - Inputs needed to evaluate review progress. * @returns Rounded completion percentage in the inclusive range [0, 100]. + * Used by `useFetchScreeningReview` for the challenge header. This function does not throw. */ export const calculateReviewProgress = ({ challengePhases, + checkpointReviewRows, + checkpointScreeningRows, + currentPhaseName, isDesignChallenge, reviewRows, screeningRows, }: CalculateReviewProgressArgs): number => { + const progressPhase = resolveReviewProgressPhase(currentPhaseName, challengePhases) + + if (progressPhase === 'checkpointreview') { + return calculateScreeningRowsProgress(checkpointReviewRows) + } + + if (progressPhase === 'checkpointscreening') { + return calculateScreeningRowsProgress(checkpointScreeningRows) + } + + if (progressPhase === 'screening') { + return calculateScreeningRowsProgress(screeningRows) + } + if (!reviewRows.length) { return 0 }