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.
This commit is contained in:
pyr0ball 2026-03-20 18:36:19 -07:00
parent 71480d630a
commit de69140386
3 changed files with 309 additions and 0 deletions

View file

@ -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<T>(url: string, opts?: RequestInit): Promise<T | null> {
const { data } = await _useApiFetch<T>(url, opts)
return data
}

157
web/src/stores/prep.test.ts Normal file
View file

@ -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()
})
})

142
web/src/stores/prep.ts Normal file
View file

@ -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<ResearchBrief | null>(null)
const contacts = ref<Contact[]>([])
const taskStatus = ref<TaskStatus>({ status: null, stage: null, message: null })
const fullJob = ref<FullJobDetail | null>(null)
const loading = ref(false)
const error = ref<string | null>(null)
const currentJobId = ref<number | null>(null)
let pollInterval: ReturnType<typeof setInterval> | 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<ResearchBrief>(`/api/jobs/${jobId}/research`),
useApiFetch<Contact[]>(`/api/jobs/${jobId}/contacts`),
useApiFetch<TaskStatus>(`/api/jobs/${jobId}/research/task`),
useApiFetch<FullJobDetail>(`/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<TaskStatus>(`/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,
}
})