49 KiB
Interview Prep Vue Page — Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Implement the Interview Prep Vue page at /prep/:id — research brief with background-task generation/polling, tabbed reference panel (JD/Emails/Cover Letter), and localStorage call notes.
Architecture: Four new FastAPI endpoints supply research and contacts data. A new usePrepStore handles fetching, research generation, and 3s polling. InterviewPrepView.vue is a two-column layout (research left, reference right) with a redirect guard if no valid active-stage job is selected. The InterviewCard.vue "Prep →" button and InterviewsView.vue @prep handler are already wired — no changes needed to those files.
Tech Stack: Python/FastAPI + SQLite (backend), Vue 3 Composition API + Pinia (frontend), @vueuse/core useLocalStorage (notes), Vitest (store tests), pytest + FastAPI TestClient (backend tests)
Files
| Action | Path | Purpose |
|---|---|---|
| Modify | dev-api.py |
4 new endpoints (research GET/generate/task, contacts GET) |
| Create | tests/test_dev_api_prep.py |
Backend endpoint tests |
| Create | web/src/stores/prep.ts |
Pinia store (research, contacts, task status, polling) |
| Create | web/src/stores/prep.test.ts |
Store unit tests |
| Modify | web/src/views/InterviewPrepView.vue |
Full page implementation (replaces stub) |
Not changed: InterviewCard.vue, InterviewsView.vue, router/index.ts — navigation is already wired.
Verification (before starting): Confirm the "Prep →" button already exists:
web/src/components/InterviewCard.vueline 183:<button class="card-action" @click.stop="emit('prep', job.id)">Prep →</button>web/src/views/InterviewsView.vueline 464:@prep="router.push(\/prep/${$event}`)"`- If these lines are absent, add the button and handler before proceeding to Task 1.
Codebase orientation
dev-api.py— FastAPI app. Use_get_db()for SQLite connections. Pattern for background-task endpoints: seegenerate_cover_letter(line ~280) andcover_letter_task(line ~296). DB path comes fromDB_PATH = os.environ.get("STAGING_DB", "staging.db").scripts/db.py—get_research(db_path, job_id)returns dict or None.get_contacts(db_path, job_id)returns list oldest-first.company_researchcolumns:id, job_id, generated_at, company_brief, ceo_brief, talking_points, raw_output, tech_brief, funding_brief, competitors_brief, red_flags, accessibility_brief.scripts/task_runner.py—submit_task(db_path: Path, task_type: str, job_id: int) → (task_id: int, is_new: bool).web/src/composables/useApi.ts—useApiFetch<T>(url, opts?)returns{data: T|null, error: ApiError|null}, never rejects.web/src/stores/interviews.ts—PipelineJobinterface,useInterviewsStore. Important:PipelineJobdoes NOT includedescriptionorcover_letter(those fields are excluded from the/api/interviewsquery to keep kanban payloads small). The prep store fetches these on-demand fromGET /api/jobs/{id}(see below).GET /api/jobs/{job_id}(dev-api.pyline ~233) — returns full job includingdescription,cover_letter,match_score,keyword_gaps,url. Already exists, no changes needed.@vueuse/coreuseLocalStorage(key, default)— reactive ref backed by localStorage; already inpackage.json.- Test pattern: see
tests/test_dev_api_digest.pyfor fixture/monkeypatch pattern. Seeweb/src/stores/interviews.test.tsfor Vitest pattern.
Task 1: Backend — research + contacts endpoints + tests
Files:
-
Create:
tests/test_dev_api_prep.py -
Modify:
dev-api.py(add 4 endpoints aftercover_letter_task, beforedownload_pdfat line ~317) -
Step 1: Write the failing tests
Create tests/test_dev_api_prep.py:
"""Tests for interview prep API endpoints (research + contacts)."""
import sqlite3
import pytest
from fastapi.testclient import TestClient
@pytest.fixture()
def tmp_db(tmp_path):
"""Temp DB with the tables needed by prep endpoints."""
db_path = str(tmp_path / "staging.db")
con = sqlite3.connect(db_path)
con.executescript("""
CREATE TABLE jobs (
id INTEGER PRIMARY KEY,
title TEXT, company TEXT, url TEXT, location TEXT,
is_remote INTEGER DEFAULT 0, salary TEXT, description TEXT,
match_score REAL, keyword_gaps TEXT, status TEXT DEFAULT 'pending',
date_found TEXT, source TEXT, cover_letter TEXT,
applied_at TEXT, phone_screen_at TEXT, interviewing_at TEXT,
offer_at TEXT, hired_at TEXT, survey_at TEXT,
interview_date TEXT, rejection_stage TEXT
);
CREATE TABLE company_research (
id INTEGER PRIMARY KEY AUTOINCREMENT,
job_id INTEGER NOT NULL UNIQUE,
generated_at TEXT,
company_brief TEXT, ceo_brief TEXT, talking_points TEXT,
raw_output TEXT, tech_brief TEXT, funding_brief TEXT,
competitors_brief TEXT, red_flags TEXT, accessibility_brief TEXT
);
CREATE TABLE job_contacts (
id INTEGER PRIMARY KEY,
job_id INTEGER,
direction TEXT DEFAULT 'inbound',
subject TEXT, from_addr TEXT, body TEXT, received_at TEXT,
stage_signal TEXT, suggestion_dismissed INTEGER DEFAULT 0
);
CREATE TABLE background_tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_type TEXT NOT NULL,
job_id INTEGER DEFAULT 0,
params TEXT,
status TEXT NOT NULL DEFAULT 'queued',
stage TEXT, error TEXT
);
CREATE TABLE digest_queue (
id INTEGER PRIMARY KEY,
job_contact_id INTEGER, created_at TEXT
);
INSERT INTO jobs (id, title, company, url, status, source, date_found)
VALUES (1, 'Sr Engineer', 'Acme', 'https://acme.com/job/1',
'phone_screen', 'test', '2026-03-20');
""")
con.close()
return db_path
@pytest.fixture()
def client(tmp_db, monkeypatch):
monkeypatch.setenv("STAGING_DB", tmp_db)
import importlib
import dev_api
importlib.reload(dev_api)
return TestClient(dev_api.app)
def _seed_research(db_path: str) -> None:
con = sqlite3.connect(db_path)
con.execute("""
INSERT INTO company_research
(job_id, generated_at, company_brief, ceo_brief, talking_points,
tech_brief, funding_brief, red_flags, accessibility_brief)
VALUES
(1, '2026-03-20 12:00:00', 'Acme builds widgets.', 'CEO is Jane.',
'- Strong mission\n- Remote culture',
'Python, React', 'Series B $10M',
'No significant red flags.', 'Disability ERG active.')
""")
con.commit()
con.close()
def _seed_task(db_path: str, status: str = "queued",
stage: str | None = None, error: str | None = None) -> None:
con = sqlite3.connect(db_path)
con.execute(
"INSERT INTO background_tasks (task_type, job_id, status, stage, error)"
" VALUES ('company_research', 1, ?, ?, ?)",
(status, stage, error),
)
con.commit()
con.close()
def _seed_contacts(db_path: str) -> None:
con = sqlite3.connect(db_path)
con.executemany(
"INSERT INTO job_contacts (id, job_id, direction, subject, from_addr, body, received_at)"
" VALUES (?, 1, ?, ?, ?, ?, ?)",
[
(1, 'inbound', 'Phone screen invite', 'hr@acme.com',
'We would love to chat.', '2026-03-19T10:00:00'),
(2, 'outbound', 'Re: Phone screen', 'me@email.com',
'Sounds great, confirmed.', '2026-03-19T11:00:00'),
],
)
con.commit()
con.close()
# ── GET /api/jobs/{id}/research ──────────────────────────────────────────────
def test_research_get_not_found(client):
resp = client.get("/api/jobs/1/research")
assert resp.status_code == 404
def test_research_get_found(client, tmp_db):
_seed_research(tmp_db)
resp = client.get("/api/jobs/1/research")
assert resp.status_code == 200
data = resp.json()
assert data["company_brief"] == "Acme builds widgets."
assert data["talking_points"] == "- Strong mission\n- Remote culture"
assert data["generated_at"] == "2026-03-20 12:00:00"
assert "raw_output" not in data # stripped — not displayed in UI
def test_research_get_unknown_job(client):
resp = client.get("/api/jobs/999/research")
assert resp.status_code == 404
# ── POST /api/jobs/{id}/research/generate ───────────────────────────────────
def test_research_generate_queues_task(client, monkeypatch):
monkeypatch.setattr(
"scripts.task_runner.submit_task",
lambda db_path, task_type, job_id: (42, True),
)
resp = client.post("/api/jobs/1/research/generate")
assert resp.status_code == 200
data = resp.json()
assert data["task_id"] == 42
assert data["is_new"] is True
def test_research_generate_dedup(client, monkeypatch):
monkeypatch.setattr(
"scripts.task_runner.submit_task",
lambda db_path, task_type, job_id: (7, False),
)
resp = client.post("/api/jobs/1/research/generate")
assert resp.status_code == 200
assert resp.json()["is_new"] is False
# ── GET /api/jobs/{id}/research/task ────────────────────────────────────────
def test_research_task_no_task(client):
resp = client.get("/api/jobs/1/research/task")
assert resp.status_code == 200
data = resp.json()
assert data["status"] == "none"
assert data["stage"] is None
assert data["message"] is None
def test_research_task_running(client, tmp_db):
_seed_task(tmp_db, "running", stage="Scraping company site")
resp = client.get("/api/jobs/1/research/task")
assert resp.status_code == 200
data = resp.json()
assert data["status"] == "running"
assert data["stage"] == "Scraping company site"
assert data["message"] is None
def test_research_task_failed(client, tmp_db):
_seed_task(tmp_db, "failed", error="LLM timeout")
resp = client.get("/api/jobs/1/research/task")
assert resp.status_code == 200
assert resp.json()["status"] == "failed"
assert resp.json()["message"] == "LLM timeout"
# ── GET /api/jobs/{id}/contacts ──────────────────────────────────────────────
def test_contacts_empty(client):
resp = client.get("/api/jobs/1/contacts")
assert resp.status_code == 200
assert resp.json() == []
def test_contacts_returns_ordered_newest_first(client, tmp_db):
_seed_contacts(tmp_db)
resp = client.get("/api/jobs/1/contacts")
assert resp.status_code == 200
contacts = resp.json()
assert len(contacts) == 2
# newest first (outbound reply is more recent)
assert contacts[0]["direction"] == "outbound"
assert contacts[0]["subject"] == "Re: Phone screen"
assert contacts[1]["direction"] == "inbound"
assert "body" in contacts[0]
assert "from_addr" in contacts[0]
- Step 2: Run tests to verify they fail
/devl/miniconda3/envs/job-seeker/bin/pytest tests/test_dev_api_prep.py -v 2>&1 | head -40
Expected: all 11 tests fail with 404/422 errors (endpoints don't exist yet).
- Step 3: Add the 4 endpoints to
dev-api.py
Insert after cover_letter_task (after line ~312, before download_pdf):
# ── GET /api/jobs/:id/research ────────────────────────────────────────────────
@app.get("/api/jobs/{job_id}/research")
def get_research_brief(job_id: int):
from scripts.db import get_research as _get_research
row = _get_research(DEFAULT_DB, job_id=job_id)
if not row:
raise HTTPException(404, "No research found for this job")
row.pop("raw_output", None) # not displayed in UI
return row
# ── POST /api/jobs/:id/research/generate ─────────────────────────────────────
@app.post("/api/jobs/{job_id}/research/generate")
def generate_research(job_id: int):
try:
from scripts.task_runner import submit_task
task_id, is_new = submit_task(
db_path=Path(DB_PATH),
task_type="company_research",
job_id=job_id,
)
return {"task_id": task_id, "is_new": is_new}
except Exception as e:
raise HTTPException(500, str(e))
# ── GET /api/jobs/:id/research/task ──────────────────────────────────────────
@app.get("/api/jobs/{job_id}/research/task")
def research_task(job_id: int):
db = _get_db()
row = db.execute(
"SELECT status, stage, error FROM background_tasks "
"WHERE task_type = 'company_research' AND job_id = ? "
"ORDER BY id DESC LIMIT 1",
(job_id,),
).fetchone()
db.close()
if not row:
return {"status": "none", "stage": None, "message": None}
return {
"status": row["status"],
"stage": row["stage"],
"message": row["error"],
}
# ── GET /api/jobs/:id/contacts ────────────────────────────────────────────────
@app.get("/api/jobs/{job_id}/contacts")
def get_job_contacts(job_id: int):
db = _get_db()
rows = db.execute(
"SELECT id, direction, subject, from_addr, body, received_at "
"FROM job_contacts WHERE job_id = ? ORDER BY received_at DESC",
(job_id,),
).fetchall()
db.close()
return [dict(r) for r in rows]
- Step 4: Run tests to verify they pass
/devl/miniconda3/envs/job-seeker/bin/pytest tests/test_dev_api_prep.py -v
Expected: all 11 pass.
- Step 5: Run the full test suite to check for regressions
/devl/miniconda3/envs/job-seeker/bin/pytest tests/ -q --tb=short 2>&1 | tail -5
Expected: 539 passed, 0 failed.
- Step 6: Commit
git add dev-api.py tests/test_dev_api_prep.py
git commit -m "feat: add research and contacts endpoints for interview prep"
Task 2: Prep Pinia store + store tests
Files:
-
Create:
web/src/stores/prep.ts -
Create:
web/src/stores/prep.test.ts -
Step 1: Write the failing store tests
Create web/src/stores/prep.test.ts:
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { usePrepStore } from './prep'
vi.mock('../composables/useApi', () => ({
useApiFetch: vi.fn(),
}))
import { useApiFetch } from '../composables/useApi'
const mockFetch = vi.mocked(useApiFetch)
const SAMPLE_RESEARCH = {
job_id: 1,
company_brief: 'Acme builds widgets.',
ceo_brief: 'CEO is Jane.',
talking_points: '- Strong mission',
tech_brief: null,
funding_brief: null,
red_flags: null,
accessibility_brief: null,
generated_at: '2026-03-20 12:00:00',
}
const SAMPLE_CONTACTS = [
{ id: 2, direction: 'outbound', subject: 'Re: invite', from_addr: 'me@x.com',
body: 'Confirmed.', received_at: '2026-03-19T11:00:00' },
]
const SAMPLE_FULL_JOB = {
id: 1, title: 'Sr Engineer', company: 'Acme', url: 'https://acme.com/job/1',
description: 'We build widgets.', cover_letter: 'Dear Acme…',
match_score: 85, keyword_gaps: 'Rust',
}
const TASK_NONE = { status: 'none', stage: null, message: null }
const TASK_RUNNING = { status: 'running', stage: 'Scraping…', message: null }
const TASK_DONE = { status: 'completed', stage: null, message: null }
const TASK_FAILED = { status: 'failed', stage: null, message: 'LLM timeout' }
describe('usePrepStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
vi.clearAllMocks()
})
// fetchFor fires 4 parallel requests: research, contacts, task, full-job
// Order in Promise.all: research, contacts, task, fullJob
it('fetchFor loads research, contacts, task status, and full job in parallel', async () => {
mockFetch
.mockResolvedValueOnce({ data: SAMPLE_RESEARCH, error: null }) // research
.mockResolvedValueOnce({ data: SAMPLE_CONTACTS, error: null }) // contacts
.mockResolvedValueOnce({ data: TASK_NONE, error: null }) // task
.mockResolvedValueOnce({ data: SAMPLE_FULL_JOB, error: null }) // fullJob
const store = usePrepStore()
await store.fetchFor(1)
expect(store.research?.company_brief).toBe('Acme builds widgets.')
expect(store.contacts).toHaveLength(1)
expect(store.taskStatus.status).toBe('none')
expect(store.fullJob?.description).toBe('We build widgets.')
expect(store.fullJob?.cover_letter).toBe('Dear Acme…')
expect(store.currentJobId).toBe(1)
})
it('fetchFor clears state when called with a different jobId', async () => {
const store = usePrepStore()
// First load
mockFetch
.mockResolvedValueOnce({ data: SAMPLE_RESEARCH, error: null })
.mockResolvedValueOnce({ data: SAMPLE_CONTACTS, error: null })
.mockResolvedValueOnce({ data: TASK_NONE, error: null })
.mockResolvedValueOnce({ data: SAMPLE_FULL_JOB, error: null })
await store.fetchFor(1)
expect(store.research).not.toBeNull()
// Load different job — state clears first
mockFetch
.mockResolvedValueOnce({ data: null, error: { kind: 'http', status: 404, detail: '' } })
.mockResolvedValueOnce({ data: [], error: null })
.mockResolvedValueOnce({ data: TASK_NONE, error: null })
.mockResolvedValueOnce({ data: { ...SAMPLE_FULL_JOB, id: 2 }, error: null })
await store.fetchFor(2)
expect(store.research).toBeNull()
expect(store.currentJobId).toBe(2)
})
it('fetchFor starts polling when task is already running', async () => {
mockFetch
.mockResolvedValueOnce({ data: null, error: { kind: 'http', status: 404, detail: '' } })
.mockResolvedValueOnce({ data: [], error: null })
.mockResolvedValueOnce({ data: TASK_RUNNING, error: null }) // task running on mount
.mockResolvedValueOnce({ data: SAMPLE_FULL_JOB, error: null })
.mockResolvedValueOnce({ data: TASK_DONE, error: null }) // poll tick → done
// fetchFor re-runs after completion (4 fetches again):
.mockResolvedValueOnce({ data: SAMPLE_RESEARCH, error: null })
.mockResolvedValueOnce({ data: SAMPLE_CONTACTS, error: null })
.mockResolvedValueOnce({ data: TASK_NONE, error: null })
.mockResolvedValueOnce({ data: SAMPLE_FULL_JOB, error: null })
const store = usePrepStore()
await store.fetchFor(1)
expect(store.taskStatus.status).toBe('running')
// Advance timer by 3s — poll fires, task is done, fetchFor re-runs
await vi.advanceTimersByTimeAsync(3000)
expect(store.research?.company_brief).toBe('Acme builds widgets.')
})
it('generateResearch posts and starts polling', async () => {
mockFetch
.mockResolvedValueOnce({ data: { task_id: 42, is_new: true }, error: null }) // generate POST
.mockResolvedValueOnce({ data: TASK_RUNNING, error: null }) // first poll tick
.mockResolvedValueOnce({ data: TASK_DONE, error: null }) // second poll tick → done
// fetchFor re-runs (4 fetches):
.mockResolvedValueOnce({ data: SAMPLE_RESEARCH, error: null })
.mockResolvedValueOnce({ data: SAMPLE_CONTACTS, error: null })
.mockResolvedValueOnce({ data: TASK_NONE, error: null })
.mockResolvedValueOnce({ data: SAMPLE_FULL_JOB, error: null })
const store = usePrepStore()
store.currentJobId = 1 // simulate already loaded for job 1
await store.generateResearch(1)
await vi.advanceTimersByTimeAsync(3000) // first tick → running
await vi.advanceTimersByTimeAsync(3000) // second tick → done, re-fetch
expect(store.research?.company_brief).toBe('Acme builds widgets.')
})
it('clear cancels polling interval and resets state', async () => {
mockFetch
.mockResolvedValueOnce({ data: null, error: { kind: 'http', status: 404, detail: '' } })
.mockResolvedValueOnce({ data: [], error: null })
.mockResolvedValueOnce({ data: TASK_RUNNING, error: null })
.mockResolvedValueOnce({ data: SAMPLE_FULL_JOB, error: null })
const store = usePrepStore()
await store.fetchFor(1)
store.clear()
expect(store.research).toBeNull()
expect(store.contacts).toHaveLength(0)
expect(store.fullJob).toBeNull()
expect(store.currentJobId).toBeNull()
// Advance timer — no more fetch calls should happen
const callsBefore = mockFetch.mock.calls.length
await vi.advanceTimersByTimeAsync(3000)
expect(mockFetch.mock.calls.length).toBe(callsBefore) // no new calls
})
})
- Step 2: Run tests to verify they fail
cd /Library/Development/CircuitForge/peregrine/.worktrees/feature-vue-spa/web && npm test -- --reporter=verbose 2>&1 | grep -A 3 "prep.test"
Expected: import error or test failures (store doesn't exist yet).
- Step 3: Implement the store
Create web/src/stores/prep.ts:
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { useApiFetch } from '../composables/useApi'
export interface ResearchBrief {
job_id: number
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
// raw_output intentionally omitted — not displayed in UI
}
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 // maps background_tasks.error; matches cover_letter_task shape
}
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 pollId: ReturnType<typeof setInterval> | null = null
function _stopPoll() {
if (pollId !== null) { clearInterval(pollId); pollId = null }
}
async function pollTask(jobId: number) {
_stopPoll()
pollId = setInterval(async () => {
const { data } = await useApiFetch<TaskStatus>(`/api/jobs/${jobId}/research/task`)
if (!data) return
taskStatus.value = data
if (data.status === 'completed' || data.status === 'failed') {
_stopPoll()
if (data.status === 'completed') await fetchFor(jobId)
}
}, 3000)
}
async function fetchFor(jobId: number) {
if (jobId !== currentJobId.value) {
_stopPoll()
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
// 4 parallel fetches: research (may 404), contacts, task status, full job detail
// Full job needed for description + cover_letter (not on PipelineJob in interviews store)
const [resRes, conRes, taskRes, jobRes] = 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}`),
])
loading.value = false
// 404 = no research yet — not an error, show generate button
if (resRes.error && resRes.error.kind === 'http' && resRes.error.status !== 404) {
error.value = `Could not load research: ${resRes.error.status}`
} else {
research.value = resRes.data
}
contacts.value = conRes.data ?? []
taskStatus.value = taskRes.data ?? { status: null, stage: null, message: null }
fullJob.value = jobRes.data
if (taskStatus.value.status === 'queued' || taskStatus.value.status === 'running') {
pollTask(jobId)
}
}
async function generateResearch(jobId: number) {
const { error: err } = await useApiFetch(`/api/jobs/${jobId}/research/generate`, {
method: 'POST',
})
if (err) {
error.value = 'Could not start research generation'
return
}
taskStatus.value = { status: 'queued', stage: null, message: null }
pollTask(jobId)
}
function clear() {
_stopPoll()
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 }
})
- Step 4: Run tests to verify they pass
cd /Library/Development/CircuitForge/peregrine/.worktrees/feature-vue-spa/web && npm test -- --reporter=verbose 2>&1 | grep -A 5 "prep"
Expected: 5/5 prep store tests pass.
- Step 5: Run full frontend test suite
cd /Library/Development/CircuitForge/peregrine/.worktrees/feature-vue-spa/web && npm test
Expected: all tests pass (no regressions).
- Step 6: Commit
git add web/src/stores/prep.ts web/src/stores/prep.test.ts
git commit -m "feat: add prep Pinia store with research polling"
Task 3: InterviewPrepView.vue
Files:
- Modify:
web/src/views/InterviewPrepView.vue(replace stub)
No automated tests for the component itself — verified manually against the dev server.
Key patterns from the codebase:
-
useInterviewsStore()— already has all PipelineJob data (title, company, url, description, cover_letter, match_score, keyword_gaps, interview_date, status) -
usePrepStore()from Task 2 -
useLocalStorage(key, default)from@vueuse/core— reactive ref backed by localStorage -
useRouter()/useRoute()fromvue-routerfor redirect + params -
Active stages for prep:
phone_screen,interviewing,offer -
All CSS uses
var(--color-*),var(--space-*),var(--app-primary)etc. (seeAppNav.vuefor reference) -
Mobile breakpoint:
@media (max-width: 1023px)matches the rest of the app -
Step 1: Implement
InterviewPrepView.vue
Replace the stub with the full implementation:
<template>
<div v-if="job" class="prep-root">
<!-- ── Left column: research brief ─────────────────────────── -->
<section class="prep-left" aria-label="Interview preparation">
<!-- Job header -->
<header class="prep-header">
<h1 class="prep-title">{{ job.company }} — {{ job.title }}</h1>
<div class="prep-meta">
<span class="stage-badge" :class="`stage-badge--${job.status}`">
{{ STAGE_LABELS[job.status as PipelineStage] ?? job.status }}
</span>
<span v-if="countdownLabel" class="countdown">{{ countdownLabel }}</span>
</div>
<a v-if="job.url" :href="job.url" target="_blank" rel="noopener"
class="listing-link">Open job listing ↗</a>
</header>
<!-- Research controls -->
<div class="research-controls">
<!-- No research, no task running -->
<template v-if="!prepStore.research && !isTaskActive">
<p v-if="prepStore.error" class="research-error">{{ prepStore.error }}</p>
<button class="btn btn--primary" @click="prepStore.generateResearch(jobId)">
🔬 Generate research brief
</button>
</template>
<!-- Task running / queued -->
<template v-else-if="isTaskActive">
<div class="task-spinner" role="status">
<span class="spinner" aria-hidden="true"></span>
<span>{{ prepStore.taskStatus.stage || 'Generating… this may take 30–60 seconds' }}</span>
</div>
</template>
<!-- Research loaded header -->
<template v-else-if="prepStore.research">
<div class="research-meta">
<span class="research-ts">Generated: {{ prepStore.research.generated_at?.slice(0, 16) ?? '—' }}</span>
<button class="btn btn--ghost btn--sm"
:disabled="isTaskActive"
@click="prepStore.generateResearch(jobId)">
🔄 Refresh
</button>
</div>
<!-- Task failed while research exists -->
<p v-if="prepStore.taskStatus.status === 'failed'" class="research-error">
Refresh failed: {{ prepStore.taskStatus.message }}
<button class="btn btn--ghost btn--sm" @click="prepStore.generateResearch(jobId)">Retry</button>
</p>
</template>
<!-- Task failed, no research -->
<template v-else-if="prepStore.taskStatus.status === 'failed'">
<p class="research-error">Generation failed: {{ prepStore.taskStatus.message }}</p>
<button class="btn btn--primary" @click="prepStore.generateResearch(jobId)">Retry</button>
</template>
</div>
<!-- Research sections — only when loaded -->
<template v-if="prepStore.research">
<div class="research-divider"></div>
<section v-if="prepStore.research.talking_points?.trim()" class="research-section">
<h2 class="research-section__title">🎯 Talking Points</h2>
<pre class="research-text">{{ prepStore.research.talking_points }}</pre>
</section>
<section v-if="prepStore.research.company_brief?.trim()" class="research-section">
<h2 class="research-section__title">🏢 Company Overview</h2>
<pre class="research-text">{{ prepStore.research.company_brief }}</pre>
</section>
<section v-if="prepStore.research.ceo_brief?.trim()" class="research-section">
<h2 class="research-section__title">👤 Leadership & Culture</h2>
<pre class="research-text">{{ prepStore.research.ceo_brief }}</pre>
</section>
<section v-if="prepStore.research.tech_brief?.trim()" class="research-section">
<h2 class="research-section__title">⚙️ Tech Stack & Product</h2>
<pre class="research-text">{{ prepStore.research.tech_brief }}</pre>
</section>
<section v-if="prepStore.research.funding_brief?.trim()" class="research-section">
<h2 class="research-section__title">💰 Funding & Market Position</h2>
<pre class="research-text">{{ prepStore.research.funding_brief }}</pre>
</section>
<section v-if="showRedFlags" class="research-section research-section--warning">
<h2 class="research-section__title">⚠️ Red Flags & Watch-outs</h2>
<pre class="research-text">{{ prepStore.research.red_flags }}</pre>
</section>
<section v-if="prepStore.research.accessibility_brief?.trim()" class="research-section">
<h2 class="research-section__title">♿ Inclusion & Accessibility</h2>
<p class="research-privacy">For your personal evaluation — not disclosed in any application.</p>
<pre class="research-text">{{ prepStore.research.accessibility_brief }}</pre>
</section>
</template>
</section>
<!-- ── Right column: reference panel ───────────────────────── -->
<section class="prep-right" aria-label="Reference materials">
<!-- Tabs -->
<div class="ref-tabs" role="tablist">
<button
v-for="tab in TABS" :key="tab.id"
class="ref-tab"
:class="{ 'ref-tab--active': activeTab === tab.id }"
role="tab"
:aria-selected="activeTab === tab.id"
@click="activeTab = tab.id"
>{{ tab.label }}</button>
</div>
<div class="ref-panel">
<!-- Job Description -->
<div v-show="activeTab === 'jd'" role="tabpanel">
<!-- match_score + keyword_gaps come from prepStore.fullJob (not PipelineJob) -->
<div v-if="prepStore.fullJob?.match_score != null" class="score-row">
<span class="score-badge" :class="scoreBadgeClass">
{{ scoreBadgeLabel }}
</span>
<span v-if="prepStore.fullJob?.keyword_gaps" class="keyword-gaps">
Gaps: {{ prepStore.fullJob.keyword_gaps }}
</span>
</div>
<pre v-if="prepStore.fullJob?.description" class="research-text jd-text">{{ prepStore.fullJob.description }}</pre>
<p v-else class="empty-state">No description saved for this listing.</p>
</div>
<!-- Email History -->
<div v-show="activeTab === 'email'" role="tabpanel">
<p v-if="prepStore.contacts.length === 0" class="empty-state">
No emails logged yet.
</p>
<template v-else>
<article v-for="c in prepStore.contacts" :key="c.id" class="contact-item">
<div class="contact-header">
<span>{{ c.direction === 'inbound' ? '📥' : '📤' }}</span>
<strong class="contact-subject">{{ c.subject || '(no subject)' }}</strong>
<span class="contact-date">{{ c.received_at?.slice(0, 10) ?? '' }}</span>
</div>
<p v-if="c.from_addr" class="contact-from">From: {{ c.from_addr }}</p>
<pre v-if="c.body" class="contact-body">{{ c.body.slice(0, 500) }}{{ c.body.length > 500 ? '…' : '' }}</pre>
</article>
</template>
</div>
<!-- Cover Letter -->
<div v-show="activeTab === 'letter'" role="tabpanel">
<!-- cover_letter comes from prepStore.fullJob (not PipelineJob) -->
<pre v-if="prepStore.fullJob?.cover_letter?.trim()" class="research-text">{{ prepStore.fullJob.cover_letter }}</pre>
<p v-else class="empty-state">No cover letter saved for this job.</p>
</div>
</div>
<!-- Call Notes -->
<div class="notes-section">
<h2 class="notes-title">📝 Call Notes</h2>
<p class="notes-caption">Notes are saved locally — they won't sync between devices.</p>
<textarea
v-model="notes"
class="notes-textarea"
placeholder="Type notes during or after the call…"
rows="8"
></textarea>
</div>
</section>
</div>
<!-- Loading state — interviewsStore not yet populated -->
<div v-else class="prep-loading">
<span class="spinner" aria-hidden="true"></span>
<span>Loading…</span>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useLocalStorage } from '@vueuse/core'
import { useInterviewsStore } from '../stores/interviews'
import { usePrepStore } from '../stores/prep'
import type { PipelineStage } from '../stores/interviews'
import { STAGE_LABELS } from '../stores/interviews'
const route = useRoute()
const router = useRouter()
const interviewsStore = useInterviewsStore()
const prepStore = usePrepStore()
const ACTIVE_STAGES = new Set<string>(['phone_screen', 'interviewing', 'offer'])
const TABS = [
{ id: 'jd' as const, label: '📄 Job Description' },
{ id: 'email' as const, label: '📧 Email History' },
{ id: 'letter' as const, label: '📝 Cover Letter' },
] as const
type TabId = typeof TABS[number]['id']
const activeTab = ref<TabId>('jd')
// ── Job resolution ────────────────────────────────────────────────────────────
const jobId = computed(() => {
const raw = route.params.id
return Array.isArray(raw) ? parseInt(raw[0]) : parseInt(raw as string)
})
const job = computed(() =>
isNaN(jobId.value)
? null
: interviewsStore.jobs.find(j => j.id === jobId.value && ACTIVE_STAGES.has(j.status)) ?? null
)
// ── Interview date countdown ──────────────────────────────────────────────────
const countdownLabel = computed(() => {
const idate = job.value?.interview_date
if (!idate) return ''
const today = new Date(); today.setHours(0, 0, 0, 0)
const target = new Date(idate); target.setHours(0, 0, 0, 0)
const delta = Math.round((target.getTime() - today.getTime()) / 86_400_000)
if (delta === 0) return '🔴 TODAY'
if (delta === 1) return '🟡 TOMORROW'
if (delta > 0) return `🟢 in ${delta} days`
return `(was ${Math.abs(delta)}d ago)`
})
// ── Research task status helpers ──────────────────────────────────────────────
const isTaskActive = computed(() =>
prepStore.taskStatus.status === 'queued' || prepStore.taskStatus.status === 'running'
)
const showRedFlags = computed(() => {
const rf = prepStore.research?.red_flags?.trim()
return rf && !rf.toLowerCase().includes('no significant red flags')
})
// ── Match score badge — uses prepStore.fullJob (has description/cover_letter/match_score) ──
const scoreBadgeClass = computed(() => {
const s = prepStore.fullJob?.match_score
if (s == null) return ''
return s >= 70 ? 'score--green' : s >= 40 ? 'score--yellow' : 'score--red'
})
const scoreBadgeLabel = computed(() => {
const s = prepStore.fullJob?.match_score
if (s == null) return ''
const emoji = s >= 70 ? '🟢' : s >= 40 ? '🟡' : '🔴'
return `${emoji} ${s.toFixed(0)}% match`
})
// ── Call Notes — localStorage per job ────────────────────────────────────────
const notes = useLocalStorage(
computed(() => `cf-prep-notes-${jobId.value}`),
'',
)
// ── Lifecycle ─────────────────────────────────────────────────────────────────
onMounted(async () => {
// /prep with no id or non-numeric id → redirect
if (isNaN(jobId.value)) { router.replace('/interviews'); return }
// If interviews store is empty (direct navigation), fetch first
if (interviewsStore.jobs.length === 0) await interviewsStore.fetchAll()
// Job not found or wrong stage → redirect
if (!job.value) { router.replace('/interviews'); return }
await prepStore.fetchFor(jobId.value)
})
onUnmounted(() => {
prepStore.clear()
})
</script>
<style scoped>
/* ── Layout ──────────────────────────────────────────────────── */
.prep-root {
display: flex;
gap: var(--space-6);
padding: var(--space-6);
min-height: 100dvh;
align-items: flex-start;
}
.prep-left { flex: 0 0 40%; min-width: 0; }
.prep-right { flex: 1; min-width: 0; }
/* ── Job header ──────────────────────────────────────────────── */
.prep-title {
font-family: var(--font-display);
font-size: var(--text-xl);
font-weight: 700;
color: var(--color-text);
margin-bottom: var(--space-2);
}
.prep-meta {
display: flex;
align-items: center;
gap: var(--space-3);
flex-wrap: wrap;
margin-bottom: var(--space-3);
}
.stage-badge {
font-size: var(--text-xs);
font-weight: 600;
padding: 2px var(--space-2);
border-radius: var(--radius-full);
background: var(--app-primary-light);
color: var(--app-primary);
}
.countdown {
font-size: var(--text-sm);
color: var(--color-text-muted);
}
.listing-link {
display: inline-block;
font-size: var(--text-sm);
color: var(--app-primary);
text-decoration: none;
margin-bottom: var(--space-4);
}
.listing-link:hover { text-decoration: underline; }
/* ── Research controls ───────────────────────────────────────── */
.research-controls {
margin-bottom: var(--space-4);
}
.task-spinner {
display: flex;
align-items: center;
gap: var(--space-2);
font-size: var(--text-sm);
color: var(--color-text-muted);
}
.spinner {
display: inline-block;
width: 1rem;
height: 1rem;
border: 2px solid var(--color-border);
border-top-color: var(--app-primary);
border-radius: 50%;
animation: spin 0.8s linear infinite;
flex-shrink: 0;
}
@keyframes spin { to { transform: rotate(360deg); } }
.research-meta {
display: flex;
align-items: center;
gap: var(--space-3);
flex-wrap: wrap;
}
.research-ts {
font-size: var(--text-xs);
color: var(--color-text-muted);
}
.research-error {
color: var(--color-error, #c0392b);
font-size: var(--text-sm);
margin-bottom: var(--space-2);
}
.research-divider {
border-top: 1px solid var(--color-border-light);
margin: var(--space-4) 0;
}
/* ── Research sections ───────────────────────────────────────── */
.research-section {
margin-bottom: var(--space-5);
}
.research-section__title {
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-text);
margin-bottom: var(--space-2);
}
.research-section--warning {
background: var(--color-warning-bg, #fff8e1);
border-left: 3px solid var(--color-warning, #f39c12);
padding: var(--space-3);
border-radius: 0 var(--radius-md) var(--radius-md) 0;
}
.research-privacy {
font-size: var(--text-xs);
color: var(--color-text-muted);
font-style: italic;
margin-bottom: var(--space-2);
}
.research-text {
white-space: pre-wrap;
word-break: break-word;
font-family: var(--font-body, sans-serif);
font-size: var(--text-sm);
line-height: 1.6;
color: var(--color-text);
margin: 0;
}
/* ── Tabs ────────────────────────────────────────────────────── */
.ref-tabs {
display: flex;
gap: var(--space-1);
border-bottom: 2px solid var(--color-border);
margin-bottom: var(--space-4);
}
.ref-tab {
padding: var(--space-2) var(--space-3);
font-size: var(--text-sm);
font-weight: 500;
border: none;
background: none;
color: var(--color-text-muted);
cursor: pointer;
border-bottom: 2px solid transparent;
margin-bottom: -2px;
transition: color 150ms ease, border-color 150ms ease;
}
.ref-tab:hover { color: var(--app-primary); }
.ref-tab--active {
color: var(--app-primary);
border-bottom-color: var(--app-primary);
font-weight: 600;
}
.ref-panel {
min-height: 200px;
}
/* ── JD panel ────────────────────────────────────────────────── */
.score-row {
display: flex;
align-items: center;
gap: var(--space-3);
flex-wrap: wrap;
margin-bottom: var(--space-3);
font-size: var(--text-sm);
}
.score-badge { font-weight: 600; }
.score--green { color: var(--color-success, #27ae60); }
.score--yellow { color: var(--color-warning, #f39c12); }
.score--red { color: var(--color-error, #c0392b); }
.keyword-gaps { color: var(--color-text-muted); }
.jd-text {
max-height: 60vh;
overflow-y: auto;
}
/* ── Email tab ───────────────────────────────────────────────── */
.contact-item {
padding: var(--space-3) 0;
border-bottom: 1px solid var(--color-border-light);
}
.contact-item:last-child { border-bottom: none; }
.contact-header {
display: flex;
align-items: baseline;
gap: var(--space-2);
flex-wrap: wrap;
margin-bottom: var(--space-1);
}
.contact-subject {
font-size: var(--text-sm);
font-weight: 600;
}
.contact-date {
font-size: var(--text-xs);
color: var(--color-text-muted);
}
.contact-from {
font-size: var(--text-xs);
color: var(--color-text-muted);
margin-bottom: var(--space-1);
}
.contact-body {
white-space: pre-wrap;
word-break: break-word;
font-family: var(--font-body, sans-serif);
font-size: var(--text-xs);
color: var(--color-text-muted);
margin: 0;
max-height: 120px;
overflow: hidden;
}
/* ── Notes ───────────────────────────────────────────────────── */
.notes-section {
margin-top: var(--space-6);
border-top: 1px solid var(--color-border-light);
padding-top: var(--space-4);
}
.notes-title {
font-size: var(--text-sm);
font-weight: 600;
margin-bottom: var(--space-1);
}
.notes-caption {
font-size: var(--text-xs);
color: var(--color-text-muted);
margin-bottom: var(--space-2);
}
.notes-textarea {
width: 100%;
resize: vertical;
padding: var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-surface);
color: var(--color-text);
font-family: var(--font-body, sans-serif);
font-size: var(--text-sm);
line-height: 1.5;
transition: border-color 150ms ease;
}
.notes-textarea:focus {
outline: none;
border-color: var(--app-primary);
}
/* ── Buttons ─────────────────────────────────────────────────── */
.btn {
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding: var(--space-2) var(--space-4);
border-radius: var(--radius-md);
font-size: var(--text-sm);
font-weight: 500;
cursor: pointer;
border: 1px solid transparent;
transition: background 150ms ease, color 150ms ease;
}
.btn--primary {
background: var(--app-primary);
color: white;
border-color: var(--app-primary);
}
.btn--primary:hover { filter: brightness(1.1); }
.btn--ghost {
background: transparent;
color: var(--app-primary);
border-color: var(--app-primary);
}
.btn--ghost:hover { background: var(--app-primary-light); }
.btn--sm { padding: var(--space-1) var(--space-2); font-size: var(--text-xs); }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
/* ── Empty / loading states ──────────────────────────────────── */
.empty-state {
color: var(--color-text-muted);
font-size: var(--text-sm);
padding: var(--space-4) 0;
}
.prep-loading {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-3);
min-height: 40vh;
color: var(--color-text-muted);
}
/* ── Mobile: single column ───────────────────────────────────── */
@media (max-width: 1023px) {
.prep-root {
flex-direction: column;
padding: var(--space-4);
gap: var(--space-4);
}
.prep-left { flex: none; width: 100%; }
.prep-right { flex: none; width: 100%; }
}
</style>
- Step 2: Open the dev server and verify the page loads
# From the worktree web directory — dev server should already be running at :5173
# Navigate to http://10.1.10.71:5173/prep/<a job id in phone_screen/interviewing/offer>
# Verify:
# - Job header, stage badge, countdown show correctly
# - Left panel shows "Generate research brief" button (if no research)
# - Right panel shows JD / Email / Letter tabs
# - Switching tabs works
# - Notes textarea persists after page refresh (localStorage)
# - Navigating to /prep (no id) redirects to /interviews
- Step 3: Run full test suite (Python)
/devl/miniconda3/envs/job-seeker/bin/pytest tests/ -q --tb=short 2>&1 | tail -5
Expected: 539 passed, 0 failed.
- Step 4: Run full frontend tests
cd /Library/Development/CircuitForge/peregrine/.worktrees/feature-vue-spa/web && npm test
Expected: all pass.
- Step 5: Commit
git add web/src/views/InterviewPrepView.vue
git commit -m "feat: implement InterviewPrepView with research polling and reference tabs"