Skip to content

Commit 4ab7dec

Browse files
committed
fix(PM-6310): show AI workflow submission details
1 parent 9e7bd6a commit 4ab7dec

11 files changed

Lines changed: 792 additions & 129 deletions

src/apps/opportunities/README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -384,7 +384,10 @@ tab, and that tab exists only when Work Manager enables its challenge metadata.
384384
Review API submissions and Marathon Match review summations own provisional
385385
and final scores. Active My Submissions pages periodically revalidate so an
386386
asynchronous AI decision score appears without requiring the member to reload
387-
the page. A transient background failure retains the last successful submission
387+
the page. AI-reviewed challenges expand the newest submission's workflow details
388+
by default; each row can reveal the reviewer, completion date, threshold-derived
389+
result, and a score deep-link to Review App, polling while a run is pending or
390+
has not yet been created. A transient background failure retains the last successful submission
388391
page, retries twice with a delay, and revalidates when the member returns to the
389392
tab; initial failures still expose the explicit retry action. Optional score
390393
requests do not enter an automatic retry loop. Final
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
.tableWrap {
2+
background: #f5f7fa;
3+
overflow-x: auto;
4+
padding: 12px 16px 16px;
5+
6+
table {
7+
border-collapse: collapse;
8+
min-width: 640px;
9+
table-layout: fixed;
10+
width: 100%;
11+
}
12+
13+
th,
14+
td {
15+
border-bottom: 1px solid #c6c6c6;
16+
font-size: 14px;
17+
line-height: 20px;
18+
padding: 12px 16px;
19+
text-align: left;
20+
}
21+
22+
th {
23+
font-weight: 700;
24+
}
25+
26+
th:first-child {
27+
width: 46%;
28+
}
29+
30+
th:nth-child(2) {
31+
width: 26%;
32+
}
33+
34+
a,
35+
button {
36+
color: #007d79;
37+
}
38+
}
39+
40+
.requestState {
41+
align-items: center;
42+
background: #f5f7fa;
43+
color: #525252;
44+
display: flex;
45+
font-size: 14px;
46+
gap: 12px;
47+
margin: 0;
48+
min-height: 52px;
49+
padding: 12px 16px;
50+
51+
button {
52+
background: transparent;
53+
border: 0;
54+
color: #007d79;
55+
cursor: pointer;
56+
font: inherit;
57+
font-weight: 700;
58+
padding: 0;
59+
}
60+
}
61+
62+
.result {
63+
align-items: center;
64+
display: inline-flex;
65+
font-size: 12px;
66+
font-weight: 700;
67+
gap: 4px;
68+
69+
&::before {
70+
align-items: center;
71+
border: 1px solid currentColor;
72+
border-radius: 50%;
73+
content: '';
74+
display: inline-flex;
75+
height: 14px;
76+
justify-content: center;
77+
width: 14px;
78+
}
79+
}
80+
81+
.passed {
82+
color: #198038;
83+
84+
&::before {
85+
content: '';
86+
}
87+
}
88+
89+
.failed {
90+
color: #da1e28;
91+
92+
&::before {
93+
content: '';
94+
}
95+
}
96+
97+
.pending,
98+
.status {
99+
color: #525252;
100+
}
101+
102+
@media (max-width: 767px) {
103+
.tableWrap {
104+
padding: 8px;
105+
106+
table {
107+
min-width: 560px;
108+
}
109+
}
110+
}
111+
112+
@media (max-width: 620px) {
113+
.tableWrap {
114+
overflow-x: visible;
115+
116+
table {
117+
min-width: 0;
118+
width: 100%;
119+
}
120+
}
121+
}
Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
/* eslint-disable react/jsx-no-bind */
2+
import { FC } from 'react'
3+
import useSWR, { SWRResponse } from 'swr'
4+
5+
import { ChallengeSubmissionAiWorkflowRun } from '../models'
6+
import { getChallengeSubmissionAiWorkflowRuns } from '../services'
7+
import { submissionAiReviewAppUrl } from '../utils'
8+
9+
import styles from './SubmissionAiReviewDetails.module.scss'
10+
11+
interface SubmissionAiReviewDetailsProps {
12+
challengeId: string
13+
id: string
14+
submissionId: string
15+
}
16+
17+
interface WorkflowRunResult {
18+
kind: 'failed' | 'passed' | 'pending' | 'status'
19+
label: string
20+
}
21+
22+
const PENDING_STATUSES = new Set(['INIT', 'QUEUED', 'DISPATCHED', 'IN_PROGRESS'])
23+
const TERMINAL_STATUSES = new Set(['CANCELLED', 'COMPLETED', 'FAILED', 'FAILURE', 'SUCCESS', 'TIMEOUT'])
24+
const WORKFLOW_RUN_REFRESH_INTERVAL_MS = 10_000
25+
26+
/**
27+
* Normalizes an optional Review API workflow status for comparisons and display.
28+
*
29+
* @param run workflow run returned by Review API.
30+
* @returns trimmed uppercase status, or an empty string when omitted.
31+
* @throws Does not throw.
32+
*/
33+
function workflowRunStatus(run: ChallengeSubmissionAiWorkflowRun): string {
34+
return (run.status ?? '').trim()
35+
.toUpperCase()
36+
}
37+
38+
/**
39+
* Converts one Review API workflow run into its member-facing result.
40+
*
41+
* Successful runs use the configured minimum passing score, matching the legacy
42+
* submission-management experience. Other lifecycle values remain explicit.
43+
*
44+
* @param run workflow run returned by Review API.
45+
* @returns uppercase result label and visual kind.
46+
* @throws Does not throw.
47+
*/
48+
export function submissionAiWorkflowRunResult(
49+
run: ChallengeSubmissionAiWorkflowRun,
50+
): WorkflowRunResult {
51+
const status = workflowRunStatus(run)
52+
if (PENDING_STATUSES.has(status)) return { kind: 'pending', label: 'PENDING' }
53+
if (status === 'SUCCESS') {
54+
const score = Number(run.score)
55+
const minimumPassingScore = Number(run.workflow?.scorecard?.minimumPassingScore ?? 0)
56+
const passed = Number.isFinite(score)
57+
&& Number.isFinite(minimumPassingScore)
58+
&& score >= minimumPassingScore
59+
return { kind: passed ? 'passed' : 'failed', label: passed ? 'PASSED' : 'FAILED' }
60+
}
61+
62+
if (status === 'FAILED' || status === 'FAILURE' || status === 'TIMEOUT') {
63+
return { kind: 'failed', label: 'FAILED' }
64+
}
65+
66+
return { kind: 'status', label: status.replace(/_/g, ' ') || 'UNKNOWN' }
67+
}
68+
69+
/**
70+
* Formats a workflow completion timestamp for the Opportunities locale.
71+
*
72+
* @param value ISO timestamp returned by Review API.
73+
* @returns localized date/time or a dash when missing/invalid.
74+
* @throws Does not throw.
75+
*/
76+
function workflowReviewDate(value: string | undefined): string {
77+
if (!value) return '-'
78+
const date = new Date(value)
79+
if (Number.isNaN(date.getTime())) return '-'
80+
return date.toLocaleString('en-US', {
81+
day: '2-digit',
82+
hour: '2-digit',
83+
minute: '2-digit',
84+
month: 'short',
85+
year: 'numeric',
86+
})
87+
}
88+
89+
/**
90+
* Determines whether an unfinished workflow run should keep the details fresh.
91+
*
92+
* @param runs latest workflow records, when loaded.
93+
* @returns polling interval while any run is non-terminal, otherwise zero.
94+
* @throws Does not throw.
95+
*/
96+
function workflowRunRefreshInterval(
97+
runs: ChallengeSubmissionAiWorkflowRun[] | undefined,
98+
): number {
99+
return !runs?.length || runs.some(run => !TERMINAL_STATUSES.has(workflowRunStatus(run)))
100+
? WORKFLOW_RUN_REFRESH_INTERVAL_MS
101+
: 0
102+
}
103+
104+
/**
105+
* Displays workflow-run details for an expanded member submission row.
106+
*
107+
* The component owns its authenticated request so one failed submission renders
108+
* an inline retry state without producing a page-wide toast.
109+
*
110+
* @param props challenge/submission identifiers and the controlled panel id.
111+
* @returns workflow details table or a compact request state.
112+
* @throws Does not throw; request failures are rendered inline.
113+
*/
114+
export const SubmissionAiReviewDetails: FC<SubmissionAiReviewDetailsProps> = props => {
115+
const response: SWRResponse<ChallengeSubmissionAiWorkflowRun[], Error> = useSWR(
116+
['opportunities:submission-ai-workflow-runs', props.submissionId],
117+
() => getChallengeSubmissionAiWorkflowRuns(props.submissionId),
118+
{
119+
refreshInterval: workflowRunRefreshInterval,
120+
revalidateOnFocus: false,
121+
shouldRetryOnError: false,
122+
},
123+
)
124+
125+
/** Retries only this submission's workflow-run request. */
126+
const retry = (): void => {
127+
response.mutate()
128+
}
129+
130+
if (response.error && response.data === undefined) {
131+
return (
132+
<div className={styles.requestState} id={props.id} role='alert'>
133+
<span>AI review details are unavailable.</span>
134+
<button onClick={retry} type='button'>Try again</button>
135+
</div>
136+
)
137+
}
138+
139+
if (!response.data) {
140+
return <p className={styles.requestState} id={props.id} role='status'>Loading AI review details…</p>
141+
}
142+
143+
if (!response.data.length) {
144+
return <p className={styles.requestState} id={props.id}>No AI review details are available yet.</p>
145+
}
146+
147+
return (
148+
<div className={styles.tableWrap} id={props.id}>
149+
<table aria-label={`AI review details for submission ${props.submissionId}`}>
150+
<thead>
151+
<tr>
152+
<th>AI Reviewer</th>
153+
<th>Review Date</th>
154+
<th>Score</th>
155+
<th>Result</th>
156+
</tr>
157+
</thead>
158+
<tbody>
159+
{response.data.map(run => {
160+
const successful = workflowRunStatus(run) === 'SUCCESS'
161+
const workflowId = run.workflowId ?? run.workflow?.id
162+
const result = submissionAiWorkflowRunResult(run)
163+
const score = successful && run.score !== null && run.score !== undefined
164+
? String(run.score)
165+
: '-'
166+
return (
167+
<tr key={run.id}>
168+
<td data-mobile-label='AI Reviewer'>
169+
{run.workflow?.name ?? 'AI review workflow'}
170+
</td>
171+
<td data-mobile-label='Review Date'>
172+
{successful ? workflowReviewDate(run.completedAt) : '-'}
173+
</td>
174+
<td data-mobile-label='Score'>
175+
{successful && workflowId ? (
176+
<a
177+
href={submissionAiReviewAppUrl(
178+
props.challengeId,
179+
props.submissionId,
180+
workflowId,
181+
)}
182+
rel='noreferrer'
183+
target='_blank'
184+
>
185+
{score}
186+
</a>
187+
) : score}
188+
</td>
189+
<td data-mobile-label='Result'>
190+
<span className={`${styles.result} ${styles[result.kind]}`}>
191+
{result.label}
192+
</span>
193+
</td>
194+
</tr>
195+
)
196+
})}
197+
</tbody>
198+
</table>
199+
</div>
200+
)
201+
}

