Compare commits

...

20 commits

Author SHA1 Message Date
a7303c1dff feat(survey): show job picker when navigating to /survey with no id
Some checks failed
CI / test (pull_request) Failing after 25s
2026-03-21 00:49:55 -07:00
e94c66dce1 fix: SurveyView history reactivity, timer cleanup, accessibility
- Reassign expandedHistory.value to a new Set on toggle so Vue tracks
  the change and template expressions re-evaluate correctly
- Capture saveSuccess setTimeout in a module-level variable; clear it
  on unmount to prevent state mutation after component teardown
- Add role="region" + aria-label to screenshot drop zone div
- Add box-sizing: border-box to .save-input to match .survey-textarea
2026-03-21 00:31:31 -07:00
ff45f4f6a8 feat(survey): implement SurveyView with navigation wiring 2026-03-21 00:27:57 -07:00
7b634cb46a fix: survey store quality issues — loading in fetchFor, source guard, saveResponse failure test 2026-03-21 00:21:21 -07:00
ac8f949a19 feat(survey): add survey Pinia store with tests
Setup-store pattern (setup function style) with fetchFor, analyze,
saveResponse, and clear. analysis ref stores mode + rawInput so
saveResponse can build the full POST body without re-passing them.
6/6 unit tests pass; full suite 15/15.
2026-03-21 00:17:13 -07:00
afa462b7f5 fix(survey): validate mode input and handle malformed base64 in save endpoint 2026-03-21 00:14:39 -07:00
0f21733e41 feat(survey): add 4 backend survey endpoints with tests
Add GET /api/vision/health, POST /api/jobs/{id}/survey/analyze,
POST /api/jobs/{id}/survey/responses, and GET /api/jobs/{id}/survey/responses
to dev-api.py. All 10 TDD tests pass; 549 total suite tests pass (0 regressions).
2026-03-21 00:09:02 -07:00
a8ff406955 docs: add survey Vue SPA implementation plan 2026-03-20 22:57:59 -07:00
437a9c3f55 docs: add Survey Assistant Vue SPA design spec 2026-03-20 22:43:57 -07:00
e4f4b0c67f fix: contacts fetch error degrades partially, not full panel blank
Contacts 5xx no longer early-returns from fetchFor, leaving the entire
right panel blank. A new contactsError ref surfaces the failure message
in the Email tab only; JD tab, Cover Letter tab, and match score all
render normally. Adds test asserting partial degradation behavior.
2026-03-20 19:16:03 -07:00
fc645d276f fix: aria-label binding, dead import, guardAndLoad network error handling
- Fix 1: Add missing `:` binding prefix to aria-label on score badge
  (was emitting literal backtick template string to DOM)
- Fix 2: Remove unused `watch` import from InterviewPrepView.vue
- Fix 3: guardAndLoad now checks interviewsStore.error after fetchAll;
  shows pageError banner instead of silently redirecting to /interviews
  on network failure; job is now a ref set explicitly in the guard
- Fix 4: Remove unconditional research-badge from InterviewCard.vue
  (added in this branch; card has no access to prep store so badge
  always showed regardless of whether research exists)
2026-03-20 18:57:41 -07:00
048edb6cb4 fix: hide Prep button on hired stage cards 2026-03-20 18:51:18 -07:00
e89fe51041 feat: implement interview prep view with two-column layout
Two-column desktop layout (40/60 split, sticky left panel):
- Left: job header with stage badge, interview countdown chip, research
  controls (generate/spinner/refresh/retry), and research sections
  (talking points, company, leadership, tech, funding, red flags, A11y)
- Right: tabbed panel (JD + match score/keyword gaps, email history,
  cover letter) plus locally-persisted call notes via @vueuse/core
- Mobile (≤1023px): single-column, left content first
- Routing guard: redirects to /interviews if no id, job not found, or
  wrong status; calls prepStore.fetchFor on mount, clear on unmount
