feat: add collaborative interview scheduling

Add candidate decisions, co-interviewer accounts, scheduled interview inbox and calendar views, candidate-safe meeting links, shared documents, and private live notes backed by RabbitMQ.

Extend the disposable integration environment to validate PostgreSQL migrations, seeded users, interview lifecycle behavior, and RabbitMQ fanout.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Daniel Noulet 2026-09-19 21:58:24 +02:00
parent fd06fbbb5d
commit 75e3c73a61
35 changed files with 3621 additions and 58 deletions

View file

@ -37,6 +37,8 @@ Supported routes:
- `GET /api/jobs/:id/candidates`
- `GET|POST /api/jobs/:id/invitations`
- `GET /api/jobs/:jobId/invitations/:invitationId` for protected answer review
- `PATCH /api/jobs/:jobId/invitations/:invitationId/outcome`
- `GET|POST /api/jobs/:jobId/interviews`
- `GET|POST /api/questions` (`GET` accepts `?search=`)
- `PUT /api/questions/:id` to update question text, type, options, reference answer, and code snippet
- `PATCH /api/questions/:id` to deprecate or restore a question
@ -48,6 +50,15 @@ Supported routes:
- `POST /api/interviews/:token/accept`
- `PUT /api/interviews/:token/answers/:questionId`
- `POST /api/interviews/:token/finish`
- `GET|POST /api/users`
- `GET /api/inbox` and `PATCH /api/inbox/:interviewId`
- `GET /api/calendar`
- `GET /api/scheduled-interviews/:id`
- `PATCH /api/scheduled-interviews/:id/status`
- `PATCH /api/scheduled-interviews/:id/document`
- `GET|POST /api/scheduled-interviews/:id/notes`
- `GET /api/scheduled-interviews/:id/events` (authenticated fetch-based SSE)
- `GET /api/participant-meetings/:token` (public)
Participant creation uses multipart fields `firstName`, `lastName`, `birthday`, `email`,
`contactInfo`, `photo`, and `cv`.
@ -64,6 +75,18 @@ rendered in the participant interview. Code-review questions also require a code
Accepted and completed invitations can be opened from the job to review every participant answer,
possible choice options, reference answers, and code-review context; unanswered questions are
called out explicitly.
Completed assessments can be marked as passed or failed. Passed candidates expose a scheduling
form for the date, location, co-interviewers, and candidate-visible shared documentation.
The authenticated workspace also includes:
- **Users** for listing and creating co-interviewers.
- **Inbox** for accepting or declining interview assignments.
- **Calendar** for interviews grouped by date.
- **Interviewer sessions** for changing status, editing prominent shared documentation, and
posting private notes while an interview is started. Notes arrive live through an authenticated
streaming `fetch`; the app intentionally does not use `EventSource`, because the request needs
the JWT authorization header.
Participants open the generated `/participant/:invitationId` URL without signing in. The opaque
invitation ID authorizes the existing `/api/interviews/:token` API. Code-review questions are shown
@ -72,6 +95,10 @@ between questions and as they change, then **Submit** completes the interview. T
derived from the backend `acceptedAt` timestamp plus the job duration so refreshing cannot reset
it. Questions stay hidden until the participant explicitly accepts the invitation.
Scheduled candidates use `/participant/meeting/:token`. This public view shows the interview date,
location, status, and read-only shared documentation. It never renders interviewer notes or
assessment reference answers.
## Container
Build with `docker build -t go-guess-frontend .`. The production Nginx server serves the SPA and

View file

