feat(interviews): add interviews Pinia store with optimistic moves
Setup-form Pinia store with per-stage computed lanes, optimistic status mutation on move, and API-error rollback. Shallow-copies API response objects on fetch to prevent shared-reference mutation across tests.
This commit is contained in:
parent
6fb366e499
commit
4dcab5ff29
2 changed files with 130 additions and 0 deletions
50
web/src/stores/interviews.test.ts
Normal file
50
web/src/stores/interviews.test.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import { useInterviewsStore } from './interviews'
|
||||
|
||||
vi.mock('../composables/useApi', () => ({
|
||||
useApiFetch: vi.fn(),
|
||||
}))
|
||||
|
||||
import { useApiFetch } from '../composables/useApi'
|
||||
const mockFetch = vi.mocked(useApiFetch)
|
||||
|
||||
const SAMPLE_JOBS = [
|
||||
{ id: 1, title: 'CS Lead', company: 'Stripe', status: 'applied', match_score: 0.92, interview_date: null },
|
||||
{ id: 2, title: 'CS Dir', company: 'Notion', status: 'phone_screen', match_score: 0.78, interview_date: '2026-03-20T15:00:00' },
|
||||
{ id: 3, title: 'VP CS', company: 'Linear', status: 'hired', match_score: 0.95, interview_date: null },
|
||||
]
|
||||
|
||||
describe('useInterviewsStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
mockFetch.mockResolvedValue({ data: SAMPLE_JOBS, error: null })
|
||||
})
|
||||
|
||||
it('loads and groups jobs by status', async () => {
|
||||
const store = useInterviewsStore()
|
||||
await store.fetchAll()
|
||||
expect(store.applied).toHaveLength(1)
|
||||
expect(store.phoneScreen).toHaveLength(1)
|
||||
expect(store.hired).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('move updates status optimistically', async () => {
|
||||
mockFetch.mockResolvedValueOnce({ data: SAMPLE_JOBS, error: null })
|
||||
mockFetch.mockResolvedValueOnce({ data: null, error: null }) // move API
|
||||
const store = useInterviewsStore()
|
||||
await store.fetchAll()
|
||||
await store.move(1, 'phone_screen')
|
||||
expect(store.applied).toHaveLength(0)
|
||||
expect(store.phoneScreen).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('move rolls back on API error', async () => {
|
||||
mockFetch.mockResolvedValueOnce({ data: SAMPLE_JOBS, error: null })
|
||||
mockFetch.mockResolvedValueOnce({ data: null, error: { kind: 'http', status: 500, detail: 'err' } })
|
||||
const store = useInterviewsStore()
|
||||
await store.fetchAll()
|
||||
await store.move(1, 'phone_screen')
|
||||
expect(store.applied).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
80
web/src/stores/interviews.ts
Normal file
80
web/src/stores/interviews.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { useApiFetch } from '../composables/useApi'
|
||||
|
||||
export interface PipelineJob {
|
||||
id: number
|
||||
title: string
|
||||
company: string
|
||||
url: string | null
|
||||
location: string | null
|
||||
is_remote: boolean
|
||||
salary: string | null
|
||||
match_score: number | null
|
||||
keyword_gaps: string | null
|
||||
status: string
|
||||
interview_date: string | null
|
||||
rejection_stage: string | null
|
||||
applied_at: string | null
|
||||
phone_screen_at: string | null
|
||||
interviewing_at: string | null
|
||||
offer_at: string | null
|
||||
hired_at: string | null
|
||||
survey_at: string | null
|
||||
}
|
||||
|
||||
export const PIPELINE_STAGES = ['applied', 'survey', 'phone_screen', 'interviewing', 'offer', 'hired', 'interview_rejected'] as const
|
||||
export type PipelineStage = typeof PIPELINE_STAGES[number]
|
||||
|
||||
export const STAGE_LABELS: Record<PipelineStage, string> = {
|
||||
applied: 'Applied',
|
||||
survey: 'Survey',
|
||||
phone_screen: 'Phone Screen',
|
||||
interviewing: 'Interviewing',
|
||||
offer: 'Offer',
|
||||
hired: 'Hired',
|
||||
interview_rejected: 'Rejected',
|
||||
}
|
||||
|
||||
export const useInterviewsStore = defineStore('interviews', () => {
|
||||
const jobs = ref<PipelineJob[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
const applied = computed(() => jobs.value.filter(j => j.status === 'applied'))
|
||||
const survey = computed(() => jobs.value.filter(j => j.status === 'survey'))
|
||||
const phoneScreen = computed(() => jobs.value.filter(j => j.status === 'phone_screen'))
|
||||
const interviewing = computed(() => jobs.value.filter(j => j.status === 'interviewing'))
|
||||
const offer = computed(() => jobs.value.filter(j => j.status === 'offer'))
|
||||
const hired = computed(() => jobs.value.filter(j => j.status === 'hired'))
|
||||
const offerHired = computed(() => jobs.value.filter(j => j.status === 'offer' || j.status === 'hired'))
|
||||
const rejected = computed(() => jobs.value.filter(j => j.status === 'interview_rejected'))
|
||||
|
||||
async function fetchAll() {
|
||||
loading.value = true
|
||||
const { data, error: err } = await useApiFetch<PipelineJob[]>('/api/interviews')
|
||||
loading.value = false
|
||||
if (err) { error.value = 'Could not load interview pipeline'; return }
|
||||
jobs.value = (data ?? []).map(j => ({ ...j }))
|
||||
}
|
||||
|
||||
async function move(jobId: number, status: PipelineStage, opts: { interview_date?: string; rejection_stage?: string } = {}) {
|
||||
const job = jobs.value.find(j => j.id === jobId)
|
||||
if (!job) return
|
||||
const prevStatus = job.status
|
||||
job.status = status
|
||||
|
||||
const { error: err } = await useApiFetch(`/api/jobs/${jobId}/move`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status, ...opts }),
|
||||
})
|
||||
|
||||
if (err) {
|
||||
job.status = prevStatus
|
||||
error.value = 'Move failed — please try again'
|
||||
}
|
||||
}
|
||||
|
||||
return { jobs, loading, error, applied, survey, phoneScreen, interviewing, offer, hired, offerHired, rejected, fetchAll, move }
|
||||
})
|
||||
Loading…
Reference in a new issue