diff --git a/frontend/.eslintrc.json b/frontend/.eslintrc.json new file mode 100644 index 0000000..bffb357 --- /dev/null +++ b/frontend/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": "next/core-web-vitals" +} diff --git a/frontend/e2e/smoke.spec.ts b/frontend/e2e/smoke.spec.ts index d3a8c60..351479b 100644 --- a/frontend/e2e/smoke.spec.ts +++ b/frontend/e2e/smoke.spec.ts @@ -22,31 +22,42 @@ const PERSONA = { story_bank: [], skills: [], summary: "", custom_directives: "", }; +const app = (id: string, status: string, extra: object = {}) => ({ + id, job_id: `job-${id}`, job_title: `Role ${id}`, company: `Company ${id}`, location: "London", + job_url: `https://jobs.example.com/${id}`, status, date_applied: null, composite_score: 0, exposure_score: 0.4, + fit_score: 80, skill_gaps: [], level_match: "strong", notes: "", created_at: new Date().toISOString(), ...extra, +}); + +/** What every signed-in page asks for: the sidebar reads the persona and the applications. */ +const SHELL = { "GET /api/persona": PERSONA, "GET /api/applications": [] }; + test("a signed-out visitor is sent to the login page", async ({ page }) => { const api = await mockApi(page, {}); await page.goto("/"); await expect(page).toHaveURL(/\/login$/); - await expect(page.getByRole("heading", { name: "Welcome Back" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "Welcome back" })).toBeVisible(); api.expectAllMocked(); }); test("logging in keeps the token and opens the dashboard", async ({ page }) => { let form = ""; const api = await mockApi(page, { + ...SHELL, "POST /api/login": (request) => { form = request.postData() ?? ""; return { access_token: "fresh-token", token_type: "bearer" }; }, "GET /api/onboarding/status": { complete: true }, - "GET /api/applications": [], "POST /api/score-skills": { skill_risks: [] }, + "GET /api/jobs": { jobs: [], has_more: false, discovering: false, sponsorship: "any" }, }); await page.goto("/login"); - await page.locator('input[type="email"]').fill("sam@example.com"); - await page.locator('input[type="password"]').fill("correct horse"); + await page.getByLabel("Email").fill("sam@example.com"); + await page.getByLabel("Password").fill("correct horse"); await page.getByRole("button", { name: /sign in/i }).click(); await expect(page).toHaveURL(/\/dashboard$/); + await expect(page.getByRole("heading", { level: 1 })).toContainText("Sam"); expect(form).toContain("sam@example.com"); expect(await page.evaluate(() => localStorage.getItem("windrush_token"))).toBe("fresh-token"); api.expectAllMocked(); @@ -57,8 +68,8 @@ test("a wrong password shows the server's message", async ({ page }) => { "POST /api/login": { status: 401, json: { detail: "Incorrect email or password" } }, }); await page.goto("/login"); - await page.locator('input[type="email"]').fill("sam@example.com"); - await page.locator('input[type="password"]').fill("wrong"); + await page.getByLabel("Email").fill("sam@example.com"); + await page.getByLabel("Password").fill("wrong"); await page.getByRole("button", { name: /sign in/i }).click(); await expect(page.getByText("Incorrect email or password")).toBeVisible(); await expect(page).toHaveURL(/\/login$/); @@ -68,15 +79,14 @@ test("a wrong password shows the server's message", async ({ page }) => { test("the feed shows each job's badges and sends the filters to the API", async ({ page }) => { await signIn(page); const api = await mockApi(page, { + ...SHELL, // The server picks "possible" for a profile that needs sponsorship and says so. "GET /api/jobs": { jobs: JOBS, has_more: false, discovering: false, sponsorship: "possible" }, }); await page.goto("/jobs"); - // A card: the innermost element holding both its title and its location. - const card = (job: (typeof JOBS)[number]) => page.locator("div") - .filter({ has: page.getByRole("heading", { name: job.title, exact: true }) }) - .filter({ hasText: job.location }).last(); + const card = (job: (typeof JOBS)[number]) => + page.getByRole("region", { name: "Roles" }).getByRole("button").filter({ hasText: job.title }); await expect(page.getByText("2 jobs")).toBeVisible(); for (const text of ["Visa sponsorship", "No experience needed", "Degree required"]) { await expect(card(JOBS[0]).getByText(text, { exact: true })).toBeVisible(); @@ -86,18 +96,16 @@ test("the feed shows each job's badges and sends the filters to the API", async } await expect(card(JOBS[1]).getByText("Degree required")).toHaveCount(0); // only preferred - const sponsorship = page.locator('select[title^="Possible hides"]'); - const experience = page.locator('select[title^="Hides jobs whose ad asks"]'); - await expect(sponsorship).toHaveValue("possible"); + await expect(page.getByRole("checkbox", { name: "Possible" })).toBeChecked(); - await experience.selectOption({ label: "No experience required" }); + await page.getByLabel("Experience asked").selectOption({ label: "No experience required" }); await expect.poll(() => api.params("/api/jobs").at(-1)?.get("max_years")).toBe("0"); - await page.getByRole("button", { name: "πŸŽ“ No degree needed" }).click(); + await page.getByRole("checkbox", { name: "No degree needed" }).check(); await expect.poll(() => api.params("/api/jobs").at(-1)?.get("no_degree")).toBe("true"); expect(api.params("/api/jobs").at(-1)?.get("max_years")).toBe("0"); // filters combine - await page.getByRole("button", { name: "βœ• Clear filters" }).click(); + await page.getByRole("button", { name: "Clear all" }).click(); await expect.poll(() => api.params("/api/jobs").at(-1)?.has("no_degree")).toBe(false); const last = api.params("/api/jobs").at(-1)!; expect([last.has("max_years"), last.has("sponsorship"), last.get("page")]).toEqual([false, false, "1"]); @@ -105,8 +113,9 @@ test("the feed shows each job's badges and sends the filters to the API", async }); test("an expired session sends you back to the login page", async ({ page }) => { + const expired = { status: 401, json: { detail: "Could not validate credentials" } }; const api = await mockApi(page, { - "GET /api/jobs": { status: 401, json: { detail: "Could not validate credentials" } }, + "GET /api/jobs": expired, "GET /api/persona": expired, "GET /api/applications": expired, }); await page.goto("/login"); await page.evaluate(() => localStorage.setItem("windrush_token", "expired-token")); @@ -120,24 +129,90 @@ test("target titles and locations typed with commas are saved as lists", async ( await signIn(page); let saved: typeof PERSONA | null = null; const api = await mockApi(page, { - "GET /api/persona": PERSONA, + ...SHELL, "PUT /api/persona": (request) => { saved = request.postDataJSON(); return { status: "ok" }; }, }); await page.goto("/profile"); - await page.getByRole("button", { name: "Preferences", exact: true }).click(); + await page.getByRole("button", { name: /^Job preferences/ }).click(); + await expect(page).toHaveURL(/#prefs$/); // Typed key by key: a comma or a space must survive until the next word is typed. - await page.getByPlaceholder("e.g. Graduate Software Engineer, Junior ML Engineer") - .pressSequentially("Graduate Software Engineer, Junior ML Engineer"); - await page.getByPlaceholder("e.g. London, Manchester, New York").pressSequentially("London, New York"); - await page.getByRole("button", { name: "Save Persona" }).click(); + await page.getByLabel("Target job titles").pressSequentially("Graduate Software Engineer, Junior ML Engineer"); + await page.getByLabel("Preferred locations").pressSequentially("London, New York"); - await expect.poll(() => saved).not.toBeNull(); + // Saved on its own, a moment after the typing stops. + await expect.poll(() => saved?.preferences.preferred_locations).toEqual(["London", "New York"]); expect(saved!.preferences.target_titles).toEqual(["Graduate Software Engineer", "Junior ML Engineer"]); - expect(saved!.preferences.preferred_locations).toEqual(["London", "New York"]); expect(saved!.core_info.first_name).toBe("Sam"); // the rest of the profile is kept + await expect(page.getByText("All changes saved")).toBeVisible(); + api.expectAllMocked(); +}); + +test("changing a status in the application panel saves it, and Esc closes the panel", async ({ page }) => { + await signIn(page); + const patches: unknown[] = []; + const api = await mockApi(page, { + "GET /api/persona": PERSONA, + "GET /api/applications": [app("a1", "Applied"), app("a2", "Saved")], + "PATCH /api/applications/a1/status": (request) => { + patches.push(request.postDataJSON()); + return { updated: true }; + }, + }); + await page.goto("/applications"); + await page.getByRole("button", { name: /Role a1 at Company a1/ }).click(); + const panel = page.getByRole("dialog", { name: "Application details" }); + await expect(panel).toBeVisible(); + + await panel.getByLabel("Status").selectOption({ label: "Interview" }); + await expect.poll(() => patches).toEqual([{ status: "Interview" }]); + await expect(page.getByRole("region", { name: "Interview, 1" })).toBeVisible(); // moved columns + + await page.keyboard.press("Escape"); + await expect(panel).toHaveCount(0); + api.expectAllMocked(); +}); + +test("the review queue lists paused applications and records one you sent yourself", async ({ page }) => { + await signIn(page); + const patches: unknown[] = []; + const api = await mockApi(page, { + "GET /api/persona": PERSONA, + "GET /api/applications": [app("a1", "Pending Review"), app("a2", "Pending Review"), app("a3", "Applied")], + "PATCH /api/applications/a1/status": (request) => { + patches.push(request.postDataJSON()); + return { updated: true }; + }, + }); + await page.goto("/review"); + await expect(page.getByRole("tab")).toHaveCount(2); + await expect(page.getByRole("link", { name: /Agent review/ })).toContainText("2"); // sidebar badge + await expect(page.getByRole("heading", { name: "Role a1 Β· Company a1" })).toBeVisible(); + + await page.getByRole("button", { name: "I submitted it myself" }).click(); + await expect.poll(() => patches).toEqual([{ status: "Applied" }]); + await expect(page.getByRole("heading", { name: "Role a2 Β· Company a2" })).toBeVisible(); + await expect(page.getByRole("tab")).toHaveCount(1); + api.expectAllMocked(); +}); + +test("the theme switch applies dark mode and remembers it", async ({ page }) => { + await signIn(page); + const api = await mockApi(page, { + ...SHELL, + "GET /api/jobs": { jobs: [], has_more: false, discovering: false, sponsorship: "any" }, + }); + await page.goto("/jobs"); + await page.getByRole("button", { name: "Dark" }).click(); + await expect(page.locator("html")).toHaveClass(/\bdark\b/); + expect(await page.evaluate(() => localStorage.getItem("windrush_theme"))).toBe("dark"); + + await page.reload(); + await expect(page.locator("html")).toHaveClass(/\bdark\b/); // applied before paint, from storage + await page.getByRole("button", { name: "Light" }).click(); + await expect(page.locator("html")).not.toHaveClass(/\bdark\b/); api.expectAllMocked(); }); diff --git a/frontend/src/app/applications/page.tsx b/frontend/src/app/applications/page.tsx index eb21126..65b94d9 100644 --- a/frontend/src/app/applications/page.tsx +++ b/frontend/src/app/applications/page.tsx @@ -1,225 +1,388 @@ "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; +import Link from "next/link"; +import { useEffect, useMemo, useState } from "react"; +import Icon from "@/components/ui/Icon"; +import TopBar, { Page } from "@/components/ui/TopBar"; +import SlideOver from "@/components/ui/SlideOver"; +import { Segmented } from "@/components/ui/Tabs"; +import { + buttonClass, Callout, Chip, CompanyMark, cx, EmptyState, labelClass, Skeleton, StatusChip, textareaClass, +} from "@/components/ui/kit"; +import { useResource } from "@/lib/store"; +import { setStatus } from "@/lib/actions"; +import { daysSince, longAgo, shortDate } from "@/lib/format"; +import { applicationLink } from "@/lib/jobs"; +import { CLOSED, CLOSED_STAGES, OPEN_STAGES, STATUS_CHOICES, stageOf, statusMeta } from "@/lib/status"; +import type { Application } from "@/lib/types"; + +type View = "board" | "table"; +type Quick = "review" | "follow" | "quiet"; + +const FOLLOW_UP_DAYS = 7; +const QUIET_DAYS = 14; + +const appliedDays = (a: Application) => (a.status === "Applied" ? daysSince(a.date_applied) : null); + +const QUICK: { key: Quick; label: string; dot: string; test: (a: Application) => boolean }[] = [ + { key: "review", label: "Needs review", dot: "bg-amber", test: (a) => a.status === "Pending Review" }, + { key: "follow", label: "Follow-up due", dot: "bg-blue", + test: (a) => { const d = appliedDays(a); return d != null && d >= FOLLOW_UP_DAYS && d < QUIET_DAYS; } }, + { key: "quiet", label: `No reply in ${QUIET_DAYS}+ days`, dot: "bg-stone", + test: (a) => { const d = appliedDays(a); return d != null && d >= QUIET_DAYS; } }, +]; + +const LEVEL: Record = { strong: "Strong", ok: "Reasonable", reach: "Stretch" }; +/** The level match, only when the job was analysed (unanalysed rows carry the default "ok"). */ +const levelOf = (a: Application) => ((a.fit_score ?? 0) > 0 ? LEVEL[a.level_match] ?? "–" : "–"); + +function dateLine(a: Application): string { + switch (a.status) { + case "Pending Review": return `Filled ${longAgo(a.created_at) ?? ""}`; + case "Saved": + case "Evaluated": return `Saved ${longAgo(a.created_at) ?? ""}`; + default: return a.date_applied ? `Applied ${shortDate(a.date_applied)}` : `Added ${shortDate(a.created_at) ?? ""}`; + } } -const STATUSES = ["Pending Review", "Evaluated", "Applied", "Responded", "Interview", "Offer", "Rejected", "Discarded"]; +function StageAge({ days }: { days: number }) { + const overdue = days >= QUIET_DAYS; + return ( + + {days}d + in this stage + + ); +} -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", -}; +function BoardCard({ app, selected, onOpen, onDragStart }: { + app: Application; selected: boolean; onOpen: () => void; onDragStart: (e: React.DragEvent) => void; +}) { + const days = appliedDays(app); + return ( + + ); +} 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); + const apps = useResource("/api/applications"); + const [view, setView] = useState("board"); + const [query, setQuery] = useState(""); + const [showClosed, setShowClosed] = useState(false); + const [quick, setQuick] = useState(null); + const [openId, setOpenId] = useState(null); + const [dragOver, setDragOver] = useState(null); + const [error, setError] = useState(null); + + // Slide-over edits + const [notes, setNotes] = useState(""); + const [savingNotes, setSavingNotes] = useState(false); + const [notesSaved, setNotesSaved] = useState(false); + + const all = useMemo(() => apps.data ?? [], [apps.data]); + const open = all.find((a) => a.id === openId) ?? null; + + useEffect(() => { + if (open) { + setNotes(open.notes ?? ""); + setNotesSaved(false); } - }; + // Only when a different application opens, not on each data refresh. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [openId]); - useEffect(() => { fetchApps(); }, []); + const closedCount = all.filter((a) => CLOSED.has(a.status)).length; + const activeCount = all.length - closedCount; - const handleFilterChange = (s: string) => { - setFilter(s); - fetchApps(s !== "all" ? s : undefined); - }; + const shown = useMemo(() => { + const q = query.trim().toLowerCase(); + const test = QUICK.find((x) => x.key === quick)?.test; + return all.filter((a) => + (showClosed || !CLOSED.has(a.status)) && + (!q || a.job_title.toLowerCase().includes(q) || a.company.toLowerCase().includes(q)) && + (!test || test(a))); + }, [all, query, showClosed, quick]); - 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 columns = [...OPEN_STAGES, ...(showClosed ? CLOSED_STAGES : [])]; - 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 move = async (id: string, status: string) => { + const app = all.find((a) => a.id === id); + if (!app || stageOf(app.status) === status) return; + setError(null); + try { + await setStatus(id, status); + } catch (e) { + setError(e instanceof Error ? e.message : "Couldn't change the status."); + } }; - const toggleExpand = (id: string, notes: string) => { - if (expandedId === id) { - setExpandedId(null); - } else { - setExpandedId(id); - setEditingNotes((prev) => ({ ...prev, [id]: notes ?? "" })); + const saveNotes = async () => { + if (!open) return; + setSavingNotes(true); + setError(null); + try { + await setStatus(open.id, open.status, notes); + setNotesSaved(true); + } catch (e) { + setError(e instanceof Error ? e.message : "Couldn't save the notes."); + } finally { + setSavingNotes(false); } }; + const avgApplied = (() => { + const ds = all.map(appliedDays).filter((d): d is number => d != null); + return ds.length ? Math.round(ds.reduce((n, d) => n + d, 0) / ds.length) : null; + })(); + + const link = open ? applicationLink(open) : null; + return ( -
-
-