@ -58,7 +58,7 @@ function mockApi() {
if (path === '/api/auth/login')
return response({
token: 'jwt-token',
user: { id: 1, email: 'admin@example.com', role: 'admin' },
user: { id: 1, email: 'admin@example.com', displayName: 'Admin', role: 'admin' },
})
if (path === '/api/jobs') return response([job])
if (path === '/api/participants') return response([participant])
@ -73,6 +73,7 @@ function mockApi() {
},
])
if (path === '/api/jobs/7/invitations' && method === 'GET') return response([])
if (path === '/api/jobs/7/interviews' && method === 'GET') return response([])
if (path === '/api/jobs/7/invitations' && method === 'POST')
return response({
id: 31,
@ -110,7 +111,12 @@ function authenticate() {
sessionStorage.setItem('go-guess-token', 'jwt-token')
sessionStorage.setItem(
'go-guess-user',
JSON.stringify({ id: 1, email: 'admin@example.com', role: 'admin' }),
JSON.stringify({
id: 1,
email: 'admin@example.com',
displayName: 'Admin',
role: 'admin',
}),
)
}
@ -570,4 +576,331 @@ describe('Go Guess frontend', () => {
expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '100')
expect(screen.queryByText(codeQuestion.referenceAnswer)).not.toBeInTheDocument()
})
it('records pass and fail decisions from a completed answer review', async () => {
authenticate()
const user = userEvent.setup()
const completed = {
id: 44,
jobId: 7,
participantId: 3,
participantName: 'Amélie Dubois',
participantEmail: 'amelie@example.com',
token: 'review-token',
status: 'completed',
outcome: 'pending',
participantUrl: '/participant/review-token',
acceptedAt: '2026-09-19T10:05:00Z',
completedAt: '2026-09-19T10:15:00Z',
createdAt: '2026-09-19T10:00:00Z',
}
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 === '/api/jobs/7/invitations' && method === 'GET') return response([completed])
if (path === '/api/jobs/7/invitations/44' && method === 'GET')
return response({
invitation: completed,
job: { ...job, questions: [question] },
answers: { '11': 'Retries with jitter.' },
})
if (path === '/api/jobs/7/invitations/44/outcome' && method === 'PATCH')
return response({
...completed,
outcome: JSON.parse(String(options?.body)).outcome,
})
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' }))
await user.click(await screen.findByRole('button', { name: 'Pass candidate' }))
await user.click(screen.getByRole('button', { name: 'Fail candidate' }))
expect(fetch).toHaveBeenCalledWith(
'/api/jobs/7/invitations/44/outcome',
expect.objectContaining({ method: 'PATCH', body: JSON.stringify({ outcome: 'passed' }) }),
)
expect(fetch).toHaveBeenCalledWith(
'/api/jobs/7/invitations/44/outcome',
expect.objectContaining({ method: 'PATCH', body: JSON.stringify({ outcome: 'failed' }) }),
)
})
it('creates an interview from a passed invitation with co-interviewers and shared documentation', async () => {
authenticate()
const user = userEvent.setup()
const passed = {
id: 45,
jobId: 7,
participantId: 3,
participantName: 'Amélie Dubois',
participantEmail: 'amelie@example.com',
token: 'passed-token',
status: 'completed',
outcome: 'passed',
participantUrl: '/participant/passed-token',
acceptedAt: '2026-09-19T10:05:00Z',
completedAt: '2026-09-19T10:15:00Z',
createdAt: '2026-09-19T10:00:00Z',
}
const interviewer = {
id: 2,
email: 'alex@example.com',
displayName: 'Alex Morgan',
role: 'interviewer',
}
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 === '/api/jobs/7/invitations' && method === 'GET') return response([passed])
if (path === '/api/users') return response([interviewer])
if (path === '/api/jobs/7/interviews' && method === 'POST')
return response({
id: 80,
jobId: 7,
jobTitle: job.title,
participantId: 3,
participantName: 'Amélie Dubois',
startsAt: '2026-09-22T08:30:00.000Z',
location: 'Brussels',
status: 'scheduled',
candidateToken: 'meeting-token',
candidateUrl: '/participant/meeting/meeting-token',
sharedDocument: 'Read the architecture brief.',
attendees: [],
notes: [],
})
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: 'Schedule Amélie Dubois' }))
await user.type(screen.getByLabelText('Date and time'), '2026-09-22T10:30')
await user.type(screen.getByLabelText('Location or meeting link'), 'Brussels')
await user.click(await screen.findByLabelText(/Alex Morgan/))
await user.type(screen.getByLabelText('Shared documentation'), 'Read the architecture brief.')
await user.click(screen.getByRole('button', { name: 'Create interview' }))
expect(fetch).toHaveBeenCalledWith(
'/api/jobs/7/interviews',
expect.objectContaining({
method: 'POST',
body: expect.stringContaining('"interviewerIds":[2]'),
}),
)
expect(await screen.findByText(/Brussels/)).toBeInTheDocument()
expect(screen.getAllByText('Amélie Dubois')).not.toHaveLength(0)
})
it('lists users and creates a co-interviewer', async () => {
authenticate()
const user = userEvent.setup()
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 === '/api/users' && method === 'GET')
return response([
{ id: 1, email: 'admin@example.com', displayName: 'Admin User', role: 'admin' },
])
if (path === '/api/users' && method === 'POST')
return response(
{ id: 2, email: 'sam@example.com', displayName: 'Sam Lee', role: 'interviewer' },
201,
)
if (!defaultFetch) throw new Error(`Unexpected request: ${path}`)
return defaultFetch(input, options)
})
renderApp('/users')
expect(await screen.findByText('Admin User')).toBeInTheDocument()
await user.type(screen.getByLabelText('Display name'), 'Sam Lee')
await user.type(screen.getByLabelText('Email address'), 'sam@example.com')
await user.type(screen.getByLabelText('Temporary password'), 'password123')
await user.click(screen.getByRole('button', { name: 'Create co-interviewer' }))
expect(await screen.findByText('Sam Lee')).toBeInTheDocument()
expect(fetch).toHaveBeenCalledWith(
'/api/users',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
email: 'sam@example.com',
password: 'password123',
displayName: 'Sam Lee',
}),
}),
)
})
it('accepts and declines interviews from the inbox', async () => {
authenticate()
const user = userEvent.setup()
const scheduled = {
id: 80,
jobId: 7,
jobTitle: job.title,
participantId: 3,
participantName: 'Amélie Dubois',
startsAt: '2026-09-22T08:30:00Z',
location: 'Brussels',
status: 'pending',
candidateToken: 'meeting-token',
candidateUrl: '/participant/meeting/meeting-token',
sharedDocument: 'Agenda',
attendees: [],
notes: [],
}
const defaultFetch = vi.mocked(fetch).getMockImplementation()
vi.mocked(fetch).mockImplementation((input: RequestInfo | URL, options?: RequestInit) => {
const path = String(input)
if (path === '/api/inbox' && !options?.method) return response([scheduled])
if (path === '/api/inbox/80')
return response({ ...scheduled, status: JSON.parse(String(options?.body)).status })
if (!defaultFetch) throw new Error(`Unexpected request: ${path}`)
return defaultFetch(input, options)
})
renderApp('/inbox')
await user.click(await screen.findByRole('button', { name: 'Accept' }))
await user.click(screen.getByRole('button', { name: 'Decline' }))
expect(fetch).toHaveBeenCalledWith(
'/api/inbox/80',
expect.objectContaining({ method: 'PATCH', body: JSON.stringify({ status: 'accepted' }) }),
)
expect(screen.getByText('declined')).toBeInTheDocument()
})
it('groups calendar interviews by date and links to the session', async () => {
authenticate()
const defaultFetch = vi.mocked(fetch).getMockImplementation()
vi.mocked(fetch).mockImplementation((input: RequestInfo | URL, options?: RequestInit) => {
if (String(input) === '/api/calendar')
return response([
{
id: 80,
jobId: 7,
jobTitle: job.title,
participantId: 3,
participantName: 'Amélie Dubois',
startsAt: '2026-09-22T08:30:00Z',
location: 'Brussels',
status: 'scheduled',
candidateToken: 'meeting-token',
candidateUrl: '/participant/meeting/meeting-token',
sharedDocument: 'Agenda',
attendees: [],
notes: [],
},
])
if (!defaultFetch) throw new Error(`Unexpected request: ${String(input)}`)
return defaultFetch(input, options)
})
renderApp('/calendar')
expect(await screen.findByText('Amélie Dubois')).toBeInTheDocument()
expect(screen.getByRole('link', { name: 'Open session' })).toHaveAttribute(
'href',
'/scheduled-interviews/80',
)
expect(
screen.getByRole('heading', { level: 2, name: /September 22, 2026/ }),
).toBeInTheDocument()
})
it('edits shared documentation and displays notes received over authenticated fetch streaming', async () => {
authenticate()
const user = userEvent.setup()
const scheduled = {
id: 80,
jobId: 7,
jobTitle: job.title,
participantId: 3,
participantName: 'Amélie Dubois',
startsAt: '2026-09-22T08:30:00Z',
location: 'Brussels',
status: 'started',
candidateToken: 'meeting-token',
candidateUrl: '/participant/meeting/meeting-token',
sharedDocument: 'Original agenda',
attendees: [
{ userId: 2, displayName: 'Alex Morgan', email: 'alex@example.com', status: 'accepted' },
],
notes: [],
}
const liveNote = {
id: 9,
authorUserId: 2,
authorName: 'Alex Morgan',
body: 'Strong system design answer.',
createdAt: '2026-09-22T08:35:00Z',
}
const defaultFetch = vi.mocked(fetch).getMockImplementation()
vi.mocked(fetch).mockImplementation((input: RequestInfo | URL, options?: RequestInit) => {
const path = String(input)
if (path === '/api/scheduled-interviews/80') return response(scheduled)
if (path === '/api/scheduled-interviews/80/notes' && !options?.method) return response([])
if (path === '/api/scheduled-interviews/80/document')
return response({
...scheduled,
sharedDocument: JSON.parse(String(options?.body)).sharedDocument,
})
if (path === '/api/scheduled-interviews/80/events') {
const stream = new ReadableStream({
start(controller) {
controller.enqueue(
new TextEncoder().encode(`event: note\ndata: ${JSON.stringify(liveNote)}\n\n`),
)
controller.close()
},
})
return Promise.resolve(
new Response(stream, { status: 200, headers: { 'Content-Type': 'text/event-stream' } }),
)
}
if (!defaultFetch) throw new Error(`Unexpected request: ${path}`)
return defaultFetch(input, options)
})
renderApp('/scheduled-interviews/80')
expect(await screen.findByText('Strong system design answer.')).toBeInTheDocument()
const document = screen.getByLabelText('Shared documentation')
await user.clear(document)
await user.type(document, 'Updated candidate exercise')
await user.click(screen.getByRole('button', { name: 'Save shared documentation' }))
expect(fetch).toHaveBeenCalledWith(
'/api/scheduled-interviews/80/document',
expect.objectContaining({
method: 'PATCH',
body: JSON.stringify({ sharedDocument: 'Updated candidate exercise' }),
}),
)
expect(fetch).toHaveBeenCalledWith(
'/api/scheduled-interviews/80/events',
expect.objectContaining({
headers: expect.objectContaining({ Accept: 'text/event-stream' }),
}),
)
})
it('shows the public participant meeting without internal notes', async () => {
vi.mocked(fetch).mockImplementation((input: RequestInfo | URL) => {
if (String(input) === '/api/participant-meetings/meeting-token')
return response({
id: 80,
jobTitle: job.title,
participantName: 'Amélie Dubois',
startsAt: '2026-09-22T08:30:00Z',
location: 'Brussels',
status: 'scheduled',
sharedDocument: 'Please prepare the architecture exercise.',
})
throw new Error(`Unexpected request: ${String(input)}`)
})
renderApp('/participant/meeting/meeting-token')
expect(await screen.findByRole('heading', { name: job.title })).toBeInTheDocument()
expect(screen.getByText('Please prepare the architecture exercise.')).toBeInTheDocument()
expect(screen.queryByText(/live notes/i)).not.toBeInTheDocument()
expect(screen.queryByText(question.referenceAnswer)).not.toBeInTheDocument()
})
})

