Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion src/apps/opportunities/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -384,7 +384,10 @@ tab, and that tab exists only when Work Manager enables its challenge metadata.
Review API submissions and Marathon Match review summations own provisional
and final scores. Active My Submissions pages periodically revalidate so an
asynchronous AI decision score appears without requiring the member to reload
the page. A transient background failure retains the last successful submission
the page. AI-reviewed challenges expand the newest submission's workflow details
by default; each row can reveal the reviewer, completion date, threshold-derived
result, and a score deep-link to Review App, polling while a run is pending or
has not yet been created. A transient background failure retains the last successful submission
page, retries twice with a delay, and revalidates when the member returns to the
tab; initial failures still expose the explicit retry action. Optional score
requests do not enter an automatic retry loop. Final
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
.tableWrap {
background: #f5f7fa;
overflow-x: auto;
padding: 12px 16px 16px;

table {
border-collapse: collapse;
min-width: 640px;
table-layout: fixed;
width: 100%;
}

th,
td {
border-bottom: 1px solid #c6c6c6;
font-size: 14px;
line-height: 20px;
padding: 12px 16px;
text-align: left;
}

th {
font-weight: 700;
}

th:first-child {
width: 46%;
}

th:nth-child(2) {
width: 26%;
}

a,
button {
color: #007d79;
}
}

.requestState {
align-items: center;
background: #f5f7fa;
color: #525252;
display: flex;
font-size: 14px;
gap: 12px;
margin: 0;
min-height: 52px;
padding: 12px 16px;

button {
background: transparent;
border: 0;
color: #007d79;
cursor: pointer;
font: inherit;
font-weight: 700;
padding: 0;
}
}

.result {
align-items: center;
display: inline-flex;
font-size: 12px;
font-weight: 700;
gap: 4px;

&::before {
align-items: center;
border: 1px solid currentColor;
border-radius: 50%;
content: '';
display: inline-flex;
height: 14px;
justify-content: center;
width: 14px;
}
}

.passed {
color: #198038;

&::before {
content: '✓';
}
}

.failed {
color: #da1e28;

&::before {
content: '−';
}
}

.pending,
.status {
color: #525252;
}

@media (max-width: 767px) {
.tableWrap {
padding: 8px;

table {
min-width: 560px;
}
}
}

@media (max-width: 620px) {
.tableWrap {
overflow-x: visible;

table {
min-width: 0;
width: 100%;
}
}
}
201 changes: 201 additions & 0 deletions src/apps/opportunities/src/components/SubmissionAiReviewDetails.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
/* eslint-disable react/jsx-no-bind */
import { FC } from 'react'
import useSWR, { SWRResponse } from 'swr'

import { ChallengeSubmissionAiWorkflowRun } from '../models'
import { getChallengeSubmissionAiWorkflowRuns } from '../services'
import { submissionAiReviewAppUrl } from '../utils'

import styles from './SubmissionAiReviewDetails.module.scss'

interface SubmissionAiReviewDetailsProps {
challengeId: string
id: string
submissionId: string
}

interface WorkflowRunResult {
kind: 'failed' | 'passed' | 'pending' | 'status'
label: string
}

const PENDING_STATUSES = new Set(['INIT', 'QUEUED', 'DISPATCHED', 'IN_PROGRESS'])
const TERMINAL_STATUSES = new Set(['CANCELLED', 'COMPLETED', 'FAILED', 'FAILURE', 'SUCCESS', 'TIMEOUT'])
const WORKFLOW_RUN_REFRESH_INTERVAL_MS = 10_000

/**
* Normalizes an optional Review API workflow status for comparisons and display.
*
* @param run workflow run returned by Review API.
* @returns trimmed uppercase status, or an empty string when omitted.
* @throws Does not throw.
*/
function workflowRunStatus(run: ChallengeSubmissionAiWorkflowRun): string {
return (run.status ?? '').trim()
.toUpperCase()
}

/**
* Converts one Review API workflow run into its member-facing result.
*
* Successful runs use the configured minimum passing score, matching the legacy
* submission-management experience. Other lifecycle values remain explicit.
*
* @param run workflow run returned by Review API.
* @returns uppercase result label and visual kind.
* @throws Does not throw.
*/
export function submissionAiWorkflowRunResult(
run: ChallengeSubmissionAiWorkflowRun,
): WorkflowRunResult {
const status = workflowRunStatus(run)
if (PENDING_STATUSES.has(status)) return { kind: 'pending', label: 'PENDING' }
if (status === 'SUCCESS') {
const score = Number(run.score)
const minimumPassingScore = Number(run.workflow?.scorecard?.minimumPassingScore ?? 0)
const passed = Number.isFinite(score)
&& Number.isFinite(minimumPassingScore)
&& score >= minimumPassingScore
return { kind: passed ? 'passed' : 'failed', label: passed ? 'PASSED' : 'FAILED' }
}

if (status === 'FAILED' || status === 'FAILURE' || status === 'TIMEOUT') {
return { kind: 'failed', label: 'FAILED' }
}

return { kind: 'status', label: status.replace(/_/g, ' ') || 'UNKNOWN' }
}

