diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index a5f992d..cf7cd3d 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -19,7 +19,7 @@ Focused tests: ```bash cd backend && go test ./internal/service -run '^TestCandidatesRequireSixtyPercent$' cd backend && go test -tags=integration ./internal/integration -run '^TestCompleteInterviewWorkflow$' -cd frontend && npm test -- src/App.test.tsx -t "filters jobs by title" +cd frontend && npm test -- src/app/App.test.tsx -t "filters jobs by title" ``` The required toolchain is Go 1.27.1 and React 19.3 with TypeScript/Vite. Use @@ -37,9 +37,12 @@ The required toolchain is Go 1.27.1 and React 19.3 with TypeScript/Vite. Use `go-guess.interview-notes` RabbitMQ fanout exchange. Each API instance consumes through its own exclusive queue and distributes events to authenticated SSE subscribers. -- `frontend/src/App.tsx` owns routing/layout/screens, `src/api.ts` is the typed HTTP - boundary, and `src/types.ts` contains client domain types. Vite proxies `/api` to - the Go service in development. +- `frontend/src/app` owns application routing and providers, `src/features` groups + feature pages and components, `src/components` contains reusable UI, + `src/api/client.ts` is the typed HTTP boundary, `src/types` contains client + domain types, and `src/styles/global.css` contains application-wide styles. + Keep feature-specific components inside their feature directory. Vite proxies + `/api` to the Go service in development. Goose files in `backend/migrations` are schema-only and run in every environment. `internal/database/seed/common.sql` contains shared reference data; diff --git a/README.md b/README.md index 7d01334..9097176 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ cd backend && go test ./internal/service -run '^TestCandidatesRequireSixtyPercen Run one frontend test: ```bash -cd frontend && npm test -- src/App.test.tsx -t "filters jobs by title" +cd frontend && npm test -- src/app/App.test.tsx -t "filters jobs by title" ``` Docker must be available for `make test`. The integration suite starts PostgreSQL diff --git a/frontend/README.md b/frontend/README.md index b5aff32..a259c1c 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -18,9 +18,21 @@ npm run build Run one test: ```bash -npm test -- src/App.test.tsx -t "shows answer inputs" +npm test -- src/app/App.test.tsx -t "filters jobs by title" ``` +## Source structure + +- `src/app` contains the application shell, providers, routes, and route-level tests. +- `src/features` groups pages and components by business capability. +- `src/components` contains UI shared by multiple features. +- `src/api/client.ts` is the typed HTTP boundary. +- `src/types` contains API and domain types. +- `src/styles/global.css` contains shared tokens and layout styles. + +Keep feature-only UI close to its feature instead of adding more screens to the +application shell. + ## API integration Vite proxies `/api` to `http://localhost:8080`. Login stores the returned JWT in diff --git a/frontend/src/api.test.ts b/frontend/src/api/client.test.ts similarity index 97% rename from frontend/src/api.test.ts rename to frontend/src/api/client.test.ts index a74dbf5..03f4780 100644 --- a/frontend/src/api.test.ts +++ b/frontend/src/api/client.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { api, authStorage } from './api' +import { api, authStorage } from './client' describe('API client', () => { beforeEach(() => { diff --git a/frontend/src/api.ts b/frontend/src/api/client.ts similarity index 99% rename from frontend/src/api.ts rename to frontend/src/api/client.ts index d05c221..75ea02c 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api/client.ts @@ -15,7 +15,7 @@ import type { ScheduledInterview, User, InterviewNote, -} from './types' +} from '../types' const TOKEN_KEY = 'go-guess-token' const USER_KEY = 'go-guess-user' diff --git a/frontend/src/App.test.tsx b/frontend/src/app/App.test.tsx similarity index 99% rename from frontend/src/App.test.tsx rename to frontend/src/app/App.test.tsx index cd10d08..35643f1 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/app/App.test.tsx @@ -3,7 +3,7 @@ import userEvent from '@testing-library/user-event' import { BrowserRouter, MemoryRouter } from 'react-router-dom' import { beforeEach, describe, expect, it, vi } from 'vitest' import App from './App' -import type { Job, Participant } from './types' +import type { Job, Participant } from '../types' const question = { id: 11, @@ -680,6 +680,8 @@ describe('Go Guess frontend', () => { renderApp('/jobs/7') await user.click(await screen.findByRole('tab', { name: /invitations/i })) await user.click(screen.getByRole('button', { name: 'Schedule Amélie Dubois' })) + expect(screen.getByRole('heading', { name: 'Interview details' })).toBeInTheDocument() + expect(screen.getByRole('heading', { name: 'Interview team' })).toBeInTheDocument() 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/)) diff --git a/frontend/src/App.tsx b/frontend/src/app/App.tsx similarity index 99% rename from frontend/src/App.tsx rename to frontend/src/app/App.tsx index d263176..4b5cd4e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/app/App.tsx @@ -43,15 +43,15 @@ import { useNavigate, useParams, } from 'react-router-dom' -import { api, ApiError, authStorage } from './api' +import { api, ApiError, authStorage } from '../api/client' import { CalendarPage, InboxPage, ParticipantMeetingPage, ScheduleInterviewForm, ScheduledInterviewPage, - UsersPage, -} from './interview-workflow' +} from '../features/interviews' +import { UsersPage } from '../features/users' import type { CandidateMatch, CreateJobInput, @@ -65,7 +65,7 @@ import type { Question, QuestionType, ScheduledInterview, -} from './types' +} from '../types' interface AppData { jobs: Job[] diff --git a/frontend/src/components/ui/AsyncState.tsx b/frontend/src/components/ui/AsyncState.tsx new file mode 100644 index 0000000..5a928ee --- /dev/null +++ b/frontend/src/components/ui/AsyncState.tsx @@ -0,0 +1,17 @@ +export function ErrorAlert({ error }: { error: string }) { + return ( +
+ Request failed + {error} +
+ ) +} + +export function Loading() { + return ( +
+ +

Loading…

+
+ ) +} diff --git a/frontend/src/features/interviews/components/InterviewCard.tsx b/frontend/src/features/interviews/components/InterviewCard.tsx new file mode 100644 index 0000000..286a89e --- /dev/null +++ b/frontend/src/features/interviews/components/InterviewCard.tsx @@ -0,0 +1,42 @@ +import { CalendarDays, MapPin } from 'lucide-react' +import type { ReactNode } from 'react' +import type { ParticipantMeeting, ScheduledInterview } from '../../../types' + +export function DateAndPlace({ + interview, +}: { + interview: ScheduledInterview | ParticipantMeeting +}) { + return ( +
+ + + + +
+ ) +} + +export function InterviewCard({ + interview, + children, +}: { + interview: ScheduledInterview + children?: ReactNode +}) { + return ( +
+
+ {interview.status} +

{interview.participantName}

+

{interview.jobTitle}

+
+ + {children} +
+ ) +} diff --git a/frontend/src/features/interviews/components/ScheduleInterviewForm.tsx b/frontend/src/features/interviews/components/ScheduleInterviewForm.tsx new file mode 100644 index 0000000..b573711 --- /dev/null +++ b/frontend/src/features/interviews/components/ScheduleInterviewForm.tsx @@ -0,0 +1,203 @@ +import { CalendarDays, FileText, MapPin, UsersRound, X } from 'lucide-react' +import { type FormEvent, useEffect, useState } from 'react' +import { api, ApiError, authStorage } from '../../../api/client' +import type { Invitation, ScheduledInterview, User } from '../../../types' + +interface ScheduleInterviewFormProps { + jobId: number + invitation: Invitation + onCreated: (interview: ScheduledInterview) => void + onCancel: () => void +} + +function errorMessage(error: unknown) { + return error instanceof ApiError || error instanceof Error + ? error.message + : 'Something went wrong' +} + +export function ScheduleInterviewForm({ + jobId, + invitation, + onCreated, + onCancel, +}: ScheduleInterviewFormProps) { + const [users, setUsers] = useState([]) + const [selected, setSelected] = useState([]) + const [error, setError] = useState('') + const [loadingUsers, setLoadingUsers] = useState(true) + const [saving, setSaving] = useState(false) + + useEffect(() => { + let active = true + void api.users + .list() + .then((items) => { + if (active) { + const currentUserId = authStorage.user()?.id + setUsers(items.filter((user) => user.id !== currentUserId)) + } + }) + .catch((value) => { + if (active) setError(errorMessage(value)) + }) + .finally(() => { + if (active) setLoadingUsers(false) + }) + return () => { + active = false + } + }, []) + + function toggleInterviewer(userId: number, checked: boolean) { + setSelected((current) => + checked ? [...new Set([...current, userId])] : current.filter((id) => id !== userId), + ) + } + + async function submit(event: FormEvent) { + event.preventDefault() + const values = new FormData(event.currentTarget) + setSaving(true) + setError('') + try { + const startsAt = new Date(String(values.get('startsAt'))).toISOString() + const interview = await api.jobs.scheduleInterview(jobId, { + invitationId: invitation.id, + startsAt, + location: String(values.get('location')), + interviewerIds: selected, + sharedDocument: String(values.get('sharedDocument')), + }) + onCreated(interview) + } catch (value) { + setError(errorMessage(value)) + setSaving(false) + } + } + + return ( +
+
+
+ Passed assessment +

Schedule interview

+

+ Set up the next conversation with {invitation.participantName}. +

+
+ +
+ +
+
+
+
+
+ + +
+ +