Compare commits
20 commits
b9ef1f631e
...
a7303c1dff
| Author | SHA1 | Date | |
|---|---|---|---|
| a7303c1dff | |||
| e94c66dce1 | |||
| ff45f4f6a8 | |||
| 7b634cb46a | |||
| ac8f949a19 | |||
| afa462b7f5 | |||
| 0f21733e41 | |||
| a8ff406955 | |||
| 437a9c3f55 | |||
| e4f4b0c67f | |||
| fc645d276f | |||
| 048edb6cb4 | |||
| e89fe51041 | |||
| 3aed304434 | |||
| dc21e730d9 | |||
| 44adfd6691 | |||
| 0ef8547c99 | |||
| dc158ba802 | |||
| 26484f111c | |||
| 0e1dd29938 |
16 changed files with 6419 additions and 39 deletions
187
dev-api.py
187
dev-api.py
|
|
@ -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")
|
||||
|
|
|
|||
1372
docs/superpowers/plans/2026-03-20-interview-prep-vue-plan.md
Normal file
1372
docs/superpowers/plans/2026-03-20-interview-prep-vue-plan.md
Normal file
File diff suppressed because it is too large
Load diff
1633
docs/superpowers/plans/2026-03-21-survey-vue-plan.md
Normal file
1633
docs/superpowers/plans/2026-03-21-survey-vue-plan.md
Normal file
File diff suppressed because it is too large
Load diff
184
docs/superpowers/specs/2026-03-20-interview-prep-vue-design.md
Normal file
184
docs/superpowers/specs/2026-03-20-interview-prep-vue-design.md
Normal 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` |
|
||||
224
docs/superpowers/specs/2026-03-21-survey-vue-design.md
Normal file
224
docs/superpowers/specs/2026-03-21-survey-vue-design.md
Normal 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
161
tests/test_dev_api_prep.py
Normal 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
|
||||
164
tests/test_dev_api_survey.py
Normal file
164
tests/test_dev_api_survey.py
Normal 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"
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
186
web/src/stores/prep.test.ts
Normal 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
173
web/src/stores/prep.ts
Normal 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,
|
||||
}
|
||||
})
|
||||
173
web/src/stores/survey.test.ts
Normal file
173
web/src/stores/survey.test.ts
Normal 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
157
web/src/stores/survey.ts
Normal 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,
|
||||
}
|
||||
})
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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.: Q1: Which best describes your work style? A. I prefer working alone B. I thrive in teams 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 & 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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue