This commit is contained in:
Daniel Noulet 2026-09-19 20:56:27 +02:00
parent 6ffc0d2c99
commit 6153017b62
22 changed files with 835 additions and 100 deletions

View file

@ -11,6 +11,7 @@ const question = {
type: 'open' as const,
options: [],
referenceAnswer: 'Use timeouts, retries, and idempotency.',
codeSnippet: '',
deprecated: false,
}
@ -81,7 +82,7 @@ function mockApi() {
participantEmail: 'amelie@example.com',
token: 'invite-token',
status: 'pending',
participantUrl: 'https://example.test/interview/invite-token',
participantUrl: 'https://example.test/participant/invite-token',
acceptedAt: null,
completedAt: null,
createdAt: '2026-09-19T10:00:00Z',
@ -95,7 +96,9 @@ function mockApi() {
return response({
...question,
text: 'Updated resilience question',
type: 'code_review',
referenceAnswer: 'Prefer bounded retries with jitter.',
codeSnippet: '+ return retry(request)',
})
if (path === '/api/jobs/7/questions/11') return response({}, method === 'DELETE' ? 200 : 201)
throw new Error(`Unexpected request: ${path}`)
@ -175,6 +178,7 @@ describe('Go Guess frontend', () => {
expect(screen.queryByRole('button', { name: 'Remove option Retries' })).not.toBeInTheDocument()
await user.selectOptions(screen.getByLabelText('Response type'), 'code_review')
expect(screen.getByLabelText('Expected answer')).toBeInTheDocument()
expect(screen.getByLabelText('Code snippet')).toBeRequired()
})
it('surfaces API failures instead of demo content', async () => {
@ -218,6 +222,7 @@ describe('Go Guess frontend', () => {
type: 'open',
options: [],
referenceAnswer: 'Mention ownership and closure.',
codeSnippet: '',
}),
}),
)
@ -231,28 +236,52 @@ describe('Go Guess frontend', () => {
it('populates and persists the reference answer while editing', async () => {
authenticate()
const user = userEvent.setup()
const codeReviewQuestion = {
...question,
id: 21,
type: 'code_review' as const,
text: 'Review this retry change',
codeSnippet: '+ return retry(request)',
}
const defaultFetch = vi.mocked(fetch).getMockImplementation()
vi.mocked(fetch).mockImplementation((input: RequestInfo | URL, options?: RequestInit) => {
const path = String(input)
const method = options?.method ?? 'GET'
if (path.startsWith('/api/questions') && method === 'GET')
return response([codeReviewQuestion])
if (path === '/api/questions/21' && method === 'PUT')
return response({
...codeReviewQuestion,
text: 'Updated resilience question',
referenceAnswer: 'Prefer bounded retries with jitter.',
})
if (!defaultFetch) throw new Error(`Unexpected request: ${path}`)
return defaultFetch(input, options)
})
renderApp('/questions')
await user.click(await screen.findByRole('button', { name: `Edit ${question.text}` }))
await user.click(await screen.findByRole('button', { name: `Edit ${codeReviewQuestion.text}` }))
const prompt = screen.getByLabelText('Question prompt')
await user.clear(prompt)
await user.type(prompt, 'Updated resilience question')
const reference = screen.getByLabelText('Reference answer')
expect(reference).toHaveValue(question.referenceAnswer)
const reference = screen.getByLabelText('Expected answer')
expect(reference).toHaveValue(codeReviewQuestion.referenceAnswer)
expect(screen.getByLabelText('Code snippet')).toHaveValue(codeReviewQuestion.codeSnippet)
await user.clear(reference)
await user.type(reference, 'Prefer bounded retries with jitter.')
await user.click(screen.getByRole('button', { name: 'Save changes' }))
expect(fetch).toHaveBeenCalledWith(
'/api/questions/11',
'/api/questions/21',
expect.objectContaining({
method: 'PUT',
body: JSON.stringify({
text: 'Updated resilience question',
type: 'open',
type: 'code_review',
options: [],
referenceAnswer: 'Prefer bounded retries with jitter.',
codeSnippet: '+ return retry(request)',
}),
}),
)
@ -281,17 +310,16 @@ describe('Go Guess frontend', () => {
)
})
it('sends invitations and displays the generated participant URL', async () => {
it('generates an invitation for an eligible participant and displays its URL', async () => {
authenticate()
const user = userEvent.setup()
renderApp('/jobs/7')
await user.click(await screen.findByRole('tab', { name: /invitations/i }))
await user.selectOptions(screen.getByLabelText('Eligible candidate'), '3')
await user.click(screen.getByRole('button', { name: /send invitation/i }))
await user.click(screen.getByRole('button', { name: 'Generate invitation' }))
expect(
await screen.findByText('https://example.test/interview/invite-token'),
await screen.findByText('https://example.test/participant/invite-token'),
).toBeInTheDocument()
expect(fetch).toHaveBeenCalledWith(
'/api/jobs/7/invitations',
@ -315,7 +343,7 @@ describe('Go Guess frontend', () => {
participantEmail: 'amelie@example.com',
token: 'invite-token',
status: 'pending',
participantUrl: 'https://example.test/interview/invite-token',
participantUrl: 'https://example.test/participant/invite-token',
acceptedAt: null,
completedAt: null,
createdAt: now.toISOString(),
@ -355,17 +383,25 @@ describe('Go Guess frontend', () => {
})
throw new Error(`Unexpected request: ${path}`)
})
renderApp('/interview/invite-token')
renderApp('/participant/invite-token')
await act(async () => {
await Promise.resolve()
await Promise.resolve()
})
expect(screen.queryByText(question.text)).not.toBeInTheDocument()
expect(screen.queryByRole('textbox', { name: 'Your answer' })).not.toBeInTheDocument()
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Accept and start' }))
fireEvent.click(screen.getByRole('button', { name: 'Accept' }))
})
expect(screen.getByText(question.text)).toBeInTheDocument()
expect(screen.queryByText(question.referenceAnswer)).not.toBeInTheDocument()
expect(screen.getByRole('progressbar', { name: 'Interview progress' })).toHaveAttribute(
'aria-valuenow',
'50',
)
for (const name of ['First', 'Previous', 'Next', 'Last', 'Submit']) {
expect(screen.getByRole('button', { name })).toBeInTheDocument()
}
expect(screen.getByTestId('countdown')).toHaveTextContent('10:00')
await act(async () => {
await vi.advanceTimersByTimeAsync(1000)
@ -390,6 +426,137 @@ describe('Go Guess frontend', () => {
fireEvent.click(screen.getByRole('button', { name: 'First' }))
})
expect(screen.getByText(question.text)).toBeInTheDocument()
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Submit' }))
})
expect(fetch).toHaveBeenCalledWith(
'/api/interviews/invite-token/finish',
expect.objectContaining({ method: 'POST' }),
)
expect(screen.getByRole('heading', { name: /thank you/i })).toBeInTheDocument()
vi.useRealTimers()
})
it('renders protected invitation answers for interviewer review', async () => {
authenticate()
const user = userEvent.setup()
const acceptedInvitation = {
id: 44,
jobId: 7,
participantId: 3,
participantName: 'Amélie Dubois',
participantEmail: 'amelie@example.com',
token: 'review-token',
status: 'accepted',
participantUrl: 'https://example.test/participant/review-token',
acceptedAt: '2026-09-19T10:05:00Z',
completedAt: null,
createdAt: '2026-09-19T10:00:00Z',
}
const choiceQuestion = {
...question,
id: 22,
text: 'Choose a strategy',
type: 'radio' as const,
options: ['Retry', 'Fail fast'],
referenceAnswer: '',
}
const codeQuestion = {
...question,
id: 23,
text: 'Review the patch',
type: 'code_review' as const,
codeSnippet: '+ return retry(request)',
referenceAnswer: 'Bound the retry count.',
}
const defaultFetch = vi.mocked(fetch).getMockImplementation()
vi.mocked(fetch).mockImplementation((input: RequestInfo | URL, options?: RequestInit) => {
const path = String(input)
if (path === '/api/jobs/7/invitations' && !options?.method)
return response([acceptedInvitation])
if (path === '/api/jobs/7/invitations/44')
return response({
invitation: acceptedInvitation,
job: {
id: 7,
title: job.title,
description: job.description,
position: job.position,
seniority: job.seniority,
durationMinutes: job.durationMinutes,
questions: [question, choiceQuestion, codeQuestion],
},
answers: {
'11': 'Use exponential backoff.',
'23': 'The retry loop needs a maximum.',
},
})
if (!defaultFetch) throw new Error(`Unexpected request: ${path}`)
return defaultFetch(input, options)
})
renderApp('/jobs/7')
await user.click(await screen.findByRole('tab', { name: /invitations/i }))
await user.click(screen.getByRole('button', { name: 'Review answers' }))
expect(await screen.findByRole('heading', { name: 'Amélie Dubois' })).toBeInTheDocument()
expect(screen.getByText('Use exponential backoff.')).toBeInTheDocument()
expect(screen.getByText(question.referenceAnswer)).toBeInTheDocument()
expect(screen.getByText('Retry · Fail fast')).toBeInTheDocument()
expect(screen.getByText('Unanswered')).toBeInTheDocument()
expect(screen.getByText('The retry loop needs a maximum.')).toBeInTheDocument()
expect(screen.getByRole('region', { name: 'Code changes' })).toBeInTheDocument()
expect(fetch).toHaveBeenCalledWith('/api/jobs/7/invitations/44', expect.any(Object))
})
it('renders code review lines and a review comment without exposing the reference answer', async () => {
const now = new Date()
const codeQuestion = {
...question,
id: 20,
type: 'code_review' as const,
text: 'Review this change',
codeSnippet: ['func total() int {', '+ return 42', '}'].join('\n'),
referenceAnswer: 'The hard-coded value should be replaced.',
}
vi.mocked(fetch).mockImplementation((input: RequestInfo | URL) => {
const path = String(input)
if (path === '/api/interviews/code-token')
return response({
invitation: {
id: 32,
jobId: 7,
participantId: 3,
participantName: 'Amélie Dubois',
participantEmail: 'amelie@example.com',
token: 'code-token',
status: 'accepted',
participantUrl: 'https://example.test/participant/code-token',
acceptedAt: now.toISOString(),
completedAt: null,
createdAt: now.toISOString(),
},
job: {
id: 7,
title: job.title,
description: job.description,
position: job.position,
seniority: job.seniority,
durationMinutes: 10,
questions: [codeQuestion],
},
answers: {},
})
throw new Error(`Unexpected request: ${path}`)
})
const { container } = renderApp('/participant/code-token')
expect(await screen.findByRole('region', { name: 'Code changes' })).toBeInTheDocument()
expect(container.querySelectorAll('.code-line')).toHaveLength(3)
expect(container.querySelector('.code-line.added code')?.textContent).toBe('+ return 42')
expect(screen.getByRole('textbox', { name: 'Review comment' })).toBeInTheDocument()
expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '100')
expect(screen.queryByText(codeQuestion.referenceAnswer)).not.toBeInTheDocument()
})
})

View file

@ -759,24 +759,22 @@ function InvitationsView({
invitations: Invitation[]
onInvited: (invitation: Invitation) => void
}) {
const [participantId, setParticipantId] = useState('')
const [sending, setSending] = useState(false)
const [sendingId, setSendingId] = useState<number>()
const [review, setReview] = useState<Interview>()
const [reviewingId, setReviewingId] = useState<number>()
const [error, setError] = useState('')
const invitedIds = new Set(invitations.map((invitation) => invitation.participantId))
const eligible = matches.filter((match) => !invitedIds.has(match.participant.id))
async function send(event: FormEvent) {
event.preventDefault()
if (!participantId) return
setSending(true)
async function send(participantId: number) {
setSendingId(participantId)
setError('')
try {
onInvited(await api.jobs.invite(job.id, Number(participantId)))
setParticipantId('')
onInvited(await api.jobs.invite(job.id, participantId))
} catch (requestError) {
setError(errorMessage(requestError))
} finally {
setSending(false)
setSendingId(undefined)
}
}
@ -788,6 +786,18 @@ function InvitationsView({
}
}
async function reviewAnswers(invitationId: number) {
setReviewingId(invitationId)
setError('')
try {
setReview(await api.jobs.invitation(job.id, invitationId))
} catch (requestError) {
setError(errorMessage(requestError))
} finally {
setReviewingId(undefined)
}
}
return (
<section className="card">
<div className="section-heading">
@ -796,26 +806,29 @@ function InvitationsView({
<p>Invite an eligible candidate and share their private participant URL.</p>
</div>
</div>
<form className="invite-form" onSubmit={send}>
<FormField label="Eligible candidate" htmlFor="eligible-candidate">
<select
id="eligible-candidate"
value={participantId}
onChange={(event) => setParticipantId(event.target.value)}
required
>
<option value="">Select a candidate</option>
{eligible.map(({ participant }) => (
<option value={participant.id} key={participant.id}>
{participant.firstName} {participant.lastName} — {participant.email}
</option>
))}
</select>
</FormField>
<button className="button primary" type="submit" disabled={sending || !participantId}>
<Send size={16} /> {sending ? 'Sending…' : 'Send invitation'}
</button>
</form>
<div className="eligible-list">
{eligible.map(({ participant }) => (
<article className="eligible-row" key={participant.id}>
<Avatar person={participant} />
<div>
<strong>
{participant.firstName} {participant.lastName}
</strong>
<small>{participant.email}</small>
</div>
<button
className="button primary"
type="button"
disabled={sendingId === participant.id}
onClick={() => void send(participant.id)}
>
<Send size={16} />{' '}
{sendingId === participant.id ? 'Generating…' : 'Generate invitation'}
</button>
</article>
))}
{!eligible.length && <p className="muted">All eligible candidates have been invited.</p>}
</div>
{error && <ErrorAlert message={error} />}
<div className="invitation-list">
{invitations.map((invitation) => (
@ -834,12 +847,84 @@ function InvitationsView({
>
<Copy size={16} /> Copy URL
</button>
{['accepted', 'completed'].includes(invitation.status) && (
<button
className="button secondary"
type="button"
disabled={reviewingId === invitation.id}
onClick={() => void reviewAnswers(invitation.id)}
>
{reviewingId === invitation.id ? 'Loading…' : 'Review answers'}
</button>
)}
</article>
))}
{!invitations.length && (
<EmptyState title="No invitations" description="Invite a candidate to this interview." />
)}
</div>
{review && <InvitationAnswerReview interview={review} onClose={() => setReview(undefined)} />}
</section>
)
}
function InvitationAnswerReview({
interview,
onClose,
}: {
interview: Interview
onClose: () => void
}) {
return (
<section className="answer-review" aria-labelledby="answer-review-title">
<div className="section-heading">
<div>
<span className="eyebrow">Participant submission</span>
<h2 id="answer-review-title">{interview.invitation.participantName}</h2>
<p>{interview.invitation.participantEmail}</p>
</div>
<div className="review-heading-actions">
<StatusBadge value={interview.invitation.status} />
<button className="button ghost" type="button" onClick={onClose}>
Close review
</button>
</div>
</div>
<div className="review-question-list">
{interview.job.questions.map((question, index) => {
const answer = interview.answers[String(question.id)]?.trim()
return (
<article className="review-question" key={question.id}>
<span className="question-number">{index + 1}</span>
<div>
<span className="question-type">{question.type.replace('_', ' ')}</span>
<h3>{question.text}</h3>
{question.type === 'code_review' && question.codeSnippet && (
<CodePanel codeSnippet={question.codeSnippet} />
)}
{(question.type === 'multiple_choice' || question.type === 'radio') &&
question.options.length > 0 && (
<div className="review-options">
<strong>Possible options</strong>
<span>{question.options.join(' · ')}</span>
</div>
)}
{(question.type === 'open' || question.type === 'code_review') &&
question.referenceAnswer && (
<div className="reference-answer">
<strong>Reference answer</strong>
<p>{question.referenceAnswer}</p>
</div>
)}
<div className={answer ? 'participant-answer' : 'participant-answer unanswered'}>
<strong>Participant answer</strong>
<p>{answer || 'Unanswered'}</p>
</div>
</div>
</article>
)
})}
</div>
</section>
)
}
@ -863,6 +948,11 @@ function QuestionRow({
</div>
<p>{question.text}</p>
{question.options.length > 0 && <small>{question.options.join(' · ')}</small>}
{question.type === 'code_review' && question.codeSnippet && (
<pre className="interviewer-code-snippet">
<code>{question.codeSnippet}</code>
</pre>
)}
{question.referenceAnswer && (
<div className="reference-answer">
<strong>Reference answer</strong>
@ -1066,6 +1156,9 @@ function QuestionForm({
: [],
)
const [referenceAnswer, setReferenceAnswer] = useState(initialQuestion?.referenceAnswer ?? '')
const [codeSnippet, setCodeSnippet] = useState(
initialQuestion?.type === 'code_review' ? initialQuestion.codeSnippet : '',
)
const [optionDraft, setOptionDraft] = useState('')
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
@ -1080,6 +1173,7 @@ function QuestionForm({
} else {
setReferenceAnswer('')
}
if (nextType !== 'code_review') setCodeSnippet('')
}
function addOption() {
@ -1104,6 +1198,7 @@ function QuestionForm({
type,
options: choiceType ? options : [],
referenceAnswer: choiceType ? '' : referenceAnswer.trim(),
codeSnippet: type === 'code_review' ? codeSnippet : '',
})
if (!initialQuestion) {
setText('')
@ -1111,6 +1206,7 @@ function QuestionForm({
setOptions([])
setOptionDraft('')
setReferenceAnswer('')
setCodeSnippet('')
}
} catch (requestError) {
setError(errorMessage(requestError))
@ -1145,6 +1241,17 @@ function QuestionForm({
<option value="code_review">Code review</option>
</select>
</FormField>
{type === 'code_review' && (
<FormField label="Code snippet" htmlFor="question-form-code-snippet">
<textarea
id="question-form-code-snippet"
rows={12}
value={codeSnippet}
onChange={(event) => setCodeSnippet(event.target.value)}
required
/>
</FormField>
)}
{!choiceType && (
<FormField
label={type === 'code_review' ? 'Expected answer' : 'Reference answer'}
@ -1727,8 +1834,57 @@ function EmptyState({ title, description }: { title: string; description: string
)
}
function CodePanel({ codeSnippet }: { codeSnippet: string }) {
return (
<section className="code-review-panel" aria-label="Code changes">
<header>
<FileText size={17} />
<strong>Code changes</strong>
</header>
<div className="code-lines">
{codeSnippet.split('\n').map((line, index) => {
const added = line.startsWith('+') && !line.startsWith('+++')
return (
<div className={added ? 'code-line added' : 'code-line'} key={index}>
<span className="line-number" aria-hidden="true">
{index + 1}
</span>
<code>{line || ' '}</code>
</div>
)
})}
</div>
</section>
)
}
function CodeReviewAnswer({
codeSnippet,
answer,
onChange,
}: {
codeSnippet: string
answer: string
onChange: (answer: string) => void
}) {
return (
<div className="code-review-answer">
<CodePanel codeSnippet={codeSnippet} />
<FormField label="Review comment" htmlFor="review-comment">
<textarea
id="review-comment"
rows={7}
value={answer}
onChange={(event) => onChange(event.target.value)}
placeholder="Describe issues, suggestions, and approval notes…"
/>
</FormField>
</div>
)
}
function ParticipantInterviewPage() {
const token = useParams().token ?? ''
const token = useParams().invitationId ?? ''
const [interview, setInterview] = useState<Interview>()
const [answers, setAnswers] = useState<Record<string, string>>({})
const [index, setIndex] = useState(0)
@ -1737,6 +1893,7 @@ function ParticipantInterviewPage() {
const [working, setWorking] = useState(false)
const [error, setError] = useState('')
const finishing = useRef(false)
const saveQueues = useRef<Record<string, Promise<void>>>({})
useEffect(() => {
let active = true
@ -1788,13 +1945,21 @@ function ParticipantInterviewPage() {
const questions = interview?.job.questions ?? []
const currentQuestion = questions[index]
function updateAnswer(questionId: number, answer: string) {
const key = String(questionId)
setAnswers((current) => ({ ...current, [key]: answer }))
setError('')
const previous = saveQueues.current[key] ?? Promise.resolve()
saveQueues.current[key] = previous
.then(() => api.interviews.answer(token, questionId, answer))
.catch((requestError) => {
setError(errorMessage(requestError))
})
}
async function persistCurrent() {
if (!currentQuestion) return
await api.interviews.answer(
token,
currentQuestion.id,
answers[String(currentQuestion.id)] ?? '',
)
await saveQueues.current[String(currentQuestion.id)]
}
async function move(target: number) {
@ -1868,6 +2033,9 @@ function ParticipantInterviewPage() {
<h1>Welcome, {interview.invitation.participantName}</h1>
<h2>{interview.job.title}</h2>
<p>{interview.job.description}</p>
<p className="agreement-copy">
By accepting, you agree to begin the timed interview and submit your responses.
</p>
<div className="welcome-facts">
<span>
<Clock3 size={18} /> {interview.job.durationMinutes} minutes
@ -1878,7 +2046,7 @@ function ParticipantInterviewPage() {
</div>
{error && <ErrorAlert message={error} />}
<button className="button primary" type="button" disabled={working} onClick={accept}>
{working ? 'Starting…' : 'Accept and start'}
{working ? 'Accepting…' : 'Accept'}
<ArrowRight size={18} />
</button>
</section>
@ -1946,12 +2114,7 @@ function ParticipantInterviewPage() {
type="radio"
name={`question-${currentQuestion.id}`}
checked={answer === option}
onChange={() =>
setAnswers((current) => ({
...current,
[String(currentQuestion.id)]: option,
}))
}
onChange={() => updateAnswer(currentQuestion.id, option)}
/>
{option}
</label>
@ -1969,29 +2132,27 @@ function ParticipantInterviewPage() {
const next = event.target.checked
? [...selectedOptions, option]
: selectedOptions.filter((item) => item !== option)
setAnswers((current) => ({
...current,
[String(currentQuestion.id)]: next.join('\n'),
}))
updateAnswer(currentQuestion.id, next.join('\n'))
}}
/>
{option}
</label>
))}
</fieldset>
) : currentQuestion.type === 'code_review' ? (
<CodeReviewAnswer
codeSnippet={currentQuestion.codeSnippet}
answer={answer}
onChange={(value) => updateAnswer(currentQuestion.id, value)}
/>
) : (
<label>
<span className="sr-only">Your answer</span>
<textarea
aria-label="Your answer"
rows={currentQuestion.type === 'code_review' ? 12 : 7}
rows={7}
value={answer}
onChange={(event) =>
setAnswers((current) => ({
...current,
[String(currentQuestion.id)]: event.target.value,
}))
}
onChange={(event) => updateAnswer(currentQuestion.id, event.target.value)}
placeholder="Type your answer…"
/>
</label>
@ -2037,7 +2198,7 @@ function ParticipantInterviewPage() {
disabled={working}
onClick={() => void finish()}
>
Finish
Submit
</button>
</footer>
</section>
@ -2083,10 +2244,10 @@ export default function App() {
authStorage.clear()
setAuthenticated(false)
}
if (location.pathname.startsWith('/interview/')) {
if (location.pathname.startsWith('/participant/')) {
return (
<Routes>
<Route path="/interview/:token" element={<ParticipantInterviewPage />} />
<Route path="/participant/:invitationId" element={<ParticipantInterviewPage />} />
<Route path="*" element={<NotFound />} />
</Routes>
)

View file

@ -117,6 +117,8 @@ export const api = {
request<void>(`/jobs/${jobId}/questions/${questionId}`, { method: 'DELETE' }),
candidates: (jobId: number) => request<CandidateMatch[]>(`/jobs/${jobId}/candidates`),
invitations: (jobId: number) => request<Invitation[]>(`/jobs/${jobId}/invitations`),
invitation: (jobId: number, invitationId: number) =>
request<Interview>(`/jobs/${jobId}/invitations/${invitationId}`),
invite: (jobId: number, participantId: number) =>
request<Invitation>(`/jobs/${jobId}/invitations`, {
method: 'POST',

View file

@ -19,6 +19,12 @@
--radius: 16px;
--shadow: 0 12px 35px rgba(14, 42, 56, 0.08);
}
.eligible-row {
grid-template-columns: auto 1fr;
}
.eligible-row .button {
grid-column: 1 / 3;
}
* {
box-sizing: border-box;
@ -853,12 +859,35 @@ td small {
flex: 1;
margin: 0;
}
.eligible-list {
display: grid;
gap: 0.75rem;
padding: 1.2rem 1.5rem;
border-bottom: 1px solid var(--line);
}
.eligible-row {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 0.8rem;
padding: 0.85rem;
border: 1px solid var(--line);
border-radius: 11px;
background: #f8fbfb;
}
.eligible-row > div {
display: flex;
flex-direction: column;
}
.eligible-row small {
color: var(--muted);
}
.invitation-list {
padding: 0 1.5rem;
}
.invitation-row {
display: grid;
grid-template-columns: minmax(180px, 1fr) auto minmax(220px, 1.5fr) auto;
grid-template-columns: minmax(180px, 1fr) auto minmax(220px, 1.5fr) auto auto;
gap: 1rem;
align-items: center;
padding: 1rem 0;
@ -877,6 +906,51 @@ td small {
text-overflow: ellipsis;
white-space: nowrap;
}
.answer-review {
border-top: 1px solid var(--line);
}
.review-heading-actions {
display: flex;
align-items: center;
gap: 0.65rem;
}
.review-question-list {
padding: 0 1.5rem 1.5rem;
}
.review-question {
display: grid;
grid-template-columns: 34px minmax(0, 1fr);
gap: 1rem;
padding: 1.4rem 0;
border-bottom: 1px solid var(--line);
}
.review-question h3 {
margin: 0.35rem 0 1rem;
}
.review-options,
.participant-answer {
margin-top: 0.8rem;
padding: 0.75rem;
border-radius: 8px;
background: #f5f8f9;
}
.review-options strong,
.participant-answer strong {
display: block;
margin-bottom: 0.3rem;
color: var(--muted);
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.participant-answer p {
margin: 0;
white-space: pre-wrap;
}
.participant-answer.unanswered {
color: #934141;
background: #fff0f0;
}
.public-interview {
min-height: 100vh;
@ -914,6 +988,11 @@ td small {
color: #526872;
margin-bottom: 2rem;
}
.agreement-copy {
padding: 0.85rem 1rem;
border-radius: 9px;
background: #f5f8f9;
}
.welcome-facts span,
.timer {
display: inline-flex;
@ -991,6 +1070,55 @@ td small {
.participant-options input {
width: auto;
}
.code-review-answer {
display: grid;
gap: 1.4rem;
}
.code-review-panel {
overflow: hidden;
border: 1px solid #b9c8cf;
border-radius: 10px;
background: #ffffff;
}
.code-review-panel > header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.7rem 0.9rem;
border-bottom: 1px solid #c9d5da;
background: #f6f8fa;
}
.code-lines {
overflow-x: auto;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 0.82rem;
}
.code-line {
display: grid;
grid-template-columns: 52px minmax(max-content, 1fr);
min-height: 24px;
line-height: 24px;
background: #ffffff;
}
.code-line.added {
background: #dafbe1;
}
.line-number {
padding: 0 0.75rem;
border-right: 1px solid #d8dee4;
color: #6e7781;
background: #f6f8fa;
text-align: right;
user-select: none;
}
.code-line.added .line-number {
background: #ccffd8;
}
.code-line code {
padding: 0 0.8rem;
color: #24292f;
white-space: pre;
}
.interview-controls {
display: flex;
justify-content: flex-end;
@ -1039,6 +1167,17 @@ td small {
white-space: pre-wrap;
margin: 0;
}
.interviewer-code-snippet {
overflow-x: auto;
margin: 0.75rem 0 0;
padding: 0.8rem;
border: 1px solid var(--line);
border-radius: 8px;
color: #dbe8ed;
background: #102633;
font-size: 0.78rem;
white-space: pre;
}
.ai-label {
display: inline-flex;
align-items: center;
@ -1654,6 +1793,10 @@ fieldset {
flex-direction: column;
gap: 0.7rem;
}
.review-heading-actions {
align-items: flex-start;
flex-direction: column;
}
}
@media (prefers-reduced-motion: reduce) {

View file

@ -17,6 +17,7 @@ export interface Question {
type: QuestionType
options: string[]
referenceAnswer: string
codeSnippet: string
deprecated: boolean
}
@ -85,7 +86,10 @@ export type CreateJobInput = Omit<
Job,
'id' | 'questions' | 'createdAt' | 'status' | 'durationMinutes'
>
export type CreateQuestionInput = Pick<Question, 'text' | 'type' | 'options' | 'referenceAnswer'>
export type CreateQuestionInput = Pick<
Question,
'text' | 'type' | 'options' | 'referenceAnswer' | 'codeSnippet'
>
export interface CreateParticipantInput {
firstName: string
lastName: string