Compare commits

..

No commits in common. "a7303c1dfff19c35380521411265c0b8f8648eed" and "b9ef1f631e65d9c499570ee4c11e196f6d0045c6" have entirely different histories.

16 changed files with 39 additions and 6419 deletions

View file

@ -18,7 +18,6 @@ from fastapi import FastAPI, HTTPException, Response
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel from pydantic import BaseModel
from typing import Optional from typing import Optional
import requests
# Allow importing peregrine scripts for cover letter generation # Allow importing peregrine scripts for cover letter generation
PEREGRINE_ROOT = Path("/Library/Development/CircuitForge/peregrine") PEREGRINE_ROOT = Path("/Library/Development/CircuitForge/peregrine")
@ -313,192 +312,6 @@ def cover_letter_task(job_id: int):
} }
# ── Interview Prep endpoints ─────────────────────────────────────────────────
@app.get("/api/jobs/{job_id}/research")
def get_research_brief(job_id: int):
db = _get_db()
row = db.execute(
"SELECT job_id, company_brief, ceo_brief, talking_points, tech_brief, "
"funding_brief, red_flags, accessibility_brief, generated_at "
"FROM company_research WHERE job_id = ? LIMIT 1",
(job_id,),
).fetchone()
db.close()
if not row:
raise HTTPException(404, "No research found for this job")
return dict(row)
@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))
@app.get("/api/jobs/{job_id}/research/task")
def research_task_status(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"]}
@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]
# ── Survey endpoints ─────────────────────────────────────────────────────────
# Module-level imports so tests can patch dev_api.LLMRouter etc.
from scripts.llm_router import LLMRouter
from scripts.db import insert_survey_response, get_survey_responses
_SURVEY_SYSTEM = (
"You are a job application advisor helping a candidate answer a culture-fit survey. "
"The candidate values collaborative teamwork, clear communication, growth, and impact. "
"Choose answers that present them in the best professional light."
)
def _build_text_prompt(text: str, mode: str) -> str:
if mode == "quick":
return (
"Answer each survey question below. For each, give ONLY the letter of the best "
"option and a single-sentence reason. Format exactly as:\n"
"1. B — reason here\n2. A — reason here\n\n"
f"Survey:\n{text}"
)
return (
"Analyze each survey question below. For each question:\n"
"- Briefly evaluate each option (1 sentence each)\n"
"- State your recommendation with reasoning\n\n"
f"Survey:\n{text}"
)
def _build_image_prompt(mode: str) -> str:
if mode == "quick":
return (
"This is a screenshot of a culture-fit survey. Read all questions and answer each "
"with the letter of the best option for a collaborative, growth-oriented candidate. "
"Format: '1. B — brief reason' on separate lines."
)
return (
"This is a screenshot of a culture-fit survey. For each question, evaluate each option "
"and recommend the best choice for a collaborative, growth-oriented candidate. "
"Include a brief breakdown per option and a clear recommendation."
)
@app.get("/api/vision/health")
def vision_health():
try:
r = requests.get("http://localhost:8002/health", timeout=2)
return {"available": r.status_code == 200}
except Exception:
return {"available": False}
class SurveyAnalyzeBody(BaseModel):
text: Optional[str] = None
image_b64: Optional[str] = None
mode: str # "quick" or "detailed"
@app.post("/api/jobs/{job_id}/survey/analyze")
def survey_analyze(job_id: int, body: SurveyAnalyzeBody):
if body.mode not in ("quick", "detailed"):
raise HTTPException(400, f"Invalid mode: {body.mode!r}")
try:
router = LLMRouter()
if body.image_b64:
prompt = _build_image_prompt(body.mode)
output = router.complete(
prompt,
images=[body.image_b64],
fallback_order=router.config.get("vision_fallback_order"),
)
source = "screenshot"
else:
prompt = _build_text_prompt(body.text or "", body.mode)
output = router.complete(
prompt,
system=_SURVEY_SYSTEM,
fallback_order=router.config.get("research_fallback_order"),
)
source = "text_paste"
return {"output": output, "source": source}
except Exception as e:
raise HTTPException(500, str(e))
class SurveySaveBody(BaseModel):
survey_name: Optional[str] = None
mode: str
source: str
raw_input: Optional[str] = None
image_b64: Optional[str] = None
llm_output: str
reported_score: Optional[str] = None
@app.post("/api/jobs/{job_id}/survey/responses")
def save_survey_response(job_id: int, body: SurveySaveBody):
if body.mode not in ("quick", "detailed"):
raise HTTPException(400, f"Invalid mode: {body.mode!r}")
received_at = datetime.now().isoformat()
image_path = None
if body.image_b64:
try:
import base64
screenshots_dir = Path(DB_PATH).parent / "survey_screenshots" / str(job_id)
screenshots_dir.mkdir(parents=True, exist_ok=True)
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
img_path = screenshots_dir / f"{timestamp}.png"
img_path.write_bytes(base64.b64decode(body.image_b64))
image_path = str(img_path)
except Exception:
raise HTTPException(400, "Invalid image data")
row_id = insert_survey_response(
db_path=Path(DB_PATH),
job_id=job_id,
survey_name=body.survey_name,
received_at=received_at,
source=body.source,
raw_input=body.raw_input,
image_path=image_path,
mode=body.mode,
llm_output=body.llm_output,
reported_score=body.reported_score,
)
return {"id": row_id}
@app.get("/api/jobs/{job_id}/survey/responses")
def get_survey_history(job_id: int):
return get_survey_responses(db_path=Path(DB_PATH), job_id=job_id)
# ── GET /api/jobs/:id/cover_letter/pdf ─────────────────────────────────────── # ── GET /api/jobs/:id/cover_letter/pdf ───────────────────────────────────────
@app.get("/api/jobs/{job_id}/cover_letter/pdf") @app.get("/api/jobs/{job_id}/cover_letter/pdf")

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,184 +0,0 @@
# Interview Prep Page — Vue SPA Design
## Goal
Port the Streamlit Interview Prep page (`app/pages/6_Interview_Prep.py`) to a Vue 3 SPA view at `/prep/:id`, with a two-column layout, research brief generation, reference tabs, and localStorage call notes.
## Scope
**In scope:**
- Job header with stage badge + interview date countdown
- Research brief display with generate / refresh / polling
- All research sections: Talking Points, Company Overview, Leadership & Culture, Tech Stack (conditional), Funding (conditional), Red Flags (conditional), Inclusion & Accessibility (conditional)
- Reference panel: Job Description tab, Email History tab, Cover Letter tab
- Call Notes (localStorage, per job)
- Navigation: `/prep` redirects to `/interviews`; Interviews kanban adds "Prep →" link on active-stage cards
**Explicitly deferred:**
- Practice Q&A (LLM mock interviewer chat — needs streaming chat endpoint, deferred to a future sprint)
- "Draft reply to last email" LLM button in Email tab (present in Streamlit, requires additional LLM endpoint, deferred to a future sprint)
- Layout B / C options (full-width tabbed, accordion) — architecture supports future layout preference stored in localStorage
---
## Architecture
### Routing
- `/prep/:id` — renders `InterviewPrepView.vue` with the specified job
- `/prep` (no id) — redirects to `/interviews`
- On mount: if job id is missing, or job is not in `phone_screen` / `interviewing` / `offer`, redirect to `/interviews`
- Router already has both routes defined (`/prep` and `/prep/:id`)
### Backend — `dev-api.py` (4 new endpoints)
| Method | Path | Purpose |
|--------|------|---------|
| `GET` | `/api/jobs/{id}/research` | Returns `company_research` row for job, or 404 if none |
| `POST` | `/api/jobs/{id}/research/generate` | Submits `company_research` background task via `submit_task()`; returns `{task_id, is_new}` |
| `GET` | `/api/jobs/{id}/research/task` | Latest task status from `background_tasks`: `{status, stage, message}` — matches `cover_letter_task` response shape (`message` maps the `error` column) |
| `GET` | `/api/jobs/{id}/contacts` | Returns all `job_contacts` rows for this job, ordered by `received_at` desc |
Reuses existing patterns: `submit_task()` (same as cover letter), `background_tasks` query (same as `cover_letter_task`), `get_contacts()` (same as Streamlit). No schema changes.
### Store — `web/src/stores/prep.ts`
```ts
interface ResearchBrief {
company_brief: string | null
ceo_brief: string | null
talking_points: string | null
tech_brief: string | null // confirmed present in company_research (used in Streamlit 6_Interview_Prep.py:178)
funding_brief: string | null // confirmed present in company_research (used in Streamlit 6_Interview_Prep.py:185)
red_flags: string | null
accessibility_brief: string | null
generated_at: string | null
// raw_output is returned by the API but not used in the UI — intentionally omitted from interface
}
interface Contact {
id: number
direction: 'inbound' | 'outbound'
subject: string | null
from_addr: string | null
body: string | null
received_at: string | null
}
interface TaskStatus {
status: 'queued' | 'running' | 'completed' | 'failed' | 'none' | null
stage: string | null
message: string | null // maps the background_tasks.error column; matches cover_letter_task shape
}
```
State: `research: ResearchBrief | null`, `contacts: Contact[]`, `taskStatus: TaskStatus`, `loading: boolean`, `error: string | null`, `currentJobId: number | null`.
Methods:
- `fetchFor(jobId)` — clears state if `jobId !== currentJobId`, fires three parallel requests: `GET /research`, `GET /contacts`, `GET /research/task`. Stores results. If task status from the task fetch is `queued` or `running`, calls `pollTask(jobId)` to start the polling interval.
- `generateResearch(jobId)``POST /research/generate`, then calls `pollTask(jobId)`
- `pollTask(jobId)``setInterval` at 3s; stops when status is `completed` or `failed`; on `completed` re-calls `fetchFor(jobId)` to pull in fresh research
- `clear()` — cancels any active poll interval, resets all state
### Component — `web/src/views/InterviewPrepView.vue`
**Mount / unmount:**
- Reads `route.params.id`; redirects to `/interviews` if missing
- Looks up job in `interviewsStore.jobs`; redirects to `/interviews` if job status not in active stages
- Calls `prepStore.fetchFor(jobId)` on mount
- Calls `prepStore.clear()` on unmount (`onUnmounted`)
**Layout (desktop ≥1024px): two-column**
Left column (40%):
1. Job header
- Company + title (`h1`)
- Stage badge (pill)
- Interview date + countdown (🔴 TODAY / 🟡 TOMORROW / 🟢 in N days / grey "was N days ago")
- "Open job listing ↗" link button (if `job.url`)
2. Research controls
- State: `no research + no task` → "Generate research brief" primary button
- State: `task queued/running` → spinner + stage label (e.g. "Scraping company site…"), polling active
- State: `research loaded` → "Generated: {timestamp}" caption + "Refresh" button (disabled while task running)
- State: `task failed` → inline error + "Retry" button
3. Research sections (render only if non-empty string):
- 🎯 Talking Points
- 🏢 Company Overview
- 👤 Leadership & Culture
- ⚙️ Tech Stack & Product *(conditional)*
- 💰 Funding & Market Position *(conditional)*
- ⚠️ Red Flags & Watch-outs *(conditional; styled as warning block; skip if text contains "no significant red flags")*
- ♿ Inclusion & Accessibility *(conditional; privacy caption: "For your personal evaluation — not disclosed in any application.")*
Right column (60%):
1. Tabs: Job Description | Email History | Cover Letter
- **JD tab**: match score badge (🟢 ≥70% / 🟡 ≥40% / 🔴 <40%), keyword gaps, description rendered as markdown
- **Email tab**: list of contacts — icon (📥/📤) + subject + date + from_addr + first 500 chars of body; empty state if none
- **Letter tab**: cover letter markdown; empty state if none
2. Call Notes
- Textarea below tabs
- `v-model` bound to computed getter/setter reading `localStorage.getItem('cf-prep-notes-{jobId}')`
- Auto-saved via debounced `watch` (300ms)
- Caption: "Notes are saved locally — they won't sync between devices."
- **Intentional upgrade from Streamlit**: Streamlit stored notes in `session_state` only (lost on navigation). localStorage persists across page refreshes and sessions.
**Mobile (≤1023px):** single column — left panel content first (scrollable), then tabs panel below.
### Navigation addition — `InterviewsView.vue`
Add a "Prep →" `RouterLink` to `/prep/:id` on each job card in `phone_screen`, `interviewing`, and `offer` columns. Not shown in `applied`, `survey`, `hired`, or `interview_rejected`.
---
## Data Flow
```
User navigates to /prep/:id
→ InterviewPrepView mounts
→ redirect check (job in active stage?)
→ prepStore.fetchFor(id)
├─ GET /api/jobs/{id}/research (parallel)
├─ GET /api/jobs/{id}/contacts (parallel)
└─ GET /api/jobs/{id}/research/task (parallel — to check if a task is already running)
→ if task running: pollTask(id) starts interval
→ user clicks "Generate" / "Refresh"
→ POST /api/jobs/{id}/research/generate
→ pollTask(id) starts
→ GET /api/jobs/{id}/research/task every 3s
→ on completed: fetchFor(id) re-fetches research
User navigates away
→ prepStore.clear() cancels interval
```
---
## Error Handling
- Research fetch 404 → `research` stays null, show generate button
- Research fetch network/5xx → show inline error in left column
- Contacts fetch error → show "Could not load email history" in Email tab
- Generate task failure → `taskStatus.message` shown with "Retry" button
- Job not found / wrong stage → redirect to `/interviews` (no error flash)
---
## Testing
New test files:
- `tests/test_dev_api_prep.py` — covers all 4 endpoints: research GET (found/not-found), generate (new/duplicate), task status, contacts GET
- `web/src/stores/prep.test.ts` — unit tests for `fetchFor`, `generateResearch`, `pollTask` (mock `useApiFetch`), `clear` cancels interval
No new DB migrations. All DB access uses existing `scripts/db.py` helpers.
---
## Files Changed
| Action | Path |
|--------|------|
| Modify | `dev-api.py` — 4 new endpoints |
| Create | `tests/test_dev_api_prep.py` |
| Create | `web/src/stores/prep.ts` |
| Modify | `web/src/views/InterviewPrepView.vue` — full implementation |
| Modify | `web/src/views/InterviewsView.vue` — add "Prep →" links |
| Create | `web/src/stores/prep.test.ts` |

