From 44adfd6691b483cee1baa6bdfd37ed8128dc2412 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Fri, 20 Mar 2026 18:36:19 -0700 Subject: [PATCH] feat: add prep store with research polling Adds usePrepStore (Pinia) for interview prep data: parallel fetch of research brief, contacts, task status, and full job detail; setInterval- based polling that stops on completion and re-fetches; clear() cancels the interval and resets all state. Also adds useApiFetch composable wrapper (returns T|null directly) used by the store. --- web/src/composables/useApiFetch.ts | 10 ++ web/src/stores/prep.test.ts | 157 +++++++++++++++++++++++++++++ web/src/stores/prep.ts | 142 ++++++++++++++++++++++++++ 3 files changed, 309 insertions(+) create mode 100644 web/src/composables/useApiFetch.ts create mode 100644 web/src/stores/prep.test.ts create mode 100644 web/src/stores/prep.ts diff --git a/web/src/composables/useApiFetch.ts b/web/src/composables/useApiFetch.ts new file mode 100644 index 0000000..fbbb53e --- /dev/null +++ b/web/src/composables/useApiFetch.ts @@ -0,0 +1,10 @@ +/** + * Convenience wrapper around useApiFetch from useApi.ts that returns data directly + * (null on error), simplifying store code that doesn't need detailed error handling. + */ +import { useApiFetch as _useApiFetch } from './useApi' + +export async function useApiFetch(url: string, opts?: RequestInit): Promise { + const { data } = await _useApiFetch(url, opts) + return data +} diff --git a/web/src/stores/prep.test.ts b/web/src/stores/prep.test.ts new file mode 100644 index 0000000..1bb911a --- /dev/null +++ b/web/src/stores/prep.test.ts @@ -0,0 +1,157 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { setActivePinia, createPinia } from 'pinia' +import { usePrepStore } from './prep' + +// Mock useApiFetch +vi.mock('../composables/useApiFetch', () => ({ + useApiFetch: vi.fn(), +})) + +import { useApiFetch } from '../composables/useApiFetch' + +describe('usePrepStore', () => { + beforeEach(() => { + setActivePinia(createPinia()) + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + vi.clearAllMocks() + }) + + it('fetchFor loads research, contacts, task, and full job in parallel', async () => { + const mockApiFetch = vi.mocked(useApiFetch) + mockApiFetch + .mockResolvedValueOnce({ company_brief: 'Acme', ceo_brief: null, talking_points: null, + tech_brief: null, funding_brief: null, red_flags: null, accessibility_brief: null, + generated_at: '2026-03-20T12:00:00' }) // research + .mockResolvedValueOnce([]) // contacts + .mockResolvedValueOnce({ status: 'none', stage: null, message: null }) // task + .mockResolvedValueOnce({ id: 1, title: 'Engineer', company: 'Acme', url: null, + description: 'Build things.', cover_letter: null, match_score: 80, + keyword_gaps: null }) // fullJob + + const store = usePrepStore() + await store.fetchFor(1) + + expect(store.research?.company_brief).toBe('Acme') + expect(store.contacts).toEqual([]) + expect(store.taskStatus.status).toBe('none') + expect(store.fullJob?.description).toBe('Build things.') + expect(store.currentJobId).toBe(1) + }) + + it('fetchFor clears state when called for a different job', async () => { + const mockApiFetch = vi.mocked(useApiFetch) + // First call for job 1 + mockApiFetch + .mockResolvedValueOnce({ company_brief: 'OldCo', ceo_brief: null, talking_points: null, + tech_brief: null, funding_brief: null, red_flags: null, accessibility_brief: null, + generated_at: null }) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce({ status: 'none', stage: null, message: null }) + .mockResolvedValueOnce({ id: 1, title: 'Old Job', company: 'OldCo', url: null, + description: null, cover_letter: null, match_score: null, keyword_gaps: null }) + + const store = usePrepStore() + await store.fetchFor(1) + expect(store.research?.company_brief).toBe('OldCo') + + // Second call for job 2 - clears first + mockApiFetch + .mockResolvedValueOnce(null) // 404 → null + .mockResolvedValueOnce([]) + .mockResolvedValueOnce({ status: 'none', stage: null, message: null }) + .mockResolvedValueOnce({ id: 2, title: 'New Job', company: 'NewCo', url: null, + description: null, cover_letter: null, match_score: null, keyword_gaps: null }) + + await store.fetchFor(2) + expect(store.research).toBeNull() + expect(store.currentJobId).toBe(2) + }) + + it('generateResearch calls POST then starts polling', async () => { + const mockApiFetch = vi.mocked(useApiFetch) + mockApiFetch.mockResolvedValueOnce({ task_id: 7, is_new: true }) + + const store = usePrepStore() + store.currentJobId = 1 + + // Spy on pollTask via the interval + const pollSpy = mockApiFetch + .mockResolvedValueOnce({ status: 'running', stage: 'Analyzing', message: null }) + + await store.generateResearch(1) + + // Advance timer one tick — should poll + await vi.advanceTimersByTimeAsync(3000) + + // Should have called POST generate + poll task + expect(mockApiFetch).toHaveBeenCalledWith( + expect.stringContaining('/research/generate'), + expect.objectContaining({ method: 'POST' }) + ) + }) + + it('pollTask stops when status is completed and re-fetches research', async () => { + const mockApiFetch = vi.mocked(useApiFetch) + // Set up store with a job loaded + mockApiFetch + .mockResolvedValueOnce({ company_brief: 'Acme', ceo_brief: null, talking_points: null, + tech_brief: null, funding_brief: null, red_flags: null, accessibility_brief: null, + generated_at: null }) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce({ status: 'none', stage: null, message: null }) + .mockResolvedValueOnce({ id: 1, title: 'Eng', company: 'Acme', url: null, + description: null, cover_letter: null, match_score: null, keyword_gaps: null }) + + const store = usePrepStore() + await store.fetchFor(1) + + // Mock first poll → completed + mockApiFetch + .mockResolvedValueOnce({ status: 'completed', stage: null, message: null }) + // re-fetch on completed: research, contacts, task, fullJob + .mockResolvedValueOnce({ company_brief: 'Updated!', ceo_brief: null, talking_points: null, + tech_brief: null, funding_brief: null, red_flags: null, accessibility_brief: null, + generated_at: '2026-03-20T13:00:00' }) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce({ status: 'completed', stage: null, message: null }) + .mockResolvedValueOnce({ id: 1, title: 'Eng', company: 'Acme', url: null, + description: 'Now with content', cover_letter: null, match_score: null, keyword_gaps: null }) + + store.pollTask(1) + await vi.advanceTimersByTimeAsync(3000) + await vi.runAllTimersAsync() + + expect(store.research?.company_brief).toBe('Updated!') + }) + + it('clear cancels polling interval and resets state', async () => { + const mockApiFetch = vi.mocked(useApiFetch) + mockApiFetch + .mockResolvedValueOnce({ company_brief: 'Acme', ceo_brief: null, talking_points: null, + tech_brief: null, funding_brief: null, red_flags: null, accessibility_brief: null, + generated_at: null }) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce({ status: 'none', stage: null, message: null }) + .mockResolvedValueOnce({ id: 1, title: 'Eng', company: 'Acme', url: null, + description: null, cover_letter: null, match_score: null, keyword_gaps: null }) + + const store = usePrepStore() + await store.fetchFor(1) + store.pollTask(1) + + store.clear() + + // Advance timers — if polling wasn't cancelled, fetchFor would be called again + const callCountBeforeClear = mockApiFetch.mock.calls.length + await vi.advanceTimersByTimeAsync(9000) + expect(mockApiFetch.mock.calls.length).toBe(callCountBeforeClear) + + expect(store.research).toBeNull() + expect(store.contacts).toEqual([]) + expect(store.currentJobId).toBeNull() + }) +}) diff --git a/web/src/stores/prep.ts b/web/src/stores/prep.ts new file mode 100644 index 0000000..e1f30a2 --- /dev/null +++ b/web/src/stores/prep.ts @@ -0,0 +1,142 @@ +import { ref } from 'vue' +import { defineStore } from 'pinia' +import { useApiFetch } from '../composables/useApiFetch' + +export interface ResearchBrief { + company_brief: string | null + ceo_brief: string | null + talking_points: string | null + tech_brief: string | null + funding_brief: string | null + red_flags: string | null + accessibility_brief: string | null + generated_at: string | null +} + +export interface Contact { + id: number + direction: 'inbound' | 'outbound' + subject: string | null + from_addr: string | null + body: string | null + received_at: string | null +} + +export interface TaskStatus { + status: 'queued' | 'running' | 'completed' | 'failed' | 'none' | null + stage: string | null + message: string | null +} + +export interface FullJobDetail { + id: number + title: string + company: string + url: string | null + description: string | null + cover_letter: string | null + match_score: number | null + keyword_gaps: string | null +} + +export const usePrepStore = defineStore('prep', () => { + const research = ref(null) + const contacts = ref([]) + const taskStatus = ref({ status: null, stage: null, message: null }) + const fullJob = ref(null) + const loading = ref(false) + const error = ref(null) + const currentJobId = ref(null) + + let pollInterval: ReturnType | null = null + + function _clearInterval() { + if (pollInterval !== null) { + clearInterval(pollInterval) + pollInterval = null + } + } + + async function fetchFor(jobId: number) { + if (jobId !== currentJobId.value) { + _clearInterval() + research.value = null + contacts.value = [] + taskStatus.value = { status: null, stage: null, message: null } + fullJob.value = null + error.value = null + currentJobId.value = jobId + } + + loading.value = true + try { + const [researchData, contactsData, taskData, jobData] = await Promise.all([ + useApiFetch(`/api/jobs/${jobId}/research`), + useApiFetch(`/api/jobs/${jobId}/contacts`), + useApiFetch(`/api/jobs/${jobId}/research/task`), + useApiFetch(`/api/jobs/${jobId}`), + ]) + + research.value = researchData ?? null + contacts.value = (contactsData as Contact[]) ?? [] + taskStatus.value = (taskData as TaskStatus) ?? { status: null, stage: null, message: null } + fullJob.value = (jobData as FullJobDetail) ?? null + + // If a task is already running/queued, start polling + const ts = taskStatus.value.status + if (ts === 'queued' || ts === 'running') { + pollTask(jobId) + } + } catch (e) { + error.value = e instanceof Error ? e.message : 'Failed to load prep data' + } finally { + loading.value = false + } + } + + async function generateResearch(jobId: number) { + await useApiFetch(`/api/jobs/${jobId}/research/generate`, { method: 'POST' }) + pollTask(jobId) + } + + function pollTask(jobId: number) { + _clearInterval() + pollInterval = setInterval(async () => { + const data = await useApiFetch(`/api/jobs/${jobId}/research/task`) + if (data) { + taskStatus.value = data as TaskStatus + if (data.status === 'completed' || data.status === 'failed') { + _clearInterval() + if (data.status === 'completed') { + await fetchFor(jobId) + } + } + } + }, 3000) + } + + function clear() { + _clearInterval() + research.value = null + contacts.value = [] + taskStatus.value = { status: null, stage: null, message: null } + fullJob.value = null + loading.value = false + error.value = null + currentJobId.value = null + } + + return { + research, + contacts, + taskStatus, + fullJob, + loading, + error, + currentJobId, + fetchFor, + generateResearch, + pollTask, + clear, + } +})