2026-03-20 18:48:38 -07:00
3aed304434 fix: guard generateResearch against POST failure, surface partial fetch errors
- Check error from POST /research/generate; only start pollTask on success to prevent unresolvable polling intervals
- Surface contacts and fullJob fetch errors in fetchFor; silently ignore research 404 (expected when no research yet)
- Remove redundant type assertions (as Contact[], as TaskStatus, as FullJobDetail)
- Add @internal JSDoc to pollTask
- Remove redundant vi.runAllTimersAsync() after vi.advanceTimersByTimeAsync(3000) in test
2026-03-20 18:44:11 -07:00
dc21e730d9 refactor: use existing useApi composable in prep store, remove duplicate
Delete useApiFetch.ts wrapper (returned T|null) and update prep.ts and
prep.test.ts to import useApiFetch from useApi.ts directly, destructuring
{ data, error } to match the established pattern used by all other stores.
2026-03-20 18:40:33 -07:00
44adfd6691 feat: add prep store with research polling
Adds usePrepStore (Pinia) for interview prep data: parallel fetch of
research brief, contacts, task status, and full job detail; setInterval-
based polling that stops on completion and re-fetches; clear() cancels
the interval and resets all state. Also adds useApiFetch composable
wrapper (returns T|null directly) used by the store.
2026-03-20 18:36:19 -07:00
0ef8547c99 refactor: use _get_db() pattern in get_research_brief, fix HTTPException style
- Replace lazy import + scripts.db.get_research with inline SQL via _get_db(),
  matching the pattern used by research_task_status and get_job_contacts
- Exclude raw_output from SELECT instead of post-fetch pop
- Change HTTPException in generate_research to positional-arg style
- Update test_get_research_found/not_found to patch dev_api._get_db
2026-03-20 18:32:02 -07:00
dc158ba802 feat: add research and contacts endpoints for interview prep 2026-03-20 18:18:39 -07:00
26484f111c docs: add interview prep Vue implementation plan 2026-03-20 18:00:07 -07:00
0e1dd29938 docs: add interview prep Vue SPA design spec 2026-03-20 17:46:05 -07:00
16 changed files with 6419 additions and 39 deletions

View file

@ -18,6 +18,7 @@ from fastapi import FastAPI, HTTPException, Response
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from typing import Optional
import requests
# Allow importing peregrine scripts for cover letter generation
PEREGRINE_ROOT = Path("/Library/Development/CircuitForge/peregrine")
@ -312,6 +313,192 @@ 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 ───────────────────────────────────────
@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

@ -0,0 +1,184 @@
# 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

@ -0,0 +1,224 @@
# 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 |

161
tests/test_dev_api_prep.py Normal file
View file

@ -0,0 +1,161 @@
"""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

@ -0,0 +1,164 @@
"""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,6 +12,7 @@ const props = defineProps<{
const emit = defineEmits<{
move: [jobId: number, preSelectedStage?: PipelineStage]
prep: [jobId: number]
survey: [jobId: number]
}>()
// Signal state
@ -176,11 +177,15 @@ const columnColor = computed(() => {
<div v-if="interviewDateLabel" class="date-chip">
{{ dateChipIcon }} {{ interviewDateLabel }}
</div>
<div class="research-badge research-badge--done">🔬 Research ready</div>
</div>
<footer class="card-footer">
<button class="card-action" @click.stop="emit('move', job.id)">Move to </button>
<button class="card-action" @click.stop="emit('prep', job.id)">Prep </button>
<button v-if="['phone_screen', 'interviewing', 'offer'].includes(job.status)" 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>
<!-- Signal banners -->
<template v-if="job.stage_signals?.length">
@ -331,23 +336,6 @@ const columnColor = computed(() => {
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 {
border-top: 1px solid var(--color-border-light);

View file

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

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

@ -0,0 +1,186 @@
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()
})
})

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

@ -0,0 +1,173 @@
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

@ -0,0 +1,173 @@
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()
})
})

157
web/src/stores/survey.ts Normal file
View file

@ -0,0 +1,157 @@
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,18 +1,974 @@
<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>
<div class="view-placeholder">
<h1>InterviewPrepView</h1>
<p class="placeholder-note">Vue port in progress Streamlit equivalent at app/pages/</p>
<div class="prep-view">
<!-- Loading skeleton while interviews store loads -->
<div v-if="interviewsStore.loading && !job" class="prep-loading" aria-live="polite">
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>
</template>
<style scoped>
.view-placeholder {
padding: var(--space-8);
max-width: 60ch;
/* ── Layout ─────────────────────────────────────────────────────────────── */
.prep-view {
padding: var(--space-4) var(--space-4) var(--space-12);
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);
font-size: 0.875rem;
margin-top: var(--space-2);
font-size: var(--text-sm);
}
/* ── 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>

View file

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

View file

@ -1,18 +1,834 @@
<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>
<div class="view-placeholder">
<h1>SurveyView</h1>
<p class="placeholder-note">Vue port in progress Streamlit equivalent at app/pages/</p>
<div class="survey-layout">
<!-- Job picker (no id in route) -->
<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>
</template>
<style scoped>
.view-placeholder {
padding: var(--space-8);
max-width: 60ch;
.survey-layout {
display: flex;
flex-direction: column;
min-height: 100vh;
}
.placeholder-note {
color: var(--color-text-muted);
.context-bar {
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;
}
.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);
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>