From 376843504798057ede3279a4235aa1f798b34df2 Mon Sep 17 00:00:00 2001 From: Timothy Oluwatobi Ojebiyi Date: Sun, 10 May 2026 02:10:10 +0100 Subject: [PATCH 1/4] feat: add tailored CV generation and onboarding status tracking --- api/app/agent.py | 70 ++++++++++++++++++++++++++++++++++++++++++++++ api/app/models.py | 2 ++ api/app/tracker.py | 51 +++++++++++++++++++++++---------- 3 files changed, 109 insertions(+), 14 deletions(-) diff --git a/api/app/agent.py b/api/app/agent.py index 24f8ca0..8f0af1b 100644 --- a/api/app/agent.py +++ b/api/app/agent.py @@ -252,6 +252,18 @@ "required": ["onet_code"], }, }, + { + "name": "generate_tailored_cv", + "description": "Generate a tailored CV (Markdown) for a specific job, emphasising the most relevant experience and skills from the user persona.", + "input_schema": { + "type": "object", + "properties": { + "job": {"type": "object", "description": "Target job object with title, company, description"}, + "persona": {"type": "object", "description": "Structured User Persona"}, + }, + "required": ["job", "persona"], + }, + }, ] @@ -690,6 +702,64 @@ def _skill_gaps(desc: str) -> list[str]: onet_code = tool_input.get("onet_code", "") return lookup_onet(onet_code) + elif name == "generate_tailored_cv": + job = tool_input.get("job", {}) + persona = tool_input.get("persona", {}) + + core = persona.get("core_info", {}) + full_name = f"{core.get('first_name', '')} {core.get('last_name', '')}".strip() + contact_parts = filter(None, [ + core.get("email"), core.get("phone"), + core.get("city"), core.get("linkedin"), core.get("github"), + ]) + contact_line = " | ".join(contact_parts) + + skills_str = "" + for cat in persona.get("skills", []): + skills_str += f"**{cat.get('category','Skills')}:** {', '.join(cat.get('skills',[]))}\n" + + history_str = "" + for exp in persona.get("history", [])[:5]: + end = "Present" if exp.get("is_current") else exp.get("end_date", "") + history_str += ( + f"**{exp.get('title','')}** — {exp.get('employer','')} " + f"({exp.get('start_date','')} – {end})\n" + f"{exp.get('summary','')}\n" + f"{exp.get('metrics','')}\n\n" + ) + + edu_str = "" + for edu in persona.get("education", []): + edu_str += f"**{edu.get('degree','')}** — {edu.get('institution','')} ({edu.get('grade','')})\n" + + proj_str = "" + for proj in persona.get("projects", [])[:3]: + proj_str += f"**{proj.get('name','')}**: {proj.get('problem_solved','')} → {proj.get('outcomes','')}\n" + + cv_text = await _llm( + system=( + "You are a professional CV writer. Produce a tailored, ATS-optimised CV in clean Markdown. " + "Reorder and emphasise experience, skills, and projects that are most relevant to the target job. " + "Mirror keywords from the job description naturally. " + "Use this structure: Contact header, Professional Summary (3 sentences), Skills (grouped), " + "Experience (reverse-chronological, bullets using STAR), Education, Projects. " + "Do NOT add fictional information — only use what is provided. " + "Do NOT include placeholders. Output only the Markdown, no preamble." + ), + user=( + f"# Target Job\n{job.get('title','')} at {job.get('company','')}\n" + f"Job Description:\n{job.get('description','')[:2000]}\n\n" + f"# Candidate\nName: {full_name}\nContact: {contact_line}\n\n" + f"## Skills\n{skills_str}\n" + f"## Experience\n{history_str}\n" + f"## Education\n{edu_str}\n" + f"## Projects\n{proj_str}\n" + f"Summary from persona: {persona.get('summary','')}" + ), + max_tokens=2048, + ) + return {"cv_text": cv_text.strip()} + return {"error": f"Unknown tool: {name}"} diff --git a/api/app/models.py b/api/app/models.py index f5e4007..7e2147e 100644 --- a/api/app/models.py +++ b/api/app/models.py @@ -108,6 +108,8 @@ class CoreInfo(BaseModel): require_sponsorship: bool = False security_clearance: str = "" has_government_ties: bool = False + job_email: str = "" + job_password: str = "" class JobPreferences(BaseModel): target_titles: List[str] = [] diff --git a/api/app/tracker.py b/api/app/tracker.py index 0e82d82..44154b1 100644 --- a/api/app/tracker.py +++ b/api/app/tracker.py @@ -14,16 +14,17 @@ _DB_PATH: str = "" VALID_STATUSES = { - "Evaluated", "Applied", "Responded", "Interview", "Offer", "Rejected", "Discarded" + "Pending Review", "Evaluated", "Applied", "Responded", "Interview", "Offer", "Rejected", "Discarded" } _CREATE_USERS_TABLE = """ CREATE TABLE IF NOT EXISTS users ( - id TEXT PRIMARY KEY, - email TEXT UNIQUE NOT NULL, - password_hash TEXT NOT NULL, - persona TEXT DEFAULT '{}', - created_at TEXT NOT NULL + id TEXT PRIMARY KEY, + email TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + persona TEXT DEFAULT '{}', + onboarding_complete INTEGER DEFAULT 0, + created_at TEXT NOT NULL ); """ @@ -40,6 +41,7 @@ date_applied TEXT, cv_profile TEXT, cover_letter TEXT, + tailored_cv TEXT, composite_score REAL, exposure_score REAL, fit_score REAL, @@ -69,11 +71,16 @@ def init_db(db_path: str = "") -> None: con.execute(_CREATE_USERS_TABLE) con.execute(_CREATE_TABLE) con.execute(_CREATE_INDEX) - # Migration: add persona column if it doesn't exist - try: - con.execute("ALTER TABLE users ADD COLUMN persona TEXT DEFAULT '{}'") - except sqlite3.OperationalError: - pass # column already exists + # Migrations — safe to run on existing DBs + for stmt in [ + "ALTER TABLE users ADD COLUMN persona TEXT DEFAULT '{}'", + "ALTER TABLE users ADD COLUMN onboarding_complete INTEGER DEFAULT 0", + "ALTER TABLE applications ADD COLUMN tailored_cv TEXT", + ]: + try: + con.execute(stmt) + except sqlite3.OperationalError: + pass con.commit() con.close() logger.info("Tracker DB initialised at %s", _DB_PATH) @@ -137,12 +144,27 @@ def update_user_persona(user_id: str, persona: dict) -> bool: return False +def get_onboarding_status(user_id: str) -> bool: + con = sqlite3.connect(_DB_PATH) + row = con.execute("SELECT onboarding_complete FROM users WHERE id=?", (user_id,)).fetchone() + con.close() + return bool(row and row[0]) + + +def set_onboarding_complete(user_id: str) -> None: + con = sqlite3.connect(_DB_PATH) + con.execute("UPDATE users SET onboarding_complete=1 WHERE id=?", (user_id,)) + con.commit() + con.close() + + def add_application( user_id: str, job: dict, cv_profile: dict, cover_letter: str, score_data: dict, + tailored_cv: str = "", ) -> str | None: """ Insert a new application row for a specific user. @@ -156,10 +178,10 @@ def add_application( con.execute( """INSERT INTO applications (id, user_id, job_id, job_title, company, location, job_url, - status, cv_profile, cover_letter, + status, cv_profile, cover_letter, tailored_cv, composite_score, exposure_score, fit_score, skill_gaps, level_match, created_at) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", ( app_id, user_id, @@ -168,9 +190,10 @@ def add_application( job.get("company", ""), job.get("location", ""), job.get("url", ""), - "Evaluated", + "Pending Review", json.dumps(cv_profile), cover_letter, + tailored_cv, score_data.get("composite_score"), score_data.get("exposure_score"), score_data.get("fit_score"), From 4c5637c862de8498041febbab2ad2c8a281c522a Mon Sep 17 00:00:00 2001 From: Timothy Oluwatobi Ojebiyi Date: Sun, 10 May 2026 02:30:10 +0100 Subject: [PATCH 2/4] feat: implement AppShell and Sidebar components for improved layout and navigation --- api/app/main.py | 184 +++++++++++++++++++++++++-- frontend/src/app/layout.tsx | 3 +- frontend/src/app/page.tsx | 65 +++------- frontend/src/components/AppShell.tsx | 24 ++++ frontend/src/components/Sidebar.tsx | 85 +++++++++++++ 5 files changed, 302 insertions(+), 59 deletions(-) create mode 100644 frontend/src/components/AppShell.tsx create mode 100644 frontend/src/components/Sidebar.tsx diff --git a/api/app/main.py b/api/app/main.py index 02d623d..2cad0ee 100644 --- a/api/app/main.py +++ b/api/app/main.py @@ -12,8 +12,9 @@ from fastapi.security import OAuth2PasswordRequestForm from .cv_parser import extract_text -from .agent import run_pipeline, run_apply +from .agent import run_pipeline, run_apply, execute_tool from .guardrails import check_cv_for_injection, get_audit_log, GuardrailViolation +from .job_proxy import search_jobs from . import tracker from . import auth from .models import Persona @@ -263,7 +264,7 @@ async def apply( job_email: str = Form(default=""), job_password: str = Form(default=""), cv_session_id: str = Form(default=""), - # Score data for tracker (sent from frontend after pipeline completes) + tailored_cv: str = Form(default=""), job_title: str = Form(default=""), company: str = Form(default=""), location: str = Form(default=""), @@ -287,10 +288,15 @@ async def apply( except json.JSONDecodeError: gaps = [] + # Fall back to persona credentials if not provided in form + persona = tracker.get_user_persona(current_user.id) + core = persona.get("core_info", {}) + effective_email = job_email or core.get("job_email", "") + effective_password = job_password or core.get("job_password", "") + cv_path = _cv_files.get(cv_session_id, "") session_id = str(uuid.uuid4()) - # Add to tracker before starting browser session job_dict = { "job_id": job_id, "title": job_title, @@ -305,7 +311,9 @@ async def apply( "skill_gaps": gaps, "level_match": level_match, } - app_id = tracker.add_application(current_user.id, job_dict, profile, cover_letter, score_data) + app_id = tracker.add_application( + current_user.id, job_dict, profile, cover_letter, score_data, tailored_cv=tailored_cv + ) q: asyncio.Queue = asyncio.Queue() fq: asyncio.Queue = asyncio.Queue(maxsize=8) @@ -313,24 +321,23 @@ async def apply( _browser_frames[session_id] = fq async def cleanup_gen(): - applied_successfully = False + user_confirmed = False try: async for chunk in run_apply( current_user.id, job_id, job_url, cover_letter, risks, session_id, q, fq, - job_email=job_email, job_password=job_password, cv_path=cv_path, + job_email=effective_email, job_password=effective_password, cv_path=cv_path, ): - # Detect successful completion from done event try: payload = json.loads(chunk.removeprefix("data: ").strip()) if payload.get("type") == "done": - applied_successfully = True + user_confirmed = True except Exception: pass yield chunk finally: _browser_queues.pop(session_id, None) _browser_frames.pop(session_id, None) - if app_id and applied_successfully: + if app_id and user_confirmed: tracker.update_status(app_id, "Applied") return StreamingResponse( @@ -419,9 +426,164 @@ async def update_application_status( ): status = body.get("status", "") notes = body.get("notes") - # In a multi-user system, we should verify the app_id belongs to current_user.id - # but for now update_status is global. Let's fix tracker later if needed. ok = tracker.update_status(app_id, status, notes) if not ok: raise HTTPException(status_code=400, detail=f"Invalid status: {status!r}") return {"id": app_id, "status": status, "updated": True} + + +# ── Onboarding ──────────────────────────────────────────────────────────────── + +@app.get("/onboarding/status") +async def onboarding_status(current_user: Annotated[auth.User, Depends(auth.get_current_user)]): + return {"complete": tracker.get_onboarding_status(current_user.id)} + + +@app.post("/onboarding/complete") +async def onboarding_complete(current_user: Annotated[auth.User, Depends(auth.get_current_user)]): + tracker.set_onboarding_complete(current_user.id) + return {"complete": True} + + +# ── Job Feed ────────────────────────────────────────────────────────────────── + +@app.get("/jobs") +async def get_jobs( + query: str = Query(default=""), + location: str = Query(default="London"), + current_user: Annotated[auth.User, Depends(auth.get_current_user)] = None, +): + """Return job listings for the job feed, defaulting to persona preferences.""" + if not query: + persona = tracker.get_user_persona(current_user.id) + prefs = persona.get("preferences", {}) + titles = prefs.get("target_titles", []) + query = titles[0] if titles else "Software Engineer" + locs = prefs.get("preferred_locations", []) + if not location or location == "London": + location = locs[0] if locs else "London" + jobs = await search_jobs(query, location) + return {"jobs": jobs, "query": query, "location": location} + + +# ── Per-job agent endpoints (SSE) ───────────────────────────────────────────── + +def _sse_json(event_type: str, data: dict) -> str: + import time + payload = {"type": event_type, "timestamp": time.time(), **data} + return f"data: {json.dumps(payload)}\n\n" + + +@app.post("/jobs/analyze") +async def analyze_job( + body: dict, + current_user: Annotated[auth.User, Depends(auth.get_current_user)], +): + """SSE: score fit + AI risk for a single job against the user's persona.""" + job = body.get("job", {}) + persona = tracker.get_user_persona(current_user.id) + + async def gen(): + yield _sse_json("start", {"message": "Analysing job…"}) + + # Score AI risk for persona skills + all_skills: list[str] = [] + for cat in persona.get("skills", []): + all_skills.extend(cat.get("skills", [])) + for exp in persona.get("history", []): + title = exp.get("title", "") + if title: + all_skills.append(title) + if all_skills: + risk_result = await execute_tool("score_ai_risk", {"skills": list(set(all_skills))}) + yield _sse_json("skill_risks", risk_result) + + # Score job fit + fit_result = await execute_tool("score_job_fit", {"jobs": [job], "persona": persona}) + yield _sse_json("job_fit", fit_result) + yield _sse_json("done", {"message": "Analysis complete"}) + + return StreamingResponse(gen(), media_type="text/event-stream", + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}) + + +@app.post("/jobs/cover-letter") +async def generate_cover_letter_endpoint( + body: dict, + current_user: Annotated[auth.User, Depends(auth.get_current_user)], +): + """SSE: generate a tailored cover letter for a job.""" + job = body.get("job", {}) + tone = body.get("tone", "professional") + persona = tracker.get_user_persona(current_user.id) + + async def gen(): + yield _sse_json("start", {"message": "Writing cover letter…"}) + result = await execute_tool("generate_cover_letter", {"job": job, "persona": persona, "tone": tone}) + yield _sse_json("cover_letter", result) + yield _sse_json("done", {"message": "Cover letter ready"}) + + return StreamingResponse(gen(), media_type="text/event-stream", + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}) + + +@app.post("/jobs/tailored-cv") +async def generate_tailored_cv_endpoint( + body: dict, + current_user: Annotated[auth.User, Depends(auth.get_current_user)], +): + """SSE: generate a tailored CV (Markdown) for a specific job.""" + job = body.get("job", {}) + persona = tracker.get_user_persona(current_user.id) + + async def gen(): + yield _sse_json("start", {"message": "Tailoring your CV…"}) + result = await execute_tool("generate_tailored_cv", {"job": job, "persona": persona}) + yield _sse_json("tailored_cv", result) + yield _sse_json("done", {"message": "CV ready"}) + + return StreamingResponse(gen(), media_type="text/event-stream", + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}) + + +# ── Careers ─────────────────────────────────────────────────────────────────── + +@app.post("/score-skills") +async def score_skills( + current_user: Annotated[auth.User, Depends(auth.get_current_user)], +): + """Non-streaming: score AI risk for all skills in the user's persona.""" + persona = tracker.get_user_persona(current_user.id) + all_skills: list[str] = [] + for cat in persona.get("skills", []): + all_skills.extend(cat.get("skills", [])) + for exp in persona.get("history", []): + title = exp.get("title", "") + if title: + all_skills.append(title) + if not all_skills: + return {"skill_risks": []} + result = await execute_tool("score_ai_risk", {"skills": list(set(all_skills))}) + return result + + +@app.post("/careers/roadmap") +async def careers_roadmap( + current_user: Annotated[auth.User, Depends(auth.get_current_user)], +): + """SSE: generate a skill development roadmap from the user's persona.""" + persona = tracker.get_user_persona(current_user.id) + all_skills: list[str] = [] + for cat in persona.get("skills", []): + all_skills.extend(cat.get("skills", [])) + + async def gen(): + yield _sse_json("start", {"message": "Building your skill roadmap…"}) + risk_result = await execute_tool("score_ai_risk", {"skills": list(set(all_skills)) or ["software engineering"]}) + skill_risks = risk_result.get("skill_risks", []) + result = await execute_tool("generate_skill_roadmap", {"skill_risks": skill_risks, "persona": persona}) + yield _sse_json("roadmap", result) + yield _sse_json("done", {"message": "Roadmap ready"}) + + return StreamingResponse(gen(), media_type="text/event-stream", + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}) diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index 81fb5b5..7b18d2c 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -1,5 +1,6 @@ import type { Metadata } from "next"; import "./globals.css"; +import AppShell from "@/components/AppShell"; export const metadata: Metadata = { title: "Windrush — AI Career Navigator", @@ -20,7 +21,7 @@ export default function RootLayout({ /> - {children} + {children} ); diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 6c9030c..3114edd 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -1,62 +1,33 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect } from "react"; import { useRouter } from "next/navigation"; -import Landing from "@/components/Landing"; import { authFetch } from "./api"; -export default function Home() { +export default function RootPage() { const router = useRouter(); - const [uploading, setUploading] = useState(false); - const [error, setError] = useState(null); - const [isAuth, setIsAuth] = useState(false); useEffect(() => { const token = localStorage.getItem("windrush_token"); if (!token) { - router.push("/login"); - } else { - setIsAuth(true); - } - }, [router]); - - const handleStart = async (file?: File) => { - if (!file) { - router.push("/app"); + router.replace("/login"); return; } - - setUploading(true); - setError(null); - - try { - const form = new FormData(); - form.append("file", file); - const res = await authFetch("/api/upload", { method: "POST", body: form }); - if (!res.ok) { - const data = await res.json().catch(() => ({})); - throw new Error((data as { detail?: string }).detail ?? "Upload failed"); - } - const { cv_session_id } = await res.json() as { cv_session_id: string }; - router.push(`/app?cv=${cv_session_id}`); - } catch (err) { - setError(String(err)); - setUploading(false); - } - }; + authFetch("/api/onboarding/status") + .then((r) => r.json()) + .then((data) => { + if (data.complete) { + router.replace("/dashboard"); + } else { + router.replace("/onboarding"); + } + }) + .catch(() => router.replace("/dashboard")); + }, [router]); return ( - <> - {isAuth && ( -
- Persona Profile - -
- )} - - +
+ Loading… +
); -} +} \ No newline at end of file diff --git a/frontend/src/components/AppShell.tsx b/frontend/src/components/AppShell.tsx new file mode 100644 index 0000000..9b9b2ba --- /dev/null +++ b/frontend/src/components/AppShell.tsx @@ -0,0 +1,24 @@ +"use client"; + +import { usePathname } from "next/navigation"; +import Sidebar from "./Sidebar"; + +const NO_SIDEBAR_ROUTES = ["/login", "/signup", "/onboarding"]; + +export default function AppShell({ children }: { children: React.ReactNode }) { + const pathname = usePathname(); + const showSidebar = !NO_SIDEBAR_ROUTES.some((r) => pathname === r || pathname.startsWith(r + "/")); + + if (!showSidebar) { + return <>{children}; + } + + return ( +
+ +
+ {children} +
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx new file mode 100644 index 0000000..8df59c3 --- /dev/null +++ b/frontend/src/components/Sidebar.tsx @@ -0,0 +1,85 @@ +"use client"; + +import { usePathname, useRouter } from "next/navigation"; + +const NAV = [ + { href: "/dashboard", label: "Dashboard", icon: "▣" }, + { href: "/jobs", label: "Job Feed", icon: "⊕" }, + { href: "/applications", label: "Applications", icon: "◫" }, + { href: "/careers", label: "Careers", icon: "◈" }, +]; + +const BOTTOM_NAV = [ + { href: "/profile", label: "Profile", icon: "◉" }, +]; + +export default function Sidebar() { + const pathname = usePathname(); + const router = useRouter(); + + const logout = () => { + localStorage.removeItem("windrush_token"); + router.push("/login"); + }; + + const isActive = (href: string) => pathname === href || pathname.startsWith(href + "/"); + + return ( + + ); +} \ No newline at end of file From 44be29ba6c3985b0bdfeda3bc119c818bf08066e Mon Sep 17 00:00:00 2001 From: Timothy Oluwatobi Ojebiyi Date: Sun, 10 May 2026 03:34:07 +0100 Subject: [PATCH 3/4] refactor: frontend to replace monolithic app view with dedicated pages for dashboard, jobs, applications, careers, and onboarding --- .idea/workspace.xml | 39 +- frontend/src/app/app/page.tsx | 521 +------------------------ frontend/src/app/applications/page.tsx | 225 +++++++++++ frontend/src/app/careers/page.tsx | 204 ++++++++++ frontend/src/app/dashboard/page.tsx | 238 +++++++++++ frontend/src/app/jobs/[id]/page.tsx | 467 ++++++++++++++++++++++ frontend/src/app/jobs/page.tsx | 164 ++++++++ frontend/src/app/onboarding/page.tsx | 333 ++++++++++++++++ frontend/src/app/profile/page.tsx | 56 ++- frontend/src/components/JobCard.tsx | 86 ++++ 10 files changed, 1799 insertions(+), 534 deletions(-) create mode 100644 frontend/src/app/applications/page.tsx create mode 100644 frontend/src/app/careers/page.tsx create mode 100644 frontend/src/app/dashboard/page.tsx create mode 100644 frontend/src/app/jobs/[id]/page.tsx create mode 100644 frontend/src/app/jobs/page.tsx create mode 100644 frontend/src/app/onboarding/page.tsx create mode 100644 frontend/src/components/JobCard.tsx diff --git a/.idea/workspace.xml b/.idea/workspace.xml index d3735c6..5c76385 100644 --- a/.idea/workspace.xml +++ b/.idea/workspace.xml @@ -4,13 +4,10 @@ - @@ -343,7 +360,9 @@ - diff --git a/frontend/src/app/app/page.tsx b/frontend/src/app/app/page.tsx index 8545f12..4057fe0 100644 --- a/frontend/src/app/app/page.tsx +++ b/frontend/src/app/app/page.tsx @@ -1,521 +1,14 @@ "use client"; -import { useState, useEffect, Suspense } from "react"; -import { useRouter, useSearchParams } from "next/navigation"; -import AgentLog, { AgentEvent } from "@/components/AgentLog"; -import RiskRadar, { SkillRisk } from "@/components/RiskRadar"; -import JobList, { Job } from "@/components/JobList"; -import CoverLetter from "@/components/CoverLetter"; -import SkillRoadmap, { RoadmapItem } from "@/components/SkillRoadmap"; -import BrowserView from "@/components/BrowserView"; -import GuardrailBadge from "@/components/GuardrailBadge"; -import { authFetch } from "../api"; +import { useEffect } from "react"; +import { useRouter } from "next/navigation"; -interface CVProfile { - name?: string; - email?: string; - phone?: string; - address?: string; - location?: string; - linkedin?: string; - github?: string; - skills?: string[]; - job_titles?: string[]; - experience_years?: number; - summary?: string; - education?: { institution: string; degree: string; dates: string }[]; - experience?: { employer: string; title: string; dates: string; summary: string }[]; -} - -type Phase = - | "idle" - | "uploading" - | "streaming" - | "awaiting_approval" - | "applying" - | "browser" - | "done"; - -function AppContent() { +export default function AppRedirect() { const router = useRouter(); - const searchParams = useSearchParams(); - const cvParam = searchParams.get("cv"); - - const [phase, setPhase] = useState("idle"); - const [events, setEvents] = useState([]); - const [cvProfile, setCvProfile] = useState(null); - const [skillRisks, setSkillRisks] = useState([]); - const [jobs, setJobs] = useState([]); - const [selectedJob, setSelectedJob] = useState(null); - const [coverLetter, setCoverLetter] = useState(""); - const [roadmap, setRoadmap] = useState([]); - const [appliedConfirmation, setAppliedConfirmation] = useState(""); - const [dragOver, setDragOver] = useState(false); - const [pendingCoverLetter, setPendingCoverLetter] = useState(""); - const [archetype, setArchetype] = useState(undefined); - const [sessionId, setSessionId] = useState(""); - const [lastGuardrailEvent, setLastGuardrailEvent] = useState<{ fired: boolean; check: string; detail?: string } | null>(null); - const [cvSessionId, setCvSessionId] = useState(""); - const [jobEmail, setJobEmail] = useState(""); - const [jobPassword, setJobPassword] = useState(""); - const [browserScreenshot, setBrowserScreenshot] = useState(null); - const [browserAction, setBrowserAction] = useState(""); - const [browserBlocked, setBrowserBlocked] = useState(false); - const [browserReason, setBrowserReason] = useState(null); - const [browserInteractive, setBrowserInteractive] = useState(false); - const [showBrowser, setShowBrowser] = useState(false); - - const pushEvent = (ev: AgentEvent) => setEvents((prev) => [...prev, ev]); - - const handleEvent = (ev: AgentEvent) => { - pushEvent(ev); - - if (ev.type === "tool_call" && ev.tool_input) { - const input = ev.tool_input as Record; - const profileData = (input.persona || input.cv_profile) as any; - if ( - (ev.tool_name === "score_job_fit" || ev.tool_name === "generate_cover_letter") && - profileData && - typeof profileData === "object" - ) { - // Map granular persona fields to the simpler CVProfile interface used for display - if (profileData.core_info) { - setCvProfile({ - name: `${profileData.core_info.first_name || ""} ${profileData.core_info.last_name || ""}`.trim(), - email: profileData.core_info.email, - location: profileData.core_info.city, - skills: profileData.skills?.flatMap((c: any) => c.skills || []) || [], - }); - } - else { - setCvProfile(profileData as CVProfile); - } - } - } - - if (ev.type === "guardrail") { - setLastGuardrailEvent({ fired: !!ev.fired, check: ev.check ?? "", detail: ev.detail }); - } - - if (ev.type === "start" && (ev as AgentEvent & { session_id?: string }).session_id) { - setSessionId((ev as AgentEvent & { session_id?: string }).session_id!); - } - - if (ev.type === "cv_session" && (ev as AgentEvent & { cv_session_id?: string }).cv_session_id) { - setCvSessionId((ev as AgentEvent & { cv_session_id?: string }).cv_session_id!); - } - - if (ev.type === "browser_action") { - setBrowserAction(ev.action ?? ""); - if (ev.screenshot) setBrowserScreenshot(ev.screenshot); - setBrowserBlocked(false); - setBrowserReason(null); - setBrowserInteractive(false); - setShowBrowser(true); - setPhase("browser"); - } - - if (ev.type === "browser_blocked") { - setBrowserAction(ev.action ?? ""); - if (ev.screenshot) setBrowserScreenshot(ev.screenshot); - setBrowserBlocked(true); - setBrowserReason(ev.reason ?? null); - setBrowserInteractive(!!(ev as AgentEvent & { interactive?: boolean }).interactive); - setShowBrowser(true); - setPhase("browser"); - } - - if (ev.type === "tool_call") { - setShowBrowser(false); - } - - if (ev.type === "tool_result" && ev.tool_name && ev.result) { - const result = ev.result as Record; - - if (ev.tool_name === "score_ai_risk" && result.skill_risks) { - setSkillRisks(result.skill_risks as SkillRisk[]); - } - - if (ev.tool_name === "score_job_fit" && result.ranked_jobs) { - const ranked = result.ranked_jobs as Job[]; - setJobs(ranked); - if (ranked.length > 0) setSelectedJob(ranked[0]); - } - - if (ev.tool_name === "generate_cover_letter" && result.cover_letter) { - setPendingCoverLetter(result.cover_letter as string); - if (result.archetype) setArchetype(result.archetype as string); - } - - if (ev.tool_name === "generate_skill_roadmap" && result.items) { - setRoadmap(result.items as RoadmapItem[]); - } - - if (ev.tool_name === "apply_to_job") { - const r = result as { message?: string }; - setAppliedConfirmation(r.message ?? "Application submitted!"); - } - } - - if (ev.type === "done") { - setPhase((prev) => { - if (prev === "streaming") return "awaiting_approval"; - return "done"; - }); - } - }; - - const startPipeline = async (cvSessionIdParam: string) => { - setPhase("streaming"); - setEvents([]); - setCvProfile(null); - setSkillRisks([]); - setJobs([]); - setSelectedJob(null); - setCoverLetter(""); - setPendingCoverLetter(""); - setRoadmap([]); - setAppliedConfirmation(""); - setCvSessionId(""); - setBrowserScreenshot(null); - setBrowserAction(""); - setBrowserBlocked(false); - setJobEmail(""); - setJobPassword(""); - - const form = new FormData(); - form.append("cv_session_id", cvSessionIdParam); - - let response: Response; - try { - response = await authFetch("/api/stream", { method: "POST", body: form }); - } catch (err) { - pushEvent({ type: "text", timestamp: Date.now() / 1000, text: `Connection error: ${err}` }); - setPhase("idle"); - return; - } - - if (!response.body) { - pushEvent({ type: "text", timestamp: Date.now() / 1000, text: "Server returned no response body." }); - setPhase("idle"); - return; - } - - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ""; - - while (true) { - const { value, done } = await reader.read(); - if (done) break; - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split("\n"); - buffer = lines.pop() ?? ""; - for (const line of lines) { - if (line.startsWith("data: ")) { - try { - const ev = JSON.parse(line.slice(6)) as AgentEvent; - handleEvent(ev); - } catch { - // Partial JSON — ignore - } - } - } - } - - setPendingCoverLetter((cl) => { if (cl) setCoverLetter(cl); return cl; }); - }; - - // On mount: check token and auto-start pipeline if cv param is present - useEffect(() => { - const token = localStorage.getItem("windrush_token"); - if (!token) { - router.push("/login"); - return; - } - if (cvParam) startPipeline(cvParam); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [cvParam, router]); - - // In-app file upload (for re-runs without going back to landing) - const handleFileInput = async (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - if (!file) return; - setPhase("uploading"); - try { - const form = new FormData(); - form.append("file", file); - const res = await authFetch("/api/upload", { method: "POST", body: form }); - if (!res.ok) throw new Error("Upload failed"); - const { cv_session_id } = await res.json() as { cv_session_id: string }; - await startPipeline(cv_session_id); - } catch (err) { - pushEvent({ type: "text", timestamp: Date.now() / 1000, text: `Upload error: ${err}` }); - setPhase("idle"); - } - }; - - const handleDrop = async (e: React.DragEvent) => { - e.preventDefault(); - setDragOver(false); - const file = e.dataTransfer.files[0]; - if (!file || file.type !== "application/pdf") return; - setPhase("uploading"); - try { - const form = new FormData(); - form.append("file", file); - const res = await authFetch("/api/upload", { method: "POST", body: form }); - if (!res.ok) throw new Error("Upload failed"); - const { cv_session_id } = await res.json() as { cv_session_id: string }; - await startPipeline(cv_session_id); - } catch (err) { - pushEvent({ type: "text", timestamp: Date.now() / 1000, text: `Upload error: ${err}` }); - setPhase("idle"); - } - }; - - const handleApprove = async () => { - if (!selectedJob) return; - const cl = coverLetter || pendingCoverLetter; - setPhase("applying"); - setCoverLetter(""); - - const form = new FormData(); - form.append("job_id", selectedJob.job_id); - form.append("job_url", selectedJob.url ?? ""); - form.append("cover_letter", cl); - form.append("cv_profile", JSON.stringify(cvProfile ?? {})); - form.append("skill_risks", JSON.stringify(skillRisks)); - form.append("job_email", jobEmail); - form.append("job_password", jobPassword); - form.append("cv_session_id", cvSessionId); - - try { - const response = await authFetch("/api/apply", { method: "POST", body: form }); - if (!response.body) { - pushEvent({ type: "text", timestamp: Date.now() / 1000, text: "Apply returned no response body." }); - setPhase("done"); - return; - } - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ""; - - while (true) { - const { value, done } = await reader.read(); - if (done) break; - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split("\n"); - buffer = lines.pop() ?? ""; - for (const line of lines) { - if (line.startsWith("data: ")) { - try { - const ev = JSON.parse(line.slice(6)) as AgentEvent; - handleEvent(ev); - } catch {} - } - } - } - } catch (err) { - pushEvent({ type: "text", timestamp: Date.now() / 1000, text: `Apply error: ${err}` }); - } finally { - setPhase("done"); - } - }; - - const isStreaming = phase === "streaming" || phase === "uploading" || phase === "applying"; - const showApproval = phase === "awaiting_approval" && (coverLetter || pendingCoverLetter) && selectedJob; - const isApplying = phase === "applying"; - - return ( -
- {/* Header */} -
-
-

router.push("/")} - > - Windrush -

-

AI-powered career transition navigator

-
-
- - My Applications → - - - Persona Profile - - - - {isStreaming && ( - - - Agent running - - )} - {phase === "done" && appliedConfirmation && ( - {appliedConfirmation} - )} -
-
- - {/* Body — 3-column layout */} -
- {/* Left column (1/3): upload + results */} -
- {/* Upload zone */} -
- -
- - {/* CV Profile */} - {cvProfile && ( -
-

Profile

- {cvProfile.name &&
{cvProfile.name}
} - {cvProfile.email &&
{cvProfile.email}
} - {cvProfile.location &&
{cvProfile.location}
} - {cvProfile.experience_years != null && ( -
{cvProfile.experience_years} yrs exp
- )} - {cvProfile.skills && cvProfile.skills.length > 0 && ( -
- {cvProfile.skills.slice(0, 8).map((s) => ( - - {s} - - ))} -
- )} -
- )} - - {/* Risk Radar */} - {skillRisks.length > 0 && ( -
- -
- )} - - {/* Job List */} - {jobs.length > 0 && ( -
- -
- )} - - {/* Skill Roadmap */} - {roadmap.length > 0 && ( -
- -
- )} - - {/* Empty state */} - {!cvProfile && !skillRisks.length && !jobs.length && phase === "idle" && ( -
- Upload a CV PDF to begin -
- )} -
- - {/* Right column (2/3): Agent Log */} -
-
-

Agent Log

- {events.length > 0 && ( - - )} -
-
- -
-
-
- - {/* Browser View */} - {showBrowser && ( - setBrowserBlocked(false)} - /> - )} - - {/* Cover Letter Approval Modal */} - {showApproval && ( - { setCoverLetter(""); setPendingCoverLetter(""); setPhase("done"); }} - applying={isApplying} - jobEmail={jobEmail} - jobPassword={jobPassword} - onJobEmailChange={setJobEmail} - onJobPasswordChange={setJobPassword} - /> - )} -
- ); -} - -export default function AppPage() { + useEffect(() => { router.replace("/jobs"); }, [router]); return ( - - - +
+ Redirecting… +
); } diff --git a/frontend/src/app/applications/page.tsx b/frontend/src/app/applications/page.tsx new file mode 100644 index 0000000..eb21126 --- /dev/null +++ b/frontend/src/app/applications/page.tsx @@ -0,0 +1,225 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { authFetch } from "../api"; + +interface Application { + id: string; + job_title: string; + company: string; + location: string; + job_url: string; + status: string; + date_applied: string | null; + composite_score: number | null; + exposure_score: number | null; + skill_gaps: string[]; + level_match: string; + notes: string; + created_at: string; +} + +const STATUSES = ["Pending Review", "Evaluated", "Applied", "Responded", "Interview", "Offer", "Rejected", "Discarded"]; + +const STATUS_STYLE: Record = { + "Pending Review": "bg-yellow-500/20 text-yellow-400 border-yellow-500/20", + Evaluated: "bg-zinc-700/50 text-zinc-400 border-zinc-700", + Applied: "bg-blue-500/20 text-blue-400 border-blue-500/20", + Responded: "bg-amber-500/20 text-amber-400 border-amber-500/20", + Interview: "bg-teal-500/20 text-teal-400 border-teal-500/20", + Offer: "bg-green-500/20 text-green-400 border-green-500/20", + Rejected: "bg-red-500/20 text-red-400 border-red-500/20", + Discarded: "bg-zinc-800 text-zinc-600 border-zinc-800", +}; + +export default function ApplicationsPage() { + const [apps, setApps] = useState([]); + const [filter, setFilter] = useState("all"); + const [loading, setLoading] = useState(true); + const [expandedId, setExpandedId] = useState(null); + const [editingNotes, setEditingNotes] = useState>({}); + + const fetchApps = async (status?: string) => { + setLoading(true); + const url = status && status !== "all" ? `/api/applications?status=${status}` : "/api/applications"; + try { + const res = await authFetch(url); + if (res.ok) setApps(await res.json()); + } finally { + setLoading(false); + } + }; + + useEffect(() => { fetchApps(); }, []); + + const handleFilterChange = (s: string) => { + setFilter(s); + fetchApps(s !== "all" ? s : undefined); + }; + + const handleStatusChange = async (id: string, newStatus: string) => { + await authFetch(`/api/applications/${id}/status`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ status: newStatus }), + }); + fetchApps(filter !== "all" ? filter : undefined); + }; + + const saveNotes = async (id: string) => { + const app = apps.find((a) => a.id === id); + if (!app) return; + await authFetch(`/api/applications/${id}/status`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ status: app.status, notes: editingNotes[id] ?? "" }), + }); + setApps((prev) => prev.map((a) => a.id === id ? { ...a, notes: editingNotes[id] ?? a.notes } : a)); + }; + + const toggleExpand = (id: string, notes: string) => { + if (expandedId === id) { + setExpandedId(null); + } else { + setExpandedId(id); + setEditingNotes((prev) => ({ ...prev, [id]: notes ?? "" })); + } + }; + + return ( +
+
+

+ Applications +

+

{apps.length} application{apps.length !== 1 ? "s" : ""} tracked

+
+ + {/* Filter tabs */} +
+ {["all", ...STATUSES].map((s) => ( + + ))} +
+ + {loading ? ( +
+ {Array.from({ length: 4 }).map((_, i) => ( +
+ ))} +
+ ) : apps.length === 0 ? ( +
+

No applications yet. Apply to jobs from the Job Feed.

+
+ ) : ( +
+ {apps.map((app) => ( +
+ {/* Row */} +
toggleExpand(app.id, app.notes)} + > +
+
+ {app.job_title} + · + {app.company} +
+
{app.date_applied ?? app.created_at?.slice(0, 10)}
+
+ +
+ {app.composite_score != null && ( + {Math.round(app.composite_score * 100)}% fit + )} + + {app.status} + + + ▾ +
+
+ + {/* Expanded row */} + {expandedId === app.id && ( +
+
+
+

Location

+

{app.location || "—"}

+
+
+

Level Match

+

{app.level_match || "—"}

+
+
+

AI Exposure

+

{app.exposure_score != null ? `${app.exposure_score}%` : "—"}

+
+ {app.job_url && ( +
+

Posting

+ + View ↗ + +
+ )} +
+ + {app.skill_gaps && app.skill_gaps.length > 0 && ( +
+

Skill Gaps

+
+ {app.skill_gaps.map((gap) => ( + + {gap} + + ))} +
+
+ )} + +
+

Notes

+