refactor(career): Phase 1 increment — user-facing terminology + component split

UI-only restructuring of the Career Profile surface. No change to
database models, CareerProfiles schema, CvVariants, extraction APIs, AI
services, CV rendering, or public CV.

Terminology -> user-facing (i18n strings):
- "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 frames it as the
source the CV Builder consumes.

Component split (first step): extract ProfileCompleteness (completeness
meter + missing chips + version history) into src/views/career/. Display
only, props in, no state or API.

Save path untouched: api.put("/career/profile", { profile, cvText }). A
new test pins that exact call as the refactor invariant so the remaining
section extraction cannot silently change save behaviour. Existing
profile-page tests re-pointed to the new labels; every behavioural
assertion (save, parse, field values) kept.

Verified: tsc clean, production build clean, 136 frontend tests pass
(135 + 1 invariant). Sidebar fix from the previous task still passes.
Backend untouched.

The remaining Phase 1 work (per-section editor components, hiding the
template-driven builder and structure-overview blocks, actionable
per-section empty states) is staged in docs/career-workspace-ux-refactor.md
because it touches the live extraction test surface and is best verified
by driving the authenticated UI. This increment is a clean, non-regressing
checkpoint.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
cesnimda
2026-07-20 02:50:52 +02:00
parent 4f69d395be
commit 63473bae85
5 changed files with 170 additions and 72 deletions
+44 -5
View File
@@ -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
+14 -14
View File
@@ -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",
+25 -7
View File
@@ -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();
+8 -46
View File
@@ -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 (
<Paper sx={{ mt: 0, p: 2.5, borderRadius: 4, border: "none", boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)" }}>
{completeness ? (
<Box sx={{ mb: 2.5, p: 2, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.default" }}>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "baseline", gap: 2, flexWrap: "wrap", mb: 1 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 900 }}>Profile completeness</Typography>
<Typography variant="h6" sx={{ fontWeight: 900 }}>{completeness.percent}%</Typography>
</Box>
<LinearProgress
variant="determinate"
value={completeness.percent}
color={completeness.percent >= 80 ? "success" : completeness.percent >= 40 ? "primary" : "warning"}
sx={{ height: 8, borderRadius: 999 }}
/>
{completeness.missing.length > 0 ? (
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap", alignItems: "center", mt: 1.25 }}>
<Typography variant="body2" sx={{ color: "text.secondary" }}>Missing:</Typography>
{completeness.missing.map((label) => (
<Chip key={label} size="small" label={label} sx={{ height: 22, fontWeight: 700 }} />
))}
</Box>
) : (
<Typography variant="body2" sx={{ color: "success.main", mt: 1.25, fontWeight: 700 }}>Your master profile is complete.</Typography>
)}
{versions.length > 1 ? (
<Accordion disableGutters elevation={0} sx={{ mt: 1.5, "&:before": { display: "none" }, backgroundColor: "transparent" }}>
<AccordionSummary expandIcon={<ExpandMoreIcon />} sx={{ px: 0, minHeight: 0 }}>
<Typography variant="body2" sx={{ fontWeight: 700 }}>Version history ({versions.length})</Typography>
</AccordionSummary>
<AccordionDetails sx={{ px: 0, pt: 0 }}>
<Box sx={{ display: "grid", gap: 0.75 }}>
{versions.slice(0, 12).map((v) => (
<Box key={v.version} sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 1, flexWrap: "wrap" }}>
<Typography variant="body2" sx={{ color: "text.secondary" }}>
v{v.version} · {v.source} · {new Date(v.createdAtUtc).toLocaleString()}{v.isCurrent ? " · current" : ""}
</Typography>
{!v.isCurrent ? (
<Button size="small" variant="text" disabled={loading} onClick={() => void restoreVersion(v.version)}>Restore</Button>
) : null}
</Box>
))}
</Box>
</AccordionDetails>
</Accordion>
) : null}
</Box>
) : null}
<ProfileCompleteness
completeness={completeness}
versions={versions}
loading={loading}
onRestore={(version) => void restoreVersion(version)}
/>
<CropImageDialog
open={cropOpen}
file={avatarFile}
@@ -626,7 +588,7 @@ export default function CareerProfilePage() {
</Box>
<Box>
<Typography variant="h5" sx={{ fontWeight: 900 }}>
{careerOnly ? "Master career profile" : t("profileTitle")}
{careerOnly ? "Career profile" : t("profileTitle")}
</Typography>
<Typography sx={{ color: "text.secondary" }}>{me?.userName || me?.displayName || fullName || me?.email || "-"}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}>{headline || t("profileHeadlinePlaceholder")}</Typography>
@@ -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 (
<Box sx={{ mb: 2.5, p: 2, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.default" }}>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "baseline", gap: 2, flexWrap: "wrap", mb: 1 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 900 }}>Profile completeness</Typography>
<Typography variant="h6" sx={{ fontWeight: 900 }}>{completeness.percent}%</Typography>
</Box>
<LinearProgress
variant="determinate"
value={completeness.percent}
color={completeness.percent >= 80 ? "success" : completeness.percent >= 40 ? "primary" : "warning"}
sx={{ height: 8, borderRadius: 999 }}
/>
{completeness.missing.length > 0 ? (
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap", alignItems: "center", mt: 1.25 }}>
<Typography variant="body2" sx={{ color: "text.secondary" }}>Missing:</Typography>
{completeness.missing.map((label) => (
<Chip key={label} size="small" label={label} sx={{ height: 22, fontWeight: 700 }} />
))}
</Box>
) : (
<Typography variant="body2" sx={{ color: "success.main", mt: 1.25, fontWeight: 700 }}>Your career profile is complete.</Typography>
)}
{versions.length > 1 ? (
<Accordion disableGutters elevation={0} sx={{ mt: 1.5, "&:before": { display: "none" }, backgroundColor: "transparent" }}>
<AccordionSummary expandIcon={<ExpandMoreIcon />} sx={{ px: 0, minHeight: 0 }}>
<Typography variant="body2" sx={{ fontWeight: 700 }}>Version history ({versions.length})</Typography>
</AccordionSummary>
<AccordionDetails sx={{ px: 0, pt: 0 }}>
<Box sx={{ display: "grid", gap: 0.75 }}>
{versions.slice(0, 12).map((v) => (
<Box key={v.version} sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 1, flexWrap: "wrap" }}>
<Typography variant="body2" sx={{ color: "text.secondary" }}>
v{v.version} · {v.source} · {new Date(v.createdAtUtc).toLocaleString()}{v.isCurrent ? " · current" : ""}
</Typography>
{!v.isCurrent ? (
<Button size="small" variant="text" disabled={loading} onClick={() => onRestore(v.version)}>Restore</Button>
) : null}
</Box>
))}
</Box>
</AccordionDetails>
</Accordion>
) : null}
</Box>
);
}