From 457f0601c04a0a67aac7e97596ee7a1fe88f6987 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Sat, 29 Aug 2026 21:57:40 +0200 Subject: [PATCH] fix(i18n): finish career activity copy --- docs/work-programmes/master-progress.md | 7 + .../src/components/cv/AiSectionAssistant.tsx | 4 +- job-tracker-ui/src/i18n/translations.ts | 150 ++++++++++++++++-- job-tracker-ui/src/notifications.ts | 4 +- job-tracker-ui/src/operations-page.test.tsx | 23 ++- job-tracker-ui/src/profile-page.test.tsx | 10 ++ .../src/views/CareerProfilePage.tsx | 62 ++++---- job-tracker-ui/src/views/CvBuilderEditor.tsx | 6 +- job-tracker-ui/src/views/OperationsPage.tsx | 41 ++--- .../views/career/CareerProfileSections.tsx | 18 ++- .../views/career/CareerWorkspaceOverview.tsx | 3 +- .../src/views/career/ProfileCompleteness.tsx | 19 ++- .../views/career/careerCompletenessLabels.ts | 16 ++ 13 files changed, 272 insertions(+), 91 deletions(-) create mode 100644 job-tracker-ui/src/views/career/careerCompletenessLabels.ts diff --git a/docs/work-programmes/master-progress.md b/docs/work-programmes/master-progress.md index f43c777..53fdd8e 100644 --- a/docs/work-programmes/master-progress.md +++ b/docs/work-programmes/master-progress.md @@ -24,6 +24,10 @@ Updated: 2026-08-29 - Localized Cover Letter, CV attachment, application-draft, and Interview Prep failure states and document-language selectors. Interview categories now derive from stable category keys while user-authored questions and answers remain unchanged. - Reused the checklist's stable system-key translation mapping for the Overview's recommended next action, preventing the English backend seed text from leaking into a Bokmål workspace. - Corrected the CV Match source caption to interpolate the selected CV name once, localized known match bands, and made intelligence/timeline load failures and dates respect the selected UI locale. +- Finished the Career Profile import/review localization pass: queue, cancel, retry, diff, confidence, destructive, save-state, completeness, link-editor, language-selector, and version-history controls now use the global English/Bokmål catalogue while imported CV values remain unchanged. +- Localized backend-owned career completeness labels through one shared UI mapping so Overview and Profile display Bokmål without changing the API contract or stored profile data. +- Removed the remaining mojibake from the translation catalogue, including account email, Microsoft relink, account-deletion, and learning-path messages. +- Localized the Operations and Notifications activity page controls, dates, progress accessibility labels, empty/error/loading states, cancellation feedback, and retry/dismiss actions in English and Norwegian Bokmål. ### In progress @@ -67,6 +71,9 @@ Updated: 2026-08-29 - Cover Letter/CV assets and Interview Prep focused verification: 2 suites, 27/27 passed, including Bokmål category rendering that preserves user content. - Workspace/checklist integration verification: 2 suites, 14/14 passed, including localized system-derived next actions. - Application intelligence focused verification: 11/11 passed; TypeScript passed after the creation-tab/localization batch. +- Career Profile and Operations focused verification: 2 suites, 16/16 passed, including Bokmål completeness/confidence and operations empty-state regressions. +- Full frontend after the Career/Operations localization batch: 62 suites, 260/260 tests passed. +- Next optimized production build and its integrated TypeScript check passed after the Career/Operations localization batch. - Backend matcher/intelligence focused verification: 35/35 passed, including detection of a manually created Norwegian advert with no saved translation. - Full backend: 712/712 tests passed on .NET 9. - Next optimized production build and TypeScript: passed after the Job Workspace/checklist batch. diff --git a/job-tracker-ui/src/components/cv/AiSectionAssistant.tsx b/job-tracker-ui/src/components/cv/AiSectionAssistant.tsx index ff38a87..b994cfa 100644 --- a/job-tracker-ui/src/components/cv/AiSectionAssistant.tsx +++ b/job-tracker-ui/src/components/cv/AiSectionAssistant.tsx @@ -78,8 +78,8 @@ export default function AiSectionAssistant({ sectionName, text, context, documen {AI_ACTIONS.filter((item) => SECTION_ACTIONS.has(item.key)).map((item) => {actionLabel(item.key, item.label, t)})} setTargetLanguage(event.target.value)} sx={{ minWidth: 150 }}> - English - Norsk bokmål + {t("languageEnglish")} + {t("languageNorwegianBokmal")} diff --git a/job-tracker-ui/src/i18n/translations.ts b/job-tracker-ui/src/i18n/translations.ts index 52154df..2d022d7 100644 --- a/job-tracker-ui/src/i18n/translations.ts +++ b/job-tracker-ui/src/i18n/translations.ts @@ -86,6 +86,17 @@ export const translations = { jobDetails: "Job details", jobDetailsWorkspaceSubtitle: "Manage this application, its documents, timeline, and correspondence.", operations: "Operations", + refresh: "Refresh", + operationsSubtitle: "Background work survives navigation and refresh.", + operationsLoadFailed: "Operations could not be loaded.", + operationsActionFailed: "The action could not be completed.", + operationsLoading: "Loading operations", + operationsNoNotifications: "No notifications.", + operationsEmpty: "No background operations yet.", + operationsStarted: "Started {date}", + operationsProgress: "{task} progress", + operationsCancellationRequested: "Cancellation requested.", + operationsFailed: "Failed: {category}", gmailReviewQueue: "Gmail review queue", connectedAccounts: "Connected accounts", correspondenceInboxTitle: "Correspondence inbox", @@ -746,6 +757,10 @@ export const translations = { profileImageRemoved: "Profile image removed.", profileImageUploadFailed: "Failed to upload profile image.", profileImageRemoveFailed: "Failed to remove profile image.", + profileLoadFailed: "Unable to load profile.", + profileProImportTitle: "Build your Career Profile faster with Pro.", + profileProImportBody: "Import, rebuild and improve CV content with AI. Manual profile editing remains available on Free.", + profileUnsavedBeforeActions: "You have unsaved career edits. Save them before running actions that use the stored profile.", profileAccountSection: "Account", profileReadOnlyInfo: "This session is not using a local app token, so profile edits are read-only right now.", profileDisplayName: "Display name", @@ -781,6 +796,51 @@ export const translations = { profileCopyCvText: "Copy CV text", profileCvUploaded: "CV uploaded and processed.", profileCvUploadFailed: "Failed to upload CV.", + profileCvQueuedUpload: "Queued CV upload (run {id}).", + profileCvQueuedRebuild: "Queued CV rebuild (run {id}).", + profileCvQueuedImprove: "Queued CV improvement (run {id}).", + profileCvQueuedReprocess: "Queued CV reprocessing (run {id}).", + profileCvCancelRequested: "CV processing cancellation requested.", + profileCvCancelFailed: "Could not cancel CV processing.", + profileCvCancelProcessing: "Cancel processing", + profileCvRetryQueued: "CV processing queued again.", + profileCvRetryFailed: "Could not retry CV processing.", + profileCvRetryProcessing: "Retry processing", + profileCvDiffSummary: "{added} additions | {updated} updates", + profileCvNeedAttention: "{count} need attention", + profileCvNoChangesFound: "No profile changes found", + profileCvIncludeLowConfidence: "Include low-confidence {category}: {label}", + profileCvSaveBeforeImport: "Save your current career-profile edits before applying imported changes.", + profileCvChangesApplied: "CV changes merged into your career profile.", + profileCvApplyChangesFailed: "Could not apply CV changes.", + profileCvApplyChanges: "Apply changes", + profileCvExtractionDiscarded: "CV extraction discarded.", + profileCvDiscardFailed: "Could not discard CV extraction.", + profileCvDiscard: "Discard", + profileCvHideAdvancedTools: "Hide advanced CV tools", + profileCvAdvancedTools: "Advanced CV tools", + profileCvCompletenessTitle: "Profile completeness", + profileCvCompletenessMissing: "Missing:", + profileCvCompletenessComplete: "Your career profile is complete.", + profileCvCompletenessPersonal: "Personal details", + profileCvCompletenessSummary: "Professional summary", + profileCvCompletenessExperience: "Experience", + profileCvCompletenessEducation: "Education", + profileCvCompletenessSkills: "Skills", + profileCvCompletenessProjects: "Projects", + profileCvCompletenessLanguages: "Languages", + profileCvVersionHistory: "Version history ({count})", + profileCvVersionCurrent: "current", + profileCvVersionRestore: "Restore", + profileCvConfidenceReview: "Review", + profileCvConfidenceHigh: "High {percent}%", + profileCvConfidenceMedium: "Medium {percent}%", + profileCvConfidenceLow: "Low {percent}%", + profileCvOtherLinks: "Other links", + profileCvAddLink: "Add link", + profileCvLinkLabel: "Label", + profileCvLinkUrl: "URL", + profileCvDeleteCustomLink: "Delete custom link {index}", 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: "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.", @@ -1894,6 +1954,17 @@ export const translations = { jobDetails: "Jobbdetaljer", jobDetailsWorkspaceSubtitle: "Administrer søknaden, dokumentene, tidslinjen og korrespondansen.", operations: "Operasjoner", + refresh: "Oppdater", + operationsSubtitle: "Bakgrunnsarbeid fortsetter når du navigerer eller oppdaterer siden.", + operationsLoadFailed: "Operasjonene kunne ikke lastes.", + operationsActionFailed: "Handlingen kunne ikke fullføres.", + operationsLoading: "Laster operasjoner", + operationsNoNotifications: "Ingen varsler.", + operationsEmpty: "Ingen bakgrunnsoperasjoner ennå.", + operationsStarted: "Startet {date}", + operationsProgress: "Fremdrift for {task}", + operationsCancellationRequested: "Avbrytelse er forespurt.", + operationsFailed: "Mislyktes: {category}", gmailReviewQueue: "Gmail-gjennomgang", connectedAccounts: "Tilkoblede kontoer", correspondenceInboxTitle: "Korrespondanseinnboks", @@ -2554,6 +2625,10 @@ export const translations = { profileImageRemoved: "Profilbildet ble fjernet.", profileImageUploadFailed: "Kunne ikke laste opp profilbildet.", profileImageRemoveFailed: "Kunne ikke fjerne profilbildet.", + profileLoadFailed: "Kunne ikke laste profilen.", + profileProImportTitle: "Bygg karriereprofilen raskere med Pro.", + profileProImportBody: "Importer, bygg opp og forbedre CV-innhold med AI. Manuell profilredigering er fortsatt tilgjengelig på Free.", + profileUnsavedBeforeActions: "Du har ulagrede karriereendringer. Lagre dem før du kjører handlinger som bruker den lagrede profilen.", profileAccountSection: "Konto", profileReadOnlyInfo: "Denne økten bruker ikke et lokalt apptoken, så profilendringer er skrivebeskyttet akkurat nå.", profileDisplayName: "Visningsnavn", @@ -2565,9 +2640,9 @@ export const translations = { usernameOrEmailRequired: "Brukernavn eller e-post er påkrevd", or: "eller", profileNewEmail: "Ny e-post", - profileCurrentEmail: "NÃ¥værende e-post: {email}", - profileEmailChangePassword: "Passord for Ã¥ bekrefte e-postendring", - profilePendingEmail: "Venter pÃ¥ bekreftelse fra {email}.", + profileCurrentEmail: "Nåværende e-post: {email}", + profileEmailChangePassword: "Passord for å bekrefte e-postendring", + profilePendingEmail: "Venter på bekreftelse fra {email}.", profileRequestEmailChange: "Send bekreftelse", profileEmailChangeSent: "Bekreftelse sendt til den nye e-postadressen.", profileEmailChangeCancelled: "Ventende e-postendring avbrutt.", @@ -2589,6 +2664,51 @@ export const translations = { profileCopyCvText: "Kopier CV-tekst", profileCvUploaded: "CV lastet opp og behandlet.", profileCvUploadFailed: "Kunne ikke laste opp CV.", + profileCvQueuedUpload: "CV-opplasting satt i kø (kjøring {id}).", + profileCvQueuedRebuild: "Gjenoppbygging av CV satt i kø (kjøring {id}).", + profileCvQueuedImprove: "CV-forbedring satt i kø (kjøring {id}).", + profileCvQueuedReprocess: "Ny behandling av CV satt i kø (kjøring {id}).", + profileCvCancelRequested: "Avbrytelse av CV-behandling er forespurt.", + profileCvCancelFailed: "Kunne ikke avbryte CV-behandlingen.", + profileCvCancelProcessing: "Avbryt behandling", + profileCvRetryQueued: "CV-behandlingen er satt i kø på nytt.", + profileCvRetryFailed: "Kunne ikke prøve CV-behandlingen på nytt.", + profileCvRetryProcessing: "Prøv behandlingen på nytt", + profileCvDiffSummary: "{added} tillegg | {updated} oppdateringer", + profileCvNeedAttention: "{count} trenger gjennomgang", + profileCvNoChangesFound: "Ingen profilendringer funnet", + profileCvIncludeLowConfidence: "Ta med {category} med lav sikkerhet: {label}", + profileCvSaveBeforeImport: "Lagre de nåværende karriereprofilendringene før du bruker importerte endringer.", + profileCvChangesApplied: "CV-endringene ble slått sammen med karriereprofilen.", + profileCvApplyChangesFailed: "Kunne ikke bruke CV-endringene.", + profileCvApplyChanges: "Bruk endringer", + profileCvExtractionDiscarded: "CV-uttrekket ble forkastet.", + profileCvDiscardFailed: "Kunne ikke forkaste CV-uttrekket.", + profileCvDiscard: "Forkast", + profileCvHideAdvancedTools: "Skjul avanserte CV-verktøy", + profileCvAdvancedTools: "Avanserte CV-verktøy", + profileCvCompletenessTitle: "Profilfullstendighet", + profileCvCompletenessMissing: "Mangler:", + profileCvCompletenessComplete: "Karriereprofilen din er fullstendig.", + profileCvCompletenessPersonal: "Personopplysninger", + profileCvCompletenessSummary: "Profesjonelt sammendrag", + profileCvCompletenessExperience: "Erfaring", + profileCvCompletenessEducation: "Utdanning", + profileCvCompletenessSkills: "Ferdigheter", + profileCvCompletenessProjects: "Prosjekter", + profileCvCompletenessLanguages: "Språk", + profileCvVersionHistory: "Versjonshistorikk ({count})", + profileCvVersionCurrent: "gjeldende", + profileCvVersionRestore: "Gjenopprett", + profileCvConfidenceReview: "Gjennomgå", + profileCvConfidenceHigh: "Høy {percent}%", + profileCvConfidenceMedium: "Middels {percent}%", + profileCvConfidenceLow: "Lav {percent}%", + profileCvOtherLinks: "Andre lenker", + profileCvAddLink: "Legg til lenke", + profileCvLinkLabel: "Etikett", + profileCvLinkUrl: "URL", + profileCvDeleteCustomLink: "Slett egendefinert lenke {index}", profileCvTextLabel: "Profil-CV / hovedtekst for CV", profileCvTextHelp: "Hold denne oppdatert og konkret. Ta med nylige roller, verktøy, prestasjoner, målbare resultater og arbeidet du vil bli ansatt for neste gang. Hvis tekstuttrekket mangler noe, kan du redigere manuelt her.", profileCvStructuredDefaultHint: "Den strukturerte CV-en er hovedvisningen. Åpne originaluttrekket bare når du vil kontrollere eller rydde opp i parserresultatet.", @@ -3134,13 +3254,13 @@ export const translations = { microsoftAuthFailed: "Microsoft-autentisering mislyktes.", microsoftUnlinked: "Microsoft-konto koblet fra.", microsoftUnlinkFailed: "Kunne ikke koble fra Microsoft-kontoen.", - microsoftSecurityChangeSignInAgain: "Av sikkerhetsgrunner mÃ¥ du logge inn igjen etter Ã¥ ha endret en koblet konto.", - microsoftLegacyRelinkEmailSent: "Sjekk den bekreftede Jobbjakt-e-posten din for Ã¥ gjenopprette den eldre Microsoft-koblingen.", + microsoftSecurityChangeSignInAgain: "Av sikkerhetsgrunner må du logge inn igjen etter å ha endret en koblet konto.", + microsoftLegacyRelinkEmailSent: "Sjekk den bekreftede Jobbjakt-e-posten din for å gjenopprette den eldre Microsoft-koblingen.", microsoftLegacyRelinkTitle: "Gjenopprett Microsoft-innlogging", microsoftLegacyRelinkBody: "Autentiser med den samme Microsoft-kontoen som startet gjenopprettingen. Lik e-post alene kobler aldri kontoer.", microsoftLegacyRelinkMissing: "Gjenopprettingslenken er ufullstendig, eller Microsoft-innlogging er ikke konfigurert.", microsoftLegacyRelinkFailed: "Gjenoppretting av Microsoft-konto mislyktes.", - microsoftLegacyRelinkSuccess: "Microsoft-innloggingen er koblet pÃ¥ nytt. Du kan nÃ¥ logge inn som vanlig.", + microsoftLegacyRelinkSuccess: "Microsoft-innloggingen er koblet på nytt. Du kan nå logge inn som vanlig.", unlinkMicrosoft: "Koble fra Microsoft", signedOut: "Logget ut.", signedInAs: "Logget inn som {name}.", @@ -3159,14 +3279,14 @@ export const translations = { backupDownloaded: "Sikkerhetskopi lastet ned.", backupFailed: "Sikkerhetskopiering mislyktes.", accountDeleteTitle: "Slett konto", - accountDeleteWarning: "Dette fjerner kontoen, jobbhistorikken, karrieredata, dokumenter, økter og tilkoblede kontolegitimasjoner permanent. Eksporter dataene dine først. Du logges ut umiddelbart.", - accountDeletePrompt: "Dette kan ikke angres. Skriv {confirmation} nøyaktig for Ã¥ fortsette.", + accountDeleteWarning: "Dette fjerner kontoen, jobbhistorikken, karrieredata, dokumenter, økter og tilkoblede kontolegitimasjoner permanent. Eksporter dataene dine først. Du logges ut umiddelbart.", + accountDeletePrompt: "Dette kan ikke angres. Skriv {confirmation} nøyaktig for å fortsette.", accountDeleteButton: "Slett kontoen min", accountDeleteStarting: "Starter sletting...", accountDeleteConfirmationMismatch: "Bekreftelsen stemte ikke. Kontoen ble ikke endret.", accountDeleteFailed: "Kontosletting kunne ikke startes.", - accountDeletePending: "Kontosletting pÃ¥gÃ¥r ({stage}). Tilgangen er allerede deaktivert.", - accountDeleteDisabled: "Kontosletting er ikke aktivert ennÃ¥. Arbeidsflyten forblir trygt avslÃ¥tt til vern for oppbevaring, gjenoppretting og tømming av produksjonsbuffer er godkjent.", + accountDeletePending: "Kontosletting pågår ({stage}). Tilgangen er allerede deaktivert.", + accountDeleteDisabled: "Kontosletting er ikke aktivert ennå. Arbeidsflyten forblir trygt avslått til vern for oppbevaring, gjenoppretting og tømming av produksjonsbuffer er godkjent.", authStatusTitle: "Autentisering", authStatusNotSignedIn: "Ikke logget inn.", authStatusRoles: "Roller: {roles}", @@ -3449,13 +3569,13 @@ export const translations = { matchScoreNoneYet: "Ingen treff ennå.", matchScoreAllCovered: "Alle viktige begreper er dekket.", matchScoreSectionCoverage: "Hvor CV-en dekker denne rollen", - matchScoreLearningPath: "Læringssti", - matchScoreLearningPathHint: "Jobbspesifikke ferdighetsgap fra den deterministiske matchen. Bekreft reell erfaring før du legger en ferdighet til profilen.", - matchScoreMarkLearned: "Marker som lært", - matchScoreLearned: "Lært", + matchScoreLearningPath: "Læringssti", + matchScoreLearningPathHint: "Jobbspesifikke ferdighetsgap fra den deterministiske matchen. Bekreft reell erfaring før du legger en ferdighet til profilen.", + matchScoreMarkLearned: "Marker som lært", + matchScoreLearned: "Lært", matchScoreDismiss: "Avvis", matchScoreDismissedCount: "{count} avvist", - matchScoreLearningUpdateFailed: "Kunne ikke oppdatere læringsanbefalingen.", + matchScoreLearningUpdateFailed: "Kunne ikke oppdatere læringsanbefalingen.", jobDetailsStrategySnapshot: "Strategioversikt", jobDetailsGenerateStrategySnapshot: "Generer strategioversikt", jobDetailsStrategySnapshotEmpty: "Generer en oversikt for å se match, posisjonering og viktigste prioriteringer på ett sted.", diff --git a/job-tracker-ui/src/notifications.ts b/job-tracker-ui/src/notifications.ts index 4c4b804..097ef53 100644 --- a/job-tracker-ui/src/notifications.ts +++ b/job-tracker-ui/src/notifications.ts @@ -9,7 +9,7 @@ export type UserNotification = { readAtUtc?: string | null; }; -export function notificationDateLabel(value: string): string { +export function notificationDateLabel(value: string, locale?: string): string { const date = new Date(value); - return Number.isNaN(date.getTime()) ? "" : date.toLocaleString(); + return Number.isNaN(date.getTime()) ? "" : date.toLocaleString(locale); } diff --git a/job-tracker-ui/src/operations-page.test.tsx b/job-tracker-ui/src/operations-page.test.tsx index 3e3a8ac..15a29f3 100644 --- a/job-tracker-ui/src/operations-page.test.tsx +++ b/job-tracker-ui/src/operations-page.test.tsx @@ -3,6 +3,7 @@ import "@testing-library/jest-dom"; import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { api } from "./api"; +import { I18nProvider } from "./i18n/I18nProvider"; import OperationsPage from "./views/OperationsPage"; jest.mock("./api", () => ({ @@ -17,6 +18,10 @@ jest.mock("./api", () => ({ const mockedApi = api as jest.Mocked; +function renderPage() { + return render(); +} + const operation = { id: "11111111-1111-1111-1111-111111111111", taskType: "strategy_snapshot", @@ -40,6 +45,7 @@ const notification = { beforeEach(() => { jest.clearAllMocks(); + window.localStorage.clear(); mockedApi.get.mockImplementation((url: string) => Promise.resolve({ data: url.startsWith("/operations") ? [operation] : [notification], } as any)); @@ -48,7 +54,7 @@ beforeEach(() => { }); test("shows persistent operation and notification states with accessible actions", async () => { - render(); + renderPage(); expect(await screen.findByText("strategy_snapshot")).toBeInTheDocument(); expect(screen.getByText("Analysing role")).toBeInTheDocument(); @@ -71,12 +77,23 @@ test("shows persistent operation and notification states with accessible actions test("shows honest empty and failure states", async () => { mockedApi.get.mockResolvedValueOnce({ data: [] } as any).mockResolvedValueOnce({ data: [] } as any); - const { unmount } = render(); + const { unmount } = renderPage(); expect(await screen.findByText("No notifications.")).toBeInTheDocument(); expect(screen.getByText("No background operations yet.")).toBeInTheDocument(); unmount(); mockedApi.get.mockRejectedValue(new Error("offline")); - render(); + renderPage(); expect(await screen.findByRole("alert")).toHaveTextContent("Operations could not be loaded."); }); + +test("localizes operation controls and empty states in Norwegian Bokmål", async () => { + window.localStorage.setItem("uiLanguage", "nb"); + mockedApi.get.mockResolvedValue({ data: [] } as any); + + renderPage(); + + expect(await screen.findByText("Ingen varsler.")).toBeInTheDocument(); + expect(screen.getByText("Ingen bakgrunnsoperasjoner ennå.")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Oppdater" })).toBeInTheDocument(); +}); diff --git a/job-tracker-ui/src/profile-page.test.tsx b/job-tracker-ui/src/profile-page.test.tsx index 4f7f3d0..d7caa2c 100644 --- a/job-tracker-ui/src/profile-page.test.tsx +++ b/job-tracker-ui/src/profile-page.test.tsx @@ -416,6 +416,16 @@ test('/career shows the profile completeness overview', async () => { expect(await screen.findByText(/70%/)).toBeInTheDocument(); }); +test('/career localizes completeness and review confidence without changing CV data', async () => { + window.localStorage.setItem('uiLanguage', 'nb'); + renderWith(CareerProfilePage); + + expect(await screen.findByText(/Legg til neste: Prosjekter/i)).toBeInTheDocument(); + fireEvent.click(screen.getByRole('tab', { name: 'Karriereprofil' })); + expect(screen.getByText(/Høy 92%/i)).toBeInTheDocument(); + expect(screen.getByDisplayValue('Demo User')).toBeInTheDocument(); +}); + test('/career separates overview, profile and import while preserving unsaved profile edits', async () => { renderWith(CareerProfilePage); await screen.findByText(/70%/); diff --git a/job-tracker-ui/src/views/CareerProfilePage.tsx b/job-tracker-ui/src/views/CareerProfilePage.tsx index 46d9398..1c9a377 100644 --- a/job-tracker-ui/src/views/CareerProfilePage.tsx +++ b/job-tracker-ui/src/views/CareerProfilePage.tsx @@ -153,7 +153,7 @@ export default function CareerProfilePage() { // Retained so the shared JSX (copied from ProfilePage) reads identically; hardcoded for /career. const careerOnly = true; const { toast } = useToast(); - const { t } = useI18n(); + const { language, t } = useI18n(); const cvInputRef = useRef(null); const avatarInputRef = useRef(null); const [me, setMe] = useState(null); @@ -393,8 +393,8 @@ export default function CareerProfilePage() { /> : null} {loadError ? ( - void loadProfile()}>Retry}> - Unable to load profile. + void loadProfile()}>{t("retry")}}> + {t("profileLoadFailed")} {loadError} ) : null} @@ -468,12 +468,12 @@ export default function CareerProfilePage() { {!canUseAi && ( - - Import, rebuild and improve CV content with AI. Manual profile editing remains available on Free. + + {t("profileProImportBody")} )} - {profileDirty ? You have unsaved career edits. Save them before running actions that use the stored profile. : null} + {profileDirty ? {t("profileUnsavedBeforeActions")} : null} {t("profileMasterCv")} @@ -497,7 +497,7 @@ export default function CareerProfilePage() { try { const res = await api.post("/profile-cv/upload", formData, { headers: { "Content-Type": "multipart/form-data" } }); await loadExtractionRuns(); - toast(`Queued CV upload (run ${res.data.extractionRunId}).`, "info"); + toast(t("profileCvQueuedUpload", { id: res.data.extractionRunId }), "info"); } catch (e: any) { toast(String(e?.response?.data || e?.message || t("profileCvUploadFailed")), "error"); } finally { @@ -516,7 +516,7 @@ export default function CareerProfilePage() { try { const res = await api.post("/profile-cv/rebuild"); await loadExtractionRuns(); - toast(`Queued CV rebuild (run ${res.data.extractionRunId}).`, "info"); + toast(t("profileCvQueuedRebuild", { id: res.data.extractionRunId }), "info"); } catch (e: any) { toast(String(e?.response?.data || e?.message || t("profileCvRebuildFailed")), "error"); } finally { @@ -534,7 +534,7 @@ export default function CareerProfilePage() { try { const res = await api.post("/profile-cv/improve"); await loadExtractionRuns(); - toast(`Queued CV improve run (run ${res.data.extractionRunId}).`, "info"); + toast(t("profileCvQueuedImprove", { id: res.data.extractionRunId }), "info"); } catch (e: any) { toast(String(e?.response?.data || e?.message || t("profileCvImproveFailed")), "error"); } finally { @@ -552,7 +552,7 @@ export default function CareerProfilePage() { try { const res = await api.post("/profile-cv/reprocess"); await loadExtractionRuns(); - toast(`Queued CV reprocess run (run ${res.data.extractionRunId}).`, "info"); + toast(t("profileCvQueuedReprocess", { id: res.data.extractionRunId }), "info"); } catch (e: any) { toast(String(e?.response?.data || e?.message || t("profileCvReprocessFailed")), "error"); } finally { @@ -617,7 +617,7 @@ export default function CareerProfilePage() { {run.artifactFileName || t("profileCvNoStoredArtifact")} - {run.parserVersion} · {new Date(run.startedAtUtc).toLocaleString()} + {run.parserVersion} · {new Date(run.startedAtUtc).toLocaleString(language === "nb" ? "nb-NO" : "en")} {run.errorMessage ? ( @@ -629,36 +629,36 @@ export default function CareerProfilePage() { try { await api.post(`/operations/${run.operation!.id}/cancel`); await loadExtractionRuns(); - toast("CV processing cancellation requested.", "info"); + toast(t("profileCvCancelRequested"), "info"); } catch (error) { - toast(getApiErrorMessage(error, "Could not cancel CV processing."), "error"); + toast(getApiErrorMessage(error, t("profileCvCancelFailed")), "error"); } - }}>Cancel processing + }}>{t("profileCvCancelProcessing")} ) : null} {run.operation?.canRetry ? ( + }}>{t("profileCvRetryProcessing")} ) : null} {run.status === "pending_review" ? ( {runDiffs[run.id] ? ( <> - {runDiffs[run.id].totalAdded} additions | {runDiffs[run.id].totalUpdated} updates - {runDiffs[run.id].totalLowConfidence ? ` | ${runDiffs[run.id].totalLowConfidence} need attention` : ""} + {t("profileCvDiffSummary", { added: runDiffs[run.id].totalAdded, updated: runDiffs[run.id].totalUpdated })} + {runDiffs[run.id].totalLowConfidence ? ` | ${t("profileCvNeedAttention", { count: runDiffs[run.id].totalLowConfidence })}` : ""} {runDiffs[run.id].categories .filter((category) => category.added.length || category.updated.length) .map((category) => `${category.category}: +${category.added.length} / ~${category.updated.length}`) - .join(" | ") || "No profile changes found"} + .join(" | ") || t("profileCvNoChangesFound")} ) : } @@ -672,12 +672,12 @@ export default function CareerProfilePage() { ? [...(current[run.id] ?? []), change.id] : (current[run.id] ?? []).filter((id) => id !== change.id), }))} />} - label={`Include low-confidence ${change.category}: ${change.label}`} + label={t("profileCvIncludeLowConfidence", { category: change.category, label: change.label })} /> ))} {profileDirty ? ( - Save your current career-profile edits before applying imported changes. + {t("profileCvSaveBeforeImport")} ) : null} @@ -687,22 +687,22 @@ export default function CareerProfilePage() { await api.post(`/profile-cv/runs/${run.id}/accept`, { acceptedLowConfidenceIds: acceptedLowConfidenceIds[run.id] ?? [] }); setRunDiffs((current) => { const next = { ...current }; delete next[run.id]; return next; }); await Promise.all([loadProfile(), loadExtractionRuns(), loadVersions()]); - toast("CV changes merged into your career profile.", "success"); + toast(t("profileCvChangesApplied"), "success"); } catch (error) { - toast(getApiErrorMessage(error, "Could not apply CV changes."), "error"); + toast(getApiErrorMessage(error, t("profileCvApplyChangesFailed")), "error"); } finally { setReviewingRunId(null); } - }}>Apply changes + }}>{t("profileCvApplyChanges")} + }}>{t("profileCvDiscard")} ) : null} @@ -715,7 +715,7 @@ export default function CareerProfilePage() { @@ -794,7 +794,7 @@ export default function CareerProfilePage() { - {cvWordCount} words + {t("profileCvSectionWordCount", { count: cvWordCount })} {t("profileCvPreferredUploads")} @@ -803,7 +803,7 @@ export default function CareerProfilePage() { - {profileDirty ? : null} + {profileDirty ? : null} + {t("operationsSubtitle")} + {error ? {error} : null} - {loading ? : null} + {loading ? : null} - Notifications - {notifications.length === 0 && !loading ? No notifications. : null} + {t("notifications")} + {notifications.length === 0 && !loading ? {t("operationsNoNotifications")} : null} {notifications.map((notification) => ( {notification.title} {notification.message} - {dateLabel(notification.createdAtUtc)} + {notificationDateLabel(notification.createdAtUtc, language === "nb" ? "nb-NO" : "en")} {!notification.readAtUtc ? ( ) : null} @@ -111,8 +112,8 @@ export default function OperationsPage() { - Operations - {operations.length === 0 && !loading ? No background operations yet. : null} + {t("operations")} + {operations.length === 0 && !loading ? {t("operationsEmpty")} : null} {operations.map((operation) => ( @@ -120,20 +121,20 @@ export default function OperationsPage() { {operation.taskType} - Started {dateLabel(operation.createdAtUtc)} + {t("operationsStarted", { date: notificationDateLabel(operation.createdAtUtc, language === "nb" ? "nb-NO" : "en") })} {operation.progressStage ? {operation.progressStage} : null} - {operation.progressPercent != null ? : null} - {operation.cancellationRequestedAtUtc ? Cancellation requested. : null} - {operation.failureCategory ? Failed: {statusLabel(operation.failureCategory)} : null} + {operation.progressPercent != null ? : null} + {operation.cancellationRequestedAtUtc ? {t("operationsCancellationRequested")} : null} + {operation.failureCategory ? {t("operationsFailed", { category: statusLabel(operation.failureCategory) })} : null} {operation.canCancel ? ( ) : null} {operation.canRetry ? ( ) : null} diff --git a/job-tracker-ui/src/views/career/CareerProfileSections.tsx b/job-tracker-ui/src/views/career/CareerProfileSections.tsx index b2f147c..f0585f1 100644 --- a/job-tracker-ui/src/views/career/CareerProfileSections.tsx +++ b/job-tracker-ui/src/views/career/CareerProfileSections.tsx @@ -30,18 +30,22 @@ type MetadataLookup = (path: string) => StructuredCvFieldMetadata | undefined; // Field-review chip + tone, moved verbatim from CareerProfilePage so the sections and the parent // share one definition. Behaviour (thresholds, labels, source snippet) is unchanged. function confidenceTone(confidence?: number) { - if (typeof confidence !== "number") return { label: "Review", color: "default" as const }; - if (confidence >= 0.8) return { label: `High ${Math.round(confidence * 100)}%`, color: "success" as const }; - if (confidence >= 0.65) return { label: `Medium ${Math.round(confidence * 100)}%`, color: "warning" as const }; - return { label: `Low ${Math.round(confidence * 100)}%`, color: "error" as const }; + if (typeof confidence !== "number") return { key: "profileCvConfidenceReview" as const, color: "default" as const }; + if (confidence >= 0.8) return { key: "profileCvConfidenceHigh" as const, color: "success" as const }; + if (confidence >= 0.65) return { key: "profileCvConfidenceMedium" as const, color: "warning" as const }; + return { key: "profileCvConfidenceLow" as const, color: "error" as const }; } export function FieldReviewNote({ metadata }: { metadata?: StructuredCvFieldMetadata }) { + const { t } = useI18n(); if (!metadata) return null; const tone = confidenceTone(metadata.confidence); + const label = typeof metadata.confidence === "number" + ? t(tone.key, { percent: Math.round(metadata.confidence * 100) }) + : t(tone.key); return ( - + {metadata.method ? : null} {metadata.sourceBlockId ? : null} {metadata.reviewState ? : null} @@ -91,8 +95,8 @@ export function PersonalInformationSection({ set({ linkedIn: e.target.value || undefined })} fullWidth sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" } }} /> set({ gitHub: e.target.value || undefined })} fullWidth sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" } }} /> - Other links - {(value.links ?? []).map((link, index) => set({ links: (value.links ?? []).map((item, itemIndex) => itemIndex === index ? { ...item, label: event.target.value || undefined } : item) })} sx={{ flex: "0 1 180px" }} /> set({ links: (value.links ?? []).map((item, itemIndex) => itemIndex === index ? { ...item, url: event.target.value || undefined } : item) })} fullWidth /> set({ links: (value.links ?? []).filter((_, itemIndex) => itemIndex !== index) })}>)} + {t("profileCvOtherLinks")} + {(value.links ?? []).map((link, index) => set({ links: (value.links ?? []).map((item, itemIndex) => itemIndex === index ? { ...item, label: event.target.value || undefined } : item) })} sx={{ flex: "0 1 180px" }} /> set({ links: (value.links ?? []).map((item, itemIndex) => itemIndex === index ? { ...item, url: event.target.value || undefined } : item) })} fullWidth /> set({ links: (value.links ?? []).filter((_, itemIndex) => itemIndex !== index) })}>)} ); diff --git a/job-tracker-ui/src/views/career/CareerWorkspaceOverview.tsx b/job-tracker-ui/src/views/career/CareerWorkspaceOverview.tsx index 67b5f41..173519f 100644 --- a/job-tracker-ui/src/views/career/CareerWorkspaceOverview.tsx +++ b/job-tracker-ui/src/views/career/CareerWorkspaceOverview.tsx @@ -9,6 +9,7 @@ import WorkOutlineIcon from "@mui/icons-material/WorkOutline"; import { CvVariantSummary, cvBuilderApi } from "../../cvBuilder"; import type { UserOperation } from "../../types"; import { useI18n } from "../../i18n/I18nProvider"; +import { localizeCompletenessLabel } from "./careerCompletenessLabels"; export type CareerWorkspaceCompleteness = { percent: number; @@ -123,7 +124,7 @@ export default function CareerWorkspaceOverview({ completeness, runs, loading, l {!loading && completeness?.missing.length ? ( - {t("careerOverviewAddNext", { items: completeness.missing.slice(0, 3).join(", ") })} + {t("careerOverviewAddNext", { items: completeness.missing.slice(0, 3).map((label) => localizeCompletenessLabel(label, t)).join(", ") })} ) : null} diff --git a/job-tracker-ui/src/views/career/ProfileCompleteness.tsx b/job-tracker-ui/src/views/career/ProfileCompleteness.tsx index f99cdf5..f2a85c6 100644 --- a/job-tracker-ui/src/views/career/ProfileCompleteness.tsx +++ b/job-tracker-ui/src/views/career/ProfileCompleteness.tsx @@ -1,6 +1,8 @@ import React from "react"; import { Accordion, AccordionDetails, AccordionSummary, Box, Button, Chip, LinearProgress, Typography } from "@mui/material"; import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; +import { useI18n } from "../../i18n/I18nProvider"; +import { localizeCompletenessLabel } from "./careerCompletenessLabels"; // 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 @@ -31,14 +33,17 @@ export default function ProfileCompleteness({ onRestore: (version: number) => void; showSummary?: boolean; }) { + const { language, t } = useI18n(); if ((!showSummary || !completeness) && versions.length <= 1) return null; + const locale = language === "nb" ? "nb-NO" : "en"; + return ( {showSummary && completeness ? ( <> - Profile completeness + {t("profileCvCompletenessTitle")} {completeness.percent}% {completeness.missing.length > 0 ? ( - Missing: + {t("profileCvCompletenessMissing")} {completeness.missing.map((label) => ( - + ))} ) : ( - Your career profile is complete. + {t("profileCvCompletenessComplete")} )} ) : null} {versions.length > 1 ? ( } sx={{ px: 0, minHeight: 0 }}> - Version history ({versions.length}) + {t("profileCvVersionHistory", { count: versions.length })} {versions.slice(0, 12).map((v) => ( - v{v.version} · {v.source} · {new Date(v.createdAtUtc).toLocaleString()}{v.isCurrent ? " · current" : ""} + v{v.version} · {v.source} · {new Date(v.createdAtUtc).toLocaleString(locale)}{v.isCurrent ? ` · ${t("profileCvVersionCurrent")}` : ""} {!v.isCurrent ? ( - + ) : null} ))} diff --git a/job-tracker-ui/src/views/career/careerCompletenessLabels.ts b/job-tracker-ui/src/views/career/careerCompletenessLabels.ts new file mode 100644 index 0000000..d8116fc --- /dev/null +++ b/job-tracker-ui/src/views/career/careerCompletenessLabels.ts @@ -0,0 +1,16 @@ +import type { TranslationKey } from "../../i18n/translations"; + +const completenessLabelKeys: Record = { + "Personal details": "profileCvCompletenessPersonal", + "Professional summary": "profileCvCompletenessSummary", + Experience: "profileCvCompletenessExperience", + Education: "profileCvCompletenessEducation", + Skills: "profileCvCompletenessSkills", + Projects: "profileCvCompletenessProjects", + Languages: "profileCvCompletenessLanguages", +}; + +export function localizeCompletenessLabel(label: string, t: (key: TranslationKey) => string) { + const key = completenessLabelKeys[label]; + return key ? t(key) : label; +}