src/apps/opportunities/src/models/opportunity.models.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,11 @@ export interface ChallengeAiReviewConfig {
114114
mode: ChallengeAiReviewMode
115115
}
116116

117+
/** Challenge API reviewer assignment used to identify AI-reviewed challenges. */
118+
export interface ChallengeReviewer {
119+
aiWorkflowId?: string
120+
}
121+
117122
export interface ChallengeOpportunity {
118123
attachments?: ChallengeAttachment[]
119124
currentPhase?: ChallengePhase
@@ -139,6 +144,7 @@ export interface ChallengeOpportunity {
139144
prizeSets?: ChallengePrizeSet[]
140145
projectId?: string
141146
registrationEndDate?: string
147+
reviewers?: ChallengeReviewer[]
142148
skills?: OpportunitySkill[]
143149
startDate?: string
144150
status?: string
@@ -406,6 +412,23 @@ export interface ChallengeSubmission {
406412
virusScan?: boolean
407413
}
408414

415+
/** Review API workflow-run projection displayed beneath a member submission. */
416+
export interface ChallengeSubmissionAiWorkflowRun {
417+
completedAt?: string
418+
id: string
419+
score?: number | string | null
420+
status?: string
421+
submissionId?: string
422+
workflow?: {
423+
id?: string
424+
name?: string
425+
scorecard?: {
426+
minimumPassingScore?: number | string | null
427+
}
428+
}
429+
workflowId?: string
430+
}
431+
409432
/** Submission categories accepted by the v6 Review API upload endpoint. */
410433
export type ChallengeSubmissionType =
411434
| 'CONTEST_SUBMISSION'

0 commit comments

Comments
 (0)