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
+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>
);
}