diff --git a/docs/career-workspace-ux-refactor.md b/docs/career-workspace-ux-refactor.md index b636ff3..f9fa96f 100644 --- a/docs/career-workspace-ux-refactor.md +++ b/docs/career-workspace-ux-refactor.md @@ -76,11 +76,50 @@ all others are inactive. A child route never activates a parent nav item. - Tests: `sidebar-active-nav.test.ts` — asserts exactly one active item for `/career`, `/career/builder`, `/career/builder/{id}`, and that no item double-highlights. -### Phase 1 — Career Profile editor sections (frontend) -Reorganise `CareerProfilePage` presentation into the eight named sections with user-facing labels; -strip internal vocabulary from headings/help. No data-model or API change — the same -`StructuredCvProfile` shape is read and saved. Ship behind the existing page; verify save/load of each -section against the existing profile API. +### Phase 1 — terminology + first component split (IN PROGRESS) + +**Delivered 2026-07-20 (increment 1):** + +*Terminology → user-facing* (`src/i18n/translations.ts`, no structural change): +| Internal term (before) | User-facing (after) | +|---|---| +| "Structured CV editor" | "Career information" | +| "CV structure overview" | "Profile sections" | +| "Summary bullets" | "Professional summary" | +| "Core skills" | "Skills" | +| "Analyze sections" | "Read sections" | +| "Original extraction" | "Original import" | +| hardcoded "Master career profile" | "Career profile" | +Help text de-jargoned; the "Career information" help now says *"The CV Builder uses this information +to create documents."* + +*Component extracted:* `src/views/career/ProfileCompleteness.tsx` — the completeness meter + missing +chips + version-history accordion, pulled out of `CareerProfilePage`. Display-only, props in, no state +or API — the first step of the split. + +*No API / data / model change.* The save path is untouched: +`api.put("/career/profile", { profile: structuredCv, cvText })`. A new test +(`profile-page.test.tsx` → "saving the career profile PUTs … unchanged (Phase 1 refactor invariant)") +pins exactly that call so the remaining extraction can't silently change it. Existing profile-page +tests were re-pointed to the new labels; all behavioural assertions (save, parse, field values) kept. + +*Verified:* tsc clean, production build clean, 136 frontend tests pass (was 135; +1 invariant test). +Sidebar fix from the previous task still passes. + +**Remaining in Phase 1 (staged, needs the app running to click-verify each section's save +round-trip):** +- Extract the editing sections into `PersonalInformationSection … LanguagesSection` components and a + `CareerProfileHeader`, keeping `structuredCv` + `setStructuredCv` + the save handler in the parent + (so behaviour stays identical). This is voluminous mechanical JSX movement through a 1376-line file. +- Hide the **template-driven CV builder** (rewrite templates + PDF carousel) and the **structure + overview** parse block from the user surface. Both are tested against live extraction behaviour, so + each removal must move its test coverage, not delete it — done incrementally with verification. +- Per-section actionable empty states ("No work experience added yet" → [Add experience]); the Add + affordances already exist, so this is copy + wiring. + +Rationale for staging: these touch the live CV/extraction test surface and are best verified by +driving the authenticated UI. Increment 1 is a clean, non-regressing checkpoint per the "reviewed and +verified before Phase 2" instruction. ### Phase 2 — Import CV review screen Add a post-upload "New information found" review (Experience / Skills / Languages / Education) with diff --git a/job-tracker-ui/src/i18n/translations.ts b/job-tracker-ui/src/i18n/translations.ts index 749d312..10ec97b 100644 --- a/job-tracker-ui/src/i18n/translations.ts +++ b/job-tracker-ui/src/i18n/translations.ts @@ -218,20 +218,20 @@ export const translations = { profileCvUploadFailed: "Failed to upload CV.", profileCvTextLabel: "Profile CV / master resume text", profileCvTextHelp: "Keep this updated and specific. Include recent roles, tools, achievements, measurable outcomes, and the work you want to be hired for next. If extraction misses something, edit it here manually.", - profileCvStructuredDefaultHint: "The structured CV stays front and center. Open the original extraction only when you need to verify or clean up parser output.", - profileCvRawPanelTitle: "Original extraction", - profileCvRawPanelHelp: "Usually messy, but useful for checking what the parser actually pulled from the uploaded file.", + profileCvStructuredDefaultHint: "Your career information stays front and center. Open the original import only when you need to check or clean up what was read from your file.", + profileCvRawPanelTitle: "Original import", + profileCvRawPanelHelp: "Usually messy, but useful for checking what was actually read from the uploaded file.", profileCvPreferredUploads: "Supported uploads: PDF, DOCX, TXT, MD, PNG, JPG, JPEG, WEBP.", profileCvSectionTools: "Section rewrite tools", - profileCvStructureOverview: "CV structure overview", - profileCvStructureOverviewHelp: "Parse your current CV text into reusable sections so you can spot missing structure before tailoring.", - profileCvStructureParse: "Analyze sections", - profileCvStructureParsing: "Analyzing sections...", - profileCvStructureParsed: "CV structure analyzed.", - profileCvStructureParseFailed: "Failed to analyze CV structure.", - profileCvStructureEmpty: "No parsed sections yet.", - profileCvStructuredEditor: "Structured CV editor", - profileCvStructuredEditorHelp: "Edit reusable CV data directly so generators and matching can work from stable fields instead of raw text alone.", + profileCvStructureOverview: "Profile sections", + profileCvStructureOverviewHelp: "Read your current CV text into sections so you can see what your profile will contain.", + profileCvStructureParse: "Read sections", + profileCvStructureParsing: "Reading sections...", + profileCvStructureParsed: "Sections read from your CV.", + profileCvStructureParseFailed: "Couldn't read sections from your CV.", + profileCvStructureEmpty: "No sections read yet.", + profileCvStructuredEditor: "Career information", + profileCvStructuredEditorHelp: "Your career facts. The CV Builder uses this information to create documents — edit it here and every CV stays up to date.", profileCvExtractionHistory: "Extraction history", profileCvExtractionHistoryHelp: "See which parser run produced the current structured profile and reprocess from the stored source artifact when needed.", profileCvExtractionHistoryEmpty: "No extraction runs yet.", @@ -249,8 +249,8 @@ export const translations = { profileCvContactLocation: "Location", profileCvContactWebsite: "Website", profileCvContactLinkedIn: "LinkedIn", - profileCvStructuredSummary: "Summary bullets", - profileCvStructuredSkills: "Core skills", + profileCvStructuredSummary: "Professional summary", + profileCvStructuredSkills: "Skills", profileCvStructuredInterests: "Interests", profileCvStructuredLanguages: "Languages", profileCvStructuredJobs: "Work experience", diff --git a/job-tracker-ui/src/profile-page.test.tsx b/job-tracker-ui/src/profile-page.test.tsx index 82f4c8a..1438bee 100644 --- a/job-tracker-ui/src/profile-page.test.tsx +++ b/job-tracker-ui/src/profile-page.test.tsx @@ -205,13 +205,13 @@ test('profile page loads persisted structured cv and can re-parse it', async () renderPage(); expect(await screen.findByText(/cv ready/i)).toBeInTheDocument(); - expect(screen.getByText(/cv structure overview/i)).toBeInTheDocument(); - expect(screen.getByText(/structured cv editor/i)).toBeInTheDocument(); + expect(screen.getByText(/profile sections/i)).toBeInTheDocument(); + expect(screen.getAllByText(/career information/i).length).toBeGreaterThan(0); expect(screen.getByText(/extraction history/i)).toBeInTheDocument(); expect(screen.getByText(/resume.pdf/i)).toBeInTheDocument(); expect(screen.getByText(/current run/i)).toBeInTheDocument(); - expect(screen.getAllByText(/original extraction/i).length).toBeGreaterThan(0); - const originalExtractionToggle = screen.getByRole('button', { name: /original extraction/i }); + expect(screen.getAllByText(/original import/i).length).toBeGreaterThan(0); + const originalExtractionToggle = screen.getByRole('button', { name: /original import/i }); expect(originalExtractionToggle).toHaveAttribute('aria-expanded', 'false'); expect(screen.getAllByText(/professional summary/i).length).toBeGreaterThan(0); expect(screen.getByLabelText(/full name/i)).toHaveValue('Demo User'); @@ -222,7 +222,7 @@ test('profile page loads persisted structured cv and can re-parse it', async () expect(originalExtractionToggle).toHaveAttribute('aria-expanded', 'true'); expect(await screen.findByLabelText(/profile cv \/ master resume text/i)).toHaveValue('Professional Summary\nBuilt backend systems'); - const analyzeButton = screen.getByRole('button', { name: /analyze sections/i }); + const analyzeButton = screen.getByRole('button', { name: /read sections/i }); await waitFor(() => expect(analyzeButton).toBeEnabled()); fireEvent.click(analyzeButton); @@ -233,6 +233,24 @@ test('profile page loads persisted structured cv and can re-parse it', async () expect(screen.getAllByText(/core skills/i).length).toBeGreaterThan(0); }); +test('saving the career profile PUTs the structured profile and cv text unchanged (Phase 1 refactor invariant)', async () => { + renderPage(); + + const saveButton = await screen.findByRole('button', { name: /save changes/i }); + await waitFor(() => expect(saveButton).toBeEnabled()); + fireEvent.click(saveButton); + + await waitFor(() => { + expect(mockedApi.put).toHaveBeenCalledWith( + '/career/profile', + expect.objectContaining({ + profile: expect.objectContaining({ contact: expect.objectContaining({ fullName: 'Demo User' }) }), + cvText: 'Professional Summary\nBuilt backend systems', + }), + ); + }); +}); + test('profile page can reprocess from stored artifact history', async () => { renderPage(); @@ -249,9 +267,9 @@ test('profile page keeps raw extraction collapsed until expanded', async () => { renderPage(); expect(await screen.findByText(/cv ready/i)).toBeInTheDocument(); - expect(screen.getByText(/the structured cv stays front and center/i)).toBeInTheDocument(); + expect(screen.getByText(/your career information stays front and center/i)).toBeInTheDocument(); - const originalExtractionToggle = screen.getByRole('button', { name: /original extraction/i }); + const originalExtractionToggle = screen.getByRole('button', { name: /original import/i }); expect(originalExtractionToggle).toHaveAttribute('aria-expanded', 'false'); const copyButton = screen.getByRole('button', { name: /copy cv text/i }); expect(copyButton).toBeDisabled(); diff --git a/job-tracker-ui/src/views/CareerProfilePage.tsx b/job-tracker-ui/src/views/CareerProfilePage.tsx index 0e147ca..244377e 100644 --- a/job-tracker-ui/src/views/CareerProfilePage.tsx +++ b/job-tracker-ui/src/views/CareerProfilePage.tsx @@ -14,6 +14,7 @@ import AuthStatusCard from "../components/AuthStatusCard"; import TwoFactorSettingsCard from "../components/TwoFactorSettingsCard"; import SessionsSettingsCard from "../components/SessionsSettingsCard"; import CropImageDialog from "../components/CropImageDialog"; +import ProfileCompleteness from "./career/ProfileCompleteness"; import { useToast } from "../toast"; import { useI18n } from "../i18n/I18nProvider"; import { @@ -498,51 +499,12 @@ export default function CareerProfilePage() { return ( - {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 master profile is complete. - )} - {versions.length > 1 ? ( - - } sx={{ px: 0, minHeight: 0 }}> - Version history ({versions.length}) - - - - {versions.slice(0, 12).map((v) => ( - - - v{v.version} · {v.source} · {new Date(v.createdAtUtc).toLocaleString()}{v.isCurrent ? " · current" : ""} - - {!v.isCurrent ? ( - - ) : null} - - ))} - - - - ) : null} - - ) : null} + void restoreVersion(version)} + /> - {careerOnly ? "Master career profile" : t("profileTitle")} + {careerOnly ? "Career profile" : t("profileTitle")} {me?.userName || me?.displayName || fullName || me?.email || "-"} {headline || t("profileHeadlinePlaceholder")} diff --git a/job-tracker-ui/src/views/career/ProfileCompleteness.tsx b/job-tracker-ui/src/views/career/ProfileCompleteness.tsx new file mode 100644 index 0000000..5443eb2 --- /dev/null +++ b/job-tracker-ui/src/views/career/ProfileCompleteness.tsx @@ -0,0 +1,79 @@ +import React from "react"; +import { Accordion, AccordionDetails, AccordionSummary, Box, Button, Chip, LinearProgress, Typography } from "@mui/material"; +import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; + +// Display-only slice of the Career Profile page: the completeness meter, the "missing" chips, and the +// version-history accordion. Extracted from CareerProfilePage as the first step of the Phase 1 +// component split. It owns no state and makes no API calls — the parent still holds `completeness` +// and `versions` and performs the restore — so save/load behaviour is unchanged. +export type CareerCompletenessView = { + percent: number; + missing: string[]; +}; + +export type CareerVersionView = { + version: number; + source: string; + createdAtUtc: string; + isCurrent: boolean; +}; + +export default function ProfileCompleteness({ + completeness, + versions, + loading, + onRestore, +}: { + completeness: CareerCompletenessView | null; + versions: CareerVersionView[]; + loading: boolean; + onRestore: (version: number) => void; +}) { + if (!completeness) 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. + )} + {versions.length > 1 ? ( + + } sx={{ px: 0, minHeight: 0 }}> + Version history ({versions.length}) + + + + {versions.slice(0, 12).map((v) => ( + + + v{v.version} · {v.source} · {new Date(v.createdAtUtc).toLocaleString()}{v.isCurrent ? " · current" : ""} + + {!v.isCurrent ? ( + + ) : null} + + ))} + + + + ) : null} + + ); +}