/**
* Formats a workflow completion timestamp for the Opportunities locale.
*
* @param value ISO timestamp returned by Review API.
* @returns localized date/time or a dash when missing/invalid.
* @throws Does not throw.
*/
function workflowReviewDate(value: string | undefined): string {
if (!value) return '-'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return '-'
return date.toLocaleString('en-US', {
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
month: 'short',
year: 'numeric',
})
}

/**
* Determines whether an unfinished workflow run should keep the details fresh.
*
* @param runs latest workflow records, when loaded.
* @returns polling interval while any run is non-terminal, otherwise zero.
* @throws Does not throw.
*/
function workflowRunRefreshInterval(
runs: ChallengeSubmissionAiWorkflowRun[] | undefined,
): number {
return !runs?.length || runs.some(run => !TERMINAL_STATUSES.has(workflowRunStatus(run)))
? WORKFLOW_RUN_REFRESH_INTERVAL_MS
: 0
}

/**
* Displays workflow-run details for an expanded member submission row.
*
* The component owns its authenticated request so one failed submission renders
* an inline retry state without producing a page-wide toast.
*
* @param props challenge/submission identifiers and the controlled panel id.
* @returns workflow details table or a compact request state.
* @throws Does not throw; request failures are rendered inline.
*/
export const SubmissionAiReviewDetails: FC<SubmissionAiReviewDetailsProps> = props => {
const response: SWRResponse<ChallengeSubmissionAiWorkflowRun[], Error> = useSWR(
['opportunities:submission-ai-workflow-runs', props.submissionId],
() => getChallengeSubmissionAiWorkflowRuns(props.submissionId),
{
refreshInterval: workflowRunRefreshInterval,
revalidateOnFocus: false,
shouldRetryOnError: false,
},
)

/** Retries only this submission's workflow-run request. */
const retry = (): void => {
response.mutate()
}

if (response.error && response.data === undefined) {
return (
<div className={styles.requestState} id={props.id} role='alert'>
<span>AI review details are unavailable.</span>
<button onClick={retry} type='button'>Try again</button>
</div>
)
}

if (!response.data) {
return <p className={styles.requestState} id={props.id} role='status'>Loading AI review details…</p>
}

if (!response.data.length) {
return <p className={styles.requestState} id={props.id}>No AI review details are available yet.</p>
}

return (
<div className={styles.tableWrap} id={props.id}>
<table aria-label={`AI review details for submission ${props.submissionId}`}>
<thead>
<tr>
<th>AI Reviewer</th>
<th>Review Date</th>
<th>Score</th>
<th>Result</th>
</tr>
</thead>
<tbody>
{response.data.map(run => {
const successful = workflowRunStatus(run) === 'SUCCESS'
const workflowId = run.workflowId ?? run.workflow?.id
const result = submissionAiWorkflowRunResult(run)
const score = successful && run.score !== null && run.score !== undefined
? String(run.score)
: '-'
return (
<tr key={run.id}>
<td data-mobile-label='AI Reviewer'>
{run.workflow?.name ?? 'AI review workflow'}
</td>
<td data-mobile-label='Review Date'>
{successful ? workflowReviewDate(run.completedAt) : '-'}
</td>
<td data-mobile-label='Score'>
{successful && workflowId ? (
<a
href={submissionAiReviewAppUrl(
props.challengeId,
props.submissionId,
workflowId,
)}
rel='noreferrer'
target='_blank'
>
{score}
</a>
) : score}
</td>
<td data-mobile-label='Result'>
<span className={`${styles.result} ${styles[result.kind]}`}>
{result.label}
</span>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)
}
23 changes: 23 additions & 0 deletions src/apps/opportunities/src/models/opportunity.models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,11 @@ export interface ChallengeAiReviewConfig {
mode: ChallengeAiReviewMode
}

/** Challenge API reviewer assignment used to identify AI-reviewed challenges. */
export interface ChallengeReviewer {
aiWorkflowId?: string
}

export interface ChallengeOpportunity {
attachments?: ChallengeAttachment[]
currentPhase?: ChallengePhase
Expand All @@ -139,6 +144,7 @@ export interface ChallengeOpportunity {
prizeSets?: ChallengePrizeSet[]
projectId?: string
registrationEndDate?: string
reviewers?: ChallengeReviewer[]
skills?: OpportunitySkill[]
startDate?: string
status?: string
Expand Down Expand Up @@ -406,6 +412,23 @@ export interface ChallengeSubmission {
virusScan?: boolean
}

/** Review API workflow-run projection displayed beneath a member submission. */
export interface ChallengeSubmissionAiWorkflowRun {
completedAt?: string
id: string
score?: number | string | null
status?: string
submissionId?: string
workflow?: {
id?: string
name?: string
scorecard?: {
minimumPassingScore?: number | string | null
}
}
workflowId?: string
}

/** Submission categories accepted by the v6 Review API upload endpoint. */
export type ChallengeSubmissionType =
| 'CONTEST_SUBMISSION'
Expand Down
Loading
Loading