- Applications -

-

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

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

Applications

+

+ {activeCount} active, {closedCount} closed.{view === "board" && all.length > 0 ? " Drag a card to change its status." : ""} +

+
+
+ + + + + setQuery(e.target.value)} + className="w-[140px] border-0 bg-transparent text-[13px] outline-none text-ink placeholder:text-ink-3" /> + + +
- ) : apps.length === 0 ? ( -
-

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

+ +
+ Needs attention + {QUICK.map((q) => { + const on = quick === q.key; + const n = all.filter(q.test).length; + return ( + + ); + })}
- ) : ( -
- {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} - - - β–Ύ -
-
+ {error && {error}} - {/* 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 β†— - -
+ {apps.loading ? ( +
+ {Array.from({ length: 6 }).map((_, i) => )} +
+ ) : apps.error ? ( + {apps.error} + ) : all.length === 0 ? ( + Browse matches}> + No applications yet. Save a job from the feed or let the agent apply, and it shows up here. + + ) : view === "board" ? ( +
+
+ {columns.map((col) => { + const cards = shown.filter((a) => stageOf(a.status) === col); + const meta = statusMeta(col); + return ( +
{ e.preventDefault(); setDragOver(col); }} + onDragLeave={() => setDragOver((d) => (d === col ? null : d))} + onDrop={(e) => { e.preventDefault(); setDragOver(null); const id = e.dataTransfer.getData("text/plain"); if (id) move(id, col); }} + className={cx("min-w-0 flex flex-col gap-2 p-[10px] rounded-[14px] bg-track transition-shadow", + dragOver === col && "shadow-[inset_0_0_0_2px_var(--teal)]")}> +

+ + + {col === "Applied" && avgApplied != null && ( + avg {avgApplied}d in stage + )} +

+ {cards.map((a) => ( + setOpenId(a.id)} + onDragStart={(e) => { e.dataTransfer.setData("text/plain", a.id); e.dataTransfer.effectAllowed = "move"; }} /> + ))} + {cards.length === 0 && ( + + Nothing here yet + )} -
- - {app.skill_gaps && app.skill_gaps.length > 0 && ( -
-

Skill Gaps

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

Notes

-