View file

@ -2,6 +2,7 @@ import {
ArrowLeft,
ArrowRight,
BriefcaseBusiness,
CalendarDays,
Check,
ChevronRight,
CircleHelp,
@ -15,6 +16,7 @@ import {
Plus,
Search,
Send,
Inbox,
Sparkles,
Trash2,
Upload,
@ -42,6 +44,14 @@ import {
useParams,
} from 'react-router-dom'
import { api, ApiError, authStorage } from './api'
import {
CalendarPage,
InboxPage,
ParticipantMeetingPage,
ScheduleInterviewForm,
ScheduledInterviewPage,
UsersPage,
} from './interview-workflow'
import type {
CandidateMatch,
CreateJobInput,
@ -54,6 +64,7 @@ import type {
Participant,
Question,
QuestionType,
ScheduledInterview,
} from './types'
interface AppData {
@ -276,6 +287,24 @@ function AppLayout({ onLogout }: { onLogout: () => void }) {
icon={<CircleHelp size={20} />}
close={() => setOpen(false)}
/>
<NavItem
to="/inbox"
label="Inbox"
icon={<Inbox size={20} />}
close={() => setOpen(false)}
/>
<NavItem
to="/calendar"
label="Calendar"
icon={<CalendarDays size={20} />}
close={() => setOpen(false)}
/>
<NavItem
to="/users"
label="Users"
icon={<UserRound size={20} />}
close={() => setOpen(false)}
/>
</nav>
<div className="sidebar-bottom">
<div className="user-chip">
@ -301,6 +330,10 @@ function AppLayout({ onLogout }: { onLogout: () => void }) {
<Route path="/participants" element={<ParticipantsPage />} />
<Route path="/participants/new" element={<CreateParticipantPage />} />
<Route path="/participants/:participantId" element={<ParticipantDetailPage />} />
<Route path="/users" element={<UsersPage />} />
<Route path="/inbox" element={<InboxPage />} />
<Route path="/calendar" element={<CalendarPage />} />
<Route path="/scheduled-interviews/:id" element={<ScheduledInterviewPage />} />
<Route path="*" element={<NotFound />} />
</Routes>
</main>
@ -458,6 +491,7 @@ function JobDetailPage() {
const [job, setJob] = useState<Job>()
const [matches, setMatches] = useState<CandidateMatch[]>([])
const [invitations, setInvitations] = useState<Invitation[]>([])
const [scheduledInterviews, setScheduledInterviews] = useState<ScheduledInterview[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [tab, setTab] = useState<'overview' | 'questions' | 'candidates' | 'invitations'>(
@ -468,14 +502,16 @@ function JobDetailPage() {
setLoading(true)
setError('')
try {
const [jobValue, candidates, invitationItems] = await Promise.all([
const [jobValue, candidates, invitationItems, scheduledItems] = await Promise.all([
api.jobs.get(id),
api.jobs.candidates(id),
api.jobs.invitations(id),
api.jobs.interviews(id),
])
setJob(jobValue)
setMatches(candidates)
setInvitations(invitationItems)
setScheduledInterviews(scheduledItems)
} catch (requestError) {
setError(errorMessage(requestError))
} finally {
@ -484,12 +520,18 @@ function JobDetailPage() {
}
useEffect(() => {
let active = true
void Promise.all([api.jobs.get(id), api.jobs.candidates(id), api.jobs.invitations(id)])
.then(([jobValue, candidates, invitationItems]) => {
void Promise.all([
api.jobs.get(id),
api.jobs.candidates(id),
api.jobs.invitations(id),
api.jobs.interviews(id),
])
.then(([jobValue, candidates, invitationItems, scheduledItems]) => {
if (!active) return
setJob(jobValue)
setMatches(candidates)
setInvitations(invitationItems)
setScheduledInterviews(scheduledItems)
})
.catch((requestError: unknown) => {
if (active) setError(errorMessage(requestError))
@ -563,7 +605,14 @@ function JobDetailPage() {
job={job}
matches={matches}
invitations={invitations}
scheduledInterviews={scheduledInterviews}
onInvited={(invitation) => setInvitations((current) => [invitation, ...current])}
onInvitationUpdated={(invitation) =>
setInvitations((current) =>
current.map((item) => (item.id === invitation.id ? invitation : item)),
)
}
onScheduled={(interview) => setScheduledInterviews((current) => [interview, ...current])}
/>
)}
</>
@ -752,17 +801,24 @@ function InvitationsView({
job,
matches,
invitations,
scheduledInterviews,
onInvited,
onInvitationUpdated,
onScheduled,
}: {
job: Job
matches: CandidateMatch[]
invitations: Invitation[]
scheduledInterviews: ScheduledInterview[]
onInvited: (invitation: Invitation) => void
onInvitationUpdated: (invitation: Invitation) => void
onScheduled: (interview: ScheduledInterview) => void
}) {
const [sendingId, setSendingId] = useState<number>()
const [review, setReview] = useState<Interview>()
const [reviewingId, setReviewingId] = useState<number>()
const [error, setError] = useState('')
const [scheduling, setScheduling] = useState<Invitation>()
const invitedIds = new Set(invitations.map((invitation) => invitation.participantId))
const eligible = matches.filter((match) => !invitedIds.has(match.participant.id))
@ -863,7 +919,62 @@ function InvitationsView({
<EmptyState title="No invitations" description="Invite a candidate to this interview." />
)}
</div>
{review && <InvitationAnswerReview interview={review} onClose={() => setReview(undefined)} />}
{review && (
<InvitationAnswerReview
interview={review}
onClose={() => setReview(undefined)}
onOutcome={(invitation) => {
onInvitationUpdated(invitation)
setReview((current) => current && { ...current, invitation })
}}
/>
)}
{scheduling && (
<ScheduleInterviewForm
jobId={job.id}
invitation={scheduling}
onCancel={() => setScheduling(undefined)}
onCreated={(interview) => {
onScheduled(interview)
setScheduling(undefined)
}}
/>
)}
<div className="scheduled-list">
{invitations
.filter((invitation) => invitation.outcome === 'passed')
.filter(
(invitation) =>
!scheduledInterviews.some(
(interview) => interview.participantId === invitation.participantId,
),
)
.map((invitation) => (
<button
className="button primary"
type="button"
key={invitation.id}
onClick={() => setScheduling(invitation)}
>
<CalendarDays size={17} /> Schedule {invitation.participantName}
</button>
))}
{scheduledInterviews.map((interview) => (
<Link
className="scheduled-summary"
to={`/scheduled-interviews/${interview.id}`}
key={interview.id}
>
<span>
<strong>{interview.participantName}</strong>
<small>
{new Date(interview.startsAt).toLocaleString()} · {interview.location}
</small>
</span>
<StatusBadge value={interview.status} />
</Link>
))}
</div>
</section>
)
}
@ -871,10 +982,32 @@ function InvitationsView({
function InvitationAnswerReview({
interview,
onClose,
onOutcome,
}: {
interview: Interview
onClose: () => void
onOutcome: (invitation: Invitation) => void
}) {
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
async function decide(outcome: 'passed' | 'failed') {
setSaving(true)
setError('')
try {
onOutcome(
await api.jobs.setInvitationOutcome(
interview.invitation.jobId,
interview.invitation.id,
outcome,
),
)
} catch (requestError) {
setError(errorMessage(requestError))
} finally {
setSaving(false)
}
}
return (
<section className="answer-review" aria-labelledby="answer-review-title">
<div className="section-heading">
@ -883,6 +1016,32 @@ function InvitationAnswerReview({
<h2 id="answer-review-title">{interview.invitation.participantName}</h2>
<p>{interview.invitation.participantEmail}</p>
</div>
{interview.invitation.status === 'completed' && (
<div className="outcome-actions" aria-label="Candidate outcome">
<div>
<strong>Assessment decision</strong>
<p>Record whether this candidate should proceed to a scheduled interview.</p>
</div>
<button
className="button pass"
type="button"
disabled={saving}
onClick={() => void decide('passed')}
>
<Check size={18} /> Pass candidate
</button>
<button
className="button fail"
type="button"
disabled={saving}
onClick={() => void decide('failed')}
>
<X size={18} /> Fail candidate
</button>
{interview.invitation.outcome && <StatusBadge value={interview.invitation.outcome} />}
</div>
)}
{error && <ErrorAlert message={error} />}
<div className="review-heading-actions">
<StatusBadge value={interview.invitation.status} />
<button className="button ghost" type="button" onClick={onClose}>
@ -2247,6 +2406,7 @@ export default function App() {
if (location.pathname.startsWith('/participant/')) {
return (
<Routes>
<Route path="/participant/meeting/:token" element={<ParticipantMeetingPage />} />
<Route path="/participant/:invitationId" element={<ParticipantInterviewPage />} />
<Route path="*" element={<NotFound />} />
</Routes>

View file

@ -9,7 +9,7 @@ describe('API client', () => {
it('sends the persisted bearer token on protected requests', async () => {
authStorage.save({
token: 'signed-jwt',
user: { id: 1, email: 'admin@example.com', role: 'admin' },
user: { id: 1, email: 'admin@example.com', displayName: 'Admin', role: 'admin' },
})
const fetchMock = vi.fn().mockResolvedValue(new Response('[]', { status: 200 }))
vi.stubGlobal('fetch', fetchMock)
@ -27,7 +27,7 @@ describe('API client', () => {
it('uses the backend participant multipart field names', async () => {
authStorage.save({
token: 'signed-jwt',
user: { id: 1, email: 'admin@example.com', role: 'admin' },
user: { id: 1, email: 'admin@example.com', displayName: 'Admin', role: 'admin' },
})
const fetchMock = vi.fn().mockResolvedValue(
new Response(

View file

@ -4,12 +4,17 @@ import type {
CreateJobInput,
CreateParticipantInput,
CreateQuestionInput,
CreateScheduledInterviewInput,
Interview,
Invitation,
Job,
JobStatus,
Participant,
ParticipantMeeting,
Question,
ScheduledInterview,
User,
InterviewNote,
} from './types'
const TOKEN_KEY = 'go-guess-token'
@ -131,6 +136,17 @@ export const api = {
method: 'POST',
body: JSON.stringify({ participantId }),
}),
setInvitationOutcome: (jobId: number, invitationId: number, outcome: Invitation['outcome']) =>
request<Invitation>(`/jobs/${jobId}/invitations/${invitationId}/outcome`, {
method: 'PATCH',
body: JSON.stringify({ outcome }),
}),
interviews: (jobId: number) => request<ScheduledInterview[]>(`/jobs/${jobId}/interviews`),
scheduleInterview: (jobId: number, input: CreateScheduledInterviewInput) =>
request<ScheduledInterview>(`/jobs/${jobId}/interviews`, {
method: 'POST',
body: JSON.stringify(input),
}),
},
questions: {
list: (search = '') =>
@ -178,4 +194,76 @@ export const api = {
return request<Participant>('/participants', { method: 'POST', body: form })
},
},
users: {
list: () => request<User[]>('/users'),
create: (input: { email: string; password: string; displayName: string }) =>
request<User>('/users', { method: 'POST', body: JSON.stringify(input) }),
},
inbox: {
list: () => request<ScheduledInterview[]>('/inbox'),
respond: (interviewId: number, status: 'accepted' | 'declined') =>
request<ScheduledInterview>(`/inbox/${interviewId}`, {
method: 'PATCH',
body: JSON.stringify({ status }),
}),
},
calendar: () => request<ScheduledInterview[]>('/calendar'),
scheduledInterviews: {
get: (id: number) => request<ScheduledInterview>(`/scheduled-interviews/${id}`),
setStatus: (id: number, status: string) =>
request<ScheduledInterview>(`/scheduled-interviews/${id}/status`, {
method: 'PATCH',
body: JSON.stringify({ status }),
}),
updateDocument: (id: number, sharedDocument: string) =>
request<ScheduledInterview>(`/scheduled-interviews/${id}/document`, {
method: 'PATCH',
body: JSON.stringify({ sharedDocument }),
}),
notes: (id: number) => request<InterviewNote[]>(`/scheduled-interviews/${id}/notes`),
addNote: (id: number, body: string) =>
request<InterviewNote>(`/scheduled-interviews/${id}/notes`, {
method: 'POST',
body: JSON.stringify({ body }),
}),
subscribe: async (id: number, onNote: (note: InterviewNote) => void, signal: AbortSignal) => {
const token = authStorage.token()
const response = await fetch(`/api/scheduled-interviews/${id}/events`, {
headers: {
Accept: 'text/event-stream',
...(token ? { Authorization: 'Bearer ' + token } : {}),
},
signal,
})
if (!response.ok || !response.body)
throw new ApiError(`Live updates failed with status ${response.status}`, response.status)
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
while (!signal.aborted) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true }).replace(/\r\n/g, '\n')
let boundary = buffer.indexOf('\n\n')
while (boundary >= 0) {
const message = buffer.slice(0, boundary)
buffer = buffer.slice(boundary + 2)
const data = message
.split('\n')
.filter((line) => line.startsWith('data:'))
.map((line) => line.slice(5).trimStart())
.join('\n')
if (data) {
const event = JSON.parse(data) as InterviewNote | { note: InterviewNote }
onNote('note' in event ? event.note : event)
}
boundary = buffer.indexOf('\n\n')
}
}
},
},
participantMeetings: {
get: (token: string) =>
request<ParticipantMeeting>(`/participant-meetings/${token}`, {}, false),
},
}

View file

@ -0,0 +1,660 @@
import { CalendarDays, Check, MapPin, Plus, UsersRound, X } from 'lucide-react'
import { type FormEvent, useEffect, useMemo, useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import { api, ApiError } from './api'
import type { Invitation, ParticipantMeeting, ScheduledInterview, User } from './types'
function message(error: unknown) {
return error instanceof ApiError || error instanceof Error
? error.message
: 'Something went wrong'
}
function ErrorAlert({ error }: { error: string }) {
return (
<div className="error-alert" role="alert">
<strong>Request failed</strong>
<span>{error}</span>
</div>
)
}
function Loading() {
return (
<div className="empty-state" role="status">
<span className="loading-spinner" />
<p>Loading…</p>
</div>
)
}
function DateAndPlace({ interview }: { interview: ScheduledInterview | ParticipantMeeting }) {
return (
<div className="meeting-facts">
<span>
<CalendarDays size={18} />
{new Date(interview.startsAt).toLocaleString()}
</span>
<span>
<MapPin size={18} />
{interview.location}
</span>
</div>
)
}
export function UsersPage() {
const [users, setUsers] = useState<User[]>([])
const [error, setError] = useState('')
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
useEffect(() => {
void api.users
.list()
.then(setUsers)
.catch((value) => setError(message(value)))
.finally(() => setLoading(false))
}, [])
async function create(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
setSaving(true)
setError('')
const form = event.currentTarget
const values = new FormData(form)
try {
const created = await api.users.create({
email: String(values.get('email')),
password: String(values.get('password')),
displayName: String(values.get('displayName')),
})
setUsers((current) => [...current, created])
form.reset()
} catch (value) {
setError(message(value))
} finally {
setSaving(false)
}
}
return (
<>
<header className="page-header">
<div>
<span className="eyebrow">Team access</span>
<h1>Users</h1>
<p>Create co-interviewers and manage the people who collaborate on interviews.</p>
</div>
</header>
{error && <ErrorAlert error={error} />}
<div className="library-grid">
<section className="card">
<div className="section-heading">
<div>
<h2>Interview team</h2>
<p>{users.length} workspace users</p>
</div>
</div>
{loading ? (
<Loading />
) : (
<div className="user-list">
{users.map((user) => (
<article className="user-row" key={user.id}>
<span className="avatar">{user.displayName.slice(0, 2).toUpperCase()}</span>
<div>
<strong>{user.displayName}</strong>
<small>{user.email}</small>
</div>
<span className={`status status-${user.role}`}>{user.role}</span>
</article>
))}
</div>
)}
</section>
<form className="card form-card library-form" onSubmit={create}>
<div className="form-section">
<h2>Create co-interviewer</h2>
<label className="field">
<span className="field-label">Display name</span>
<input name="displayName" required autoComplete="name" />
</label>
<label className="field">
<span className="field-label">Email address</span>
<input name="email" type="email" required autoComplete="email" />
</label>
<label className="field">
<span className="field-label">Temporary password</span>
<input
name="password"
type="password"
required
minLength={8}
autoComplete="new-password"
/>
</label>
<button className="button primary wide" type="submit" disabled={saving}>
<Plus size={18} /> {saving ? 'Creating…' : 'Create co-interviewer'}
</button>
</div>
</form>
</div>
</>
)
}
export function ScheduleInterviewForm({
jobId,
invitation,
onCreated,
onCancel,
}: {
jobId: number
invitation: Invitation
onCreated: (interview: ScheduledInterview) => void
onCancel: () => void
}) {
const [users, setUsers] = useState<User[]>([])
const [selected, setSelected] = useState<number[]>([])
const [error, setError] = useState('')
const [saving, setSaving] = useState(false)
useEffect(() => {
void api.users
.list()
.then(setUsers)
.catch((value) => setError(message(value)))
}, [])
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
const values = new FormData(event.currentTarget)
setSaving(true)
setError('')
try {
const startsAt = new Date(String(values.get('startsAt'))).toISOString()
onCreated(
await api.jobs.scheduleInterview(jobId, {
invitationId: invitation.id,
startsAt,
location: String(values.get('location')),
interviewerIds: selected,
sharedDocument: String(values.get('sharedDocument')),
}),
)
} catch (value) {
setError(message(value))
setSaving(false)
}
}
return (
<form
className="schedule-form"
onSubmit={submit}
aria-label={`Schedule ${invitation.participantName}`}
>
<div className="section-heading">
<div>
<span className="eyebrow">Passed assessment</span>
<h2>Schedule interview with {invitation.participantName}</h2>
</div>
<button
className="icon-button"
type="button"
onClick={onCancel}
aria-label="Close scheduling form"
>
<X />
</button>
</div>
<div className="form-section">
<div className="form-grid">
<label className="field">
<span className="field-label">Date and time</span>
<input name="startsAt" type="datetime-local" required />
</label>
<label className="field">
<span className="field-label">Location or meeting link</span>
<input name="location" required />
</label>
</div>
<fieldset className="interviewer-picker">
<legend className="field-label">Co-interviewers</legend>
{users.map((user) => (
<label key={user.id}>
<input
type="checkbox"
checked={selected.includes(user.id)}
onChange={(event) =>
setSelected((current) =>
event.target.checked
? [...current, user.id]
: current.filter((id) => id !== user.id),
)
}
/>
<span>
<strong>{user.displayName}</strong>
<small>{user.email}</small>
</span>
</label>
))}
</fieldset>
<label className="field shared-document-field">
<span className="field-label">Shared documentation</span>
<textarea
name="sharedDocument"
rows={10}
required
placeholder="Add the agenda, exercise, links, and information shared with the candidate…"
/>
</label>
{error && <ErrorAlert error={error} />}
<div className="question-form-actions">
<button className="button ghost" type="button" onClick={onCancel}>
Cancel
</button>
<button className="button primary" type="submit" disabled={saving}>
{saving ? 'Scheduling…' : 'Create interview'}
</button>
</div>
</div>
</form>
)
}
function InterviewCard({
interview,
children,
}: {
interview: ScheduledInterview
children?: React.ReactNode
}) {
return (
<article className="meeting-card">
<div>
<span className={`status status-${interview.status}`}>{interview.status}</span>
<h2>{interview.participantName}</h2>
<p>{interview.jobTitle}</p>
</div>
<DateAndPlace interview={interview} />
{children}
</article>
)
}
export function InboxPage() {
const [items, setItems] = useState<ScheduledInterview[]>([])
const [error, setError] = useState('')
const [loading, setLoading] = useState(true)
useEffect(() => {
void api.inbox
.list()
.then(setItems)
.catch((value) => setError(message(value)))
.finally(() => setLoading(false))
}, [])
async function respond(id: number, status: 'accepted' | 'declined') {
setError('')
try {
const updated = await api.inbox.respond(id, status)
setItems((current) => current.map((item) => (item.id === id ? updated : item)))
} catch (value) {
setError(message(value))
}
}
return (
<>
<header className="page-header">
<div>
<span className="eyebrow">Invitations</span>
<h1>Inbox</h1>
<p>Respond to interview invitations assigned to you.</p>
</div>
</header>
{error && <ErrorAlert error={error} />}
{loading ? (
<Loading />
) : (
<section className="meeting-list">
{items.map((item) => (
<InterviewCard interview={item} key={item.id}>
<div className="meeting-actions">
<button
className="button primary"
type="button"
onClick={() => void respond(item.id, 'accepted')}
>
<Check size={17} /> Accept
</button>
<button
className="button secondary danger"
type="button"
onClick={() => void respond(item.id, 'declined')}
>
<X size={17} /> Decline
</button>
</div>
</InterviewCard>
))}
{!items.length && (
<div className="card empty-state">
<h3>Inbox zero</h3>
<p>You have no interview invitations.</p>
</div>
)}
</section>
)}
</>
)
}
export function CalendarPage() {
const [items, setItems] = useState<ScheduledInterview[]>([])
const [error, setError] = useState('')
const [loading, setLoading] = useState(true)
useEffect(() => {
void api
.calendar()
.then(setItems)
.catch((value) => setError(message(value)))
.finally(() => setLoading(false))
}, [])
const groups = useMemo(() => {
return [...items]
.sort((a, b) => a.startsAt.localeCompare(b.startsAt))
.reduce<Record<string, ScheduledInterview[]>>((result, item) => {
const key = new Date(item.startsAt).toLocaleDateString(undefined, {
weekday: 'long',
month: 'long',
day: 'numeric',
year: 'numeric',
})
;(result[key] ??= []).push(item)
return result
}, {})
}, [items])
return (
<>
<header className="page-header">
<div>
<span className="eyebrow">Schedule</span>
<h1>Calendar</h1>
<p>Your accepted and organized interviews, grouped by date.</p>
</div>
</header>
{error && <ErrorAlert error={error} />}
{loading ? (
<Loading />
) : (
Object.entries(groups).map(([date, interviews]) => (
<section className="calendar-day" key={date}>
<h2>{date}</h2>
<div className="meeting-list">
{interviews.map((item) => (
<InterviewCard interview={item} key={item.id}>
<Link className="button secondary" to={`/scheduled-interviews/${item.id}`}>
Open session
</Link>
</InterviewCard>
))}
</div>
</section>
))
)}
</>
)
}
export function ScheduledInterviewPage() {
const id = Number(useParams().id)
const [interview, setInterview] = useState<ScheduledInterview>()
const [document, setDocument] = useState('')
const [notes, setNotes] = useState<ScheduledInterview['notes']>([])
const [error, setError] = useState('')
const [saving, setSaving] = useState(false)
useEffect(() => {
let active = true
void Promise.all([api.scheduledInterviews.get(id), api.scheduledInterviews.notes(id)])
.then(([item, loadedNotes]) => {
if (!active) return
setInterview(item)
setDocument(item.sharedDocument)
setNotes(loadedNotes)
})
.catch((value) => active && setError(message(value)))
return () => {
active = false
}
}, [id])
useEffect(() => {
if (interview?.status !== 'started') return
const controller = new AbortController()
void api.scheduledInterviews
.subscribe(
id,
(note) => {
setNotes((current) =>
current.some((item) => item.id === note.id) ? current : [...current, note],
)
},
controller.signal,
)
.catch((value) => {
if (!controller.signal.aborted) setError(message(value))
})
return () => controller.abort()
}, [id, interview?.status])
async function setStatus(status: string) {
setError('')
try {
setInterview(await api.scheduledInterviews.setStatus(id, status))
} catch (value) {
setError(message(value))
}
}
async function saveDocument(event: FormEvent) {
event.preventDefault()
setSaving(true)
setError('')
try {
setInterview(await api.scheduledInterviews.updateDocument(id, document))
} catch (value) {
setError(message(value))
} finally {
setSaving(false)
}
}
async function addNote(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
const form = event.currentTarget
const body = String(new FormData(form).get('body')).trim()
if (!body) return
try {
const note = await api.scheduledInterviews.addNote(id, body)
setNotes((current) =>
current.some((item) => item.id === note.id) ? current : [...current, note],
)
form.reset()
} catch (value) {
setError(message(value))
}
}
if (!interview && !error) return <Loading />
if (!interview) return <ErrorAlert error={error} />
return (
<>
<Link className="back-link" to="/calendar">
← Calendar
</Link>
<header className="page-header">
<div>
<span className="eyebrow">Interviewer session</span>
<h1>{interview.participantName}</h1>
<p>{interview.jobTitle}</p>
</div>
<span className={`status status-${interview.status}`}>{interview.status}</span>
</header>
{error && <ErrorAlert error={error} />}
<DateAndPlace interview={interview} />
<div className="session-actions">
{interview.status !== 'started' && interview.status !== 'completed' && (
<button
className="button primary"
type="button"
onClick={() => void setStatus('started')}
>
Start interview
</button>
)}
{interview.status === 'started' && (
<button
className="button primary"
type="button"
onClick={() => void setStatus('completed')}
>
Complete interview
</button>
)}
</div>
<div className="session-grid">
<form className="card shared-document" onSubmit={saveDocument}>
<div className="section-heading">
<div>
<span className="eyebrow">Visible to candidate</span>
<h2>Shared documentation</h2>
</div>
</div>
<div className="form-section">
<textarea
aria-label="Shared documentation"
rows={18}
value={document}
onChange={(event) => setDocument(event.target.value)}
/>
<button className="button primary" type="submit" disabled={saving}>
{saving ? 'Saving…' : 'Save shared documentation'}
</button>
</div>
</form>
<section className="card notes-panel">
<div className="section-heading">
<div>
<span className="eyebrow">Interviewers only</span>
<h2>Live notes</h2>
</div>
</div>
<div className="notes-list" aria-live="polite">
{notes.map((note) => (
<article className="note" key={note.id}>
<strong>{note.authorName}</strong>
<time>{new Date(note.createdAt).toLocaleTimeString()}</time>
<p>{note.body}</p>
</article>
))}
</div>
{interview.status === 'started' ? (
<form className="note-form" onSubmit={addNote}>
<label className="sr-only" htmlFor="interview-note">
Add a note
</label>
<textarea
id="interview-note"
name="body"
rows={3}
required
placeholder="Add a private interviewer note…"
/>
<button className="button primary" type="submit">
Post note
</button>
</form>
) : (
<p className="panel-padding muted">
Notes can be added while the interview is started.
</p>
)}
</section>
</div>
<section className="card attendee-section">
<div className="section-heading">
<div>
<h2>Attendees</h2>
<p>Interview team and response status.</p>
</div>
</div>
<div className="user-list">
{interview.attendees.map((attendee) => (
<article className="user-row" key={attendee.userId}>
<UsersRound />
<div>
<strong>{attendee.displayName}</strong>
<small>{attendee.email}</small>
</div>
<span className={`status status-${attendee.status}`}>{attendee.status}</span>
</article>
))}
</div>
</section>
</>
)
}
export function ParticipantMeetingPage() {
const token = useParams().token ?? ''
const [meeting, setMeeting] = useState<ParticipantMeeting>()
const [error, setError] = useState('')
useEffect(() => {
void api.participantMeetings
.get(token)
.then(setMeeting)
.catch((value) => setError(message(value)))
}, [token])
return (
<main className="public-interview">
<header>
<Link className="logo" to="/" aria-label="Go Guess home">
<span className="logo-mark">G</span>
<span>
<strong>GO GUESS</strong>
<small>Talent workspace</small>
</span>
</Link>
</header>
{!meeting && !error && (
<section className="interview-card">
<Loading />
</section>
)}
{error && (
<section className="interview-card">
<ErrorAlert error={error} />
</section>
)}
{meeting && (
<section className="interview-card meeting-public-card">
<span className="eyebrow">Your scheduled interview</span>
<h1>{meeting.jobTitle}</h1>
<p>Welcome, {meeting.participantName}.</p>
<span className={`status status-${meeting.status}`}>{meeting.status}</span>
<DateAndPlace interview={meeting} />
<section className="public-document" aria-labelledby="shared-document-title">
<h2 id="shared-document-title">Shared documentation</h2>
<div>{meeting.sharedDocument || 'No shared documentation has been added yet.'}</div>
</section>
</section>
)}
</main>
)
}

View file

@ -767,6 +767,33 @@ td small {
.button.danger {
color: #934141;
}
.button.pass {
color: #0b573c;
background: #d9ef4d;
border-color: #bed338;
}
.button.fail {
color: white;
background: #a43d3d;
}
.status-passed,
.status-accepted,
.status-started,
.status-completed {
color: #13704d;
background: #e3f5ed;
}
.status-failed,
.status-declined,
.status-cancelled {
color: #934141;
background: #f4e7e7;
}
.status-pending,
.status-scheduled {
color: #925a09;
background: #fff1dc;
}
.panel-padding,
.invite-form {
padding: 1.2rem 1.5rem;
@ -909,6 +936,215 @@ td small {
.answer-review {
border-top: 1px solid var(--line);
}
.outcome-actions {
display: flex;
align-items: center;
gap: 0.8rem;
padding: 1.2rem 1.5rem;
border-bottom: 1px solid var(--line);
background: #f6f9df;
}
.outcome-actions > div {
margin-right: auto;
}
.outcome-actions p {
color: var(--muted);
margin: 0.2rem 0 0;
}
.schedule-form {
margin: 1.5rem;
border: 2px solid var(--cyan);
border-radius: 14px;
overflow: hidden;
background: white;
}
.scheduled-list {
display: grid;
gap: 0.7rem;
padding: 1.5rem;
border-top: 1px solid var(--line);
}
.scheduled-list > .button {
justify-self: start;
}
.scheduled-summary {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.9rem 1rem;
border: 1px solid var(--line);
border-radius: 10px;
}
.scheduled-summary > span {
display: flex;
flex-direction: column;
}
.scheduled-summary small {
color: var(--muted);
margin-top: 0.2rem;
}
.interviewer-picker {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.65rem;
margin-bottom: 1.2rem;
}
.interviewer-picker legend {
margin-bottom: 0.5rem;
}
.interviewer-picker label {
display: flex;
align-items: center;
gap: 0.65rem;
padding: 0.75rem;
border: 1px solid var(--line);
border-radius: 9px;
}
.interviewer-picker input {
width: auto;
}
.interviewer-picker span {
display: flex;
flex-direction: column;
}
.interviewer-picker small {
color: var(--muted);
}
.shared-document-field {
padding: 1rem;
border: 2px solid var(--cyan);
border-radius: 12px;
background: #f2fbfc;
}
.user-list {
padding: 0 1.5rem;
}
.user-row {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 0.9rem;
padding: 1rem 0;
border-bottom: 1px solid var(--line);
}
.user-row > div {
display: flex;
flex-direction: column;
}
.user-row small {
color: var(--muted);
}
.meeting-list {
display: grid;
gap: 1rem;
}
.meeting-card {
display: grid;
grid-template-columns: minmax(200px, 1fr) minmax(260px, 1fr) auto;
gap: 1.2rem;
align-items: center;
padding: 1.3rem 1.5rem;
border: 1px solid #e1e9eb;
border-radius: var(--radius);
background: white;
box-shadow: 0 6px 24px rgba(14, 42, 56, 0.04);
}
.meeting-card h2 {
margin: 0.55rem 0 0.2rem;
}
.meeting-card p {
color: var(--muted);
margin: 0;
}
.meeting-facts {
display: flex;
gap: 1rem;
flex-wrap: wrap;
color: #526872;
}
.meeting-facts span {
display: inline-flex;
align-items: center;
gap: 0.45rem;
}
.meeting-actions,
.session-actions {
display: flex;
gap: 0.6rem;
}
.calendar-day {
margin-bottom: 2rem;
}
.calendar-day > h2 {
color: var(--navy);
border-bottom: 2px solid var(--cyan);
padding-bottom: 0.65rem;
}
.session-actions {
margin: 1.2rem 0;
}
.session-grid {
display: grid;
grid-template-columns: minmax(0, 1.35fr) minmax(320px, 0.65fr);
gap: 1.2rem;
margin: 1.2rem 0;
}
.shared-document {
border: 2px solid var(--cyan);
}
.shared-document textarea {
font-size: 1rem;
margin-bottom: 0.8rem;
}
.notes-list {
max-height: 440px;
overflow-y: auto;
padding: 0 1.5rem;
}
.note {
padding: 1rem 0;
border-bottom: 1px solid var(--line);
}
.note time {
color: var(--muted);
font-size: 0.75rem;
margin-left: 0.6rem;
}
.note p {
white-space: pre-wrap;
margin: 0.45rem 0 0;
}
.note-form {
padding: 1rem 1.5rem;
border-top: 1px solid var(--line);
}
.note-form .button {
margin-top: 0.6rem;
}
.attendee-section {
margin-top: 1.2rem;
}
.meeting-public-card {
padding: clamp(2rem, 6vw, 4rem);
}
.meeting-public-card > p {
color: var(--muted);
}
.meeting-public-card .meeting-facts {
margin: 1.6rem 0;
}
.public-document {
margin-top: 2rem;
padding: clamp(1.2rem, 4vw, 2rem);
border: 3px solid var(--lime);
border-radius: 14px;
background: #f9fce8;
}
.public-document div {
line-height: 1.7;
white-space: pre-wrap;
}
.review-heading-actions {
display: flex;
align-items: center;
@ -1610,7 +1846,8 @@ fieldset {
@media (max-width: 1100px) {
.split-panels,
.library-grid {
.library-grid,
.session-grid {
grid-template-columns: 1fr;
}
.library-form {
@ -1797,6 +2034,15 @@ fieldset {
align-items: flex-start;
flex-direction: column;
}
.outcome-actions,
.meeting-card {
align-items: stretch;
flex-direction: column;
display: flex;
}
.interviewer-picker {
grid-template-columns: 1fr;
}
}
@media (prefers-reduced-motion: reduce) {

View file

@ -3,6 +3,7 @@ export type QuestionType = 'open' | 'multiple_choice' | 'radio' | 'code_review'
export interface User {
id: number
email: string
displayName: string
role: string
}
@ -67,12 +68,62 @@ export interface Invitation {
participantEmail: string
token: string
status: string
outcome: 'pending' | 'passed' | 'failed'
participantUrl: string
acceptedAt: string | null
completedAt: string | null
createdAt: string
}
export interface InterviewAttendee {
userId: number
displayName: string
email: string
status: string
}
export interface InterviewNote {
id: number
authorUserId: number
authorName: string
body: string
createdAt: string
}
export interface ScheduledInterview {
id: number
jobId: number
jobTitle: string
participantId: number
participantName: string
startsAt: string
location: string
status: string
candidateToken: string
candidateUrl: string
sharedDocument: string
attendees: InterviewAttendee[]
notes: InterviewNote[]
}
export interface CreateScheduledInterviewInput {
invitationId: number
startsAt: string
location: string
interviewerIds: number[]
sharedDocument: string
}
export interface ParticipantMeeting {
id: number
jobTitle: string
participantName: string
startsAt: string
location: string
status: string
sharedDocument: string
}
export interface Interview {
invitation: Invitation
job: Pick<