View file

@ -1,224 +0,0 @@
# Survey Assistant Page — Vue SPA Design
## Goal
Port the Streamlit Survey Assistant page (`app/pages/7_Survey.py`) to a Vue 3 SPA view at `/survey/:id`, with a calm single-column layout, text paste and screenshot input, Quick/Detailed mode selection, LLM analysis, save-to-job, and response history.
## Scope
**In scope:**
- Routing at `/survey/:id` with redirect guard (job must be in `survey`, `phone_screen`, `interviewing`, or `offer`)
- `/survey` (no id) redirects to `/interviews`
- Calm single-column layout (max-width 760px, centered) with sticky job context bar
- Input: tabbed text paste / screenshot (paste Ctrl+V + drag-and-drop + file upload)
- Screenshot tab disabled (but visible) when vision service is unavailable
- Mode selection: two full-width labeled cards (Quick / Detailed)
- Synchronous LLM analysis via new backend endpoint
- Results rendered below input after analysis
- Save to job: optional survey name + reported score
- Response history: collapsible accordion, closed by default
- "Survey →" navigation link on kanban cards in `survey`, `phone_screen`, `interviewing`, `offer` stages
**Explicitly deferred:**
- Streaming LLM responses (requires SSE endpoint — deferred to future sprint)
- Mock Q&A / interview practice chat (separate feature, requires streaming chat endpoint)
---
## Architecture
### Routing
- `/survey/:id` — renders `SurveyView.vue` with the specified job
- `/survey` (no id) — redirects to `/interviews`
- On mount: if job id is missing, or job status not in `['survey', 'phone_screen', 'interviewing', 'offer']`, redirect to `/interviews`
### Backend — `dev-api.py` (4 new endpoints)
| Method | Path | Purpose |
|--------|------|---------|
| `GET` | `/api/vision/health` | Proxy to vision service health check; returns `{available: bool}` |
| `POST` | `/api/jobs/{id}/survey/analyze` | Accepts `{text?, image_b64?, mode}`; runs LLM synchronously; returns `{output, source}` |
| `POST` | `/api/jobs/{id}/survey/responses` | Saves survey response to `survey_responses` table; saves image file if `image_b64` provided |
| `GET` | `/api/jobs/{id}/survey/responses` | Returns all `survey_responses` rows for job, newest first |
**Analyze endpoint details:**
- `mode`: `"quick"` or `"detailed"` (lowercase) — frontend sends lowercase; backend uses as-is to select prompt template (same as Streamlit `_build_text_prompt` / `_build_image_prompt`, which expect lowercase `mode`)
- If `image_b64` provided: routes through `vision_fallback_order`; **no system prompt** (matches Streamlit vision path); `source = "screenshot"`
- If `text` provided: routes through `research_fallback_order`; passes `system=_SURVEY_SYSTEM` (matches Streamlit text path); `source = "text_paste"`
- `_SURVEY_SYSTEM` constant: `"You are a job application advisor helping a candidate answer a culture-fit survey. The candidate values collaborative teamwork, clear communication, growth, and impact. Choose answers that present them in the best professional light."`
- Returns `{output: str, source: str}` on success; raises HTTP 500 on LLM failure
**Save endpoint details:**
- Body: `{survey_name?, mode, source, raw_input?, image_b64?, llm_output, reported_score?}`
- Backend generates `received_at = datetime.now().isoformat()` — not passed by client
- If `image_b64` present: saves PNG to `data/survey_screenshots/{job_id}/{timestamp}.png`; stores path in `image_path` column; `image_b64` is NOT stored in DB
- Calls `scripts.db.insert_survey_response(db_path, job_id, survey_name, received_at, source, raw_input, image_path, mode, llm_output, reported_score)` — note `received_at` is the second positional arg after `job_id`
- `SurveyResponse.created_at` in the store interface maps the DB `created_at` column (SQLite auto-set on insert); `received_at` is a separate column storing the analysis timestamp — both are returned by `GET /survey/responses`; store interface exposes `received_at` for display
- Returns `{id: int}` of new row
**Vision health endpoint:**
- Attempts `GET http://localhost:8002/health` with 2s timeout
- Returns `{available: true}` on 200, `{available: false}` on any error/timeout
### Store — `web/src/stores/survey.ts`
```ts
interface SurveyAnalysis {
output: string
source: 'text_paste' | 'screenshot'
mode: 'quick' | 'detailed' // retained so saveResponse can include it
rawInput: string | null // retained so saveResponse can include raw_input
}
interface SurveyResponse {
id: number
survey_name: string | null
mode: 'quick' | 'detailed'
source: string
raw_input: string | null
image_path: string | null
llm_output: string
reported_score: string | null
received_at: string | null // analysis timestamp (from DB received_at column)
created_at: string | null // row insert timestamp (SQLite auto)
}
```
State: `analysis: SurveyAnalysis | null`, `history: SurveyResponse[]`, `loading: boolean`, `saving: boolean`, `error: string | null`, `visionAvailable: boolean`, `currentJobId: number | null`
Methods:
- `fetchFor(jobId)` — clears state if `jobId !== currentJobId`; fires two parallel requests: `GET /api/jobs/{id}/survey/responses` and `GET /api/vision/health`; stores results
- `analyze(jobId, payload: {text?: string, image_b64?: string, mode: 'quick' | 'detailed'})` — sets `loading = true`; POST to analyze endpoint; stores result in `analysis` (including `mode` and `rawInput = payload.text ?? null` for later use by `saveResponse`); sets `error` on failure
- `saveResponse(jobId, {surveyName: string, reportedScore: string, image_b64?: string})` — sets `saving = true`; constructs full save body from current `analysis` (`mode`, `source`, `rawInput`, `llm_output`) + method args; POST to save endpoint; prepends new response to `history`; clears `analysis`; sets `error` on failure
- `clear()` — resets all state to initial values
### Component — `web/src/views/SurveyView.vue`
**Mount / unmount:**
- Reads `route.params.id`; redirects to `/interviews` if missing or non-numeric
- Looks up job in `interviewsStore.jobs` (fetches if empty); redirects if job status not in valid stages
- Calls `surveyStore.fetchFor(jobId)` on mount
- Calls `surveyStore.clear()` on unmount
**Layout:** Single column, `max-width: 760px`, centered (`margin: 0 auto`), padding `var(--space-6)`.
**1. Sticky context bar**
- Sticky top, low height (~40px), soft background color
- Shows: company name + job title + stage badge
- Always visible while scrolling
**2. Input card**
- Tabs: "📝 Paste Text" (always active) / "📷 Screenshot"
- Screenshot tab: rendered but non-interactive (`aria-disabled`) when `!surveyStore.visionAvailable`; tooltip on hover: "Vision service not running — start it with: bash scripts/manage-vision.sh start"
- **Text tab:** `<textarea>` with placeholder showing example Q&A format, min-height 200px
- **Screenshot tab:** Combined drop zone with three affordances:
- Paste: listens for `paste` event on the zone (Ctrl+V); accepts `image/*` items from `ClipboardEvent.clipboardData`
- Drag-and-drop: `dragover` / `drop` events; accepts image files
- File upload: `<input type="file" accept="image/*">` button within the zone
- Preview: shows thumbnail of loaded image with "✕ Remove" button
- Stores image as base64 string in component state
**3. Mode selection**
- Two full-width stacked cards, one per mode:
- ⚡ **Quick** — "Best answer + one-liner per question"
- 📋 **Detailed** — "Option-by-option breakdown with reasoning"
- Selected card: border highlight + subtle background fill
- Reactive `selectedMode` ref, default `'quick'`
**4. Analyze button**
- Full-width primary button
- Disabled when: no text input AND no image loaded
- While `surveyStore.loading`: shows spinner + "Analyzing…" label, disabled
- On click: calls `surveyStore.analyze(jobId, {text?, image_b64?, mode: selectedMode})`
**5. Results card** (rendered when `surveyStore.analysis` is set)
- Appears below the Analyze button (pushes history further down)
- LLM output rendered with `whitespace-pre-wrap`
- Inline save form below output:
- Optional "Survey name" text input (placeholder: "e.g. Culture Fit Round 1")
- Optional "Reported score" text input (placeholder: "e.g. 82% or 4.2/5")
- "💾 Save to job" button — calls `surveyStore.saveResponse()`; shows spinner while `surveyStore.saving`
- Inline success message on save; clears results card
**6. History accordion**
- Header: "Survey history (N responses)" — closed by default
- Low visual weight (muted header style)
- Each entry: survey name (fallback "Survey response") + date + score if present
- Expandable per entry: shows full LLM output + mode + source + `received_at` timestamp
- `raw_input` and `image_path` are intentionally not shown in history — raw input can be long and images are not served by the API
- Empty state if no history
**Error display:**
- Analyze error: inline below Analyze button
- Save error: inline below save form (analysis output preserved)
- Store-level load error (history/vision fetch): subtle banner below context bar
**Mobile:** identical — already single column.
### Navigation addition — `InterviewsView.vue` / `InterviewCard.vue`
Follow the existing `InterviewCard.vue` emit pattern (same as "Prep →"):
- Add `emit('survey', job.id)` button to `InterviewCard.vue` with `v-if="['survey', 'phone_screen', 'interviewing', 'offer'].includes(job.status)"`
- Add `@survey="router.push('/survey/' + $event)"` handler in `InterviewsView.vue` on the relevant column card instances
Do NOT use a `RouterLink` directly on the card — the established pattern is event emission to the parent view for navigation.
---
## Data Flow
```
User navigates to /survey/:id (from kanban "Survey →" link)
→ SurveyView mounts
→ redirect check (job in valid stage?)
→ surveyStore.fetchFor(id)
├─ GET /api/jobs/{id}/survey/responses (parallel)
└─ GET /api/vision/health (parallel)
→ user pastes text OR uploads/pastes/drags screenshot
→ user selects mode (Quick / Detailed)
→ user clicks Analyze
→ POST /api/jobs/{id}/survey/analyze
→ surveyStore.analysis set with output
→ user reviews output
→ user optionally fills survey name + reported score
→ user clicks Save
→ POST /api/jobs/{id}/survey/responses
→ new entry prepended to surveyStore.history
→ results card cleared
User navigates away
→ surveyStore.clear() resets state
```
---
## Error Handling
- Vision health check fails → `visionAvailable = false`; screenshot tab disabled; text input unaffected
- Analyze POST fails → `error` set; inline error below button; input preserved for retry
- Save POST fails → `saving` error set; inline error on save form; analysis output preserved
- Job not found / wrong stage → redirect to `/interviews`
- History fetch fails → empty history, inline error banner; does not block analyze flow
---
## Testing
New test files:
- `tests/test_dev_api_survey.py` — covers all 4 endpoints: vision health (up/down), analyze text (quick/detailed), analyze image, analyze LLM failure, save response (with/without image), get history (empty/populated)
- `web/src/stores/survey.test.ts` — unit tests: `fetchFor` parallel loads, job change clears state, `analyze` stores result, `analyze` sets error on failure, `saveResponse` prepends to history and clears analysis, `clear` resets all state
No new DB migrations. All DB access uses existing `scripts/db.py` helpers (`insert_survey_response`, `get_survey_responses`).
---
## Files Changed
| Action | Path |
|--------|------|
| Modify | `dev-api.py` — 4 new endpoints |
| Create | `tests/test_dev_api_survey.py` |
| Create | `web/src/stores/survey.ts` |
| Create | `web/src/stores/survey.test.ts` |
| Create | `web/src/views/SurveyView.vue` — full implementation (replaces placeholder stub) |
| Modify | `web/src/components/InterviewCard.vue` — add "Survey →" link |

View file

