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:
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user