From cce0f8195a72bf855c1f6dfa36f68877582b511d Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Wed, 18 Mar 2026 09:05:40 -0700 Subject: [PATCH] =?UTF-8?q?feat(vue-spa):=20Apply=20view=20=E2=80=94=20job?= =?UTF-8?q?=20picker=20list=20+=20cover=20letter=20workspace?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - router: add /apply/:id → ApplyWorkspaceView (lazy-loaded) - ApplyView.vue: approved job list sorted by match score; badges for match %, remote, and cover-letter draft status; links to workspace - ApplyWorkspaceView.vue: two-panel desktop layout (sticky job details left, editor right); cover letter state machine (none/queued/running/ ready/failed); auto-resize textarea; word count toolbar; Save button with dirty tracking; Download PDF (programmatic click, named file); Generate with AI + Retry; Mark as Applied + Reject Listing actions; polling loop for in-flight generation tasks; toast on action - HomeView.vue: split combined "Archive Pending + Rejected" into three separate per-status archive buttons (only shown when count > 0) - dev-api.py: add GET /api/jobs/:id, POST /api/jobs/:id/applied, PATCH /api/jobs/:id/cover_letter, POST .../cover_letter/generate (wires submit_task), GET .../cover_letter/task (poll), GET .../pdf (reportlab); has_cover_letter field on list + detail responses --- dev-api.py | 160 ++++- web/src/router/index.ts | 1 + web/src/views/ApplyView.vue | 253 +++++++- web/src/views/ApplyWorkspaceView.vue | 846 +++++++++++++++++++++++++++ web/src/views/HomeView.vue | 30 +- 5 files changed, 1263 insertions(+), 27 deletions(-) create mode 100644 web/src/views/ApplyWorkspaceView.vue diff --git a/dev-api.py b/dev-api.py index 9b604e0..c8064ce 100644 --- a/dev-api.py +++ b/dev-api.py @@ -6,12 +6,22 @@ Run with: """ import sqlite3 import os +import sys +import re import json -from fastapi import FastAPI, HTTPException +import threading +from datetime import datetime +from pathlib import Path +from fastapi import FastAPI, HTTPException, Response from fastapi.middleware.cors import CORSMiddleware from pydantic import BaseModel from typing import Optional +# Allow importing peregrine scripts for cover letter generation +PEREGRINE_ROOT = Path("/Library/Development/CircuitForge/peregrine") +if str(PEREGRINE_ROOT) not in sys.path: + sys.path.insert(0, str(PEREGRINE_ROOT)) + DB_PATH = os.environ.get("STAGING_DB", "/devl/job-seeker/staging.db") app = FastAPI(title="Peregrine Dev API") @@ -39,16 +49,23 @@ def _row_to_job(row) -> dict: # ── GET /api/jobs ───────────────────────────────────────────────────────────── @app.get("/api/jobs") -def list_jobs(status: str = "pending", limit: int = 50): +def list_jobs(status: str = "pending", limit: int = 50, fields: str = ""): db = _get_db() rows = db.execute( "SELECT id, title, company, url, source, location, is_remote, salary, " - "description, match_score, keyword_gaps, date_found, status " + "description, match_score, keyword_gaps, date_found, status, cover_letter " "FROM jobs WHERE status = ? ORDER BY match_score DESC NULLS LAST LIMIT ?", (status, limit), ).fetchall() db.close() - return [_row_to_job(r) for r in rows] + result = [] + for r in rows: + d = _row_to_job(r) + d["has_cover_letter"] = bool(d.get("cover_letter")) + # Don't send full cover_letter text in the list view + d.pop("cover_letter", None) + result.append(d) + return result # ── GET /api/jobs/counts ────────────────────────────────────────────────────── @@ -122,6 +139,141 @@ def system_status(): } +# ── GET /api/jobs/:id ──────────────────────────────────────────────────────── + +@app.get("/api/jobs/{job_id}") +def get_job(job_id: int): + db = _get_db() + row = db.execute( + "SELECT id, title, company, url, source, location, is_remote, salary, " + "description, match_score, keyword_gaps, date_found, status, cover_letter " + "FROM jobs WHERE id = ?", + (job_id,), + ).fetchone() + db.close() + if not row: + raise HTTPException(404, "Job not found") + d = _row_to_job(row) + d["has_cover_letter"] = bool(d.get("cover_letter")) + return d + + +# ── POST /api/jobs/:id/applied ──────────────────────────────────────────────── + +@app.post("/api/jobs/{job_id}/applied") +def mark_applied(job_id: int): + db = _get_db() + db.execute( + "UPDATE jobs SET status = 'applied', applied_at = datetime('now') WHERE id = ?", + (job_id,), + ) + db.commit() + db.close() + return {"ok": True} + + +# ── PATCH /api/jobs/:id/cover_letter ───────────────────────────────────────── + +class CoverLetterBody(BaseModel): + text: str + +@app.patch("/api/jobs/{job_id}/cover_letter") +def save_cover_letter(job_id: int, body: CoverLetterBody): + db = _get_db() + db.execute("UPDATE jobs SET cover_letter = ? WHERE id = ?", (body.text, job_id)) + db.commit() + db.close() + return {"ok": True} + + +# ── POST /api/jobs/:id/cover_letter/generate ───────────────────────────────── + +@app.post("/api/jobs/{job_id}/cover_letter/generate") +def generate_cover_letter(job_id: int): + try: + from scripts.task_runner import submit_task + task_id, is_new = submit_task( + db_path=Path(DB_PATH), + task_type="cover_letter", + job_id=job_id, + ) + return {"task_id": task_id, "is_new": is_new} + except Exception as e: + raise HTTPException(500, str(e)) + + +# ── GET /api/jobs/:id/cover_letter/task ────────────────────────────────────── + +@app.get("/api/jobs/{job_id}/cover_letter/task") +def cover_letter_task(job_id: int): + db = _get_db() + row = db.execute( + "SELECT status, stage, error FROM background_tasks " + "WHERE task_type = 'cover_letter' AND job_id = ? " + "ORDER BY id DESC LIMIT 1", + (job_id,), + ).fetchone() + db.close() + if not row: + return {"status": "none", "stage": None, "message": None} + return { + "status": row["status"], + "stage": row["stage"], + "message": row["error"], + } + + +# ── GET /api/jobs/:id/cover_letter/pdf ─────────────────────────────────────── + +@app.get("/api/jobs/{job_id}/cover_letter/pdf") +def download_pdf(job_id: int): + db = _get_db() + row = db.execute( + "SELECT title, company, cover_letter FROM jobs WHERE id = ?", (job_id,) + ).fetchone() + db.close() + if not row or not row["cover_letter"]: + raise HTTPException(404, "No cover letter found") + + try: + from reportlab.lib.pagesizes import letter as letter_size + from reportlab.lib.units import inch + from reportlab.lib.colors import HexColor + from reportlab.lib.styles import ParagraphStyle + from reportlab.lib.enums import TA_LEFT + from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer + import io + + buf = io.BytesIO() + doc = SimpleDocTemplate(buf, pagesize=letter_size, + leftMargin=inch, rightMargin=inch, + topMargin=inch, bottomMargin=inch) + dark = HexColor("#1a2338") + body_style = ParagraphStyle( + "Body", fontName="Helvetica", fontSize=11, + textColor=dark, leading=16, spaceAfter=12, alignment=TA_LEFT, + ) + story = [] + for para in row["cover_letter"].split("\n\n"): + para = para.strip() + if para: + story.append(Paragraph(para.replace("\n", "
"), body_style)) + story.append(Spacer(1, 2)) + doc.build(story) + + company_safe = re.sub(r"[^a-zA-Z0-9]", "", row["company"] or "Company") + date_str = datetime.now().strftime("%Y-%m-%d") + filename = f"CoverLetter_{company_safe}_{date_str}.pdf" + + return Response( + content=buf.getvalue(), + media_type="application/pdf", + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) + except ImportError: + raise HTTPException(501, "reportlab not installed — install it to generate PDFs") + + # ── GET /api/config/user ────────────────────────────────────────────────────── @app.get("/api/config/user") diff --git a/web/src/router/index.ts b/web/src/router/index.ts index d46884e..f7ea856 100644 --- a/web/src/router/index.ts +++ b/web/src/router/index.ts @@ -6,6 +6,7 @@ export const router = createRouter({ { path: '/', component: () => import('../views/HomeView.vue') }, { path: '/review', component: () => import('../views/JobReviewView.vue') }, { path: '/apply', component: () => import('../views/ApplyView.vue') }, + { path: '/apply/:id', component: () => import('../views/ApplyWorkspaceView.vue') }, { path: '/interviews', component: () => import('../views/InterviewsView.vue') }, { path: '/prep', component: () => import('../views/InterviewPrepView.vue') }, { path: '/survey', component: () => import('../views/SurveyView.vue') }, diff --git a/web/src/views/ApplyView.vue b/web/src/views/ApplyView.vue index 6657ee0..c6d8f69 100644 --- a/web/src/views/ApplyView.vue +++ b/web/src/views/ApplyView.vue @@ -1,18 +1,253 @@ - diff --git a/web/src/views/ApplyWorkspaceView.vue b/web/src/views/ApplyWorkspaceView.vue new file mode 100644 index 0000000..366020b --- /dev/null +++ b/web/src/views/ApplyWorkspaceView.vue @@ -0,0 +1,846 @@ +