@ -1,161 +0,0 @@
"""Tests for interview prep endpoints: research GET/generate/task, contacts GET."""
import json
import pytest
from unittest.mock import patch, MagicMock
from fastapi.testclient import TestClient
@pytest.fixture
def client():
import sys
sys.path.insert(0, "/Library/Development/CircuitForge/peregrine/.worktrees/feature-vue-spa")
from dev_api import app
return TestClient(app)
# ── /api/jobs/{id}/research ─────────────────────────────────────────────────
def test_get_research_found(client):
"""Returns research row (minus raw_output) when present."""
import sqlite3
mock_row = {
"job_id": 1,
"company_brief": "Acme Corp makes anvils.",
"ceo_brief": "Wile E Coyote",
"talking_points": "- Ask about roadrunner containment",
"tech_brief": "Python, Rust",
"funding_brief": "Series B",
"red_flags": None,
"accessibility_brief": None,
"generated_at": "2026-03-20T12:00:00",
}
mock_db = MagicMock()
mock_db.execute.return_value.fetchone.return_value = mock_row
with patch("dev_api._get_db", return_value=mock_db):
resp = client.get("/api/jobs/1/research")
assert resp.status_code == 200
data = resp.json()
assert data["company_brief"] == "Acme Corp makes anvils."
assert "raw_output" not in data
def test_get_research_not_found(client):
"""Returns 404 when no research row exists for job."""
mock_db = MagicMock()
mock_db.execute.return_value.fetchone.return_value = None
with patch("dev_api._get_db", return_value=mock_db):
resp = client.get("/api/jobs/99/research")
assert resp.status_code == 404
# ── /api/jobs/{id}/research/generate ────────────────────────────────────────
def test_generate_research_new_task(client):
"""POST generate returns task_id and is_new=True for fresh submission."""
with patch("scripts.task_runner.submit_task", return_value=(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_generate_research_duplicate_task(client):
"""POST generate returns is_new=False when task already queued."""
with patch("scripts.task_runner.submit_task", return_value=(17, False)):
resp = client.post("/api/jobs/1/research/generate")
assert resp.status_code == 200
data = resp.json()
assert data["is_new"] is False
def test_generate_research_error(client):
"""POST generate returns 500 when submit_task raises."""
with patch("scripts.task_runner.submit_task", side_effect=Exception("LLM unavailable")):
resp = client.post("/api/jobs/1/research/generate")
assert resp.status_code == 500
# ── /api/jobs/{id}/research/task ────────────────────────────────────────────
def test_research_task_none(client):
"""Returns status=none when no background task exists for job."""
mock_db = MagicMock()
mock_db.execute.return_value.fetchone.return_value = None
with patch("dev_api._get_db", return_value=mock_db):
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):
"""Returns current status/stage/message for an active task."""
mock_row = {"status": "running", "stage": "Scraping company site", "error": None}
mock_db = MagicMock()
mock_db.execute.return_value.fetchone.return_value = mock_row
with patch("dev_api._get_db", return_value=mock_db):
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):
"""Returns message (mapped from error column) for failed task."""
mock_row = {"status": "failed", "stage": None, "error": "LLM timeout"}
mock_db = MagicMock()
mock_db.execute.return_value.fetchone.return_value = mock_row
with patch("dev_api._get_db", return_value=mock_db):
resp = client.get("/api/jobs/1/research/task")
assert resp.status_code == 200
data = resp.json()
assert data["status"] == "failed"
assert data["message"] == "LLM timeout"
# ── /api/jobs/{id}/contacts ──────────────────────────────────────────────────
def test_get_contacts_empty(client):
"""Returns empty list when job has no contacts."""
mock_db = MagicMock()
mock_db.execute.return_value.fetchall.return_value = []
with patch("dev_api._get_db", return_value=mock_db):
resp = client.get("/api/jobs/1/contacts")
assert resp.status_code == 200
assert resp.json() == []
def test_get_contacts_list(client):
"""Returns list of contact dicts for job."""
mock_rows = [
{"id": 1, "direction": "inbound", "subject": "Interview next week",
"from_addr": "hr@acme.com", "body": "Hi! We'd like to...", "received_at": "2026-03-19T10:00:00"},
{"id": 2, "direction": "outbound", "subject": "Re: Interview next week",
"from_addr": None, "body": "Thank you!", "received_at": "2026-03-19T11:00:00"},
]
mock_db = MagicMock()
mock_db.execute.return_value.fetchall.return_value = mock_rows
with patch("dev_api._get_db", return_value=mock_db):
resp = client.get("/api/jobs/1/contacts")
assert resp.status_code == 200
data = resp.json()
assert len(data) == 2
assert data[0]["direction"] == "inbound"
assert data[1]["direction"] == "outbound"
def test_get_contacts_ordered_by_received_at(client):
"""Most recent contacts appear first (ORDER BY received_at DESC)."""
mock_db = MagicMock()
mock_db.execute.return_value.fetchall.return_value = []
with patch("dev_api._get_db", return_value=mock_db):
resp = client.get("/api/jobs/99/contacts")
# Verify the SQL contains ORDER BY received_at DESC
call_args = mock_db.execute.call_args
sql = call_args[0][0]
assert "ORDER BY received_at DESC" in sql

View file

@ -1,164 +0,0 @@
"""Tests for survey endpoints: vision health, analyze, save response, get history."""
import pytest
from unittest.mock import patch, MagicMock
from fastapi.testclient import TestClient
@pytest.fixture
def client():
import sys
sys.path.insert(0, "/Library/Development/CircuitForge/peregrine/.worktrees/feature-vue-spa")
from dev_api import app
return TestClient(app)
# ── GET /api/vision/health ───────────────────────────────────────────────────
def test_vision_health_available(client):
"""Returns available=true when vision service responds 200."""
mock_resp = MagicMock()
mock_resp.status_code = 200
with patch("dev_api.requests.get", return_value=mock_resp):
resp = client.get("/api/vision/health")
assert resp.status_code == 200
assert resp.json() == {"available": True}
def test_vision_health_unavailable(client):
"""Returns available=false when vision service times out or errors."""
with patch("dev_api.requests.get", side_effect=Exception("timeout")):
resp = client.get("/api/vision/health")
assert resp.status_code == 200
assert resp.json() == {"available": False}
# ── POST /api/jobs/{id}/survey/analyze ──────────────────────────────────────
def test_analyze_text_quick(client):
"""Text mode quick analysis returns output and source=text_paste."""
mock_router = MagicMock()
mock_router.complete.return_value = "1. B — best option"
mock_router.config.get.return_value = ["claude_code", "vllm"]
with patch("dev_api.LLMRouter", return_value=mock_router):
resp = client.post("/api/jobs/1/survey/analyze", json={
"text": "Q1: Do you prefer teamwork?\nA. Solo B. Together",
"mode": "quick",
})
assert resp.status_code == 200
data = resp.json()
assert data["source"] == "text_paste"
assert "B" in data["output"]
# System prompt must be passed for text path
call_kwargs = mock_router.complete.call_args[1]
assert "system" in call_kwargs
assert "culture-fit survey" in call_kwargs["system"]
def test_analyze_text_detailed(client):
"""Text mode detailed analysis passes correct prompt."""
mock_router = MagicMock()
mock_router.complete.return_value = "Option A: good for... Option B: better because..."
mock_router.config.get.return_value = []
with patch("dev_api.LLMRouter", return_value=mock_router):
resp = client.post("/api/jobs/1/survey/analyze", json={
"text": "Q1: Describe your work style.",
"mode": "detailed",
})
assert resp.status_code == 200
assert resp.json()["source"] == "text_paste"
def test_analyze_image(client):
"""Image mode routes through vision path with NO system prompt."""
mock_router = MagicMock()
mock_router.complete.return_value = "1. C — collaborative choice"
mock_router.config.get.return_value = ["vision_service", "claude_code"]
with patch("dev_api.LLMRouter", return_value=mock_router):
resp = client.post("/api/jobs/1/survey/analyze", json={
"image_b64": "aGVsbG8=",
"mode": "quick",
})
assert resp.status_code == 200
data = resp.json()
assert data["source"] == "screenshot"
# No system prompt on vision path
call_kwargs = mock_router.complete.call_args[1]
assert "system" not in call_kwargs
def test_analyze_llm_failure(client):
"""Returns 500 when LLM raises an exception."""
mock_router = MagicMock()
mock_router.complete.side_effect = Exception("LLM unavailable")
mock_router.config.get.return_value = []
with patch("dev_api.LLMRouter", return_value=mock_router):
resp = client.post("/api/jobs/1/survey/analyze", json={
"text": "Q1: test",
"mode": "quick",
})
assert resp.status_code == 500
# ── POST /api/jobs/{id}/survey/responses ────────────────────────────────────
def test_save_response_text(client):
"""Save text response writes to DB and returns id."""
mock_db = MagicMock()
with patch("dev_api._get_db", return_value=mock_db):
with patch("dev_api.insert_survey_response", return_value=42) as mock_insert:
resp = client.post("/api/jobs/1/survey/responses", json={
"mode": "quick",
"source": "text_paste",
"raw_input": "Q1: test question",
"llm_output": "1. B — good reason",
})
assert resp.status_code == 200
assert resp.json()["id"] == 42
# received_at generated by backend — not None
call_args = mock_insert.call_args
assert call_args[1]["received_at"] is not None or call_args[0][3] is not None
def test_save_response_with_image(client, tmp_path, monkeypatch):
"""Save image response writes PNG file and stores path in DB."""
monkeypatch.setenv("STAGING_DB", str(tmp_path / "test.db"))
with patch("dev_api.insert_survey_response", return_value=7) as mock_insert:
with patch("dev_api.Path") as mock_path_cls:
mock_path_cls.return_value.__truediv__ = lambda s, o: tmp_path / o
resp = client.post("/api/jobs/1/survey/responses", json={
"mode": "quick",
"source": "screenshot",
"image_b64": "aGVsbG8=", # valid base64
"llm_output": "1. B — reason",
})
assert resp.status_code == 200
assert resp.json()["id"] == 7
# ── GET /api/jobs/{id}/survey/responses ─────────────────────────────────────
def test_get_history_empty(client):
"""Returns empty list when no history exists."""
with patch("dev_api.get_survey_responses", return_value=[]):
resp = client.get("/api/jobs/1/survey/responses")
assert resp.status_code == 200
assert resp.json() == []
def test_get_history_populated(client):
"""Returns history rows newest first."""
rows = [
{"id": 2, "survey_name": "Round 2", "mode": "detailed", "source": "text_paste",
"raw_input": None, "image_path": None, "llm_output": "Option A is best",
"reported_score": "90%", "received_at": "2026-03-21T14:00:00", "created_at": "2026-03-21T14:00:01"},
{"id": 1, "survey_name": "Round 1", "mode": "quick", "source": "text_paste",
"raw_input": "Q1: test", "image_path": None, "llm_output": "1. B",
"reported_score": None, "received_at": "2026-03-21T12:00:00", "created_at": "2026-03-21T12:00:01"},
]
with patch("dev_api.get_survey_responses", return_value=rows):
resp = client.get("/api/jobs/1/survey/responses")
assert resp.status_code == 200
data = resp.json()
assert len(data) == 2
assert data[0]["id"] == 2
assert data[0]["survey_name"] == "Round 2"

View file

