From 4dcab5ff293a2005937825c85bce833772176042 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Wed, 18 Mar 2026 15:26:44 -0700 Subject: [PATCH] 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. --- web/src/stores/interviews.test.ts | 50 +++++++++++++++++++ web/src/stores/interviews.ts | 80 +++++++++++++++++++++++++++++++ 2 files changed, 130 insertions(+) create mode 100644 web/src/stores/interviews.test.ts create mode 100644 web/src/stores/interviews.ts diff --git a/web/src/stores/interviews.test.ts b/web/src/stores/interviews.test.ts new file mode 100644 index 0000000..29fb009 --- /dev/null +++ b/web/src/stores/interviews.test.ts @@ -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) + }) +}) diff --git a/web/src/stores/interviews.ts b/web/src/stores/interviews.ts new file mode 100644 index 0000000..9089af7 --- /dev/null +++ b/web/src/stores/interviews.ts @@ -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 = { + 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([]) + const loading = ref(false) + const error = ref(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('/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 } +})