From a1dd447091f492053d828a44462e316767ec1001 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Sat, 18 Jul 2026 01:01:54 +0200 Subject: [PATCH] feat(career): version history restore on /career Phase 3, versioning UI. The /career overview now shows a collapsible version history with a Restore action per past version, backed by the versioning API. Restore reapplies the chosen snapshot as a new version (non-destructive) and refreshes the profile + completeness in place. Co-Authored-By: Claude Opus 4.8 --- job-tracker-ui/src/profile-page.test.tsx | 3 ++ .../src/views/CareerProfilePage.tsx | 50 ++++++++++++++++++- 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/job-tracker-ui/src/profile-page.test.tsx b/job-tracker-ui/src/profile-page.test.tsx index 2ba15cf..82f4c8a 100644 --- a/job-tracker-ui/src/profile-page.test.tsx +++ b/job-tracker-ui/src/profile-page.test.tsx @@ -106,6 +106,9 @@ beforeEach(() => { }, } as any); } + if (url === '/career/profile/versions') { + return Promise.resolve({ data: [] } as any); + } if (url === '/auth/me') { return Promise.resolve({ data: { diff --git a/job-tracker-ui/src/views/CareerProfilePage.tsx b/job-tracker-ui/src/views/CareerProfilePage.tsx index 3ec6b9c..0e147ca 100644 --- a/job-tracker-ui/src/views/CareerProfilePage.tsx +++ b/job-tracker-ui/src/views/CareerProfilePage.tsx @@ -230,6 +230,7 @@ function FieldReviewNote({ metadata }: { metadata?: StructuredCvFieldMetadata }) type CareerSectionStatus = { key: string; label: string; complete: boolean; count: number }; type CareerCompleteness = { percent: number; missing: string[]; sections: CareerSectionStatus[] }; type CareerProfileResponse = { profile: StructuredCvProfile; completeness: CareerCompleteness; cvText?: string | null }; +type CareerVersion = { version: number; source: string; createdAtUtc: string; isCurrent: boolean }; // CareerProfilePage backs /career: the master career profile — the single editable source of // truth for all future generated documents. Split out from ProfilePage in Phase 2.2; wired to the @@ -272,6 +273,31 @@ export default function CareerProfilePage() { const [reprocessingCv, setReprocessingCv] = useState(false); const [structuredCv, setStructuredCv] = useState(emptyStructuredCv()); const [completeness, setCompleteness] = useState(null); + const [versions, setVersions] = useState([]); + + const loadVersions = useCallback(async () => { + try { + const r = await api.get("/career/profile/versions"); + setVersions(r.data ?? []); + } catch { + setVersions([]); + } + }, []); + + const restoreVersion = useCallback(async (version: number) => { + setLoading(true); + try { + const r = await api.post(`/career/profile/versions/${version}/restore`); + setStructuredCv(normalizeStructuredCv(r.data?.profile ?? emptyStructuredCv())); + setCompleteness(r.data?.completeness ?? null); + await loadVersions(); + toast(t("profileUpdated"), "success"); + } catch (e: any) { + toast(String(e?.response?.data || e?.message || t("profileUpdateFailed")), "error"); + } finally { + setLoading(false); + } + }, [loadVersions, t, toast]); const [extractionRuns, setExtractionRuns] = useState([]); const runStatusRef = useRef>({}); @@ -328,7 +354,8 @@ export default function CareerProfilePage() { useEffect(() => { void loadProfile(); - }, [loadProfile]); + void loadVersions(); + }, [loadProfile, loadVersions]); useEffect(() => { const activeRuns = extractionRuns.filter((run) => run.status === "queued" || run.status === "running"); @@ -493,6 +520,27 @@ export default function CareerProfilePage() { ) : ( 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}