diff --git a/job-tracker-ui/src/career-workspace-page.test.tsx b/job-tracker-ui/src/career-workspace-page.test.tsx new file mode 100644 index 0000000..ca5128f --- /dev/null +++ b/job-tracker-ui/src/career-workspace-page.test.tsx @@ -0,0 +1,93 @@ +import React from "react"; +import "@testing-library/jest-dom"; +import { render, screen, waitFor } from "@testing-library/react"; + +import { cvBuilderApi } from "./cvBuilder"; +import CareerWorkspaceOverview, { CareerWorkspaceImportRun } from "./views/career/CareerWorkspaceOverview"; + +jest.mock("./cvBuilder", () => ({ + cvBuilderApi: { list: jest.fn() }, +})); + +const mockedList = cvBuilderApi.list as jest.MockedFunction; + +function renderOverview({ + percent = 0, + missing = [], + runs = [], + loading = false, + loadError = null, +}: { + percent?: number; + missing?: string[]; + runs?: CareerWorkspaceImportRun[]; + loading?: boolean; + loadError?: string | null; +} = {}) { + return render( + , + ); +} + +beforeEach(() => { + mockedList.mockResolvedValue([]); +}); + +afterEach(() => { + jest.clearAllMocks(); +}); + +test("first-run workspace presents concise profile, import, general CV and job-specific actions", async () => { + renderOverview(); + + expect(screen.getByRole("heading", { name: "Career Workspace", level: 1 })).toBeInTheDocument(); + expect(await screen.findByText(/start by adding career information manually or importing a cv/i)).toBeInTheDocument(); + expect(screen.queryByText(/job-specific CVs stay separate and never overwrite/i)).not.toBeInTheDocument(); + expect(screen.getByRole("link", { name: "Edit career profile" })).toHaveAttribute("href", "#career-profile-editor"); + expect(screen.getAllByRole("link", { name: "Import a CV" })[0]).toHaveAttribute("href", "#career-cv-import"); + expect(screen.getByRole("link", { name: "Create a general CV" })).toHaveAttribute("href", "/career/builder"); + expect(screen.getByRole("link", { name: "Choose a job" })).toHaveAttribute("href", "/jobs"); + expect(await screen.findByText("No CV documents yet.")).toBeInTheDocument(); +}); + +test("returning workspace shows missing profile information and recent general and job-specific CVs", async () => { + mockedList.mockResolvedValue([ + { id: 8, name: "Backend CV", themeId: "modern", publicSlug: "backend", isPublic: false, version: 2, jobApplicationId: 42, updatedAtUtc: "2026-08-08T10:00:00Z" }, + { id: 7, name: "General CV", themeId: "modern", publicSlug: "general", isPublic: false, version: 1, jobApplicationId: null, updatedAtUtc: "2026-08-07T10:00:00Z" }, + ]); + + renderOverview({ percent: 70, missing: ["Projects", "Education", "Languages", "Interests"] }); + + expect(screen.getByText("70%")).toBeInTheDocument(); + expect(screen.getByText("Add next: Projects, Education, Languages")).toBeInTheDocument(); + expect(await screen.findByText("Backend CV")).toBeInTheDocument(); + expect(screen.getAllByText("General CV").length).toBeGreaterThan(1); + expect(screen.getByText("Job-specific")).toBeInTheDocument(); + expect(screen.getAllByRole("link", { name: "Open", exact: true })[0]).toHaveAttribute("href", "/career/builder/8"); +}); + +test.each([ + ["pending_review", "Review imported CV", /waiting for your approval/i], + ["queued", "View CV processing", /is processing/i], + ["failed", "Resolve CV import", /needs attention/i], +])("workspace exposes the %s import state as a resumable action", async (status, label, message) => { + renderOverview({ runs: [{ id: 13, status }] }); + + expect(screen.getAllByRole("link", { name: label })[0]).toHaveAttribute("href", "#career-cv-import"); + expect(screen.getByText(message)).toBeInTheDocument(); + await waitFor(() => expect(mockedList).toHaveBeenCalledTimes(1)); +}); + +test("workspace represents loading and profile-load errors without inventing status", async () => { + renderOverview({ loading: true, loadError: "offline" }); + + expect(screen.getByRole("progressbar", { name: "Career profile completeness" })).toBeInTheDocument(); + expect(screen.getByText(/workspace status is unavailable/i)).toBeInTheDocument(); + expect(screen.queryByText(/start by adding career information/i)).not.toBeInTheDocument(); + await waitFor(() => expect(mockedList).toHaveBeenCalledTimes(1)); +}); diff --git a/job-tracker-ui/src/views/CareerProfilePage.tsx b/job-tracker-ui/src/views/CareerProfilePage.tsx index 4636a9a..c0d6fe8 100644 --- a/job-tracker-ui/src/views/CareerProfilePage.tsx +++ b/job-tracker-ui/src/views/CareerProfilePage.tsx @@ -14,6 +14,7 @@ import TwoFactorSettingsCard from "../components/TwoFactorSettingsCard"; import SessionsSettingsCard from "../components/SessionsSettingsCard"; import CropImageDialog from "../components/CropImageDialog"; import ProfileCompleteness from "./career/ProfileCompleteness"; +import CareerWorkspaceOverview from "./career/CareerWorkspaceOverview"; import { EducationSection, InterestsSection, @@ -284,12 +285,15 @@ export default function CareerProfilePage() { const cvLabel = profileCvText.trim() ? t("profileCvReady", { count: cvWordCount }) : t("profileCvMissing"); const latestRun = extractionRuns[0]; return ( - + + + void restoreVersion(version)} + showSummary={false} /> - @@ -392,7 +395,7 @@ export default function CareerProfilePage() { - + {!canUseAi && View Pro}>AI CV import, rebuilding, improvement, and reprocessing require Pro. Manual profile editing remains available.} @@ -745,6 +748,7 @@ export default function CareerProfilePage() { - + + ); } diff --git a/job-tracker-ui/src/views/CareerWorkspacePage.tsx b/job-tracker-ui/src/views/CareerWorkspacePage.tsx index b3ebfeb..cdaf471 100644 --- a/job-tracker-ui/src/views/CareerWorkspacePage.tsx +++ b/job-tracker-ui/src/views/CareerWorkspacePage.tsx @@ -1,33 +1,7 @@ import React from "react"; -import { useNavigate } from "react-router-dom"; - -import { Alert, Box, Button, Paper, Typography } from "@mui/material"; -import DescriptionOutlinedIcon from "@mui/icons-material/DescriptionOutlined"; import CareerProfilePage from "./CareerProfilePage"; -// Phase 2 / 2.2: /career is the Career Workspace — the master career profile, the single source of -// truth for all generated documents. It is a dedicated component (CareerProfilePage), no longer a -// careerOnly fork of ProfilePage. The CV Builder is deliberately NOT here yet (Phase 4). export default function CareerWorkspacePage() { - const navigate = useNavigate(); - return ( - - - - Career Profile - - This information powers your CVs, applications, cover letters and AI assistance. - - - - - - Your career profile holds your information. The CV Builder creates documents from it — job-specific CVs stay separate and never overwrite your profile. - - - - - - ); + return ; } diff --git a/job-tracker-ui/src/views/career/CareerWorkspaceOverview.tsx b/job-tracker-ui/src/views/career/CareerWorkspaceOverview.tsx new file mode 100644 index 0000000..367cde8 --- /dev/null +++ b/job-tracker-ui/src/views/career/CareerWorkspaceOverview.tsx @@ -0,0 +1,165 @@ +import React, { useEffect, useMemo, useState } from "react"; + +import { Alert, Box, Button, Chip, LinearProgress, Paper, Skeleton, Stack, Typography } from "@mui/material"; +import DescriptionOutlinedIcon from "@mui/icons-material/DescriptionOutlined"; +import PersonOutlineIcon from "@mui/icons-material/PersonOutline"; +import UploadFileOutlinedIcon from "@mui/icons-material/UploadFileOutlined"; +import WorkOutlineIcon from "@mui/icons-material/WorkOutline"; + +import { CvVariantSummary, cvBuilderApi } from "../../cvBuilder"; +import type { UserOperation } from "../../types"; + +export type CareerWorkspaceCompleteness = { + percent: number; + missing: string[]; +}; + +export type CareerWorkspaceImportRun = { + id: number; + status: string; + errorMessage?: string; + operation?: UserOperation | null; +}; + +const activeStatuses = new Set(["queued", "running", "waiting_for_retry", "waiting_for_external_fallback"]); + +function isActive(run: CareerWorkspaceImportRun) { + return run.operation + ? activeStatuses.has(run.operation.status) + : run.status === "queued" || run.status === "running"; +} + +function ActionCard({ title, body, href, label, icon }: { + title: string; + body: string; + href: string; + label: string; + icon: React.ReactNode; +}) { + return ( + + + {icon} + {title} + + {body} + + + ); +} + +export default function CareerWorkspaceOverview({ completeness, runs, loading, loadError }: { + completeness: CareerWorkspaceCompleteness | null; + runs: CareerWorkspaceImportRun[]; + loading: boolean; + loadError: string | null; +}) { + const [recentCvs, setRecentCvs] = useState([]); + const [recentLoading, setRecentLoading] = useState(true); + const [recentError, setRecentError] = useState(false); + + useEffect(() => { + let cancelled = false; + cvBuilderApi.list() + .then((items) => { + if (!cancelled) { + setRecentCvs(Array.isArray(items) + ? [...items].sort((left, right) => Date.parse(right.updatedAtUtc) - Date.parse(left.updatedAtUtc)).slice(0, 3) + : []); + setRecentError(false); + } + }) + .catch(() => { + if (!cancelled) setRecentError(true); + }) + .finally(() => { + if (!cancelled) setRecentLoading(false); + }); + return () => { cancelled = true; }; + }, []); + + const importState = useMemo(() => { + const review = runs.filter((run) => run.status === "pending_review"); + const active = runs.filter(isActive); + const failed = runs.filter((run) => run.status === "failed" || run.operation?.status === "failed"); + if (review.length > 0) return { severity: "warning" as const, label: "Review imported CV", message: `${review.length} import${review.length === 1 ? " is" : "s are"} waiting for your approval.` }; + if (active.length > 0) return { severity: "info" as const, label: "View CV processing", message: `${active.length} CV import${active.length === 1 ? " is" : "s are"} processing.` }; + if (failed.length > 0) return { severity: "error" as const, label: "Resolve CV import", message: `${failed.length} CV import${failed.length === 1 ? " needs" : "s need"} attention.` }; + return { severity: "success" as const, label: "Import a CV", message: runs.length > 0 ? "Your latest CV import is complete." : "No CV has been imported yet." }; + }, [runs]); + + const isFirstRun = !loading && !recentLoading && !loadError && (completeness?.percent ?? 0) === 0 && runs.length === 0 && recentCvs.length === 0; + + return ( + + + + + Career Workspace + Choose what you want to work on next. + + + + + {isFirstRun ? Start by adding career information manually or importing a CV. : null} + {loadError ? Workspace status is unavailable. The profile editor below can retry the request. : null} + + + + + Profile completeness + {loading ? : {completeness?.percent ?? 0}%} + + + {!loading && completeness?.missing.length ? ( + Add next: {completeness.missing.slice(0, 3).join(", ")} + ) : null} + + + + + CV import + {importState.message} + + + + + + + } /> + } /> + } /> + } /> + + + + + Recent CVs + + + {recentLoading ? ( + + ) : recentError ? ( + Recent CVs could not be loaded. Open the CV Builder to try again. + ) : recentCvs.length === 0 ? ( + No CV documents yet. + ) : ( + + {recentCvs.map((cv) => ( + + + {cv.name} + Updated {new Date(cv.updatedAtUtc).toLocaleDateString()} + + + {cv.jobApplicationId ? : null} + + + + ))} + + )} + + + ); +} diff --git a/job-tracker-ui/src/views/career/ProfileCompleteness.tsx b/job-tracker-ui/src/views/career/ProfileCompleteness.tsx index 5443eb2..f99cdf5 100644 --- a/job-tracker-ui/src/views/career/ProfileCompleteness.tsx +++ b/job-tracker-ui/src/views/career/ProfileCompleteness.tsx @@ -23,38 +23,44 @@ export default function ProfileCompleteness({ versions, loading, onRestore, + showSummary = true, }: { completeness: CareerCompletenessView | null; versions: CareerVersionView[]; loading: boolean; onRestore: (version: number) => void; + showSummary?: boolean; }) { - if (!completeness) return null; + if ((!showSummary || !completeness) && versions.length <= 1) return null; return ( - - Profile completeness - {completeness.percent}% - - = 80 ? "success" : completeness.percent >= 40 ? "primary" : "warning"} - sx={{ height: 8, borderRadius: 999 }} - /> - {completeness.missing.length > 0 ? ( - - Missing: - {completeness.missing.map((label) => ( - - ))} - - ) : ( - Your career profile is complete. - )} + {showSummary && completeness ? ( + <> + + Profile completeness + {completeness.percent}% + + = 80 ? "success" : completeness.percent >= 40 ? "primary" : "warning"} + sx={{ height: 8, borderRadius: 999 }} + /> + {completeness.missing.length > 0 ? ( + + Missing: + {completeness.missing.map((label) => ( + + ))} + + ) : ( + Your career profile is complete. + )} + + ) : null} {versions.length > 1 ? ( - + } sx={{ px: 0, minHeight: 0 }}> Version history ({versions.length})