diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5c684cf..d8d0726 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,6 +39,28 @@ jobs: - run: npm run test - run: npm run build + ingestion: + name: Ingestion (lint, types, tests) + runs-on: ubuntu-latest + defaults: + run: + working-directory: ingestion + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version-file: .nvmrc + cache: npm + cache-dependency-path: ingestion/package-lock.json + + - run: npm ci + - run: npm run format:check + - run: npm run lint + - run: npm run typecheck + - run: npm run test + - run: npm run build + database: name: Database (migrations, RLS, pgTAP, API integration) runs-on: ubuntu-latest diff --git a/app/src/App.tsx b/app/src/App.tsx index f203aca..bf77598 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -6,6 +6,7 @@ import { } from 'react-router' import { RouterProvider } from 'react-router/dom' import { AuthProvider, useAuth } from './contexts/AuthContext' +import { AppLayout } from './pages/AppLayout' import { ProfileLayout } from './pages/ProfileLayout' import { BasicProfilePage } from './pages/BasicProfilePage' import { EducationEditorPage } from './pages/EducationEditorPage' @@ -14,6 +15,12 @@ import { ExperienceEditorPage } from './pages/ExperienceEditorPage' import { ExperienceListPage } from './pages/ExperienceListPage' import { ProfilePage } from './pages/ProfilePage' import { SignInPage } from './pages/SignInPage' +import { OpportunityListPage } from './pages/OpportunityListPage' +import { OpportunityDetailPage } from './pages/OpportunityDetailPage' +import { PrivateOpportunityDetailPage } from './pages/PrivateOpportunityDetailPage' +import { ManualOpportunityFormPage } from './pages/ManualOpportunityFormPage' +import { ApplicationsListPage } from './pages/ApplicationsListPage' +import { ApplicationDetailPage } from './pages/ApplicationDetailPage' function AuthPending() { const { initError, loading, retryInit } = useAuth() @@ -88,22 +95,45 @@ const router = createBrowserRouter([ errorElement: , children: [ { - path: '/profile', - element: , + element: , children: [ - { index: true, element: }, - { path: 'basic', element: }, - { path: 'education', element: }, - { path: 'education/new', element: }, { - path: 'education/:educationId/edit', - element: , + path: '/profile', + element: , + children: [ + { index: true, element: }, + { path: 'basic', element: }, + { path: 'education', element: }, + { path: 'education/new', element: }, + { + path: 'education/:educationId/edit', + element: , + }, + { path: 'experience', element: }, + { path: 'experience/new', element: }, + { + path: 'experience/:experienceId/edit', + element: , + }, + ], }, - { path: 'experience', element: }, - { path: 'experience/new', element: }, + { path: '/opportunities', element: }, { - path: 'experience/:experienceId/edit', - element: , + path: '/opportunities/import', + element: , + }, + { + path: '/opportunities/manual/:privateOpportunityId', + element: , + }, + { + path: '/opportunities/:opportunityId', + element: , + }, + { path: '/applications', element: }, + { + path: '/applications/:applicationId', + element: , }, ], }, diff --git a/app/src/lib/applicationRepository.ts b/app/src/lib/applicationRepository.ts new file mode 100644 index 0000000..8fdb84a --- /dev/null +++ b/app/src/lib/applicationRepository.ts @@ -0,0 +1,90 @@ +import { supabase } from './supabaseClient' +import type { Application, ApplicationStatus } from './opportunityTypes' + +const columns = + 'id, user_id, shared_opportunity_id, opportunity_version_id, private_opportunity_id, private_opportunity_snapshot, status, applied_at, status_updated_at, next_action, next_action_due_at, notes, contact_note, created_at, updated_at' + +export interface ApplicationFieldsInput { + next_action?: string | null + next_action_due_at?: string | null + notes?: string | null + contact_note?: string | null +} + +export const applicationRepository = { + async list(userId: string) { + return supabase + .from('applications') + .select(columns) + .eq('user_id', userId) + .order('status_updated_at', { ascending: false }) + .returns() + }, + + async get(id: string) { + return supabase + .from('applications') + .select(columns) + .eq('id', id) + .maybeSingle() + }, + + async getByOpportunity(userId: string, sharedOpportunityId: string) { + return supabase + .from('applications') + .select(columns) + .eq('user_id', userId) + .eq('shared_opportunity_id', sharedOpportunityId) + .maybeSingle() + }, + + async getByPrivateOpportunity(userId: string, privateOpportunityId: string) { + return supabase + .from('applications') + .select(columns) + .eq('user_id', userId) + .eq('private_opportunity_id', privateOpportunityId) + .maybeSingle() + }, + + async createForOpportunity(userId: string, opportunityVersionId: string) { + return supabase + .from('applications') + .insert({ user_id: userId, opportunity_version_id: opportunityVersionId }) + .select(columns) + .maybeSingle() + }, + + async createForPrivateOpportunity( + userId: string, + privateOpportunityId: string, + ) { + return supabase + .from('applications') + .insert({ user_id: userId, private_opportunity_id: privateOpportunityId }) + .select(columns) + .maybeSingle() + }, + + async updateStatus(id: string, status: ApplicationStatus) { + return supabase + .from('applications') + .update({ status }) + .eq('id', id) + .select(columns) + .maybeSingle() + }, + + async updateFields(id: string, input: ApplicationFieldsInput) { + return supabase + .from('applications') + .update(input) + .eq('id', id) + .select(columns) + .maybeSingle() + }, + + async remove(id: string) { + return supabase.from('applications').delete().eq('id', id).select('id') + }, +} diff --git a/app/src/lib/interviewPrepRepository.ts b/app/src/lib/interviewPrepRepository.ts new file mode 100644 index 0000000..493c6aa --- /dev/null +++ b/app/src/lib/interviewPrepRepository.ts @@ -0,0 +1,37 @@ +import { supabase } from './supabaseClient' +import type { InterviewPrepNotes } from './opportunityTypes' + +const columns = + 'id, application_id, responsibilities_to_discuss, required_technologies, topics_to_revise, likely_questions, questions_to_ask, interview_date, interview_format, reflections' + +export type InterviewPrepInput = Partial< + Omit +> + +export const interviewPrepRepository = { + async get(applicationId: string) { + return supabase + .from('interview_prep_notes') + .select(columns) + .eq('application_id', applicationId) + .maybeSingle() + }, + + async upsert(applicationId: string, input: InterviewPrepInput) { + const existing = await this.get(applicationId) + if (existing.error) return existing + if (existing.data) { + return supabase + .from('interview_prep_notes') + .update(input) + .eq('application_id', applicationId) + .select(columns) + .maybeSingle() + } + return supabase + .from('interview_prep_notes') + .insert({ application_id: applicationId, ...input }) + .select(columns) + .maybeSingle() + }, +} diff --git a/app/src/lib/opportunityRepository.ts b/app/src/lib/opportunityRepository.ts new file mode 100644 index 0000000..184bb41 --- /dev/null +++ b/app/src/lib/opportunityRepository.ts @@ -0,0 +1,280 @@ +import { supabase } from './supabaseClient' +import type { + OpportunitySearchRow, + UserOpportunityStateRow, +} from './opportunityTypes' + +export type OpportunitySort = + | 'discovered_desc' + | 'posted_desc' + | 'deadline_asc' + | 'organization_asc' + | 'title_asc' + +export interface OpportunityFilters { + search?: string + opportunityKind?: string + employmentType?: string + remoteMode?: string + sourceKey?: string + lifecycleStatus?: string + locationText?: string + /** Restrict results to exactly these opportunity_ids (e.g. saved-only, + * applied-only). An empty array (as opposed to undefined) means "no + * matching opportunities" and short-circuits to zero rows without a + * request. */ + includeOpportunityIds?: string[] + /** Exclude these opportunity_ids (e.g. hidden, unless "show hidden" is on). + * An empty array applies no exclusion. */ + excludeOpportunityIds?: string[] +} + +const searchColumns = + 'opportunity_id, opportunity_version_id, lifecycle_status, first_discovered_at, last_checked_at, source_id, source_key, source_display_name, source_listing_id, canonical_source_url, title, organization, description, location_text, country, region, city, opportunity_kind, employment_type, remote_mode, posted_at, application_deadline, application_url, version_captured_at' + +const stateColumns = + 'id, user_id, opportunity_id, saved_at, saved_opportunity_version_id, hidden_at, notes' + +function escapeForIlike(term: string) { + return term.replace(/[%_,]/g, (char) => `\\${char}`) +} + +export const PAGE_SIZE = 20 + +export const opportunityRepository = { + async search( + filters: OpportunityFilters, + sort: OpportunitySort, + page: number, + pageSize = PAGE_SIZE, + ) { + // Empty (but defined) include set means "nothing matches" -- e.g. + // saved-only with no saved opportunities. Short-circuit before hitting + // PostgREST so the caller sees an honest zero-row, zero-count result. + if ( + filters.includeOpportunityIds && + filters.includeOpportunityIds.length === 0 + ) { + return { + data: [] as OpportunitySearchRow[], + count: 0, + error: null, + } + } + + let query = supabase + .from('opportunity_search') + .select(searchColumns, { count: 'exact' }) + + if (filters.search?.trim()) { + const term = escapeForIlike(filters.search.trim()) + query = query.or( + `title.ilike.%${term}%,organization.ilike.%${term}%,description.ilike.%${term}%,location_text.ilike.%${term}%`, + ) + } + if (filters.opportunityKind) + query = query.eq('opportunity_kind', filters.opportunityKind) + if (filters.employmentType) + query = query.eq('employment_type', filters.employmentType) + if (filters.remoteMode) query = query.eq('remote_mode', filters.remoteMode) + if (filters.sourceKey) query = query.eq('source_key', filters.sourceKey) + if (filters.lifecycleStatus) + query = query.eq('lifecycle_status', filters.lifecycleStatus) + if (filters.locationText?.trim()) { + const term = escapeForIlike(filters.locationText.trim()) + query = query.ilike('location_text', `%${term}%`) + } + if (filters.includeOpportunityIds) { + query = query.in('opportunity_id', filters.includeOpportunityIds) + } + if ( + filters.excludeOpportunityIds && + filters.excludeOpportunityIds.length > 0 + ) { + query = query.not( + 'opportunity_id', + 'in', + `(${filters.excludeOpportunityIds.join(',')})`, + ) + } + + switch (sort) { + case 'posted_desc': + query = query.order('posted_at', { + ascending: false, + nullsFirst: false, + }) + break + case 'deadline_asc': + query = query.order('application_deadline', { + ascending: true, + nullsFirst: false, + }) + break + case 'organization_asc': + query = query.order('organization', { ascending: true }) + break + case 'title_asc': + query = query.order('title', { ascending: true }) + break + case 'discovered_desc': + default: + query = query.order('first_discovered_at', { ascending: false }) + break + } + + const from = page * pageSize + query = query.range(from, from + pageSize - 1) + return query.returns() + }, + + async listSources() { + return supabase + .from('sources') + .select('source_key, display_name') + .eq('enabled', true) + .order('display_name') + .returns<{ source_key: string; display_name: string }[]>() + }, + + async get(opportunityId: string) { + return supabase + .from('opportunity_search') + .select(searchColumns) + .eq('opportunity_id', opportunityId) + .maybeSingle() + }, + + async getVersion(opportunityVersionId: string) { + return supabase + .from('opportunity_versions') + .select( + 'id, opportunity_id, title, organization, description, location_text, remote_mode, opportunity_kind, employment_type, application_url, posted_at, application_deadline, captured_at', + ) + .eq('id', opportunityVersionId) + .maybeSingle() + }, + + async getVersions(opportunityVersionIds: string[]) { + if (opportunityVersionIds.length === 0) return { data: [], error: null } + return supabase + .from('opportunity_versions') + .select( + 'id, opportunity_id, title, organization, application_url, application_deadline', + ) + .in('id', opportunityVersionIds) + }, + + async listState(userId: string) { + return supabase + .from('user_opportunity_state') + .select(stateColumns) + .eq('user_id', userId) + .returns() + }, + + async getState(userId: string, opportunityId: string) { + return supabase + .from('user_opportunity_state') + .select(stateColumns) + .eq('user_id', userId) + .eq('opportunity_id', opportunityId) + .maybeSingle() + }, + + async setSaved( + userId: string, + opportunityId: string, + opportunityVersionId: string, + saved: boolean, + ) { + const existing = await this.getState(userId, opportunityId) + if (existing.error) return existing + if (saved) { + // Plain insert/update, never .upsert(): an ON CONFLICT DO UPDATE also + // needs UPDATE privilege on the conflict-key columns (user_id, + // opportunity_id), which are deliberately not browser-writable. + if (existing.data) { + return supabase + .from('user_opportunity_state') + .update({ + saved_at: new Date().toISOString(), + saved_opportunity_version_id: opportunityVersionId, + }) + .eq('user_id', userId) + .eq('opportunity_id', opportunityId) + .select(stateColumns) + .maybeSingle() + } + return supabase + .from('user_opportunity_state') + .insert({ + user_id: userId, + opportunity_id: opportunityId, + saved_at: new Date().toISOString(), + saved_opportunity_version_id: opportunityVersionId, + }) + .select(stateColumns) + .maybeSingle() + } + if (existing.data?.hidden_at) { + return supabase + .from('user_opportunity_state') + .update({ saved_at: null, saved_opportunity_version_id: null }) + .eq('user_id', userId) + .eq('opportunity_id', opportunityId) + .select(stateColumns) + .maybeSingle() + } + if (existing.data) { + return supabase + .from('user_opportunity_state') + .delete() + .eq('user_id', userId) + .eq('opportunity_id', opportunityId) + } + return { data: null, error: null } + }, + + async setHidden(userId: string, opportunityId: string, hidden: boolean) { + const existing = await this.getState(userId, opportunityId) + if (existing.error) return existing + if (hidden) { + if (existing.data) { + return supabase + .from('user_opportunity_state') + .update({ hidden_at: new Date().toISOString() }) + .eq('user_id', userId) + .eq('opportunity_id', opportunityId) + .select(stateColumns) + .maybeSingle() + } + return supabase + .from('user_opportunity_state') + .insert({ + user_id: userId, + opportunity_id: opportunityId, + hidden_at: new Date().toISOString(), + }) + .select(stateColumns) + .maybeSingle() + } + if (existing.data?.saved_at) { + return supabase + .from('user_opportunity_state') + .update({ hidden_at: null }) + .eq('user_id', userId) + .eq('opportunity_id', opportunityId) + .select(stateColumns) + .maybeSingle() + } + if (existing.data) { + return supabase + .from('user_opportunity_state') + .delete() + .eq('user_id', userId) + .eq('opportunity_id', opportunityId) + } + return { data: null, error: null } + }, +} diff --git a/app/src/lib/opportunityTypes.ts b/app/src/lib/opportunityTypes.ts new file mode 100644 index 0000000..012613b --- /dev/null +++ b/app/src/lib/opportunityTypes.ts @@ -0,0 +1,187 @@ +export type OpportunityKind = + | 'internship' + | 'working_student' + | 'graduate_program' + | 'entry_level' + | 'research_assistant' + | 'phd' + | 'scholarship' + | 'hackathon' + | 'fellowship' + | 'other' + +export type EmploymentType = + | 'full_time' + | 'part_time' + | 'contract' + | 'temporary' + | 'internship' + | 'volunteer' + | 'other' + +export type RemoteMode = 'onsite' | 'hybrid' | 'remote' | 'unknown' + +export type LifecycleStatus = 'active' | 'stale' | 'closed' | 'unknown' + +export type ApplicationStatus = + | 'preparing' + | 'applied' + | 'awaiting_response' + | 'interview_scheduled' + | 'interview_complete' + | 'offer' + | 'accepted' + | 'rejected' + | 'withdrawn' + | 'closed' + +export interface OpportunitySearchRow { + opportunity_id: string + opportunity_version_id: string + lifecycle_status: LifecycleStatus + first_discovered_at: string + last_checked_at: string + source_id: string + source_key: string + source_display_name: string + source_listing_id: string + canonical_source_url: string + title: string + organization: string + description: string + location_text: string | null + country: string | null + region: string | null + city: string | null + opportunity_kind: OpportunityKind + employment_type: EmploymentType + remote_mode: RemoteMode + posted_at: string | null + application_deadline: string | null + application_url: string | null + version_captured_at: string +} + +export interface UserOpportunityStateRow { + id: string + user_id: string + opportunity_id: string + saved_at: string | null + saved_opportunity_version_id: string | null + hidden_at: string | null + notes: string | null +} + +export interface PrivateOpportunity { + id: string + user_id: string + source_url: string + title: string + organization_name: string + location_text: string + opportunity_kind: OpportunityKind + employment_type: EmploymentType + remote_mode: RemoteMode + description_text: string | null + posted_at: string | null + application_deadline: string | null + application_url: string | null + dismissed_at: string | null + created_at: string + updated_at: string +} + +export interface Application { + id: string + user_id: string + shared_opportunity_id: string | null + opportunity_version_id: string | null + private_opportunity_id: string | null + private_opportunity_snapshot: Record | null + status: ApplicationStatus + applied_at: string | null + status_updated_at: string + next_action: string | null + next_action_due_at: string | null + notes: string | null + contact_note: string | null + created_at: string + updated_at: string +} + +export interface InterviewPrepNotes { + id: string + application_id: string + responsibilities_to_discuss: string | null + required_technologies: string | null + topics_to_revise: string | null + likely_questions: string | null + questions_to_ask: string | null + interview_date: string | null + interview_format: string | null + reflections: string | null +} + +export const opportunityKindLabels: Record = { + internship: 'Internship', + working_student: 'Working student', + graduate_program: 'Graduate program', + entry_level: 'Entry level', + research_assistant: 'Research assistant', + phd: 'PhD', + scholarship: 'Scholarship', + hackathon: 'Hackathon', + fellowship: 'Fellowship', + other: 'Other', +} + +export const employmentTypeLabels: Record = { + full_time: 'Full-time', + part_time: 'Part-time', + contract: 'Contract', + temporary: 'Temporary', + internship: 'Internship', + volunteer: 'Volunteer', + other: 'Other', +} + +export const remoteModeLabels: Record = { + onsite: 'On-site', + hybrid: 'Hybrid', + remote: 'Remote', + unknown: 'Unknown', +} + +export const lifecycleStatusLabels: Record = { + active: 'Active', + stale: 'Stale', + closed: 'Closed', + unknown: 'Unknown', +} + +export const applicationStatusLabels: Record = { + preparing: 'Preparing', + applied: 'Applied', + awaiting_response: 'Awaiting response', + interview_scheduled: 'Interview scheduled', + interview_complete: 'Interview complete', + offer: 'Offer', + accepted: 'Accepted', + rejected: 'Rejected', + withdrawn: 'Withdrawn', + closed: 'Closed', +} + +export const applicationStatusOptions = Object.keys( + applicationStatusLabels, +) as ApplicationStatus[] + +export const opportunityKindOptions = Object.keys( + opportunityKindLabels, +) as OpportunityKind[] + +export const employmentTypeOptions = Object.keys( + employmentTypeLabels, +) as EmploymentType[] + +export const remoteModeOptions = Object.keys(remoteModeLabels) as RemoteMode[] diff --git a/app/src/lib/privateOpportunityRepository.ts b/app/src/lib/privateOpportunityRepository.ts new file mode 100644 index 0000000..a31458e --- /dev/null +++ b/app/src/lib/privateOpportunityRepository.ts @@ -0,0 +1,81 @@ +import { supabase } from './supabaseClient' +import type { + EmploymentType, + OpportunityKind, + PrivateOpportunity, + RemoteMode, +} from './opportunityTypes' + +export type PrivateOpportunityInput = { + source_url: string + title: string + organization_name: string + location_text: string + opportunity_kind: OpportunityKind + employment_type: EmploymentType + remote_mode: RemoteMode + description_text: string | null + posted_at: string | null + application_deadline: string | null + application_url: string | null +} + +export type PrivateOpportunityUpdate = PrivateOpportunityInput + +const columns = + 'id, user_id, source_url, title, organization_name, location_text, opportunity_kind, employment_type, remote_mode, description_text, posted_at, application_deadline, application_url, dismissed_at, created_at, updated_at' + +export const privateOpportunityRepository = { + async list(userId: string, includeDismissed: boolean) { + let query = supabase + .from('private_opportunities') + .select(columns) + .eq('user_id', userId) + if (!includeDismissed) query = query.is('dismissed_at', null) + return query + .order('created_at', { ascending: false }) + .returns() + }, + + async get(id: string) { + return supabase + .from('private_opportunities') + .select(columns) + .eq('id', id) + .maybeSingle() + }, + + async create(userId: string, input: PrivateOpportunityInput) { + return supabase + .from('private_opportunities') + .insert({ user_id: userId, ...input }) + .select(columns) + .maybeSingle() + }, + + async update(id: string, input: PrivateOpportunityUpdate) { + return supabase + .from('private_opportunities') + .update(input) + .eq('id', id) + .select(columns) + .maybeSingle() + }, + + async setDismissed(id: string, dismissed: boolean) { + return supabase + .from('private_opportunities') + .update({ dismissed_at: dismissed ? new Date().toISOString() : null }) + .eq('id', id) + .select(columns) + .maybeSingle() + }, + + async remove(id: string) { + return supabase + .from('private_opportunities') + .delete() + .eq('id', id) + .select('id') + }, +} diff --git a/app/src/pages/AppLayout.tsx b/app/src/pages/AppLayout.tsx new file mode 100644 index 0000000..2f78c5b --- /dev/null +++ b/app/src/pages/AppLayout.tsx @@ -0,0 +1,36 @@ +import { NavLink, Outlet } from 'react-router' +import { useState } from 'react' +import { useAuth } from '../contexts/AuthContext' + +export function AppLayout() { + const { signOut } = useAuth() + const [error, setError] = useState(null) + + async function handleSignOut() { + setError(null) + const result = await signOut() + if (result.error) setError(result.error) + } + + return ( +
+
+

CareerOS

+ +
+ {error &&

{error}

} + + +
+ ) +} diff --git a/app/src/pages/ApplicationDetailPage.test.tsx b/app/src/pages/ApplicationDetailPage.test.tsx new file mode 100644 index 0000000..5b8681c --- /dev/null +++ b/app/src/pages/ApplicationDetailPage.test.tsx @@ -0,0 +1,226 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { MemoryRouter, Route, Routes } from 'react-router' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { ApplicationDetailPage } from './ApplicationDetailPage' + +const get = vi.fn() +const updateStatus = vi.fn() +const updateFields = vi.fn() +const remove = vi.fn() +const getVersion = vi.fn() +const prepGet = vi.fn() +const prepUpsert = vi.fn() +const navigate = vi.fn() + +vi.mock('react-router', async () => { + const actual = + await vi.importActual('react-router') + return { ...actual, useNavigate: () => navigate } +}) +vi.mock('../lib/applicationRepository', () => ({ + applicationRepository: { + get: (...args: unknown[]) => get(...args), + updateStatus: (...args: unknown[]) => updateStatus(...args), + updateFields: (...args: unknown[]) => updateFields(...args), + remove: (...args: unknown[]) => remove(...args), + }, +})) +vi.mock('../lib/opportunityRepository', () => ({ + opportunityRepository: { + getVersion: (...args: unknown[]) => getVersion(...args), + }, +})) +vi.mock('../lib/interviewPrepRepository', () => ({ + interviewPrepRepository: { + get: (...args: unknown[]) => prepGet(...args), + upsert: (...args: unknown[]) => prepUpsert(...args), + }, +})) + +const sharedApp = { + id: 'app-1', + user_id: 'user-1', + opportunity_version_id: 'ver-1', + private_opportunity_id: null, + private_opportunity_snapshot: null, + status: 'preparing' as const, + applied_at: null, + status_updated_at: '2026-08-01T00:00:00Z', + next_action: null, + next_action_due_at: null, + notes: null, + contact_note: null, + created_at: '2026-08-01T00:00:00Z', + updated_at: '2026-08-01T00:00:00Z', +} + +const version = { + id: 'ver-1', + opportunity_id: 'opp-1', + title: 'Senior Data Scientist', + organization: 'KONUX', + description: 'About us: KONUX builds sensors.', + location_text: 'Munich', + remote_mode: 'onsite', + opportunity_kind: 'other', + employment_type: 'full_time', + application_url: 'https://job-boards.greenhouse.io/konux/jobs/1', + posted_at: null, + application_deadline: null, + captured_at: '2026-08-01T00:00:00Z', +} + +function renderPage() { + return render( + + + } + /> + + , + ) +} + +afterEach(() => vi.resetAllMocks()) + +describe('ApplicationDetailPage', () => { + it('shows a missing state for a deleted application', async () => { + get.mockResolvedValue({ data: null, error: null }) + renderPage() + expect(await screen.findByText(/no longer exists/i)).toBeInTheDocument() + }) + + it('renders the pinned listing snapshot beside interview prep', async () => { + get.mockResolvedValue({ data: sharedApp, error: null }) + getVersion.mockResolvedValue({ data: version, error: null }) + prepGet.mockResolvedValue({ data: null, error: null }) + renderPage() + expect( + await screen.findByRole('heading', { name: 'Senior Data Scientist' }), + ).toBeInTheDocument() + expect( + screen.getByText(/About us: KONUX builds sensors\./), + ).toBeInTheDocument() + expect( + screen.getByText(/exact content saved at application time/i), + ).toBeInTheDocument() + const applyLink = screen.getByRole('link', { + name: /authoritative external application page/i, + }) + expect(applyLink).toHaveAttribute('target', '_blank') + expect(applyLink).toHaveAttribute('rel', 'noopener noreferrer') + }) + + it('changes status and auto-sets an applied date', async () => { + get.mockResolvedValue({ data: sharedApp, error: null }) + getVersion.mockResolvedValue({ data: version, error: null }) + prepGet.mockResolvedValue({ data: null, error: null }) + updateStatus.mockResolvedValue({ + data: { + ...sharedApp, + status: 'applied', + applied_at: '2026-08-04T00:00:00Z', + }, + error: null, + }) + renderPage() + await screen.findByRole('heading', { name: 'Senior Data Scientist' }) + await userEvent.selectOptions(screen.getByLabelText('Status'), 'applied') + expect(updateStatus).toHaveBeenCalledWith('app-1', 'applied') + }) + + it('saves interview prep notes', async () => { + get.mockResolvedValue({ data: sharedApp, error: null }) + getVersion.mockResolvedValue({ data: version, error: null }) + prepGet.mockResolvedValue({ data: null, error: null }) + prepUpsert.mockResolvedValue({ + data: { + id: 'prep-1', + application_id: 'app-1', + responsibilities_to_discuss: null, + required_technologies: null, + topics_to_revise: null, + likely_questions: 'Tell me about a model you deployed.', + questions_to_ask: null, + interview_date: null, + interview_format: null, + reflections: null, + }, + error: null, + }) + const user = userEvent.setup() + renderPage() + await screen.findByRole('heading', { name: 'Senior Data Scientist' }) + await user.type( + screen.getByLabelText('Likely interview questions'), + 'Tell me about a model you deployed.', + ) + await user.click( + screen.getByRole('button', { name: /save interview prep/i }), + ) + expect(prepUpsert).toHaveBeenCalledWith( + 'app-1', + expect.objectContaining({ + likely_questions: 'Tell me about a model you deployed.', + }), + ) + expect( + await screen.findByText(/interview prep notes saved/i), + ).toBeInTheDocument() + }) + + it('asks for confirmation before deleting the application', async () => { + get.mockResolvedValue({ data: sharedApp, error: null }) + getVersion.mockResolvedValue({ data: version, error: null }) + prepGet.mockResolvedValue({ data: null, error: null }) + const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false) + const user = userEvent.setup() + renderPage() + await screen.findByRole('heading', { name: 'Senior Data Scientist' }) + await user.click(screen.getByRole('button', { name: 'Delete application' })) + expect(confirmSpy).toHaveBeenCalled() + expect(remove).not.toHaveBeenCalled() + confirmSpy.mockRestore() + }) + + it('deletes the application and navigates back once confirmed', async () => { + get.mockResolvedValue({ data: sharedApp, error: null }) + getVersion.mockResolvedValue({ data: version, error: null }) + prepGet.mockResolvedValue({ data: null, error: null }) + remove.mockResolvedValue({ data: [{ id: 'app-1' }], error: null }) + const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true) + const user = userEvent.setup() + renderPage() + await screen.findByRole('heading', { name: 'Senior Data Scientist' }) + await user.click(screen.getByRole('button', { name: 'Delete application' })) + expect(remove).toHaveBeenCalledWith('app-1') + expect(navigate).toHaveBeenCalledWith('/applications', { replace: true }) + confirmSpy.mockRestore() + }) + + it('renders a manual application snapshot without fetching a shared version', async () => { + const manualApp = { + ...sharedApp, + opportunity_version_id: null, + private_opportunity_id: 'priv-1', + private_opportunity_snapshot: { + title: 'Research Assistant', + organization_name: 'Fraunhofer IWES', + description_text: 'Support the sensor team.', + application_url: null, + source_url: 'https://jobs.fraunhofer.de/example', + }, + } + get.mockResolvedValue({ data: manualApp, error: null }) + prepGet.mockResolvedValue({ data: null, error: null }) + renderPage() + expect( + await screen.findByRole('heading', { name: 'Research Assistant' }), + ).toBeInTheDocument() + expect(screen.getByText('Support the sensor team.')).toBeInTheDocument() + expect(getVersion).not.toHaveBeenCalled() + }) +}) diff --git a/app/src/pages/ApplicationDetailPage.tsx b/app/src/pages/ApplicationDetailPage.tsx new file mode 100644 index 0000000..1fb1ff6 --- /dev/null +++ b/app/src/pages/ApplicationDetailPage.tsx @@ -0,0 +1,475 @@ +import { Link, useNavigate, useParams } from 'react-router' +import { useCallback, useEffect, useState, type FormEvent } from 'react' +import { applicationRepository } from '../lib/applicationRepository' +import { opportunityRepository } from '../lib/opportunityRepository' +import { interviewPrepRepository } from '../lib/interviewPrepRepository' +import { + applicationStatusLabels, + applicationStatusOptions, + type Application, + type ApplicationStatus, + type InterviewPrepNotes, +} from '../lib/opportunityTypes' +import { errorMessage, safeError } from '../lib/profileTypes' + +type PinnedListing = { + title: string + organization: string + description: string + applicationUrl: string | null + sourceUrl?: string +} + +function formatDate(value: string | null) { + if (!value) return null + return new Intl.DateTimeFormat('en', { + year: 'numeric', + month: 'short', + day: 'numeric', + }).format(new Date(value)) +} + +function toDateInput(value: string | null) { + return value ? value.slice(0, 10) : '' +} + +export function ApplicationDetailPage() { + const { applicationId } = useParams() + const navigate = useNavigate() + + const [status, setStatus] = useState< + 'loading' | 'ready' | 'missing' | 'error' + >('loading') + const [application, setApplication] = useState(null) + const [pinned, setPinned] = useState(null) + const [opportunityId, setOpportunityId] = useState(null) + const [message, setMessage] = useState(null) + const [pending, setPending] = useState(false) + + const [nextAction, setNextAction] = useState('') + const [nextActionDueAt, setNextActionDueAt] = useState('') + const [notes, setNotes] = useState('') + const [contactNote, setContactNote] = useState('') + + const [prep, setPrep] = useState(null) + const [prepValues, setPrepValues] = useState({ + responsibilities_to_discuss: '', + required_technologies: '', + topics_to_revise: '', + likely_questions: '', + questions_to_ask: '', + interview_date: '', + interview_format: '', + reflections: '', + }) + const [prepSaving, setPrepSaving] = useState(false) + + const load = useCallback(async () => { + if (!applicationId) return + setStatus('loading') + const result = await applicationRepository.get(applicationId) + if (result.error) { + setStatus('error') + return + } + if (!result.data) { + setStatus('missing') + return + } + const app = result.data + setApplication(app) + setNextAction(app.next_action ?? '') + setNextActionDueAt(toDateInput(app.next_action_due_at)) + setNotes(app.notes ?? '') + setContactNote(app.contact_note ?? '') + + if (app.opportunity_version_id) { + const versionResult = await opportunityRepository.getVersion( + app.opportunity_version_id, + ) + if (versionResult.data) { + setOpportunityId(versionResult.data.opportunity_id as string) + setPinned({ + title: versionResult.data.title as string, + organization: versionResult.data.organization as string, + description: versionResult.data.description as string, + applicationUrl: versionResult.data.application_url as string | null, + }) + } + } else if (app.private_opportunity_snapshot) { + const snapshot = app.private_opportunity_snapshot + setPinned({ + title: (snapshot.title as string) ?? 'Manual opportunity', + organization: (snapshot.organization_name as string) ?? '', + description: (snapshot.description_text as string) ?? '', + applicationUrl: (snapshot.application_url as string | null) ?? null, + sourceUrl: (snapshot.source_url as string | undefined) ?? undefined, + }) + } + + const prepResult = await interviewPrepRepository.get(applicationId) + if (!prepResult.error && prepResult.data) { + setPrep(prepResult.data) + setPrepValues({ + responsibilities_to_discuss: + prepResult.data.responsibilities_to_discuss ?? '', + required_technologies: prepResult.data.required_technologies ?? '', + topics_to_revise: prepResult.data.topics_to_revise ?? '', + likely_questions: prepResult.data.likely_questions ?? '', + questions_to_ask: prepResult.data.questions_to_ask ?? '', + interview_date: toDateInput(prepResult.data.interview_date), + interview_format: prepResult.data.interview_format ?? '', + reflections: prepResult.data.reflections ?? '', + }) + } + setStatus('ready') + }, [applicationId]) + + useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect -- asynchronous initial load updates state after the repository resolves + void load() + }, [load]) + + async function changeStatus(next: ApplicationStatus) { + if (!application || pending) return + setPending(true) + const result = await applicationRepository.updateStatus( + application.id, + next, + ) + if (result.error) setMessage(errorMessage(safeError(result.error))) + else await load() + setPending(false) + } + + async function saveFields(event: FormEvent) { + event.preventDefault() + if (!application || pending) return + setPending(true) + const result = await applicationRepository.updateFields(application.id, { + next_action: nextAction.trim() || null, + next_action_due_at: nextActionDueAt || null, + notes: notes.trim() || null, + contact_note: contactNote.trim() || null, + }) + if (result.error) setMessage(errorMessage(safeError(result.error))) + else { + setApplication(result.data) + setMessage('Saved.') + } + setPending(false) + } + + async function savePrep(event: FormEvent) { + event.preventDefault() + if (!applicationId || prepSaving) return + setPrepSaving(true) + const result = await interviewPrepRepository.upsert(applicationId, { + responsibilities_to_discuss: + prepValues.responsibilities_to_discuss.trim() || null, + required_technologies: prepValues.required_technologies.trim() || null, + topics_to_revise: prepValues.topics_to_revise.trim() || null, + likely_questions: prepValues.likely_questions.trim() || null, + questions_to_ask: prepValues.questions_to_ask.trim() || null, + interview_date: prepValues.interview_date || null, + interview_format: prepValues.interview_format.trim() || null, + reflections: prepValues.reflections.trim() || null, + }) + if (result.error) setMessage(errorMessage(safeError(result.error))) + else { + setPrep(result.data) + setMessage('Interview prep notes saved.') + } + setPrepSaving(false) + } + + async function remove() { + if (!application || pending) return + if ( + !window.confirm( + 'Delete this application? This also deletes its interview-preparation notes.', + ) + ) + return + setPending(true) + const result = await applicationRepository.remove(application.id) + if (result.error) { + setMessage(errorMessage(safeError(result.error))) + setPending(false) + return + } + navigate('/applications', { replace: true }) + } + + if (status === 'loading') + return ( +
+

Application

+

Loading…

+
+ ) + if (status === 'missing') + return ( +
+

Application

+

This application no longer exists.

+ Return to applications +
+ ) + if (status === 'error' || !application) + return ( +
+

Application

+

This application could not be loaded.

+ +
+ ) + + return ( +
+

{pinned?.title ?? 'Application'}

+ {pinned &&

{pinned.organization}

} + {message &&

{message}

} + +
+ + +
+

+ Status updated {formatDate(application.status_updated_at)} + {application.applied_at && ( + <> · Applied {formatDate(application.applied_at)} + )} +

+ +
+
+ + setNextAction(event.target.value)} + /> +
+
+ + setNextActionDueAt(event.target.value)} + /> +
+
+ +