@ -12,7 +12,6 @@ const props = defineProps<{
const emit = defineEmits<{ const emit = defineEmits<{
move: [jobId: number, preSelectedStage?: PipelineStage] move: [jobId: number, preSelectedStage?: PipelineStage]
prep: [jobId: number] prep: [jobId: number]
survey: [jobId: number]
}>() }>()
// Signal state // Signal state
@ -177,15 +176,11 @@ const columnColor = computed(() => {
<div v-if="interviewDateLabel" class="date-chip"> <div v-if="interviewDateLabel" class="date-chip">
{{ dateChipIcon }} {{ interviewDateLabel }} {{ dateChipIcon }} {{ interviewDateLabel }}
</div> </div>
<div class="research-badge research-badge--done">🔬 Research ready</div>
</div> </div>
<footer class="card-footer"> <footer class="card-footer">
<button class="card-action" @click.stop="emit('move', job.id)">Move to </button> <button class="card-action" @click.stop="emit('move', job.id)">Move to </button>
<button v-if="['phone_screen', 'interviewing', 'offer'].includes(job.status)" class="card-action" @click.stop="emit('prep', job.id)">Prep </button> <button class="card-action" @click.stop="emit('prep', job.id)">Prep </button>
<button
v-if="['survey', 'phone_screen', 'interviewing', 'offer'].includes(job.status)"
class="card-action"
@click.stop="emit('survey', job.id)"
>Survey </button>
</footer> </footer>
<!-- Signal banners --> <!-- Signal banners -->
<template v-if="job.stage_signals?.length"> <template v-if="job.stage_signals?.length">
@ -336,6 +331,23 @@ const columnColor = computed(() => {
align-self: flex-start; align-self: flex-start;
} }
.research-badge {
display: inline-flex;
align-items: center;
gap: 3px;
border-radius: 99px;
padding: 2px 8px;
font-size: 0.7rem;
font-weight: 700;
align-self: flex-start;
margin-top: 2px;
}
.research-badge--done {
background: color-mix(in srgb, var(--status-phone) 12%, var(--color-surface-raised));
color: var(--status-phone);
border: 1px solid color-mix(in srgb, var(--status-phone) 30%, var(--color-surface-raised));
}
.card-footer { .card-footer {
border-top: 1px solid var(--color-border-light); border-top: 1px solid var(--color-border-light);

View file

@ -12,7 +12,6 @@ export const router = createRouter({
{ path: '/prep', component: () => import('../views/InterviewPrepView.vue') }, { path: '/prep', component: () => import('../views/InterviewPrepView.vue') },
{ path: '/prep/:id', component: () => import('../views/InterviewPrepView.vue') }, { path: '/prep/:id', component: () => import('../views/InterviewPrepView.vue') },
{ path: '/survey', component: () => import('../views/SurveyView.vue') }, { path: '/survey', component: () => import('../views/SurveyView.vue') },
{ path: '/survey/:id', component: () => import('../views/SurveyView.vue') },
{ path: '/settings', component: () => import('../views/SettingsView.vue') }, { path: '/settings', component: () => import('../views/SettingsView.vue') },
// Catch-all — FastAPI serves index.html for all unknown routes (SPA mode) // Catch-all — FastAPI serves index.html for all unknown routes (SPA mode)
{ path: '/:pathMatch(.*)*', redirect: '/' }, { path: '/:pathMatch(.*)*', redirect: '/' },

View file

@ -1,186 +0,0 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { usePrepStore } from './prep'
// Mock useApiFetch
vi.mock('../composables/useApi', () => ({
useApiFetch: vi.fn(),
}))
import { useApiFetch } from '../composables/useApi'
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({ data: { 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' }, error: null }) // research
.mockResolvedValueOnce({ data: [], error: null }) // contacts
.mockResolvedValueOnce({ data: { status: 'none', stage: null, message: null }, error: null }) // task
.mockResolvedValueOnce({ data: { id: 1, title: 'Engineer', company: 'Acme', url: null,
description: 'Build things.', cover_letter: null, match_score: 80,
keyword_gaps: null }, error: 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({ data: { company_brief: 'OldCo', ceo_brief: null, talking_points: null,
tech_brief: null, funding_brief: null, red_flags: null, accessibility_brief: null,
generated_at: null }, error: null })
.mockResolvedValueOnce({ data: [], error: null })
.mockResolvedValueOnce({ data: { status: 'none', stage: null, message: null }, error: null })
.mockResolvedValueOnce({ data: { id: 1, title: 'Old Job', company: 'OldCo', url: null,
description: null, cover_letter: null, match_score: null, keyword_gaps: null }, error: null })
const store = usePrepStore()
await store.fetchFor(1)
expect(store.research?.company_brief).toBe('OldCo')
// Second call for job 2 - clears first
mockApiFetch
.mockResolvedValueOnce({ data: null, error: { kind: 'http', status: 404, detail: '' } }) // 404 → null
.mockResolvedValueOnce({ data: [], error: null })
.mockResolvedValueOnce({ data: { status: 'none', stage: null, message: null }, error: null })
.mockResolvedValueOnce({ data: { id: 2, title: 'New Job', company: 'NewCo', url: null,
description: null, cover_letter: null, match_score: null, keyword_gaps: null }, error: 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({ data: { task_id: 7, is_new: true }, error: null })
const store = usePrepStore()
store.currentJobId = 1
// Spy on pollTask via the interval
const pollSpy = mockApiFetch
.mockResolvedValueOnce({ data: { status: 'running', stage: 'Analyzing', message: null }, error: 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({ data: { company_brief: 'Acme', ceo_brief: null, talking_points: null,
tech_brief: null, funding_brief: null, red_flags: null, accessibility_brief: null,
generated_at: null }, error: null })
.mockResolvedValueOnce({ data: [], error: null })
.mockResolvedValueOnce({ data: { status: 'none', stage: null, message: null }, error: null })
.mockResolvedValueOnce({ data: { id: 1, title: 'Eng', company: 'Acme', url: null,
description: null, cover_letter: null, match_score: null, keyword_gaps: null }, error: null })
const store = usePrepStore()
await store.fetchFor(1)
// Mock first poll → completed
mockApiFetch
.mockResolvedValueOnce({ data: { status: 'completed', stage: null, message: null }, error: null })
// re-fetch on completed: research, contacts, task, fullJob
.mockResolvedValueOnce({ data: { 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' }, error: null })
.mockResolvedValueOnce({ data: [], error: null })
.mockResolvedValueOnce({ data: { status: 'completed', stage: null, message: null }, error: null })
.mockResolvedValueOnce({ data: { id: 1, title: 'Eng', company: 'Acme', url: null,
description: 'Now with content', cover_letter: null, match_score: null, keyword_gaps: null }, error: null })
store.pollTask(1)
await vi.advanceTimersByTimeAsync(3000)
expect(store.research?.company_brief).toBe('Updated!')
})
it('clear cancels polling interval and resets state', async () => {
const mockApiFetch = vi.mocked(useApiFetch)
mockApiFetch
.mockResolvedValueOnce({ data: { company_brief: 'Acme', ceo_brief: null, talking_points: null,
tech_brief: null, funding_brief: null, red_flags: null, accessibility_brief: null,
generated_at: null }, error: null })
.mockResolvedValueOnce({ data: [], error: null })
.mockResolvedValueOnce({ data: { status: 'none', stage: null, message: null }, error: null })
.mockResolvedValueOnce({ data: { id: 1, title: 'Eng', company: 'Acme', url: null,
description: null, cover_letter: null, match_score: null, keyword_gaps: null }, error: 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.contactsError).toBeNull()
expect(store.currentJobId).toBeNull()
})
it('fetchFor sets contactsError and leaves other data intact when contacts fetch fails', async () => {
const mockApiFetch = vi.mocked(useApiFetch)
mockApiFetch
.mockResolvedValueOnce({ data: { 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' }, error: null }) // research OK
.mockResolvedValueOnce({ data: null, error: { kind: 'http', status: 500, detail: 'DB error' } }) // contacts fail
.mockResolvedValueOnce({ data: { status: 'none', stage: null, message: null }, error: null }) // task OK
.mockResolvedValueOnce({ data: { id: 1, title: 'Engineer', company: 'Acme', url: null,
description: 'Build things.', cover_letter: null, match_score: 80,
keyword_gaps: null }, error: null }) // fullJob OK
const store = usePrepStore()
await store.fetchFor(1)
// Contacts error shown in Email tab only
expect(store.contactsError).toBe('Could not load email history.')
expect(store.contacts).toEqual([])
// Everything else still renders
expect(store.research?.company_brief).toBe('Acme')
expect(store.fullJob?.description).toBe('Build things.')
expect(store.fullJob?.match_score).toBe(80)
expect(store.taskStatus.status).toBe('none')
// Top-level error stays null (no full-panel blank-out)
expect(store.error).toBeNull()
})
})

View file

@ -1,173 +0,0 @@
import { ref } from 'vue'
import { defineStore } from 'pinia'
import { useApiFetch } from '../composables/useApi'
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 contactsError = ref<string | null>(null)
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 = []
contactsError.value = null
taskStatus.value = { status: null, stage: null, message: null }
fullJob.value = null
error.value = null
currentJobId.value = jobId
}
loading.value = true
try {
const [researchResult, contactsResult, taskResult, jobResult] = 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 404 is expected (no research yet) — only surface non-404 errors
if (researchResult.error && !(researchResult.error.kind === 'http' && researchResult.error.status === 404)) {
error.value = 'Failed to load research data'
return
}
if (jobResult.error) {
error.value = 'Failed to load job details'
return
}
research.value = researchResult.data ?? null
// Contacts failure is non-fatal — degrade the Email tab only
if (contactsResult.error) {
contactsError.value = 'Could not load email history.'
contacts.value = []
} else {
contacts.value = contactsResult.data ?? []
contactsError.value = null
}
taskStatus.value = taskResult.data ?? { status: null, stage: null, message: null }
fullJob.value = jobResult.data ?? 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) {
const { data, error: fetchError } = await useApiFetch<{ task_id: number; is_new: boolean }>(
`/api/jobs/${jobId}/research/generate`,
{ method: 'POST' }
)
if (fetchError || !data) {
error.value = 'Failed to start research generation'
return
}
pollTask(jobId)
}
/** @internal — called by fetchFor and generateResearch; not for component use */
function pollTask(jobId: number) {
_clearInterval()
pollInterval = setInterval(async () => {
const { data } = await useApiFetch<TaskStatus>(`/api/jobs/${jobId}/research/task`)
if (data) {
taskStatus.value = data
if (data.status === 'completed' || data.status === 'failed') {
_clearInterval()
if (data.status === 'completed') {
await fetchFor(jobId)
}
}
}
}, 3000)
}
function clear() {
_clearInterval()
research.value = null
contacts.value = []
contactsError.value = null
taskStatus.value = { status: null, stage: null, message: null }
fullJob.value = null
loading.value = false
error.value = null
currentJobId.value = null
}
return {
research,
contacts,
contactsError,
taskStatus,
fullJob,
loading,
error,
currentJobId,
fetchFor,
generateResearch,
pollTask,
clear,
}
})

View file

@ -1,173 +0,0 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useSurveyStore } from './survey'
vi.mock('../composables/useApi', () => ({
useApiFetch: vi.fn(),
}))
import { useApiFetch } from '../composables/useApi'
describe('useSurveyStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
afterEach(() => {
vi.clearAllMocks()
})
it('fetchFor loads history and vision availability in parallel', async () => {
const mockApiFetch = vi.mocked(useApiFetch)
mockApiFetch
.mockResolvedValueOnce({ data: [], error: null }) // history
.mockResolvedValueOnce({ data: { available: true }, error: null }) // vision
const store = useSurveyStore()
await store.fetchFor(1)
expect(store.history).toEqual([])
expect(store.visionAvailable).toBe(true)
expect(store.currentJobId).toBe(1)
expect(mockApiFetch).toHaveBeenCalledTimes(2)
})
it('fetchFor clears state when called for a different job', async () => {
const mockApiFetch = vi.mocked(useApiFetch)
// Job 1
mockApiFetch
.mockResolvedValueOnce({ data: [{ id: 1, llm_output: 'old' }], error: null })
.mockResolvedValueOnce({ data: { available: false }, error: null })
const store = useSurveyStore()
await store.fetchFor(1)
expect(store.history.length).toBe(1)
// Job 2 — state must be cleared before new data arrives
mockApiFetch
.mockResolvedValueOnce({ data: [], error: null })
.mockResolvedValueOnce({ data: { available: true }, error: null })
await store.fetchFor(2)
expect(store.history).toEqual([])
expect(store.currentJobId).toBe(2)
})
it('analyze stores result including mode and rawInput', async () => {
const mockApiFetch = vi.mocked(useApiFetch)
mockApiFetch.mockResolvedValueOnce({
data: { output: '1. B — reason', source: 'text_paste' },
error: null,
})
const store = useSurveyStore()
await store.analyze(1, { text: 'Q1: test', mode: 'quick' })
expect(store.analysis).not.toBeNull()
expect(store.analysis!.output).toBe('1. B — reason')
expect(store.analysis!.source).toBe('text_paste')
expect(store.analysis!.mode).toBe('quick')
expect(store.analysis!.rawInput).toBe('Q1: test')
expect(store.loading).toBe(false)
})
it('analyze sets error on failure', async () => {
const mockApiFetch = vi.mocked(useApiFetch)
mockApiFetch.mockResolvedValueOnce({
data: null,
error: { kind: 'http', status: 500, detail: 'LLM unavailable' },
})
const store = useSurveyStore()
await store.analyze(1, { text: 'Q1: test', mode: 'quick' })
expect(store.analysis).toBeNull()
expect(store.error).toBeTruthy()
expect(store.loading).toBe(false)
})
it('saveResponse prepends to history and clears analysis', async () => {
const mockApiFetch = vi.mocked(useApiFetch)
// Setup: fetchFor
mockApiFetch
.mockResolvedValueOnce({ data: [], error: null })
.mockResolvedValueOnce({ data: { available: true }, error: null })
const store = useSurveyStore()
await store.fetchFor(1)
// Set analysis state manually (as if analyze() was called)
store.analysis = {
output: '1. B — reason',
source: 'text_paste',
mode: 'quick',
rawInput: 'Q1: test',
}
// Save
mockApiFetch.mockResolvedValueOnce({
data: { id: 42 },
error: null,
})
await store.saveResponse(1, { surveyName: 'Round 1', reportedScore: '85%' })
expect(store.history.length).toBe(1)
expect(store.history[0].id).toBe(42)
expect(store.history[0].llm_output).toBe('1. B — reason')
expect(store.analysis).toBeNull()
expect(store.saving).toBe(false)
})
it('saveResponse sets error and preserves analysis on POST failure', async () => {
const mockApiFetch = vi.mocked(useApiFetch)
// Setup: fetchFor
mockApiFetch
.mockResolvedValueOnce({ data: [], error: null })
.mockResolvedValueOnce({ data: { available: true }, error: null })
const store = useSurveyStore()
await store.fetchFor(1)
// Set analysis state manually
store.analysis = {
output: '1. B — reason',
source: 'text_paste',
mode: 'quick',
rawInput: 'Q1: test',
}
// Save fails
mockApiFetch.mockResolvedValueOnce({
data: null,
error: { kind: 'http', status: 500, detail: 'Internal Server Error' },
})
await store.saveResponse(1, { surveyName: 'Round 1', reportedScore: '85%' })
expect(store.saving).toBe(false)
expect(store.error).toBeTruthy()
expect(store.analysis).not.toBeNull()
expect(store.analysis!.output).toBe('1. B — reason')
})
it('clear resets all state to initial values', async () => {
const mockApiFetch = vi.mocked(useApiFetch)
mockApiFetch
.mockResolvedValueOnce({ data: [{ id: 1, llm_output: 'test' }], error: null })
.mockResolvedValueOnce({ data: { available: true }, error: null })
const store = useSurveyStore()
await store.fetchFor(1)
store.clear()
expect(store.history).toEqual([])
expect(store.analysis).toBeNull()
expect(store.visionAvailable).toBe(false)
expect(store.loading).toBe(false)
expect(store.saving).toBe(false)
expect(store.error).toBeNull()
expect(store.currentJobId).toBeNull()
})
})

View file

@ -1,157 +0,0 @@
import { ref } from 'vue'
import { defineStore } from 'pinia'
import { useApiFetch } from '../composables/useApi'
const validSources = ['text_paste', 'screenshot'] as const
type ValidSource = typeof validSources[number]
function isValidSource(s: string): s is ValidSource {
return validSources.includes(s as ValidSource)
}
export interface SurveyAnalysis {
output: string
source: 'text_paste' | 'screenshot'
mode: 'quick' | 'detailed'
rawInput: string | null
}
export interface SurveyResponse {
id: number
survey_name: string | null
mode: 'quick' | 'detailed'
source: string
raw_input: string | null
image_path: string | null
llm_output: string
reported_score: string | null
received_at: string | null
created_at: string | null
}
export const useSurveyStore = defineStore('survey', () => {
const analysis = ref<SurveyAnalysis | null>(null)
const history = ref<SurveyResponse[]>([])
const loading = ref(false)
const saving = ref(false)
const error = ref<string | null>(null)
const visionAvailable = ref(false)
const currentJobId = ref<number | null>(null)
async function fetchFor(jobId: number) {
if (jobId !== currentJobId.value) {
analysis.value = null
history.value = []
error.value = null
visionAvailable.value = false
currentJobId.value = jobId
}
loading.value = true
try {
const [historyResult, visionResult] = await Promise.all([
useApiFetch<SurveyResponse[]>(`/api/jobs/${jobId}/survey/responses`),
useApiFetch<{ available: boolean }>('/api/vision/health'),
])
if (historyResult.error) {
error.value = 'Could not load survey history.'
} else {
history.value = historyResult.data ?? []
}
visionAvailable.value = visionResult.data?.available ?? false
} finally {
loading.value = false
}
}
async function analyze(
jobId: number,
payload: { text?: string; image_b64?: string; mode: 'quick' | 'detailed' }
) {
loading.value = true
error.value = null
const { data, error: fetchError } = await useApiFetch<{ output: string; source: string }>(
`/api/jobs/${jobId}/survey/analyze`,
{ method: 'POST', body: JSON.stringify(payload) }
)
loading.value = false
if (fetchError || !data) {
error.value = 'Analysis failed. Please try again.'
return
}
analysis.value = {
output: data.output,
source: isValidSource(data.source) ? data.source : 'text_paste',
mode: payload.mode,
rawInput: payload.text ?? null,
}
}
async function saveResponse(
jobId: number,
args: { surveyName: string; reportedScore: string; image_b64?: string }
) {
if (!analysis.value) return
saving.value = true
error.value = null
const body = {
survey_name: args.surveyName || undefined,
mode: analysis.value.mode,
source: analysis.value.source,
raw_input: analysis.value.rawInput,
image_b64: args.image_b64,
llm_output: analysis.value.output,
reported_score: args.reportedScore || undefined,
}
const { data, error: fetchError } = await useApiFetch<{ id: number }>(
`/api/jobs/${jobId}/survey/responses`,
{ method: 'POST', body: JSON.stringify(body) }
)
saving.value = false
if (fetchError || !data) {
error.value = 'Save failed. Your analysis is preserved — try again.'
return
}
// Prepend the saved response to history
const now = new Date().toISOString()
const saved: SurveyResponse = {
id: data.id,
survey_name: args.surveyName || null,
mode: analysis.value.mode,
source: analysis.value.source,
raw_input: analysis.value.rawInput,
image_path: null,
llm_output: analysis.value.output,
reported_score: args.reportedScore || null,
received_at: now,
created_at: now,
}
history.value = [saved, ...history.value]
analysis.value = null
}
function clear() {
analysis.value = null
history.value = []
loading.value = false
saving.value = false
error.value = null
visionAvailable.value = false
currentJobId.value = null
}
return {
analysis,
history,
loading,
saving,
error,
visionAvailable,
currentJobId,
fetchFor,
analyze,
saveResponse,
clear,
}
})

View file

@ -1,974 +1,18 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useStorage } from '@vueuse/core'
import { usePrepStore } from '../stores/prep'
import { useInterviewsStore } from '../stores/interviews'
import type { PipelineJob } from '../stores/interviews'
const route = useRoute()
const router = useRouter()
const prepStore = usePrepStore()
const interviewsStore = useInterviewsStore()
// Job ID
const jobId = computed<number | null>(() => {
const raw = route.params.id
if (!raw) return null
const n = Number(Array.isArray(raw) ? raw[0] : raw)
return isNaN(n) ? null : n
})
// Current job (from interviews store)
const PREP_VALID_STATUSES = ['phone_screen', 'interviewing', 'offer'] as const
const job = ref<PipelineJob | null>(null)
// Tabs
type TabId = 'jd' | 'email' | 'letter'
const activeTab = ref<TabId>('jd')
// Call notes (localStorage via @vueuse/core)
const notesKey = computed(() => `cf-prep-notes-${jobId.value ?? 'none'}`)
const callNotes = useStorage(notesKey, '')
// Page-level error (e.g. network failure during guard)
const pageError = ref<string | null>(null)
// Routing / guard
async function guardAndLoad() {
if (jobId.value === null) {
router.replace('/interviews')
return
}
// Ensure the interviews store is populated
if (interviewsStore.jobs.length === 0) {
await interviewsStore.fetchAll()
if (interviewsStore.error) {
// Store fetch failed don't redirect, show error
pageError.value = 'Failed to load job data. Please try again.'
return
}
}
const found = interviewsStore.jobs.find(j => j.id === jobId.value)
if (!found || !PREP_VALID_STATUSES.includes(found.status as typeof PREP_VALID_STATUSES[number])) {
router.replace('/interviews')
return
}
job.value = found
await prepStore.fetchFor(jobId.value)
}
onMounted(() => {
guardAndLoad()
})
onUnmounted(() => {
prepStore.clear()
})
// Stage badge label
function stageBadgeLabel(status: string): string {
if (status === 'phone_screen') return 'Phone Screen'
if (status === 'interviewing') return 'Interviewing'
if (status === 'offer') return 'Offer'
return status
}
// Interview date countdown
interface DateCountdown {
icon: string
label: string
cls: string
}
const interviewCountdown = computed<DateCountdown | null>(() => {
const dateStr = job.value?.interview_date
if (!dateStr) return null
const today = new Date()
today.setHours(0, 0, 0, 0)
const target = new Date(dateStr)
target.setHours(0, 0, 0, 0)
const diffDays = Math.round((target.getTime() - today.getTime()) / 86400000)
if (diffDays === 0) return { icon: '🔴', label: 'TODAY', cls: 'countdown--today' }
if (diffDays === 1) return { icon: '🟡', label: 'TOMORROW', cls: 'countdown--tomorrow' }
if (diffDays > 1) return { icon: '🟢', label: `in ${diffDays} days`, cls: 'countdown--future' }
// Past
const ago = Math.abs(diffDays)
return { icon: '', label: `was ${ago} day${ago !== 1 ? 's' : ''} ago`, cls: 'countdown--past' }
})
// Research state helpers
const taskStatus = computed(() => prepStore.taskStatus)
const isRunning = computed(() => taskStatus.value.status === 'queued' || taskStatus.value.status === 'running')
const hasFailed = computed(() => taskStatus.value.status === 'failed')
const hasResearch = computed(() => !!prepStore.research)
// Stage label during generation
const stageLabel = computed(() => {
const s = taskStatus.value.stage
if (s) return s
return taskStatus.value.status === 'queued' ? 'Queued…' : 'Analyzing…'
})
// Generated-at caption
const generatedAtLabel = computed(() => {
const ts = prepStore.research?.generated_at
if (!ts) return null
const d = new Date(ts)
return d.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })
})
// Research sections
interface ResearchSection {
icon: string
title: string
content: string
cls?: string
caption?: string
}
const researchSections = computed<ResearchSection[]>(() => {
const r = prepStore.research
if (!r) return []
const sections: ResearchSection[] = []
if (r.talking_points?.trim()) {
sections.push({ icon: '🎯', title: 'Talking Points', content: r.talking_points })
}
if (r.company_brief?.trim()) {
sections.push({ icon: '🏢', title: 'Company Overview', content: r.company_brief })
}
if (r.ceo_brief?.trim()) {
sections.push({ icon: '👤', title: 'Leadership & Culture', content: r.ceo_brief })
}
if (r.tech_brief?.trim()) {
sections.push({ icon: '⚙️', title: 'Tech Stack & Product', content: r.tech_brief })
}
if (r.funding_brief?.trim()) {
sections.push({ icon: '💰', title: 'Funding & Market Position', content: r.funding_brief })
}
if (r.red_flags?.trim() && !/no significant red flags/i.test(r.red_flags)) {
sections.push({ icon: '⚠️', title: 'Red Flags & Watch-outs', content: r.red_flags, cls: 'section--warning' })
}
if (r.accessibility_brief?.trim()) {
sections.push({
icon: '♿',
title: 'Inclusion & Accessibility',
content: r.accessibility_brief,
caption: 'For your personal evaluation — not disclosed in any application.',
})
}
return sections
})
// Match score badge
const matchScore = computed(() => prepStore.fullJob?.match_score ?? null)
function matchScoreBadge(score: number | null): { icon: string; cls: string } {
if (score === null) return { icon: '—', cls: 'score--none' }
if (score >= 70) return { icon: `🟢 ${score}%`, cls: 'score--high' }
if (score >= 40) return { icon: `🟡 ${score}%`, cls: 'score--mid' }
return { icon: `🔴 ${score}%`, cls: 'score--low' }
}
// Keyword gaps
const keywordGaps = computed<string[]>(() => {
const raw = prepStore.fullJob?.keyword_gaps
if (!raw) return []
try {
const parsed = JSON.parse(raw)
if (Array.isArray(parsed)) return parsed.map(String)
} catch {
// Fall through: return raw as single item
}
return [raw]
})
// Generate / refresh
async function onGenerate() {
if (jobId.value === null) return
await prepStore.generateResearch(jobId.value)
}
</script>
<template> <template>
<div class="prep-view"> <div class="view-placeholder">
<!-- Loading skeleton while interviews store loads --> <h1>InterviewPrepView</h1>
<div v-if="interviewsStore.loading && !job" class="prep-loading" aria-live="polite"> <p class="placeholder-note">Vue port in progress Streamlit equivalent at app/pages/</p>
Loading
</div>
<template v-else-if="job">
<div class="prep-layout">
<!-- LEFT COLUMN -->
<aside class="prep-left" aria-label="Job overview and research">
<!-- Back link -->
<RouterLink to="/interviews" class="back-link"> Back to Interviews</RouterLink>
<!-- Job header -->
<header class="job-header">
<h1 class="job-title">{{ job.title }}</h1>
<p class="job-company">{{ job.company }}</p>
<div class="job-meta">
<span class="stage-badge" :class="`stage-badge--${job.status}`">
{{ stageBadgeLabel(job.status) }}
</span>
<span
v-if="interviewCountdown"
class="countdown-chip"
:class="interviewCountdown.cls"
>
<span v-if="interviewCountdown.icon" aria-hidden="true">{{ interviewCountdown.icon }}</span>
{{ interviewCountdown.label }}
</span>
</div>
<a
v-if="job.url"
:href="job.url"
target="_blank"
rel="noopener noreferrer"
class="btn-link-out"
>
Open job listing
</a>
</header>
<!-- Research controls -->
<section class="research-controls" aria-label="Research controls">
<!-- No research and no active task show generate button -->
<template v-if="!hasResearch && !isRunning && !hasFailed">
<button class="btn-primary" @click="onGenerate" :disabled="prepStore.loading">
Generate research brief
</button>
</template>
<!-- Task running/queued spinner + stage -->
<template v-else-if="isRunning">
<div class="research-running" aria-live="polite" aria-atomic="true">
<span class="spinner" aria-hidden="true"></span>
<span>{{ stageLabel }}</span>
</div>
</template>
<!-- Task failed error + retry -->
<template v-else-if="hasFailed">
<div class="research-error" role="alert">
<span> {{ taskStatus.message ?? 'Research generation failed.' }}</span>
<button class="btn-secondary" @click="onGenerate">Retry</button>
</div>
</template>
<!-- Research exists (completed or no task but research present) show refresh -->
<template v-else-if="hasResearch">
<div class="research-generated">
<span v-if="generatedAtLabel" class="research-ts">Generated: {{ generatedAtLabel }}</span>
<button
class="btn-secondary"
@click="onGenerate"
:disabled="isRunning"
>
Refresh
</button>
</div>
</template>
</section>
<!-- Error banner (store-level) -->
<div v-if="prepStore.error" class="error-banner" role="alert">
{{ prepStore.error }}
</div>
<!-- Research sections -->
<div v-if="hasResearch" class="research-sections">
<section
v-for="sec in researchSections"
:key="sec.title"
class="research-section"
:class="sec.cls"
>
<h2 class="section-title">
<span aria-hidden="true">{{ sec.icon }}</span> {{ sec.title }}
</h2>
<p v-if="sec.caption" class="section-caption">{{ sec.caption }}</p>
<div class="section-body">{{ sec.content }}</div>
</section>
</div>
<!-- Empty state: no research yet and not loading -->
<div v-else-if="!isRunning && !prepStore.loading" class="research-empty">
<span class="empty-bird">🦅</span>
<p>Generate a research brief to see company info, talking points, and more.</p>
</div>
</aside>
<!-- RIGHT COLUMN -->
<main class="prep-right" aria-label="Job details">
<!-- Tab bar -->
<div class="tab-bar" role="tablist" aria-label="Job details tabs">
<button
id="tab-jd"
class="tab-btn"
:class="{ 'tab-btn--active': activeTab === 'jd' }"
role="tab"
:aria-selected="activeTab === 'jd'"
aria-controls="tabpanel-jd"
@click="activeTab = 'jd'"
>
Job Description
</button>
<button
id="tab-email"
class="tab-btn"
:class="{ 'tab-btn--active': activeTab === 'email' }"
role="tab"
:aria-selected="activeTab === 'email'"
aria-controls="tabpanel-email"
@click="activeTab = 'email'"
>
Email History
<span v-if="prepStore.contacts.length" class="tab-count">{{ prepStore.contacts.length }}</span>
</button>
<button
id="tab-letter"
class="tab-btn"
:class="{ 'tab-btn--active': activeTab === 'letter' }"
role="tab"
:aria-selected="activeTab === 'letter'"
aria-controls="tabpanel-letter"
@click="activeTab = 'letter'"
>
Cover Letter
</button>
</div>
<!-- JD tab -->
<div
v-show="activeTab === 'jd'"
id="tabpanel-jd"
class="tab-panel"
role="tabpanel"
aria-labelledby="tab-jd"
>
<div class="jd-meta">
<span
class="score-badge"
:class="matchScoreBadge(matchScore).cls"
:aria-label="`Match score: ${matchScore ?? 'unknown'}%`"
>
{{ matchScoreBadge(matchScore).icon }}
</span>
<div v-if="keywordGaps.length" class="keyword-gaps">
<span class="keyword-gaps-label">Keyword gaps:</span>
<span class="keyword-gaps-list">{{ keywordGaps.join(', ') }}</span>
</div>
</div>
<div v-if="prepStore.fullJob?.description" class="jd-body">
{{ prepStore.fullJob.description }}
</div>
<div v-else class="tab-empty">
<span class="empty-bird">🦅</span>
<p>No job description available.</p>
</div>
</div>
<!-- Email tab -->
<div
v-show="activeTab === 'email'"
id="tabpanel-email"
class="tab-panel"
role="tabpanel"
aria-labelledby="tab-email"
>
<div v-if="prepStore.contactsError" class="error-state" role="alert">
{{ prepStore.contactsError }}
</div>
<template v-else-if="prepStore.contacts.length">
<div
v-for="contact in prepStore.contacts"
:key="contact.id"
class="email-card"
>
<div class="email-header">
<span class="email-dir" :title="contact.direction === 'inbound' ? 'Inbound' : 'Outbound'">
{{ contact.direction === 'inbound' ? '📥' : '📤' }}
</span>
<span class="email-subject">{{ contact.subject ?? '(no subject)' }}</span>
<span class="email-date" v-if="contact.received_at">
{{ new Date(contact.received_at).toLocaleDateString() }}
</span>
</div>
<div class="email-from" v-if="contact.from_addr">{{ contact.from_addr }}</div>
<div class="email-body" v-if="contact.body">{{ contact.body.slice(0, 500) }}{{ contact.body.length > 500 ? '…' : '' }}</div>
</div>
</template>
<div v-else class="tab-empty">
<span class="empty-bird">🦅</span>
<p>No email history for this job.</p>
</div>
</div>
<!-- Cover letter tab -->
<div
v-show="activeTab === 'letter'"
id="tabpanel-letter"
class="tab-panel"
role="tabpanel"
aria-labelledby="tab-letter"
>
<div v-if="prepStore.fullJob?.cover_letter" class="letter-body">
{{ prepStore.fullJob.cover_letter }}
</div>
<div v-else class="tab-empty">
<span class="empty-bird">🦅</span>
<p>No cover letter generated yet.</p>
</div>
</div>
<!-- Call notes -->
<section class="call-notes" aria-label="Call notes">
<h2 class="call-notes-title">Call Notes</h2>
<textarea
v-model="callNotes"
class="call-notes-textarea"
placeholder="Jot down notes during your call…"
aria-label="Call notes — saved locally"
></textarea>
<p class="call-notes-caption">Notes are saved locally they won't sync between devices.</p>
</section>
</main>
</div>
</template>
<!-- Network/load error don't redirect, show message -->
<div v-else-if="pageError" class="error-banner" role="alert">
{{ pageError }}
</div>
<!-- Fallback while redirecting -->
<div v-else class="prep-loading" aria-live="polite">
Redirecting
</div>
</div> </div>
</template> </template>
<style scoped> <style scoped>
/* ── Layout ─────────────────────────────────────────────────────────────── */ .view-placeholder {
.prep-view { padding: var(--space-8);
padding: var(--space-4) var(--space-4) var(--space-12); max-width: 60ch;
max-width: 1200px;
margin: 0 auto;
} }
.placeholder-note {
.prep-layout {
display: grid;
grid-template-columns: 40% 1fr;
gap: var(--space-6);
align-items: start;
}
/* Mobile: single column */
@media (max-width: 1023px) {
.prep-layout {
grid-template-columns: 1fr;
}
.prep-right {
order: 2;
}
.prep-left {
order: 1;
}
}
.prep-left {
position: sticky;
top: calc(var(--nav-height, 4rem) + var(--space-4));
max-height: calc(100vh - var(--nav-height, 4rem) - var(--space-8));
overflow-y: auto;
display: flex;
flex-direction: column;
gap: var(--space-4);
/* On mobile, don't stick */
}
@media (max-width: 1023px) {
.prep-left {
position: static;
max-height: none;
overflow-y: visible;
}
}
.prep-right {
display: flex;
flex-direction: column;
gap: var(--space-4);
min-width: 0;
}
/* ── Loading ─────────────────────────────────────────────────────────────── */
.prep-loading {
text-align: center;
padding: var(--space-16);
color: var(--color-text-muted); color: var(--color-text-muted);
font-size: var(--text-sm); font-size: 0.875rem;
} margin-top: var(--space-2);
/* ── Back link ──────────────────────────────────────────────────────────── */
.back-link {
font-size: var(--text-sm);
color: var(--app-primary);
text-decoration: none;
display: inline-flex;
align-items: center;
gap: var(--space-1);
}
.back-link:hover { text-decoration: underline; }
/* ── Job header ─────────────────────────────────────────────────────────── */
.job-header {
background: var(--color-surface-raised);
border-radius: var(--radius-lg);
padding: var(--space-4);
display: flex;
flex-direction: column;
gap: var(--space-2);
border: 1px solid var(--color-border-light);
}
.job-title {
font-family: var(--font-display);
font-size: var(--text-xl);
color: var(--color-text);
line-height: 1.3;
}
.job-company {
font-size: var(--text-base);
color: var(--color-text-muted);
margin: 0;
font-weight: 600;
}
.job-meta {
display: flex;
align-items: center;
gap: var(--space-2);
flex-wrap: wrap;
}
/* Stage badges */
.stage-badge {
display: inline-block;
padding: 2px 10px;
border-radius: var(--radius-full);
font-size: var(--text-xs);
font-weight: 700;
text-transform: uppercase;
letter-spacing: .04em;
}
.stage-badge--phone_screen {
background: color-mix(in srgb, var(--status-phone) 12%, var(--color-surface-raised));
color: var(--status-phone);
}
.stage-badge--interviewing {
background: color-mix(in srgb, var(--status-interview) 12%, var(--color-surface-raised));
color: var(--status-interview);
}
.stage-badge--offer {
background: color-mix(in srgb, var(--status-offer) 12%, var(--color-surface-raised));
color: var(--status-offer);
}
/* Countdown chip */
.countdown-chip {
font-size: var(--text-xs);
font-weight: 700;
padding: 2px 8px;
border-radius: var(--radius-full);
display: inline-flex;
align-items: center;
gap: 4px;
}
.countdown--today { background: color-mix(in srgb, var(--color-error) 12%, var(--color-surface-raised)); color: var(--color-error); }
.countdown--tomorrow { background: color-mix(in srgb, var(--color-warning) 12%, var(--color-surface-raised)); color: var(--color-warning); }
.countdown--future { background: color-mix(in srgb, var(--color-success) 12%, var(--color-surface-raised)); color: var(--color-success); }
.countdown--past { background: var(--color-surface-alt); color: var(--color-text-muted); }
.btn-link-out {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: var(--text-sm);
color: var(--app-primary);
text-decoration: none;
width: fit-content;
}
.btn-link-out:hover { text-decoration: underline; }
/* ── Research controls ──────────────────────────────────────────────────── */
.research-controls {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.btn-primary {
background: var(--app-primary);
color: var(--color-text-inverse);
border: none;
border-radius: var(--radius-md);
padding: var(--space-2) var(--space-4);
font-size: var(--text-sm);
font-weight: 600;
cursor: pointer;
transition: background var(--transition);
}
.btn-primary:hover:not(:disabled) { background: var(--app-primary-hover); }
.btn-primary:disabled { opacity: 0.6; cursor: default; }
.btn-secondary {
background: none;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: var(--space-1) var(--space-3);
font-size: var(--text-sm);
font-weight: 600;
color: var(--app-primary);
cursor: pointer;
transition: background var(--transition);
}
.btn-secondary:hover:not(:disabled) { background: var(--color-surface-alt); }
.btn-secondary:disabled { opacity: 0.6; cursor: default; }
.research-running {
display: flex;
align-items: center;
gap: var(--space-2);
font-size: var(--text-sm);
color: var(--color-info);
}
/* Spinner */
.spinner {
width: 16px;
height: 16px;
border: 2px solid color-mix(in srgb, var(--color-info) 25%, transparent);
border-top-color: var(--color-info);
border-radius: 50%;
animation: spin 700ms linear infinite;
flex-shrink: 0;
}
@keyframes spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) {
.spinner { animation: none; border-top-color: var(--color-info); }
}
.research-generated {
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 {
display: flex;
align-items: center;
gap: var(--space-3);
flex-wrap: wrap;
background: color-mix(in srgb, var(--color-error) 8%, var(--color-surface));
border: 1px solid color-mix(in srgb, var(--color-error) 25%, transparent);
border-radius: var(--radius-md);
padding: var(--space-2) var(--space-3);
font-size: var(--text-sm);
color: var(--color-error);
}
/* ── Error banner ────────────────────────────────────────────────────────── */
.error-banner {
background: color-mix(in srgb, var(--color-error) 10%, var(--color-surface));
color: var(--color-error);
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-md);
font-size: var(--text-sm);
}
/* Inline error state for tab panels (e.g. contacts fetch failure) */
.error-state {
background: color-mix(in srgb, var(--color-error) 8%, var(--color-surface));
color: var(--color-error);
border: 1px solid color-mix(in srgb, var(--color-error) 25%, transparent);
border-radius: var(--radius-md);
padding: var(--space-3) var(--space-4);
font-size: var(--text-sm);
}
/* ── Research sections ───────────────────────────────────────────────────── */
.research-sections {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.research-section {
background: var(--color-surface-raised);
border: 1px solid var(--color-border-light);
border-radius: var(--radius-md);
padding: var(--space-3) var(--space-4);
}
.research-section.section--warning {
background: color-mix(in srgb, var(--color-warning) 8%, var(--color-surface));
border-color: color-mix(in srgb, var(--color-warning) 30%, transparent);
}
.section-title {
font-family: var(--font-display);
font-size: var(--text-sm);
font-weight: 700;
color: var(--color-text);
margin-bottom: var(--space-2);
display: flex;
align-items: center;
gap: var(--space-1);
}
.section-caption {
font-size: var(--text-xs);
color: var(--color-text-muted);
font-style: italic;
margin: 0 0 var(--space-2);
}
.section-body {
font-size: var(--text-sm);
color: var(--color-text);
line-height: 1.6;
white-space: pre-wrap;
}
/* ── Empty state ─────────────────────────────────────────────────────────── */
.research-empty,
.tab-empty {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-2);
padding: var(--space-8) var(--space-4);
color: var(--color-text-muted);
text-align: center;
}
.empty-bird {
font-size: 2rem;
}
.tab-empty p {
font-size: var(--text-sm);
margin: 0;
}
/* ── Tab bar ─────────────────────────────────────────────────────────────── */
.tab-bar {
display: flex;
gap: 2px;
border-bottom: 2px solid var(--color-border-light);
overflow-x: auto;
}
.tab-btn {
background: none;
border: none;
border-bottom: 3px solid transparent;
padding: var(--space-2) var(--space-3);
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-text-muted);
cursor: pointer;
white-space: nowrap;
transition: color var(--transition), border-color var(--transition);
display: inline-flex;
align-items: center;
gap: var(--space-1);
margin-bottom: -2px;
}
.tab-btn:hover { color: var(--app-primary); }
.tab-btn--active {
color: var(--app-primary);
border-bottom-color: var(--app-primary);
}
.tab-count {
background: var(--color-surface-alt);
border-radius: var(--radius-full);
padding: 1px 6px;
font-size: var(--text-xs);
font-weight: 700;
color: var(--color-text-muted);
}
/* ── Tab panels ──────────────────────────────────────────────────────────── */
.tab-panel {
background: var(--color-surface-raised);
border: 1px solid var(--color-border-light);
border-radius: var(--radius-md);
padding: var(--space-4);
min-height: 200px;
}
/* JD tab */
.jd-meta {
display: flex;
align-items: flex-start;
flex-wrap: wrap;
gap: var(--space-3);
margin-bottom: var(--space-3);
}
.score-badge {
font-size: var(--text-sm);
font-weight: 700;
padding: 2px 10px;
border-radius: var(--radius-full);
}
.score--high { background: color-mix(in srgb, var(--color-success) 12%, var(--color-surface-raised)); color: var(--color-success); }
.score--mid { background: color-mix(in srgb, var(--color-warning) 12%, var(--color-surface-raised)); color: var(--color-warning); }
.score--low { background: color-mix(in srgb, var(--color-error) 12%, var(--color-surface-raised)); color: var(--color-error); }
.score--none { background: var(--color-surface-alt); color: var(--color-text-muted); }
.keyword-gaps {
font-size: var(--text-xs);
color: var(--color-text-muted);
display: flex;
gap: var(--space-1);
flex-wrap: wrap;
align-items: baseline;
}
.keyword-gaps-label { font-weight: 700; }
.jd-body {
font-size: var(--text-sm);
color: var(--color-text);
line-height: 1.7;
white-space: pre-wrap;
max-height: 60vh;
overflow-y: auto;
}
/* Email tab */
.email-card {
border: 1px solid var(--color-border-light);
border-radius: var(--radius-md);
padding: var(--space-3);
background: var(--color-surface);
display: flex;
flex-direction: column;
gap: var(--space-1);
margin-bottom: var(--space-3);
}
.email-card:last-child { margin-bottom: 0; }
.email-header {
display: flex;
align-items: baseline;
gap: var(--space-2);
flex-wrap: wrap;
}
.email-dir { font-size: 1rem; }
.email-subject {
font-weight: 600;
font-size: var(--text-sm);
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.email-date {
font-size: var(--text-xs);
color: var(--color-text-muted);
flex-shrink: 0;
}
.email-from {
font-size: var(--text-xs);
color: var(--color-text-muted);
}
.email-body {
font-size: var(--text-xs);
color: var(--color-text);
line-height: 1.5;
white-space: pre-wrap;
}
/* Cover letter tab */
.letter-body {
font-size: var(--text-sm);
color: var(--color-text);
line-height: 1.8;
white-space: pre-wrap;
}
/* ── Call notes ──────────────────────────────────────────────────────────── */
.call-notes {
background: var(--color-surface-raised);
border: 1px solid var(--color-border-light);
border-radius: var(--radius-md);
padding: var(--space-4);
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.call-notes-title {
font-family: var(--font-display);
font-size: var(--text-sm);
font-weight: 700;
color: var(--color-text);
}
.call-notes-textarea {
width: 100%;
min-height: 120px;
resize: vertical;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: var(--space-2) var(--space-3);
font-family: var(--font-body);
font-size: var(--text-sm);
color: var(--color-text);
line-height: 1.6;
box-sizing: border-box;
}
.call-notes-textarea::placeholder { color: var(--color-text-muted); }
.call-notes-textarea:focus-visible {
outline: 2px solid var(--app-primary);
outline-offset: 2px;
border-color: var(--app-primary);
}
.call-notes-caption {
font-size: var(--text-xs);
color: var(--color-text-muted);
margin: 0;
font-style: italic;
} }
</style> </style>

View file

@ -379,11 +379,6 @@ function daysSince(dateStr: string | null) {
<div class="pre-row-meta"> <div class="pre-row-meta">
<span v-if="daysSince(job.applied_at) !== null" class="pre-row-days">{{ daysSince(job.applied_at) }}d ago</span> <span v-if="daysSince(job.applied_at) !== null" class="pre-row-days">{{ daysSince(job.applied_at) }}d ago</span>
<button class="btn-move-pre" @click="openMove(job.id)" :aria-label="`Move ${job.title}`">Move to </button> <button class="btn-move-pre" @click="openMove(job.id)" :aria-label="`Move ${job.title}`">Move to </button>
<button
v-if="job.status === 'survey'"
class="btn-move-pre"
@click="router.push('/survey/' + job.id)"
>Survey </button>
</div> </div>
</div> </div>
<!-- Signal banners for pre-list rows --> <!-- Signal banners for pre-list rows -->
@ -466,7 +461,7 @@ function daysSince(dateStr: string | null) {
</div> </div>
<InterviewCard v-for="(job, i) in store.phoneScreen" :key="job.id" :job="job" <InterviewCard v-for="(job, i) in store.phoneScreen" :key="job.id" :job="job"
:focused="focusedCol === 0 && focusedCard === i" :focused="focusedCol === 0 && focusedCard === i"
@move="openMove" @prep="router.push(`/prep/${$event}`)" @survey="router.push('/survey/' + $event)" /> @move="openMove" @prep="router.push(`/prep/${$event}`)" />
</div> </div>
<div class="kanban-col" :class="{ 'kanban-col--focused': focusedCol === 1 }" aria-label="Interviewing"> <div class="kanban-col" :class="{ 'kanban-col--focused': focusedCol === 1 }" aria-label="Interviewing">
@ -479,7 +474,7 @@ function daysSince(dateStr: string | null) {
</div> </div>
<InterviewCard v-for="(job, i) in store.interviewing" :key="job.id" :job="job" <InterviewCard v-for="(job, i) in store.interviewing" :key="job.id" :job="job"
:focused="focusedCol === 1 && focusedCard === i" :focused="focusedCol === 1 && focusedCard === i"
@move="openMove" @prep="router.push(`/prep/${$event}`)" @survey="router.push('/survey/' + $event)" /> @move="openMove" @prep="router.push(`/prep/${$event}`)" />
</div> </div>
<div class="kanban-col" :class="{ 'kanban-col--focused': focusedCol === 2 }" aria-label="Offer and Hired"> <div class="kanban-col" :class="{ 'kanban-col--focused': focusedCol === 2 }" aria-label="Offer and Hired">
@ -492,7 +487,7 @@ function daysSince(dateStr: string | null) {
</div> </div>
<InterviewCard v-for="(job, i) in store.offerHired" :key="job.id" :job="job" <InterviewCard v-for="(job, i) in store.offerHired" :key="job.id" :job="job"
:focused="focusedCol === 2 && focusedCard === i" :focused="focusedCol === 2 && focusedCard === i"
@move="openMove" @prep="router.push(`/prep/${$event}`)" @survey="router.push('/survey/' + $event)" /> @move="openMove" @prep="router.push(`/prep/${$event}`)" />
</div> </div>
</section> </section>

View file

@ -1,834 +1,18 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useInterviewsStore } from '../stores/interviews'
import { useSurveyStore } from '../stores/survey'
const route = useRoute()
const router = useRouter()
const interviewsStore = useInterviewsStore()
const surveyStore = useSurveyStore()
const VALID_STAGES = ['survey', 'phone_screen', 'interviewing', 'offer']
const rawId = route.params.id
const jobId = rawId ? parseInt(String(rawId), 10) : NaN
const pickerMode = !rawId || isNaN(jobId)
// UI state
let saveSuccessTimer: ReturnType<typeof setTimeout> | null = null
const activeTab = ref<'text' | 'screenshot'>('text')
const textInput = ref('')
const imageB64 = ref<string | null>(null)
const imagePreviewUrl = ref<string | null>(null)
const selectedMode = ref<'quick' | 'detailed'>('quick')
const surveyName = ref('')
const reportedScore = ref('')
const saveSuccess = ref(false)
// Computed job from store
const job = computed(() =>
interviewsStore.jobs.find(j => j.id === jobId) ?? null
)
// Jobs eligible for survey (used in picker mode)
const pickerJobs = computed(() =>
interviewsStore.jobs.filter(j => VALID_STAGES.includes(j.status))
)
const stageLabel: Record<string, string> = {
survey: 'Survey', phone_screen: 'Phone Screen',
interviewing: 'Interviewing', offer: 'Offer',
}
onMounted(async () => {
if (interviewsStore.jobs.length === 0) {
await interviewsStore.fetchAll()
}
if (pickerMode) return
if (!job.value || !VALID_STAGES.includes(job.value.status)) {
router.replace('/interviews')
return
}
await surveyStore.fetchFor(jobId)
})
onUnmounted(() => {
surveyStore.clear()
if (saveSuccessTimer) clearTimeout(saveSuccessTimer)
})
// Screenshot handling
function handlePaste(e: ClipboardEvent) {
if (!surveyStore.visionAvailable) return
const items = e.clipboardData?.items
if (!items) return
for (const item of items) {
if (item.type.startsWith('image/')) {
const file = item.getAsFile()
if (file) loadImageFile(file)
break
}
}
}
function handleDrop(e: DragEvent) {
e.preventDefault()
if (!surveyStore.visionAvailable) return
const file = e.dataTransfer?.files[0]
if (file && file.type.startsWith('image/')) loadImageFile(file)
}
function handleFileUpload(e: Event) {
const file = (e.target as HTMLInputElement).files?.[0]
if (file) loadImageFile(file)
}
function loadImageFile(file: File) {
const reader = new FileReader()
reader.onload = (ev) => {
const result = ev.target?.result as string
imagePreviewUrl.value = result
imageB64.value = result.split(',')[1] // strip "data:image/...;base64,"
}
reader.readAsDataURL(file)
}
function clearImage() {
imageB64.value = null
imagePreviewUrl.value = null
}
// Analysis
const canAnalyze = computed(() =>
activeTab.value === 'text' ? textInput.value.trim().length > 0 : imageB64.value !== null
)
async function runAnalyze() {
const payload: { text?: string; image_b64?: string; mode: 'quick' | 'detailed' } = {
mode: selectedMode.value,
}
if (activeTab.value === 'screenshot' && imageB64.value) {
payload.image_b64 = imageB64.value
} else {
payload.text = textInput.value
}
await surveyStore.analyze(jobId, payload)
}
// Save
async function saveToJob() {
await surveyStore.saveResponse(jobId, {
surveyName: surveyName.value,
reportedScore: reportedScore.value,
image_b64: activeTab.value === 'screenshot' ? imageB64.value ?? undefined : undefined,
})
if (!surveyStore.error) {
saveSuccess.value = true
surveyName.value = ''
reportedScore.value = ''
if (saveSuccessTimer) clearTimeout(saveSuccessTimer)
saveSuccessTimer = setTimeout(() => { saveSuccess.value = false }, 3000)
}
}
// History accordion
const historyOpen = ref(false)
function formatDate(iso: string | null): string {
if (!iso) return ''
return new Date(iso).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
}
const expandedHistory = ref<Set<number>>(new Set())
function toggleHistoryEntry(id: number) {
const next = new Set(expandedHistory.value)
if (next.has(id)) next.delete(id)
else next.add(id)
expandedHistory.value = next
}
</script>
<template> <template>
<div class="survey-layout"> <div class="view-placeholder">
<h1>SurveyView</h1>
<!-- Job picker (no id in route) --> <p class="placeholder-note">Vue port in progress Streamlit equivalent at app/pages/</p>
<div v-if="pickerMode" class="survey-content picker-mode">
<h2 class="picker-heading">Survey Assistant</h2>
<p class="picker-sub">Select a job to open the survey assistant.</p>
<div v-if="pickerJobs.length === 0" class="picker-empty">
No jobs in an active interview stage. Move a job to Survey, Phone Screen, Interviewing, or Offer first.
</div>
<ul v-else class="picker-list" role="list">
<li
v-for="j in pickerJobs"
:key="j.id"
class="picker-item"
@click="router.push('/survey/' + j.id)"
>
<div class="picker-item__main">
<span class="picker-item__company">{{ j.company }}</span>
<span class="picker-item__title">{{ j.title }}</span>
</div>
<span class="stage-badge">{{ stageLabel[j.status] ?? j.status }}</span>
</li>
</ul>
</div>
<!-- Survey assistant (id present) -->
<template v-else>
<!-- Sticky context bar -->
<div class="context-bar" v-if="job">
<span class="context-company">{{ job.company }}</span>
<span class="context-sep">·</span>
<span class="context-title">{{ job.title }}</span>
<span class="stage-badge">{{ stageLabel[job.status] ?? job.status }}</span>
</div>
<!-- Load/history error banner -->
<div class="error-banner" v-if="surveyStore.error && !surveyStore.analysis">
{{ surveyStore.error }}
</div>
<div class="survey-content">
<!-- Input card -->
<div class="card">
<div class="tab-bar">
<button
class="tab-btn"
:class="{ active: activeTab === 'text' }"
@click="activeTab = 'text'"
>📝 Paste Text</button>
<button
class="tab-btn"
:class="{ active: activeTab === 'screenshot', disabled: !surveyStore.visionAvailable }"
:aria-disabled="!surveyStore.visionAvailable"
:title="!surveyStore.visionAvailable ? 'Vision service not running — start it with: bash scripts/manage-vision.sh start' : undefined"
@click="surveyStore.visionAvailable && (activeTab = 'screenshot')"
>📷 Screenshot</button>
</div>
<!-- Text tab -->
<div v-if="activeTab === 'text'" class="tab-panel">
<textarea
v-model="textInput"
class="survey-textarea"
placeholder="Paste your survey questions here, e.g.:&#10;Q1: Which best describes your work style?&#10;A. I prefer working alone&#10;B. I thrive in teams&#10;C. Depends on the project"
/>
</div>
<!-- Screenshot tab -->
<div
v-else
class="screenshot-zone"
role="region"
aria-label="Screenshot upload area — paste, drag, or choose file"
@paste="handlePaste"
@dragover.prevent
@drop="handleDrop"
tabindex="0"
>
<div v-if="imagePreviewUrl" class="image-preview">
<img :src="imagePreviewUrl" alt="Survey screenshot preview" />
<button class="remove-btn" @click="clearImage"> Remove</button>
</div>
<div v-else class="drop-hint">
<p>Paste (Ctrl+V), drag &amp; drop, or upload a screenshot</p>
<label class="upload-label">
Choose file
<input type="file" accept="image/*" class="file-input" @change="handleFileUpload" />
</label>
</div>
</div>
</div>
<!-- Mode selection -->
<div class="mode-cards">
<button
class="mode-card"
:class="{ selected: selectedMode === 'quick' }"
@click="selectedMode = 'quick'"
>
<span class="mode-icon"></span>
<span class="mode-name">Quick</span>
<span class="mode-desc">Best answer + one-liner per question</span>
</button>
<button
class="mode-card"
:class="{ selected: selectedMode === 'detailed' }"
@click="selectedMode = 'detailed'"
>
<span class="mode-icon">📋</span>
<span class="mode-name">Detailed</span>
<span class="mode-desc">Option-by-option breakdown with reasoning</span>
</button>
</div>
<!-- Analyze button -->
<button
class="analyze-btn"
:disabled="!canAnalyze || surveyStore.loading"
@click="runAnalyze"
>
<span v-if="surveyStore.loading" class="spinner" aria-hidden="true"></span>
{{ surveyStore.loading ? 'Analyzing…' : '🔍 Analyze' }}
</button>
<!-- Analyze error -->
<div class="error-inline" v-if="surveyStore.error && !surveyStore.analysis">
{{ surveyStore.error }}
</div>
<!-- Results card -->
<div class="card results-card" v-if="surveyStore.analysis">
<div class="results-output">{{ surveyStore.analysis.output }}</div>
<div class="save-form">
<input
v-model="surveyName"
class="save-input"
type="text"
placeholder="Survey name (e.g. Culture Fit Round 1)"
/>
<input
v-model="reportedScore"
class="save-input"
type="text"
placeholder="Reported score (e.g. 82% or 4.2/5)"
/>
<button
class="save-btn"
:disabled="surveyStore.saving"
@click="saveToJob"
>
<span v-if="surveyStore.saving" class="spinner" aria-hidden="true"></span>
💾 Save to job
</button>
<div v-if="saveSuccess" class="save-success">Saved!</div>
<div v-if="surveyStore.error" class="error-inline">{{ surveyStore.error }}</div>
</div>
</div>
<!-- History accordion -->
<details class="history-accordion" :open="historyOpen" @toggle="historyOpen = ($event.target as HTMLDetailsElement).open">
<summary class="history-summary">
Survey history ({{ surveyStore.history.length }} response{{ surveyStore.history.length === 1 ? '' : 's' }})
</summary>
<div v-if="surveyStore.history.length === 0" class="history-empty">No responses saved yet.</div>
<div v-else class="history-list">
<div v-for="resp in surveyStore.history" :key="resp.id" class="history-entry">
<button class="history-toggle" @click="toggleHistoryEntry(resp.id)">
<span class="history-name">{{ resp.survey_name ?? 'Survey response' }}</span>
<span class="history-meta">{{ formatDate(resp.received_at) }}{{ resp.reported_score ? ` · ${resp.reported_score}` : '' }}</span>
<span class="history-chevron">{{ expandedHistory.has(resp.id) ? '▲' : '▼' }}</span>
</button>
<div v-if="expandedHistory.has(resp.id)" class="history-detail">
<div class="history-tags">
<span class="tag">{{ resp.mode }}</span>
<span class="tag">{{ resp.source }}</span>
<span v-if="resp.received_at" class="tag">{{ resp.received_at }}</span>
</div>
<div class="history-output">{{ resp.llm_output }}</div>
</div>
</div>
</div>
</details>
</div>
</template><!-- end v-else (id present) -->
</div> </div>
</template> </template>
<style scoped> <style scoped>
.survey-layout { .view-placeholder {
display: flex; padding: var(--space-8);
flex-direction: column; max-width: 60ch;
min-height: 100vh;
} }
.placeholder-note {
.context-bar { color: var(--color-text-muted);
position: sticky;
top: 0;
z-index: 10;
display: flex;
align-items: center;
gap: var(--space-2);
padding: 0 var(--space-6);
height: 40px;
background: var(--color-surface-raised, #f8f9fa);
border-bottom: 1px solid var(--color-border, #e2e8f0);
font-size: 0.875rem; font-size: 0.875rem;
}
.context-company {
font-weight: 600;
color: var(--color-text, #1a202c);
}
.context-sep {
color: var(--color-text-muted, #718096);
}
.context-title {
color: var(--color-text-muted, #718096);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.stage-badge {
margin-left: auto;
padding: 2px 8px;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 600;
background: var(--color-accent-subtle, #ebf4ff);
color: var(--color-accent, #3182ce);
}
.survey-content {
max-width: 760px;
margin: 0 auto;
padding: var(--space-6);
width: 100%;
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.card {
background: var(--color-surface, #fff);
border: 1px solid var(--color-border, #e2e8f0);
border-radius: var(--radius-md, 8px);
overflow: hidden;
}
.tab-bar {
display: flex;
border-bottom: 1px solid var(--color-border, #e2e8f0);
}
.tab-btn {
flex: 1;
padding: var(--space-3) var(--space-4);
background: none;
border: none;
cursor: pointer;
font-size: 0.875rem;
color: var(--color-text-muted, #718096);
transition: color 0.15s, background 0.15s;
}
.tab-btn.active {
color: var(--color-accent, #3182ce);
background: var(--color-accent-subtle, #ebf4ff);
font-weight: 600;
}
.tab-btn.disabled {
opacity: 0.45;
cursor: not-allowed;
}
.tab-panel {
padding: var(--space-4);
}
.survey-textarea {
width: 100%;
min-height: 200px;
padding: var(--space-3);
font-family: inherit;
font-size: 0.875rem;
border: 1px solid var(--color-border, #e2e8f0);
border-radius: var(--radius-sm, 4px);
resize: vertical;
background: var(--color-bg, #fff);
color: var(--color-text, #1a202c);
box-sizing: border-box;
}
.screenshot-zone {
min-height: 160px;
padding: var(--space-6);
display: flex;
align-items: center;
justify-content: center;
border: 2px dashed var(--color-border, #e2e8f0);
margin: var(--space-4);
border-radius: var(--radius-md, 8px);
outline: none;
}
.screenshot-zone:focus {
border-color: var(--color-accent, #3182ce);
}
.drop-hint {
text-align: center;
color: var(--color-text-muted, #718096);
}
.upload-label {
display: inline-block;
margin-top: var(--space-2); margin-top: var(--space-2);
padding: var(--space-2) var(--space-4);
border: 1px solid var(--color-border, #e2e8f0);
border-radius: var(--radius-sm, 4px);
cursor: pointer;
font-size: 0.875rem;
background: var(--color-surface, #fff);
}
.file-input {
display: none;
}
.image-preview {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-2);
width: 100%;
}
.image-preview img {
max-width: 100%;
max-height: 300px;
border-radius: var(--radius-sm, 4px);
}
.remove-btn {
font-size: 0.8rem;
color: var(--color-text-muted, #718096);
background: none;
border: 1px solid var(--color-border, #e2e8f0);
border-radius: var(--radius-sm, 4px);
padding: 2px 8px;
cursor: pointer;
}
.mode-cards {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.mode-card {
display: grid;
grid-template-columns: 2rem 1fr;
grid-template-rows: auto auto;
align-items: center;
gap: 0 var(--space-2);
padding: var(--space-4);
background: var(--color-surface, #fff);
border: 2px solid var(--color-border, #e2e8f0);
border-radius: var(--radius-md, 8px);
cursor: pointer;
text-align: left;
transition: border-color 0.15s, background 0.15s;
}
.mode-card.selected {
border-color: var(--color-accent, #3182ce);
background: var(--color-accent-subtle, #ebf4ff);
}
.mode-icon {
grid-row: 1 / 3;
font-size: 1.25rem;
line-height: 1;
align-self: center;
}
.mode-name {
font-weight: 600;
color: var(--color-text, #1a202c);
line-height: 1.3;
}
.mode-desc {
font-size: 0.8rem;
color: var(--color-text-muted, #718096);
}
.analyze-btn {
width: 100%;
padding: var(--space-3) var(--space-4);
background: var(--color-accent, #3182ce);
color: #fff;
border: none;
border-radius: var(--radius-md, 8px);
font-size: 1rem;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
transition: opacity 0.15s;
}
.analyze-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.results-card {
padding: var(--space-4);
}
.results-output {
white-space: pre-wrap;
font-size: 0.9rem;
line-height: 1.6;
color: var(--color-text, #1a202c);
margin-bottom: var(--space-4);
}
.save-form {
display: flex;
flex-direction: column;
gap: var(--space-2);
padding-top: var(--space-4);
border-top: 1px solid var(--color-border, #e2e8f0);
}
.save-input {
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-border, #e2e8f0);
border-radius: var(--radius-sm, 4px);
font-size: 0.875rem;
background: var(--color-bg, #fff);
color: var(--color-text, #1a202c);
box-sizing: border-box;
}
.save-btn {
align-self: flex-start;
padding: var(--space-2) var(--space-4);
background: var(--color-surface-raised, #f8f9fa);
border: 1px solid var(--color-border, #e2e8f0);
border-radius: var(--radius-md, 8px);
cursor: pointer;
font-weight: 600;
display: flex;
align-items: center;
gap: var(--space-2);
transition: background 0.15s;
}
.save-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.save-success {
color: var(--color-success, #38a169);
font-size: 0.875rem;
font-weight: 600;
}
.history-accordion {
border: 1px solid var(--color-border, #e2e8f0);
border-radius: var(--radius-md, 8px);
background: var(--color-surface, #fff);
}
.history-summary {
padding: var(--space-3) var(--space-4);
cursor: pointer;
font-size: 0.875rem;
color: var(--color-text-muted, #718096);
font-weight: 500;
list-style: none;
}
.history-summary::-webkit-details-marker { display: none; }
.history-empty {
padding: var(--space-4);
color: var(--color-text-muted, #718096);
font-size: 0.875rem;
}
.history-list {
display: flex;
flex-direction: column;
gap: 1px;
background: var(--color-border, #e2e8f0);
}
.history-entry {
background: var(--color-surface, #fff);
}
.history-toggle {
width: 100%;
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-3) var(--space-4);
background: none;
border: none;
cursor: pointer;
text-align: left;
font-size: 0.875rem;
}
.history-name {
font-weight: 500;
color: var(--color-text, #1a202c);
}
.history-meta {
color: var(--color-text-muted, #718096);
font-size: 0.8rem;
margin-left: auto;
}
.history-chevron {
font-size: 0.7rem;
color: var(--color-text-muted, #718096);
}
.history-detail {
padding: var(--space-3) var(--space-4) var(--space-4);
border-top: 1px solid var(--color-border, #e2e8f0);
}
.history-tags {
display: flex;
flex-wrap: wrap;
gap: var(--space-1);
margin-bottom: var(--space-2);
}
.tag {
padding: 1px 6px;
background: var(--color-accent-subtle, #ebf4ff);
color: var(--color-accent, #3182ce);
border-radius: 4px;
font-size: 0.75rem;
}
.history-output {
white-space: pre-wrap;
font-size: 0.875rem;
line-height: 1.6;
color: var(--color-text, #1a202c);
}
.error-banner {
background: var(--color-error-subtle, #fff5f5);
border-bottom: 1px solid var(--color-error, #fc8181);
padding: var(--space-2) var(--space-6);
font-size: 0.875rem;
color: var(--color-error-text, #c53030);
}
.error-inline {
font-size: 0.875rem;
color: var(--color-error-text, #c53030);
padding: var(--space-1) 0;
}
.spinner {
display: inline-block;
width: 1em;
height: 1em;
border: 2px solid rgba(255,255,255,0.4);
border-top-color: #fff;
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
.analyze-btn .spinner {
border-color: rgba(255,255,255,0.4);
border-top-color: #fff;
}
.save-btn .spinner {
border-color: rgba(0,0,0,0.15);
border-top-color: var(--color-accent, #3182ce);
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* ── Picker mode ── */
.picker-mode {
padding-top: var(--space-8, 2rem);
}
.picker-heading {
font-size: 1.25rem;
font-weight: 700;
color: var(--color-text, #1a202c);
margin: 0 0 var(--space-1) 0;
}
.picker-sub {
font-size: 0.875rem;
color: var(--color-text-muted, #718096);
margin: 0 0 var(--space-4) 0;
}
.picker-empty {
font-size: 0.875rem;
color: var(--color-text-muted, #718096);
padding: var(--space-4);
border: 1px dashed var(--color-border, #e2e8f0);
border-radius: var(--radius-md, 8px);
text-align: center;
}
.picker-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.picker-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
background: var(--color-surface, #fff);
border: 1px solid var(--color-border, #e2e8f0);
border-radius: var(--radius-md, 8px);
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
}
.picker-item:hover {
border-color: var(--color-accent, #3182ce);
background: var(--color-accent-subtle, #ebf4ff);
}
.picker-item__main {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.picker-item__company {
font-weight: 600;
font-size: 0.9rem;
color: var(--color-text, #1a202c);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.picker-item__title {
font-size: 0.8rem;
color: var(--color-text-muted, #718096);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} }
</style> </style>