diff --git a/JobTrackerApi.Tests/ApplicationIntelligenceTests.cs b/JobTrackerApi.Tests/ApplicationIntelligenceTests.cs index 6b2eb83..61bfade 100644 --- a/JobTrackerApi.Tests/ApplicationIntelligenceTests.cs +++ b/JobTrackerApi.Tests/ApplicationIntelligenceTests.cs @@ -420,4 +420,27 @@ public sealed class ApplicationIntelligenceTests Assert.Contains("C#", match.MatchedSkills); Assert.DoesNotContain(match.MissingSkills, value => value.Equals("utvikler", StringComparison.OrdinalIgnoreCase)); } + + [Fact] + public async Task Match_reports_language_mismatch_when_no_translation_is_available() + { + var (db, intelligence, _) = New("user-1"); + await using var _d = db; + var job = await SeedJobAsync(db, "user-1", j => + { + j.Description = "Vi søker en utvikler som kan bygge robuste systemer og gode tjenester."; + j.DescriptionLanguage = null; // Exercise detector fallback for manually created jobs. + j.TranslatedDescription = null; + }); + await SeedProfileAsync(db, "user-1"); + await AttachCvAsync(db, "user-1", job.Id, new CvVariantSettings { Language = "en" }); + + var match = await intelligence.MatchAsync("user-1", job.Id, default); + + Assert.NotNull(match); + Assert.Equal("nb", match!.JobLanguage); + Assert.Equal("en", match.CvLanguage); + Assert.True(match.LanguageMismatch); + Assert.False(match.UsedTranslatedJobDescription); + } } diff --git a/docs/work-programmes/master-progress.md b/docs/work-programmes/master-progress.md index cd5772a..f43c777 100644 --- a/docs/work-programmes/master-progress.md +++ b/docs/work-programmes/master-progress.md @@ -21,6 +21,9 @@ Updated: 2026-08-29 - Improved the Job Workspace overview card grid so compact screens use one readable column, tablets use two, and wide screens use five without leaving a cramped orphan card. - Moved the remaining shared shell navigation labels into the global locale catalogue so sidebar and mobile-navigation controls announce themselves in the selected language. - Localized the application checklist from stable system keys rather than persisted English text. Categories, generated tasks, descriptions, progress, errors, tooltips, and accessible reorder/delete names now switch between English and Bokmål while user-authored task text remains untouched. +- 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. ### In progress @@ -61,6 +64,10 @@ Updated: 2026-08-29 - Career/Profile focused verification: 2 suites, 19/19 passed, including the final navigation/state-preservation regression; final full-suite/E2E gates remain pending. - Job Workspace focused verification: 2 suites, 9/9 passed; TypeScript passed. - Checklist localization verification: 7/7 passed, including a Bokmål regression that preserves user-authored content. +- 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. +- 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. - Playwright: 8/10 passed on the first complete run; both failures were ambiguous selectors in the newly responsive Career selector, not product failures. Both corrected focused regressions now pass (2/2); final full rerun remains in the end-of-batch gate. diff --git a/job-tracker-ui/src/application-workspace-overlay.test.tsx b/job-tracker-ui/src/application-workspace-overlay.test.tsx index 7046eaf..809285b 100644 --- a/job-tracker-ui/src/application-workspace-overlay.test.tsx +++ b/job-tracker-ui/src/application-workspace-overlay.test.tsx @@ -233,6 +233,18 @@ test("warns before section navigation would discard application edits", async () test("renders shared workspace navigation and progress in Norwegian Bokmål", async () => { window.localStorage.setItem("uiLanguage", "nb"); + mockedApi.get.mockImplementation((url: string) => { + if (url === "/jobapplications/42/workspace") return Promise.resolve({ data: { + ...overview, + nextStep: { + key: "prepare-cv", + label: "Prepare a CV for this role", + reason: "Attach a CV variant tailored to this application.", + section: "cv", + }, + } } as any); + return Promise.resolve({ data: [] } as any); + }); renderTable("/jobs/42"); expect(await screen.findByRole("tab", { name: "Oversikt" })).toHaveAttribute("aria-selected", "true"); @@ -240,6 +252,7 @@ test("renders shared workspace navigation and progress in Norwegian Bokmål", as expect(screen.getByRole("tab", { name: "Søknadsbrev" })).toBeInTheDocument(); expect(await screen.findByLabelText(/søknadsprogresjon/i)).toBeInTheDocument(); expect(screen.getByRole("button", { name: /tilbake til søknader/i })).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "Forbered en CV for denne stillingen" })).toBeInTheDocument(); }); test("hydrates list filters, sort and page from a shareable URL", async () => { diff --git a/job-tracker-ui/src/applicationWorkspace.ts b/job-tracker-ui/src/applicationWorkspace.ts index 3f94138..0647066 100644 --- a/job-tracker-ui/src/applicationWorkspace.ts +++ b/job-tracker-ui/src/applicationWorkspace.ts @@ -89,6 +89,11 @@ export const WORKSPACE_SECTIONS: { key: WorkspaceSectionKey; label: string }[] = { key: "interview", label: "Interview Prep" }, ]; +export function checklistSystemTranslationToken(systemKey?: string | null): string | null { + if (!systemKey || systemKey.startsWith("learning:")) return null; + return systemKey.replace(/-([a-z])/g, (_, letter: string) => letter.toUpperCase()); +} + const LEGACY_WORKSPACE_SECTIONS: Record = { match: "analysis", "job-details": "overview", diff --git a/job-tracker-ui/src/components/ApplicationAssets.tsx b/job-tracker-ui/src/components/ApplicationAssets.tsx index b6090fc..dc51c60 100644 --- a/job-tracker-ui/src/components/ApplicationAssets.tsx +++ b/job-tracker-ui/src/components/ApplicationAssets.tsx @@ -26,6 +26,7 @@ import { useI18n } from "../i18n/I18nProvider"; // is application-specific by nature. docs/architecture/application-workspace.md. function useAsset(load: () => Promise, deps: React.DependencyList) { + const { t } = useI18n(); const [data, setData] = useState(null); const [error, setError] = useState(null); const [loading, setLoading] = useState(true); @@ -44,7 +45,7 @@ function useAsset(load: () => Promise, deps: React.DependencyList) { } }) .catch((err) => { - if (!cancelled) setError(getApiErrorMessage(err, "Could not load this section.")); + if (!cancelled) setError(getApiErrorMessage(err, t("assetsLoadFailed"))); }) .finally(() => { if (!cancelled) setLoading(false); @@ -52,7 +53,7 @@ function useAsset(load: () => Promise, deps: React.DependencyList) { return () => { cancelled = true; }; - }, [run]); + }, [run, t]); useEffect(() => reload(), [reload]); @@ -98,7 +99,7 @@ export function ApplicationCvSection({ jobId }: { jobId: number }) { setData(await applicationAssetsApi.attachVariant(jobId, variantId)); setError(null); } catch (err) { - setError(getApiErrorMessage(err, "Could not change the attached CV.")); + setError(getApiErrorMessage(err, t("assetsAttachCvFailed"))); } finally { setBusy(false); } @@ -110,11 +111,11 @@ export function ApplicationCvSection({ jobId }: { jobId: number }) { if (!data?.attachedVariantId) return; setBusy(true); try { - const copy = await cvBuilderApi.duplicate(data.attachedVariantId, `${data.attachedVariantName || "CV"} — tailored copy`); + const copy = await cvBuilderApi.duplicate(data.attachedVariantId, t("assetsTailoredCopyName", { name: data.attachedVariantName || "CV" })); setData(await applicationAssetsApi.attachVariant(jobId, copy.id)); window.location.assign(`/career/builder/${copy.id}`); } catch (err) { - setError(getApiErrorMessage(err, "Could not create a tailored CV copy.")); + setError(getApiErrorMessage(err, t("assetsDuplicateCvFailed"))); } finally { setBusy(false); } @@ -308,7 +309,7 @@ export function ApplicationCoverLetterSection({ jobId, onDirtyChange }: { jobId: setDraftAiAction(null); setError(null); } catch (err) { - setError(getApiErrorMessage(err, "Could not save the cover letter.")); + setError(getApiErrorMessage(err, t("assetsCoverSaveFailed"))); } finally { setBusy(false); } @@ -322,7 +323,7 @@ export function ApplicationCoverLetterSection({ jobId, onDirtyChange }: { jobId: setDraftAiAction(null); setError(null); } catch (err) { - setError(getApiErrorMessage(err, "Could not restore that version.")); + setError(getApiErrorMessage(err, t("assetsCoverRestoreFailed"))); } finally { setBusy(false); } @@ -394,7 +395,7 @@ export function ApplicationCoverLetterSection({ jobId, onDirtyChange }: { jobId: {v.isCurrent && } - {new Date(v.createdAtUtc).toLocaleString()} · {t("assetsCharacterCount", { count: v.length })} + {new Date(v.createdAtUtc).toLocaleString(language === "nb" ? "nb-NO" : "en")} · {t("assetsCharacterCount", { count: v.length })} {!v.isCurrent && ( @@ -458,7 +459,7 @@ function CoverLetterAiAssistant({ jobId, currentText, onApply }: { jobId: number }); setSuggestion(result.result.text?.trim() ?? ""); } catch (err) { - setError(getApiErrorMessage(err, "Could not generate a cover-letter suggestion.")); + setError(getApiErrorMessage(err, t("coverAiGenerateFailed"))); } finally { setBusy(false); } @@ -499,8 +500,8 @@ function CoverLetterAiAssistant({ jobId, currentText, onApply }: { jobId: number {t("coverAiDocumentLanguage")} @@ -598,7 +599,7 @@ export function ApplicationPackageDraftsSection({ setError(null); onSaved?.(); } catch (err) { - setError(getApiErrorMessage(err, "Could not save the application drafts.")); + setError(getApiErrorMessage(err, t("assetsDraftsSaveFailed"))); } finally { setBusy(false); } diff --git a/job-tracker-ui/src/components/ApplicationChecklist.tsx b/job-tracker-ui/src/components/ApplicationChecklist.tsx index 679fae0..ec1aa35 100644 --- a/job-tracker-ui/src/components/ApplicationChecklist.tsx +++ b/job-tracker-ui/src/components/ApplicationChecklist.tsx @@ -11,7 +11,7 @@ import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward"; import { getApiErrorMessage } from "../api"; import { useI18n } from "../i18n/I18nProvider"; import { - CHECKLIST_CATEGORIES, Checklist, ChecklistItem, applicationChecklistApi, + CHECKLIST_CATEGORIES, Checklist, ChecklistItem, applicationChecklistApi, checklistSystemTranslationToken, } from "../applicationWorkspace"; // Phase 5 Milestone 2 — the application checklist. @@ -214,8 +214,8 @@ function checklistCategoryLabel(t: (key: any, vars?: Record) => string, item: ChecklistItem) { - if (!item.systemKey || item.systemKey.startsWith("learning:")) return { title: item.title, description: item.description }; - const token = item.systemKey.replace(/-([a-z])/g, (_, letter: string) => letter.toUpperCase()); + const token = checklistSystemTranslationToken(item.systemKey); + if (!token) return { title: item.title, description: item.description }; return { title: t(`checklistItem_${token}`), description: t(`checklistItem_${token}Description`), diff --git a/job-tracker-ui/src/components/ApplicationIntelligence.tsx b/job-tracker-ui/src/components/ApplicationIntelligence.tsx index e48950c..8ff296d 100644 --- a/job-tracker-ui/src/components/ApplicationIntelligence.tsx +++ b/job-tracker-ui/src/components/ApplicationIntelligence.tsx @@ -21,6 +21,7 @@ import { useI18n } from "../i18n/I18nProvider"; // One loader for all three sections: same fetch/loading/empty/error shape, so the sections stay // consistent and each one is just its own rendering. function useIntelligence(load: () => Promise, deps: React.DependencyList) { + const { t } = useI18n(); const [data, setData] = useState(null); const [error, setError] = useState(null); const [loading, setLoading] = useState(true); @@ -38,7 +39,7 @@ function useIntelligence(load: () => Promise, deps: React.DependencyList) setError(null); }) .catch((err) => { - if (!cancelled) setError(getApiErrorMessage(err, "Could not load this section.")); + if (!cancelled) setError(getApiErrorMessage(err, t("intelligenceLoadFailed"))); }) .finally(() => { if (!cancelled) setLoading(false); @@ -46,7 +47,7 @@ function useIntelligence(load: () => Promise, deps: React.DependencyList) return () => { cancelled = true; }; - }, [run]); + }, [run, t]); return { data, error, loading }; } @@ -110,7 +111,7 @@ function Bullets({ label, values }: { label: string; values: string[] }) { // ---------- Timeline ---------- export function ApplicationTimeline({ jobId }: { jobId: number }) { - const { t } = useI18n(); + const { language, t } = useI18n(); const [category, setCategory] = useState(""); const { data, error, loading } = useIntelligence( () => applicationIntelligenceApi.timeline(jobId, category || undefined), @@ -129,7 +130,7 @@ export function ApplicationTimeline({ jobId }: { jobId: number }) { {m.summary} - {new Date(m.at).toLocaleDateString()} + {new Date(m.at).toLocaleDateString(language === "nb" ? "nb-NO" : "en")} ))} @@ -267,7 +268,7 @@ export function ApplicationMatch({ jobId }: { jobId: number }) { {data?.score}% - + string, category: string): strin }; return keys[category] ? t(keys[category]) : category; } + +function matchBandLabel(t: (key: any) => string, band?: string): string { + if (!band) return t("matchScoreBand_Unknown"); + return ["Strong", "Partial", "Low", "Unknown"].includes(band) + ? t(`matchScoreBand_${band}`) + : band; +} diff --git a/job-tracker-ui/src/components/InterviewPrep.tsx b/job-tracker-ui/src/components/InterviewPrep.tsx index 95f41b3..138d408 100644 --- a/job-tracker-ui/src/components/InterviewPrep.tsx +++ b/job-tracker-ui/src/components/InterviewPrep.tsx @@ -61,11 +61,11 @@ export function ApplicationInterviewPrep({ jobId }: { jobId: number }) { setBoard(await interviewPrepApi.get(jobId)); setError(null); } catch (err) { - setError(getApiErrorMessage(err, "Could not load interview preparation.")); + setError(getApiErrorMessage(err, t("interviewPrepLoadFailed"))); } finally { setLoading(false); } - }, [jobId]); + }, [jobId, t]); useEffect(() => { load(); @@ -77,7 +77,7 @@ export function ApplicationInterviewPrep({ jobId }: { jobId: number }) { await run(); await load(); } catch (err) { - setError(getApiErrorMessage(err, "Could not update interview preparation.")); + setError(getApiErrorMessage(err, t("interviewPrepUpdateFailed"))); } finally { setBusy(false); } @@ -133,7 +133,7 @@ export function ApplicationInterviewPrep({ jobId }: { jobId: number }) { (board?.groups ?? []).map((group) => ( - {group.label} + {interviewCategoryLabel(t, group.category)} {group.items.map((item) => ( @@ -163,7 +163,7 @@ export function ApplicationInterviewPrep({ jobId }: { jobId: number }) { sx={{ minWidth: { sm: 200 } }} > {INTERVIEW_PREP_CATEGORIES.map((c) => ( - {c.label} + {interviewCategoryLabel(t, c.key)} ))} {t(item.labelKey)})} setLanguage(event.target.value as "en" | "nb-NO")} sx={{ minWidth: 190 }}> - English - Norsk bokmål + {t("languageEnglish")} + {t("languageNorwegianBokmal")} setInstructions(event.target.value)} /> @@ -317,7 +317,7 @@ function PrepRow({ jobId, item, busy, onChanged, onError }: { setDraft(null); onChanged(); } catch (err) { - onError(getApiErrorMessage(err, "Could not save this answer.")); + onError(getApiErrorMessage(err, t("interviewPrepAnswerSaveFailed"))); } finally { setSaving(false); } @@ -409,11 +409,11 @@ export function ApplicationFollowUp({ jobId }: { jobId: number }) { setAction(result.nextAction ?? ""); setError(null); } catch (err) { - setError(getApiErrorMessage(err, "Could not load follow-up.")); + setError(getApiErrorMessage(err, t("interviewFollowUpLoadFailed"))); } finally { setLoading(false); } - }, [jobId]); + }, [jobId, t]); useEffect(() => { load(); @@ -426,7 +426,7 @@ export function ApplicationFollowUp({ jobId }: { jobId: number }) { setData(result); setError(null); } catch (err) { - setError(getApiErrorMessage(err, "Could not save the follow-up.")); + setError(getApiErrorMessage(err, t("interviewFollowUpSaveFailed"))); } finally { setBusy(false); } @@ -476,3 +476,15 @@ export function ApplicationFollowUp({ jobId }: { jobId: number }) { ); } + +function interviewCategoryLabel(t: (key: any) => string, category: string) { + const keys: Record = { + "company-research": "interviewCategoryCompanyResearch", + technical: "interviewCategoryTechnical", + behavioural: "interviewCategoryBehavioural", + star: "interviewCategoryStar", + question: "interviewCategoryQuestions", + note: "interviewCategoryNotes", + }; + return keys[category] ? t(keys[category]) : category; +} diff --git a/job-tracker-ui/src/i18n/translations.ts b/job-tracker-ui/src/i18n/translations.ts index 806d8b7..52154df 100644 --- a/job-tracker-ui/src/i18n/translations.ts +++ b/job-tracker-ui/src/i18n/translations.ts @@ -79,6 +79,8 @@ export const translations = { noWord: "No", createAndAddAnother: "Create & add another", loading: "Loading...", + languageEnglish: "English", + languageNorwegianBokmal: "Norsk bokmål", discoverJobs: "Discover jobs", discoverJobsSubtitle: "Search official job-board feeds and save opportunities to your tracker.", jobDetails: "Job details", @@ -103,6 +105,15 @@ export const translations = { interviewPrepCategory: "Category", interviewPrepAddLabel: "Add a question, topic or note", interviewPrepAdd: "Add", + interviewPrepLoadFailed: "Could not load interview preparation.", + interviewPrepUpdateFailed: "Could not update interview preparation.", + interviewPrepAnswerSaveFailed: "Could not save this answer.", + interviewCategoryCompanyResearch: "Company research", + interviewCategoryTechnical: "Technical preparation", + interviewCategoryBehavioural: "Behavioural questions", + interviewCategoryStar: "STAR examples", + interviewCategoryQuestions: "Questions to ask them", + interviewCategoryNotes: "Notes", interviewAiTitle: "AI interview coach", interviewAiSubtitle: "Uses the linked CV, job advert and application analysis. Suggestions never overwrite your notes.", interviewAiUsing: "Using", @@ -134,7 +145,10 @@ export const translations = { interviewFollowUpOpenTasks: "{count} open follow-up tasks on the checklist.", interviewFollowUpDate: "Follow up on", interviewFollowUpEmpty: "No follow-up scheduled. Applications without one can go quiet.", + interviewFollowUpLoadFailed: "Could not load follow-up.", + interviewFollowUpSaveFailed: "Could not save the follow-up.", intelligenceMilestones: "Milestones", + intelligenceLoadFailed: "Could not load this section.", intelligenceTimeline: "Timeline", intelligenceTimelineSubtitle: "Everything recorded for this application, newest first.", intelligenceTimelineEmpty: "Nothing has happened yet. Activity appears here as the application moves forward.", @@ -170,6 +184,10 @@ export const translations = { intelligenceRelevantProjects: "Relevant projects", intelligenceSuggestions: "Suggestions", assetsCvSubtitle: "The CV variant used for this application. Variants tailor the master career profile without duplicating it.", + assetsLoadFailed: "Could not load this section.", + assetsAttachCvFailed: "Could not change the attached CV.", + assetsDuplicateCvFailed: "Could not create a tailored CV copy.", + assetsTailoredCopyName: "{name} — tailored copy", assetsNoCvVariants: "No CV variants yet. Build one in the CV builder; it starts from the master career profile, so your history stays reusable.", assetsAttachedCv: "Attached CV variant", assetsAttachedCvHelp: "Changing this only changes which CV the application uses. The CV itself is untouched.", @@ -189,6 +207,10 @@ export const translations = { assetsCoverPlaceholder: "Write it yourself, start from the template, or generate a tailored draft.", assetsDiscardChanges: "Discard changes", assetsStartTemplate: "Start from template", + assetsCoverSaveFailed: "Could not save the cover letter.", + assetsCoverRestoreFailed: "Could not restore that version.", + assetsDraftsSaveFailed: "Could not save the application drafts.", + coverAiGenerateFailed: "Could not generate a cover-letter suggestion.", cvEditorBack: "Back to CVs", cvEditorName: "CV name", cvEditorNameRequired: "Enter a name before saving.", @@ -1865,6 +1887,8 @@ export const translations = { noWord: "Nei", createAndAddAnother: "Opprett og legg til en til", loading: "Laster...", + languageEnglish: "Engelsk", + languageNorwegianBokmal: "Norsk bokmål", discoverJobs: "Finn jobber", discoverJobsSubtitle: "Søk i offisielle jobbportaler og lagre muligheter i oversikten din.", jobDetails: "Jobbdetaljer", @@ -1889,6 +1913,15 @@ export const translations = { interviewPrepCategory: "Kategori", interviewPrepAddLabel: "Legg til spørsmål, tema eller notat", interviewPrepAdd: "Legg til", + interviewPrepLoadFailed: "Kunne ikke laste intervjuforberedelsen.", + interviewPrepUpdateFailed: "Kunne ikke oppdatere intervjuforberedelsen.", + interviewPrepAnswerSaveFailed: "Kunne ikke lagre dette svaret.", + interviewCategoryCompanyResearch: "Selskapsresearch", + interviewCategoryTechnical: "Teknisk forberedelse", + interviewCategoryBehavioural: "Atferdsbaserte spørsmål", + interviewCategoryStar: "STAR-eksempler", + interviewCategoryQuestions: "Spørsmål du kan stille", + interviewCategoryNotes: "Notater", interviewAiTitle: "AI-intervjutrening", interviewAiSubtitle: "Bruker tilknyttet CV, stillingsannonse og søknadsanalyse. Forslag overskriver aldri notatene dine.", interviewAiUsing: "Bruker", @@ -1920,7 +1953,10 @@ export const translations = { interviewFollowUpOpenTasks: "{count} åpne oppfølgingsoppgaver i sjekklisten.", interviewFollowUpDate: "Følg opp den", interviewFollowUpEmpty: "Ingen oppfølging er planlagt. Søknader uten oppfølging kan bli stille.", + interviewFollowUpLoadFailed: "Kunne ikke laste oppfølgingen.", + interviewFollowUpSaveFailed: "Kunne ikke lagre oppfølgingen.", intelligenceMilestones: "Milepæler", + intelligenceLoadFailed: "Kunne ikke laste denne delen.", intelligenceTimeline: "Tidslinje", intelligenceTimelineSubtitle: "Alt som er registrert for søknaden, nyeste først.", intelligenceTimelineEmpty: "Ingenting har skjedd ennå. Aktivitet vises her når søknaden går videre.", @@ -1956,6 +1992,10 @@ export const translations = { intelligenceRelevantProjects: "Relevante prosjekter", intelligenceSuggestions: "Forslag", assetsCvSubtitle: "CV-varianten som brukes for denne søknaden. Varianter tilpasser karriereprofilen uten å duplisere den.", + assetsLoadFailed: "Kunne ikke laste denne delen.", + assetsAttachCvFailed: "Kunne ikke endre den tilknyttede CV-en.", + assetsDuplicateCvFailed: "Kunne ikke opprette en tilpasset CV-kopi.", + assetsTailoredCopyName: "{name} — tilpasset kopi", assetsNoCvVariants: "Ingen CV-varianter ennå. Bygg en i CV-byggeren; den starter fra karriereprofilen, slik at historikken kan gjenbrukes.", assetsAttachedCv: "Tilknyttet CV-variant", assetsAttachedCvHelp: "Dette endrer bare hvilken CV søknaden bruker. Selve CV-en forblir urørt.", @@ -1975,6 +2015,10 @@ export const translations = { assetsCoverPlaceholder: "Skriv selv, start fra malen eller generer et tilpasset utkast.", assetsDiscardChanges: "Forkast endringer", assetsStartTemplate: "Start fra mal", + assetsCoverSaveFailed: "Kunne ikke lagre søknadsbrevet.", + assetsCoverRestoreFailed: "Kunne ikke gjenopprette denne versjonen.", + assetsDraftsSaveFailed: "Kunne ikke lagre søknadsutkastene.", + coverAiGenerateFailed: "Kunne ikke generere et forslag til søknadsbrev.", cvEditorBack: "Tilbake til CV-er", cvEditorName: "CV-navn", cvEditorNameRequired: "Skriv inn et navn før du lagrer.", diff --git a/job-tracker-ui/src/interview-prep.test.tsx b/job-tracker-ui/src/interview-prep.test.tsx index 275b15b..d98a066 100644 --- a/job-tracker-ui/src/interview-prep.test.tsx +++ b/job-tracker-ui/src/interview-prep.test.tsx @@ -57,7 +57,21 @@ function routeGet(overrides: Record = {}) { }); } -beforeEach(() => jest.clearAllMocks()); +beforeEach(() => { + jest.clearAllMocks(); + window.localStorage.setItem("uiLanguage", "en"); +}); + +test("localizes interview categories while preserving user-authored preparation", async () => { + window.localStorage.setItem("uiLanguage", "nb"); + routeGet(); + + render(); + + expect((await screen.findAllByText("Selskapsresearch")).length).toBeGreaterThan(0); + expect(screen.getAllByText("Atferdsbaserte spørsmål").length).toBeGreaterThan(0); + expect(screen.getByText("Funding history")).toBeInTheDocument(); +}); test("prep renders grouped items with progress and marks AI-sourced ones", async () => { routeGet(); diff --git a/job-tracker-ui/src/views/ApplicationWorkspacePage.tsx b/job-tracker-ui/src/views/ApplicationWorkspacePage.tsx index d883740..d275c6a 100644 --- a/job-tracker-ui/src/views/ApplicationWorkspacePage.tsx +++ b/job-tracker-ui/src/views/ApplicationWorkspacePage.tsx @@ -35,7 +35,8 @@ import { PIPELINE_STATUSES, normalizeStatus, statusLabel, statusTone } from "../ import { useI18n } from "../i18n/I18nProvider"; import { useConfirm } from "../confirm"; import { - WORKSPACE_SECTIONS, WorkspaceOverview, WorkspaceSectionKey, applicationWorkspaceApi, workspaceSection, + WORKSPACE_SECTIONS, WorkspaceOverview, WorkspaceSectionKey, applicationWorkspaceApi, + checklistSystemTranslationToken, workspaceSection, } from "../applicationWorkspace"; // Phase 5 Milestone 1 — the dedicated Application Workspace. @@ -315,16 +316,24 @@ function OverviewSection({ overview, onGo, onReload, onEdit }: { return {[0, 1].map((i) => )}; } + const nextStepToken = checklistSystemTranslationToken(overview.nextStep?.key); + const nextStepLabel = overview.nextStep + ? nextStepToken ? t(`checklistItem_${nextStepToken}` as any) : overview.nextStep.label + : null; + const nextStepReason = overview.nextStep + ? nextStepToken ? t(`checklistItem_${nextStepToken}Description` as any) : overview.nextStep.reason + : null; + return ( {overview.nextStep ? ( {t("workspaceNextAction")} - {overview.nextStep.label} - {overview.nextStep.reason} + {nextStepLabel} + {nextStepReason} ) : (