From 3c9b22c97e0ddc44c46d3eba99a5f52f8a783e96 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Sat, 29 Aug 2026 22:45:49 +0200 Subject: [PATCH] fix(i18n): localize job email workflows --- docs/work-programmes/master-progress.md | 1 + job-tracker-ui/src/components/AddJobModal.tsx | 41 +-- job-tracker-ui/src/components/AiUsageCard.tsx | 6 +- .../src/components/Correspondence.tsx | 170 +++++------ job-tracker-ui/src/gmail-review-page.test.tsx | 10 + job-tracker-ui/src/i18n/translations.ts | 284 ++++++++++++++++++ job-tracker-ui/src/quick-capture.test.tsx | 10 + job-tracker-ui/src/views/GmailReviewPage.tsx | 91 +++--- 8 files changed, 469 insertions(+), 144 deletions(-) diff --git a/docs/work-programmes/master-progress.md b/docs/work-programmes/master-progress.md index ffdbc2b..41c3d25 100644 --- a/docs/work-programmes/master-progress.md +++ b/docs/work-programmes/master-progress.md @@ -89,6 +89,7 @@ Updated: 2026-08-29 - Public landing focused verification: 2 suites, 3/3 passed, including live switching to Bokmål and preservation of protected-route sign-in intent; TypeScript passed. - CV Builder focused verification: 12/12 passed; TypeScript passed after the final format-label pass. - Admin System focused verification: 3/3 passed, including Bokmål benchmark diagnostics that preserve runtime values; TypeScript passed. +- Job creation and Gmail workflow localization verification: 4 focused suites, 11/11 passed; TypeScript passed. The add-job stepper, Gmail continuity management, review queue, usage progress labels, errors and status copy now switch between English and Bokmål without translating user/job/email content. - 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/AddJobModal.tsx b/job-tracker-ui/src/components/AddJobModal.tsx index 7535b4b..e4cce04 100644 --- a/job-tracker-ui/src/components/AddJobModal.tsx +++ b/job-tracker-ui/src/components/AddJobModal.tsx @@ -357,7 +357,7 @@ export default function AddJobModal({ open, onClose, onCreated, initialUrl }: Pr try { await api.post(`/jobapplications/${response.data.id}/generate-tailored-cv-draft`); } catch (error: any) { - toast(getApiErrorMessage(error, "Job created, but the tailored CV could not be generated."), "warning"); + toast(getApiErrorMessage(error, t("addJobModalTailoredCvFailed")), "warning"); } } @@ -387,7 +387,14 @@ export default function AddJobModal({ open, onClose, onCreated, initialUrl }: Pr }; const canSave = normalizedCompanyName.length > 0 && jobTitle.trim().length > 0; - const steps = ["Add job", "Review details", "CV", "Cover letter", "Portfolio", "Additional files"]; + const steps = [ + t("addJobModalStepAddJob"), + t("addJobModalStepReview"), + t("addJobModalResume"), + t("addJobModalCoverLetter"), + t("addJobModalPortfolio"), + t("addJobModalStepAdditionalFiles"), + ]; const optionalStep = activeStep >= 2 && activeStep <= 5; const setFilesForBucket = (bucket: AttachmentBucketKey, files: FileList | null) => { @@ -438,14 +445,14 @@ export default function AddJobModal({ open, onClose, onCreated, initialUrl }: Pr {steps.map((label) => {label})} - Step {activeStep + 1} of {steps.length}: {steps[activeStep]} + {t("addJobModalStepProgress", { current: activeStep + 1, total: steps.length, step: steps[activeStep] })} {activeStep === 0 ? - Start with the job advert + {t("addJobModalStartWithAdvert")} - Paste a job URL and Jobbjakt will try to fill the details. You can continue with manual entry whenever extraction is unavailable. + {t("addJobModalStartWithAdvertBody")} setJobUrl(e.target.value)} fullWidth sx={FIELD_SX} /> @@ -453,7 +460,7 @@ export default function AddJobModal({ open, onClose, onCreated, initialUrl }: Pr - + : null} @@ -570,7 +577,7 @@ export default function AddJobModal({ open, onClose, onCreated, initialUrl }: Pr rows={6} value={description} onChange={(e) => setDescription(e.target.value)} - helperText={`${description.length} characters`} + helperText={t("characterCount", { count: description.length })} sx={{ gridColumn: "1 / -1" }} /> @@ -581,7 +588,7 @@ export default function AddJobModal({ open, onClose, onCreated, initialUrl }: Pr rows={6} value={translatedDescription} onChange={(e) => setTranslatedDescription(e.target.value)} - helperText={`${translatedDescription.length} characters`} + helperText={t("characterCount", { count: translatedDescription.length })} sx={{ gridColumn: "1 / -1" }} /> ) : null} @@ -594,7 +601,7 @@ export default function AddJobModal({ open, onClose, onCreated, initialUrl }: Pr sx={{ gridColumn: "1 / -1" }} /> - setNotes(e.target.value)} helperText={`${notes.length} characters`} sx={{ gridColumn: "1 / -1" }} /> + setNotes(e.target.value)} helperText={t("characterCount", { count: notes.length })} sx={{ gridColumn: "1 / -1" }} /> : null} @@ -603,15 +610,15 @@ export default function AddJobModal({ open, onClose, onCreated, initialUrl }: Pr {steps[activeStep]} - {activeStep === 2 ? "Use your master CV later in Career Workspace, or attach the version prepared for this application now." : null} - {activeStep === 3 ? "Attach a drafted cover letter now, or add/generate one from the application workspace later." : null} - {activeStep === 4 ? "Add supporting portfolio material now, or select lightweight profile-linked projects later." : null} - {activeStep === 5 ? "Certificates, references, and other supporting documents are optional." : null} + {activeStep === 2 ? t("addJobModalCvStepHelp") : null} + {activeStep === 3 ? t("addJobModalCoverLetterStepHelp") : null} + {activeStep === 4 ? t("addJobModalPortfolioStepHelp") : null} + {activeStep === 5 ? t("addJobModalOtherFilesStepHelp") : null} {activeStep === 2 ? <> - setGenerateTailoredCv(event.target.checked)} />} label={canUseAi ? "Generate a tailored CV draft after creating this job" : "Tailored CV generation requires Pro"} /> - {canUseAi ? "Uses your reviewed Career Profile and keeps the result as an editable suggestion." : "Create and track the job normally; no AI operation will be started."} + setGenerateTailoredCv(event.target.checked)} />} label={canUseAi ? t("addJobModalGenerateTailoredCv") : t("addJobModalTailoredCvRequiresPro")} /> + {canUseAi ? t("addJobModalTailoredCvHelp") : t("addJobModalNoAiHelp")} {uploadField("resume", t("addJobModalResume"), t("addJobModalResumeHelp"))} : null} {activeStep === 3 ? uploadField("coverLetter", t("addJobModalCoverLetter"), t("addJobModalCoverLetterHelp")) : null} @@ -626,9 +633,9 @@ export default function AddJobModal({ open, onClose, onCreated, initialUrl }: Pr - {activeStep > 0 ? : null} + {activeStep > 0 ? : null} {activeStep < steps.length - 1 ? : <> : null} diff --git a/job-tracker-ui/src/components/Correspondence.tsx b/job-tracker-ui/src/components/Correspondence.tsx index 1c0d201..7c913a1 100644 --- a/job-tracker-ui/src/components/Correspondence.tsx +++ b/job-tracker-ui/src/components/Correspondence.tsx @@ -78,31 +78,6 @@ function parseRawEmail(raw: string): { subject?: string; date?: string; from?: s return { subject: headers["subject"], from: headers["from"], to: headers["to"], date: iso, body }; } -function formatConfidence(value: string) { - return value ? `${value[0].toUpperCase()}${value.slice(1)} confidence` : "Confidence unknown"; -} - -function formatReasonLabel(label: string) { - switch (label) { - case "company": - return "Company"; - case "recruiterEmail": - return "Recruiter email"; - case "recruiter": - return "Recruiter"; - case "jobTitle": - return "Job title"; - case "existingSubject": - return "Existing subject"; - case "recency": - return "Recent"; - case "status": - return "Status"; - default: - return label; - } -} - type JobChoice = { id: number; jobTitle: string; companyName: string }; export type CorrespondenceJobContext = { @@ -114,6 +89,12 @@ export type CorrespondenceJobContext = { export function buildSuggestedCorrespondenceQueries( context: CorrespondenceJobContext, subjects: Array, + labels: { recruiterMailbox: string; companyAndRole: string; companyMail: string; subject: string } = { + recruiterMailbox: "Recruiter mailbox", + companyAndRole: "Company + role", + companyMail: "Company mail", + subject: "Subject", + }, ) { const uniqueSubjects = Array.from(new Set(subjects.filter(Boolean) as string[])).slice(0, 2); const companyName = context.companyName?.trim(); @@ -121,18 +102,34 @@ export function buildSuggestedCorrespondenceQueries( const jobTitle = context.jobTitle?.trim(); return [ - recruiterEmail ? { label: "Recruiter mailbox", value: `(from:${recruiterEmail} OR to:${recruiterEmail}) newer_than:365d` } : null, - companyName && jobTitle ? { label: "Company + role", value: `"${companyName}" "${jobTitle}" newer_than:365d` } : null, - companyName ? { label: "Company mail", value: `"${companyName}" (application OR interview OR recruiter) newer_than:365d` } : null, - ...uniqueSubjects.map((subject) => ({ label: `Subject: ${subject}`, value: `subject:"${subject}" newer_than:365d` })), + recruiterEmail ? { label: labels.recruiterMailbox, value: `(from:${recruiterEmail} OR to:${recruiterEmail}) newer_than:365d` } : null, + companyName && jobTitle ? { label: labels.companyAndRole, value: `"${companyName}" "${jobTitle}" newer_than:365d` } : null, + companyName ? { label: labels.companyMail, value: `"${companyName}" (application OR interview OR recruiter) newer_than:365d` } : null, + ...uniqueSubjects.map((subject) => ({ label: `${labels.subject}: ${subject}`, value: `subject:"${subject}" newer_than:365d` })), ].filter(Boolean).slice(0, 6) as Array<{ label: string; value: string }>; } export default function Correspondence({ jobId, jobContext }: { jobId: number; jobContext: CorrespondenceJobContext }) { const theme = useTheme(); const { toast } = useToast(); - const { t } = useI18n(); + const { language, t } = useI18n(); const { confirmAction } = useDialogActions(); + const formatConfidence = (value: string) => { + if (value === "high") return t("correspondenceConfidenceHigh"); + if (value === "medium") return t("correspondenceConfidenceMedium"); + if (value === "low") return t("correspondenceConfidenceLow"); + return t("correspondenceConfidenceUnknown"); + }; + const formatReasonLabel = (label: string) => { + if (label === "company") return t("company"); + if (label === "recruiterEmail") return t("correspondenceRecruiterEmail"); + if (label === "recruiter") return t("correspondenceRecruiter"); + if (label === "jobTitle") return t("correspondenceJobTitle"); + if (label === "existingSubject") return t("correspondenceExistingSubject"); + if (label === "recency") return t("correspondenceRecent"); + if (label === "status") return t("correspondenceStatus"); + return label; + }; const [messages, setMessages] = useState([]); const [from, setFrom] = useState<"Me" | "Company">("Me"); const [text, setText] = useState(""); @@ -189,7 +186,7 @@ export default function Correspondence({ jobId, jobContext }: { jobId: number; j }); setGmailMatches(res.data); } catch (error: any) { - toast(getApiErrorMessage(error, "Failed to load Gmail suggestions."), "error"); + toast(getApiErrorMessage(error, t("correspondenceLoadSuggestionsFailed")), "error"); } finally { setGmailMatchesLoading(false); } @@ -227,26 +224,26 @@ export default function Correspondence({ jobId, jobContext }: { jobId: number; j if (mode === "manual") { if (res.data.imported > 0) { - toast(`Imported ${res.data.imported} new Gmail message${res.data.imported === 1 ? "" : "s"}.`, "success"); + toast(t(res.data.imported === 1 ? "correspondenceImportedNewMessage" : "correspondenceImportedNewMessages", { count: res.data.imported }), "success"); } else if (res.data.hasLinkedThreads) { - toast("Linked Gmail threads are already current.", "success"); + toast(t("correspondenceLinkedThreadsCurrent"), "success"); } else { - toast("This job does not have any linked Gmail threads yet.", "success"); + toast(t("correspondenceNoLinkedThreadsForJob"), "success"); } } else if (res.data.imported > 0) { - toast(`Linked Gmail threads imported ${res.data.imported} new message${res.data.imported === 1 ? "" : "s"}.`, "success"); + toast(t(res.data.imported === 1 ? "correspondenceLinkedImportedMessage" : "correspondenceLinkedImportedMessages", { count: res.data.imported }), "success"); } return res.data; } catch (error: any) { if (mode === "manual") { - toast(getApiErrorMessage(error, "Failed to refresh linked Gmail threads."), "error"); + toast(getApiErrorMessage(error, t("correspondenceRefreshLinkedFailed")), "error"); } return null; } finally { setLinkedThreadRefreshLoading(false); } - }, [gmailQuery, gmailStatus?.connected, importOpen, importTab, jobId, linkedThreadIds.length, load, loadGmailMatches, loadGmailStatus, toast]); + }, [gmailQuery, gmailStatus?.connected, importOpen, importTab, jobId, linkedThreadIds.length, load, loadGmailMatches, loadGmailStatus, t, toast]); useEffect(() => { void load(); @@ -305,7 +302,12 @@ export default function Correspondence({ jobId, jobContext }: { jobId: number; j const canSend = useMemo(() => text.trim().length > 0, [text]); - const suggestedQueries = buildSuggestedCorrespondenceQueries(jobContext, messages.map((message) => message.subject)); + const suggestedQueries = buildSuggestedCorrespondenceQueries(jobContext, messages.map((message) => message.subject), { + recruiterMailbox: t("correspondenceRecruiterMailbox"), + companyAndRole: t("correspondenceCompanyAndRole"), + companyMail: t("correspondenceCompanyMail"), + subject: t("correspondenceSubject"), + }); const send = async () => { if (!canSend) return; @@ -314,7 +316,7 @@ export default function Correspondence({ jobId, jobContext }: { jobId: number; j setText(""); await load(); } catch (error) { - toast(getApiErrorMessage(error, "Failed to add message."), "error"); + toast(getApiErrorMessage(error, t("correspondenceAddFailed")), "error"); } }; @@ -387,7 +389,7 @@ export default function Correspondence({ jobId, jobContext }: { jobId: number; j if (res.data.imported > 0) { toast(t("correspondenceImportEmail"), "success"); } else { - toast("This Gmail message is already linked to the job.", "success"); + toast(t("correspondenceAlreadyLinkedToJob"), "success"); } } catch (error: any) { toast(getApiErrorMessage(error, t("correspondenceImportGmailFailed")), "error"); @@ -430,9 +432,9 @@ export default function Correspondence({ jobId, jobContext }: { jobId: number; j await load(); await loadGmailMatches(gmailQuery); setManageThreadId(null); - toast(`Unlinked ${res.data.removedMessages} message${res.data.removedMessages === 1 ? "" : "s"} from this job.`, "success"); + toast(t(res.data.removedMessages === 1 ? "correspondenceUnlinkedMessage" : "correspondenceUnlinkedMessages", { count: res.data.removedMessages }), "success"); } catch (error: any) { - toast(getApiErrorMessage(error, "Failed to unlink the Gmail thread."), "error"); + toast(getApiErrorMessage(error, t("correspondenceUnlinkThreadFailed")), "error"); } finally { setManageSaving(false); } @@ -452,9 +454,9 @@ export default function Correspondence({ jobId, jobContext }: { jobId: number; j await loadGmailMatches(gmailQuery); setManageThreadId(null); const targetJob = availableJobs.find((item) => item.id === manageTargetJobId); - toast(`Moved thread to ${targetJob?.companyName || targetJob?.jobTitle || `job ${res.data.jobApplicationId}`}.`, "success"); + toast(t("correspondenceMovedThread", { job: targetJob?.companyName || targetJob?.jobTitle || t("correspondenceJobNumber", { id: res.data.jobApplicationId }) }), "success"); } catch (error: any) { - toast(getApiErrorMessage(error, "Failed to move the Gmail thread."), "error"); + toast(getApiErrorMessage(error, t("correspondenceMoveThreadFailed")), "error"); } finally { setManageSaving(false); } @@ -477,11 +479,11 @@ export default function Correspondence({ jobId, jobContext }: { jobId: number; j {m.content} {(m.externalThreadId || m.externalFrom || m.externalTo || m.externalLabelsJson || m.attachmentMetadataJson) ? ( - {m.externalThreadId ? : null} - {m.externalFrom ? : null} - {m.externalTo ? : null} - {m.externalLabelsJson ? : null} - {m.attachmentMetadataJson ? : null} + {m.externalThreadId ? : null} + {m.externalFrom ? : null} + {m.externalTo ? : null} + {m.externalLabelsJson ? : null} + {m.attachmentMetadataJson ? : null} ) : null} @@ -501,16 +503,16 @@ export default function Correspondence({ jobId, jobContext }: { jobId: number; j - Linked Gmail thread continuity + {t("correspondenceLinkedContinuity")} - Linked Gmail refresh only checks threads that are already tied to this job, so new correspondence can appear here without re-importing the whole thread. + {t("correspondenceLinkedContinuityHelp")} - - 0 ? "success" : "default"} variant="outlined" label={linkedThreadIds.length > 0 ? `Linked threads: ${linkedThreadIds.length}` : "No linked threads yet"} /> + + 0 ? "success" : "default"} variant="outlined" label={linkedThreadIds.length > 0 ? t("correspondenceLinkedThreads", { count: linkedThreadIds.length }) : t("correspondenceNoLinkedThreads")} /> {linkedThreadIds.slice(0, 6).map((threadId) => ( ))} {gmailStatus?.lastSyncStatus ? ( @@ -519,8 +521,8 @@ export default function Correspondence({ jobId, jobContext }: { jobId: number; j color={gmailStatus.lastSyncStatus === "success" ? "success" : "warning"} variant="outlined" label={gmailStatus.lastSyncStatus === "success" - ? `Last Gmail sync ${gmailStatus.lastSyncMode || "sync"} ok` - : `Last Gmail sync ${gmailStatus.lastSyncMode || "sync"} failed`} + ? t("correspondenceLastSyncOk", { mode: gmailStatus.lastSyncMode || t("correspondenceSync") }) + : t("correspondenceLastSyncFailed", { mode: gmailStatus.lastSyncMode || t("correspondenceSync") })} /> ) : null} {linkedThreadRefresh ? ( @@ -528,16 +530,16 @@ export default function Correspondence({ jobId, jobContext }: { jobId: number; j size="small" variant="outlined" label={linkedThreadRefresh.imported > 0 - ? `Last linked refresh imported ${linkedThreadRefresh.imported} new message${linkedThreadRefresh.imported === 1 ? "" : "s"}` + ? t(linkedThreadRefresh.imported === 1 ? "correspondenceLastRefreshImportedMessage" : "correspondenceLastRefreshImportedMessages", { count: linkedThreadRefresh.imported }) : linkedThreadRefresh.hasLinkedThreads - ? `Last linked refresh checked ${linkedThreadRefresh.threadsChecked} linked thread${linkedThreadRefresh.threadsChecked === 1 ? "" : "s"}` - : "No linked Gmail refresh history yet"} + ? t(linkedThreadRefresh.threadsChecked === 1 ? "correspondenceLastRefreshCheckedThread" : "correspondenceLastRefreshCheckedThreads", { count: linkedThreadRefresh.threadsChecked }) + : t("correspondenceNoRefreshHistory")} /> ) : null} {gmailStatus?.lastSyncError ? ( - Latest Gmail sync issue: {gmailStatus.lastSyncError} + {t("correspondenceLatestSyncIssue", { error: gmailStatus.lastSyncError })} ) : null} @@ -556,19 +558,19 @@ export default function Correspondence({ jobId, jobContext }: { jobId: number; j setManageThreadId(null)} fullWidth maxWidth="sm"> - Manage linked Gmail thread + {t("correspondenceManageLinkedThread")} - Unlink this thread from the current job, or move it to another existing job. + {t("correspondenceManageLinkedThreadHelp")} - {manageThreadId ? : null} + {manageThreadId ? : null} setManageNote(event.target.value)} multiline minRows={2} - placeholder="Why this thread should stay in review or move to another job." + placeholder={t("correspondenceReviewNotePlaceholder")} /> setManageTargetJobId(value?.id ?? jobId)} isOptionEqualToValue={(option, value) => option.id === value.id} - getOptionLabel={(item) => `${item.companyName || "Unknown company"} • ${item.jobTitle}`} - renderInput={(params) => } + getOptionLabel={(item) => `${item.companyName || t("correspondenceUnknownCompany")} • ${item.jobTitle}`} + renderInput={(params) => } /> - - - + + + @@ -613,7 +615,7 @@ export default function Correspondence({ jobId, jobContext }: { jobId: number; j {jobContext.companyName || jobContext.jobTitle ? ( - Matching against {jobContext.companyName || "this company"} / {jobContext.jobTitle || "this role"} + {t("correspondenceMatchingAgainst", { company: jobContext.companyName || t("correspondenceThisCompany"), role: jobContext.jobTitle || t("correspondenceThisRole") })} ) : null} @@ -622,7 +624,7 @@ export default function Correspondence({ jobId, jobContext }: { jobId: number; j <> {linkedThreadIds.length > 0 ? ( ) : null} @@ -653,20 +655,20 @@ export default function Correspondence({ jobId, jobContext }: { jobId: number; j ) : null} {gmailStatus.lastSyncedAt ? : null} - {gmailStatus.lastSyncAttemptedAt ? : null} - {gmailStatus.lastSyncStatus === "error" && gmailStatus.lastSyncError ? : null} + {gmailStatus.lastSyncAttemptedAt ? : null} + {gmailStatus.lastSyncStatus === "error" && gmailStatus.lastSyncError ? : null} {linkedThreadIds.length > 0 ? ( - + {linkedThreadRefresh ? ( 0 - ? `Last refresh imported ${linkedThreadRefresh.imported} new message${linkedThreadRefresh.imported === 1 ? "" : "s"}` + ? t(linkedThreadRefresh.imported === 1 ? "correspondenceLastRefreshImportedMessage" : "correspondenceLastRefreshImportedMessages", { count: linkedThreadRefresh.imported }) : linkedThreadRefresh.hasLinkedThreads - ? `Last refresh checked ${linkedThreadRefresh.threadsChecked} linked thread${linkedThreadRefresh.threadsChecked === 1 ? "" : "s"}` - : "No linked Gmail threads yet"} + ? t(linkedThreadRefresh.threadsChecked === 1 ? "correspondenceLastRefreshCheckedThread" : "correspondenceLastRefreshCheckedThreads", { count: linkedThreadRefresh.threadsChecked }) + : t("correspondenceNoLinkedThreadsForJob")} /> ) : null} @@ -676,7 +678,7 @@ export default function Correspondence({ jobId, jobContext }: { jobId: number; j ) : !gmailMatches || gmailMatches.threads.length === 0 ? ( - {gmailQuery.trim() ? "No Gmail matches for this job and search override yet." : t("correspondenceNoGmailMessages")} + {gmailQuery.trim() ? t("correspondenceNoMatchesForOverride") : t("correspondenceNoGmailMessages")} ) : ( @@ -688,9 +690,9 @@ export default function Correspondence({ jobId, jobContext }: { jobId: number; j {thread.subject || t("correspondenceNoSubject")} - - - {thread.hasImportedMessages ? : null} + + + {thread.hasImportedMessages ? : null} {thread.matchReasons.map((reason) => ( @@ -713,8 +715,8 @@ export default function Correspondence({ jobId, jobContext }: { jobId: number; j {t("correspondenceFromLabel", { value: message.from || t("correspondenceUnknown") })} {message.snippet} - - {message.alreadyImported ? : null} + + {message.alreadyImported ? : null} {message.matchReasons.map((reason) => ( ))} diff --git a/job-tracker-ui/src/gmail-review-page.test.tsx b/job-tracker-ui/src/gmail-review-page.test.tsx index e8f6acb..45cd20c 100644 --- a/job-tracker-ui/src/gmail-review-page.test.tsx +++ b/job-tracker-ui/src/gmail-review-page.test.tsx @@ -35,6 +35,7 @@ function renderPage() { describe('GmailReviewPage', () => { beforeEach(() => { + window.localStorage.clear(); mockedApi.get.mockImplementation((url: string) => { if (url === '/gmail/review-candidates') { return Promise.resolve({ @@ -102,4 +103,13 @@ describe('GmailReviewPage', () => { }); }); }); + + test('renders the review workflow in Norwegian Bokmål', async () => { + window.localStorage.setItem('uiLanguage', 'nb'); + renderPage(); + + expect(await screen.findByText('Kontrollkø for Gmail')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Knytt til beste jobbtreff' })).toBeInTheDocument(); + expect(screen.getByLabelText('Kontrollnotater')).toBeInTheDocument(); + }); }); diff --git a/job-tracker-ui/src/i18n/translations.ts b/job-tracker-ui/src/i18n/translations.ts index bd6ee73..482eea2 100644 --- a/job-tracker-ui/src/i18n/translations.ts +++ b/job-tracker-ui/src/i18n/translations.ts @@ -882,6 +882,26 @@ export const translations = { addJobModalJobCreatedUploadFailed: "Job created, but file upload failed.", addJobModalJobCreatedFilesNotAttached: "Job created. Files could not be attached automatically.", addJobModalFailedAddJob: "Failed to add job.", + addJobModalTailoredCvFailed: "Job created, but the tailored CV could not be generated.", + addJobModalStepAddJob: "Add job", + addJobModalStepReview: "Review details", + addJobModalStepAdditionalFiles: "Additional files", + addJobModalStepProgress: "Step {current} of {total}: {step}", + addJobModalStartWithAdvert: "Start with the job advert", + addJobModalStartWithAdvertBody: "Paste a job URL and Jobbjakt will try to fill the details. You can continue with manual entry whenever extraction is unavailable.", + addJobModalEnterManually: "Enter details manually", + addJobModalCvStepHelp: "Use your master CV later in Career Workspace, or attach the version prepared for this application now.", + addJobModalCoverLetterStepHelp: "Attach a drafted cover letter now, or add or generate one from the application workspace later.", + addJobModalPortfolioStepHelp: "Add supporting portfolio material now, or select lightweight profile-linked projects later.", + addJobModalOtherFilesStepHelp: "Certificates, references, and other supporting documents are optional.", + addJobModalGenerateTailoredCv: "Generate a tailored CV draft after creating this job", + addJobModalTailoredCvRequiresPro: "Tailored CV generation requires Pro", + addJobModalTailoredCvHelp: "Uses your reviewed Career Profile and keeps the result as an editable suggestion.", + addJobModalNoAiHelp: "Create and track the job normally; no AI operation will be started.", + characterCount: "{count} characters", + back: "Back", + continue: "Continue", + skipAndContinue: "Skip and continue", statusSaved: "Saved", statusInterested: "Interested", statusPreparing: "Preparing", @@ -911,6 +931,9 @@ export const translations = { settingsUsageGenerations: "{used} of {limit} generations this month", settingsUsageTokens: "{used} of {limit} estimated tokens", settingsUsageStorage: "{used} of {limit} attachment storage", + settingsUsageGenerationsProgress: "Monthly AI generations used", + settingsUsageTokensProgress: "Monthly AI tokens used", + settingsUsageStorageProgress: "Attachment storage used", settingsUsageReset: "AI limits reset at the start of each calendar month.", settingsUsageNoAi: "The Free plan includes core job tracking without AI. Upgrade to Pro to use AI features.", settingsBillingUpgrade: "Upgrade to Pro", @@ -1503,6 +1526,125 @@ export const translations = { correspondenceImportThreadResult: "Imported {imported} messages{skippedText}.", correspondenceImportThreadSkipped: ", skipped {count} duplicates", correspondenceImportThreadFailed: "Failed to import Gmail thread.", + correspondenceLoadSuggestionsFailed: "Failed to load Gmail suggestions.", + correspondenceImportedNewMessage: "Imported {count} new Gmail message.", + correspondenceImportedNewMessages: "Imported {count} new Gmail messages.", + correspondenceLinkedThreadsCurrent: "Linked Gmail threads are already current.", + correspondenceNoLinkedThreadsForJob: "This job does not have any linked Gmail threads yet.", + correspondenceLinkedImportedMessage: "Linked Gmail threads imported {count} new message.", + correspondenceLinkedImportedMessages: "Linked Gmail threads imported {count} new messages.", + correspondenceRefreshLinkedFailed: "Failed to refresh linked Gmail threads.", + correspondenceAddFailed: "Failed to add message.", + correspondenceAlreadyLinkedToJob: "This Gmail message is already linked to the job.", + correspondenceUnlinkedMessage: "Unlinked {count} message from this job.", + correspondenceUnlinkedMessages: "Unlinked {count} messages from this job.", + correspondenceUnlinkThreadFailed: "Failed to unlink the Gmail thread.", + correspondenceMovedThread: "Moved thread to {job}.", + correspondenceMoveThreadFailed: "Failed to move the Gmail thread.", + correspondenceJobNumber: "job {id}", + correspondenceRecruiterMailbox: "Recruiter mailbox", + correspondenceCompanyAndRole: "Company + role", + correspondenceCompanyMail: "Company mail", + correspondenceSubject: "Subject", + correspondenceConfidenceHigh: "High confidence", + correspondenceConfidenceMedium: "Medium confidence", + correspondenceConfidenceLow: "Low confidence", + correspondenceConfidenceUnknown: "Confidence unknown", + correspondenceRecruiterEmail: "Recruiter email", + correspondenceRecruiter: "Recruiter", + correspondenceJobTitle: "Job title", + correspondenceStatus: "Status", + correspondenceExistingSubject: "Existing subject", + correspondenceRecent: "Recent", + correspondenceThread: "Thread {id}", + correspondenceFrom: "From {value}", + correspondenceTo: "To {value}", + correspondenceGmailLabel: "{count} Gmail label", + correspondenceGmailLabels: "{count} Gmail labels", + correspondenceAttachment: "{count} attachment", + correspondenceAttachments: "{count} attachments", + correspondenceLinkedContinuity: "Linked Gmail thread continuity", + correspondenceLinkedContinuityHelp: "Linked Gmail refresh only checks threads already tied to this job, so new correspondence can appear here without re-importing the whole thread.", + correspondenceGmailConnected: "Gmail connected", + correspondenceGmailNotConnected: "Gmail not connected", + correspondenceLinkedThreads: "Linked threads: {count}", + correspondenceNoLinkedThreads: "No linked threads yet", + correspondenceManageThread: "Manage {id}", + correspondenceLastSyncOk: "Last Gmail sync {mode} succeeded", + correspondenceLastSyncFailed: "Last Gmail sync {mode} failed", + correspondenceSync: "sync", + correspondenceLastRefreshImportedMessage: "Last linked refresh imported {count} new message", + correspondenceLastRefreshImportedMessages: "Last linked refresh imported {count} new messages", + correspondenceLastRefreshCheckedThread: "Last linked refresh checked {count} linked thread", + correspondenceLastRefreshCheckedThreads: "Last linked refresh checked {count} linked threads", + correspondenceNoRefreshHistory: "No linked Gmail refresh history yet", + correspondenceLatestSyncIssue: "Latest Gmail sync issue: {error}", + correspondenceManageLinkedThread: "Manage linked Gmail thread", + correspondenceManageLinkedThreadHelp: "Unlink this thread from the current job, or move it to another existing job.", + correspondenceReviewNote: "Review note", + correspondenceReviewNotePlaceholder: "Why this thread should stay in review or move to another job.", + correspondenceUnknownCompany: "Unknown company", + correspondenceMoveToJob: "Move to job", + correspondenceSearchEveryJob: "Search every job", + correspondenceUnlinkFromJob: "Unlink from this job", + correspondenceMoveThread: "Move thread", + correspondenceMatchingAgainst: "Matching against {company} / {role}", + correspondenceThisCompany: "this company", + correspondenceThisRole: "this role", + correspondenceRefreshingLinkedThreads: "Refreshing linked threads...", + correspondenceRefreshLinkedThreads: "Refresh linked threads", + correspondenceSyncChecked: "Sync checked {date}", + correspondenceSyncIssue: "Sync issue: {error}", + correspondenceNoMatchesForOverride: "No Gmail matches for this job and search override yet.", + correspondenceConfidenceScore: "{confidence} · score {score}", + correspondenceMessageCount: "{count} message", + correspondenceMessagesCount: "{count} messages", + correspondenceAlreadyLinked: "Already linked", + gmailReviewLoadFailed: "Failed to load Gmail review candidates.", + gmailReviewLinked: "Thread linked and imported.", + gmailReviewRejected: "Thread rejected from review.", + gmailReviewKeptSuggested: "Thread kept as suggested job material.", + gmailReviewReturned: "Thread returned to review.", + gmailReviewSaveFailed: "Failed to save Gmail review decision.", + gmailReviewSyncFinished: "Manual Gmail sync finished: {linked} threads linked, {review} for review, {unmatched} unmatched.", + gmailReviewSyncFailed: "Failed to run Gmail manual sync.", + gmailReviewUnknownCompany: "Unknown company", + gmailReviewSuggestedRole: "Suggested role", + gmailReviewCreatedImportedMessage: "Created suggested job and imported {count} message.", + gmailReviewCreatedImportedMessages: "Created suggested job and imported {count} messages.", + gmailReviewCreateFailed: "Failed to create the suggested job.", + gmailReviewEmbeddedTitle: "Recruitment message review", + gmailReviewTitle: "Gmail review queue", + gmailReviewSubtitle: "Manual sync, high-confidence auto-linking, medium-confidence review, and suggested jobs from unmatched Gmail threads.", + gmailReviewSyncing: "Syncing...", + gmailReviewRunSync: "Run manual sync", + gmailReviewBackToInbox: "Back to inbox", + gmailReviewFilter: "Filter: {value}", + gmailReviewRouteAll: "All", + gmailReviewRouteAutoLink: "Auto-link", + gmailReviewRouteReview: "Review", + gmailReviewRouteUnmatched: "Unmatched", + gmailReviewRouteSuggested: "Suggested", + gmailReviewRouteLinked: "Linked", + gmailReviewRouteRejected: "Rejected", + gmailReviewCandidateThreads: "{count} candidate threads", + gmailReviewAutoLinkCount: "{count} auto-link", + gmailReviewReviewCount: "{count} review", + gmailReviewUnmatchedCount: "{count} unmatched", + gmailReviewSuggestedJobsCount: "{count} suggested jobs", + gmailReviewNoCandidates: "No Gmail review candidates match the current filter.", + gmailReviewThreadSummary: "{count} messages · {route}", + gmailReviewHasImported: "Has imported messages", + gmailReviewNotes: "Review notes", + gmailReviewNotesPlaceholder: "Why this should link, stay in review, or become a suggested job.", + gmailReviewUnknownRole: "Unknown role", + gmailReviewSuggestedJob: "Suggested job: {company} · {role}", + gmailReviewOpenTopJob: "Open top job", + gmailReviewLinkTopJob: "Link top job", + gmailReviewKeepInReview: "Keep in review", + gmailReviewSuggestedJobAction: "Suggested job", + gmailReviewCreating: "Creating...", + gmailReviewReject: "Reject", attachmentsTitle: "Attachments ({count})", attachmentsSubtitle: "Upload resumes, cover letters, portfolios, and supporting files for this application.", attachmentsImages: "{count} images", @@ -3011,6 +3153,26 @@ export const translations = { addJobModalJobCreatedUploadFailed: "Jobben ble opprettet, men filopplasting mislyktes.", addJobModalJobCreatedFilesNotAttached: "Jobben ble opprettet. Filene kunne ikke knyttes automatisk.", addJobModalFailedAddJob: "Kunne ikke legge til jobb.", + addJobModalTailoredCvFailed: "Jobben ble opprettet, men den tilpassede CV-en kunne ikke genereres.", + addJobModalStepAddJob: "Legg til jobb", + addJobModalStepReview: "Se gjennom detaljer", + addJobModalStepAdditionalFiles: "Flere filer", + addJobModalStepProgress: "Trinn {current} av {total}: {step}", + addJobModalStartWithAdvert: "Start med jobbannonsen", + addJobModalStartWithAdvertBody: "Lim inn en jobb-URL, så prøver Jobbjakt å fylle ut detaljene. Du kan fortsette med manuell registrering hvis uttrekk ikke er tilgjengelig.", + addJobModalEnterManually: "Skriv inn detaljer manuelt", + addJobModalCvStepHelp: "Bruk hoved-CV-en senere i Karriereområdet, eller legg ved versjonen som er klargjort for denne søknaden nå.", + addJobModalCoverLetterStepHelp: "Legg ved et utkast til søknadsbrev nå, eller legg til eller generer et fra søknadsområdet senere.", + addJobModalPortfolioStepHelp: "Legg til støttende porteføljemateriale nå, eller velg prosjekter fra profilen senere.", + addJobModalOtherFilesStepHelp: "Sertifikater, referanser og andre støttedokumenter er valgfrie.", + addJobModalGenerateTailoredCv: "Generer et tilpasset CV-utkast etter at jobben er opprettet", + addJobModalTailoredCvRequiresPro: "Tilpasset CV-generering krever Pro", + addJobModalTailoredCvHelp: "Bruker den gjennomgåtte Karriereprofilen og beholder resultatet som et redigerbart forslag.", + addJobModalNoAiHelp: "Opprett og spor jobben som vanlig; ingen KI-operasjon startes.", + characterCount: "{count} tegn", + back: "Tilbake", + continue: "Fortsett", + skipAndContinue: "Hopp over og fortsett", statusSaved: "Lagret", statusInterested: "Interessert", statusPreparing: "Forbereder", @@ -3040,6 +3202,9 @@ export const translations = { settingsUsageGenerations: "{used} av {limit} genereringer denne måneden", settingsUsageTokens: "{used} av {limit} estimerte tokener", settingsUsageStorage: "{used} av {limit} vedleggslagring", + settingsUsageGenerationsProgress: "Brukte KI-genereringer denne måneden", + settingsUsageTokensProgress: "Brukte KI-tokener denne måneden", + settingsUsageStorageProgress: "Brukt vedleggslagring", settingsUsageReset: "KI-grensene nullstilles ved starten av hver kalendermåned.", settingsUsageNoAi: "Free-abonnementet inkluderer grunnleggende jobbsporing uten KI. Oppgrader til Pro for KI-funksjoner.", settingsBillingUpgrade: "Oppgrader til Pro", @@ -3632,6 +3797,125 @@ export const translations = { correspondenceImportThreadResult: "Importerte {imported} meldinger{skippedText}.", correspondenceImportThreadSkipped: ", hoppet over {count} duplikater", correspondenceImportThreadFailed: "Kunne ikke importere Gmail-tråd.", + correspondenceLoadSuggestionsFailed: "Kunne ikke laste Gmail-forslag.", + correspondenceImportedNewMessage: "Importerte {count} ny Gmail-melding.", + correspondenceImportedNewMessages: "Importerte {count} nye Gmail-meldinger.", + correspondenceLinkedThreadsCurrent: "Tilknyttede Gmail-tråder er allerede oppdatert.", + correspondenceNoLinkedThreadsForJob: "Denne jobben har ingen tilknyttede Gmail-tråder ennå.", + correspondenceLinkedImportedMessage: "Tilknyttede Gmail-tråder importerte {count} ny melding.", + correspondenceLinkedImportedMessages: "Tilknyttede Gmail-tråder importerte {count} nye meldinger.", + correspondenceRefreshLinkedFailed: "Kunne ikke oppdatere tilknyttede Gmail-tråder.", + correspondenceAddFailed: "Kunne ikke legge til melding.", + correspondenceAlreadyLinkedToJob: "Denne Gmail-meldingen er allerede knyttet til jobben.", + correspondenceUnlinkedMessage: "Fjernet tilknytningen til {count} melding fra denne jobben.", + correspondenceUnlinkedMessages: "Fjernet tilknytningen til {count} meldinger fra denne jobben.", + correspondenceUnlinkThreadFailed: "Kunne ikke fjerne tilknytningen til Gmail-tråden.", + correspondenceMovedThread: "Flyttet tråden til {job}.", + correspondenceMoveThreadFailed: "Kunne ikke flytte Gmail-tråden.", + correspondenceJobNumber: "jobb {id}", + correspondenceRecruiterMailbox: "Rekruttererens innboks", + correspondenceCompanyAndRole: "Selskap + rolle", + correspondenceCompanyMail: "E-post fra selskapet", + correspondenceSubject: "Emne", + correspondenceConfidenceHigh: "Høy sikkerhet", + correspondenceConfidenceMedium: "Middels sikkerhet", + correspondenceConfidenceLow: "Lav sikkerhet", + correspondenceConfidenceUnknown: "Ukjent sikkerhet", + correspondenceRecruiterEmail: "Rekruttererens e-post", + correspondenceRecruiter: "Rekrutterer", + correspondenceJobTitle: "Jobbtittel", + correspondenceStatus: "Status", + correspondenceExistingSubject: "Eksisterende emne", + correspondenceRecent: "Nylig", + correspondenceThread: "Tråd {id}", + correspondenceFrom: "Fra {value}", + correspondenceTo: "Til {value}", + correspondenceGmailLabel: "{count} Gmail-etikett", + correspondenceGmailLabels: "{count} Gmail-etiketter", + correspondenceAttachment: "{count} vedlegg", + correspondenceAttachments: "{count} vedlegg", + correspondenceLinkedContinuity: "Kontinuitet i tilknyttede Gmail-tråder", + correspondenceLinkedContinuityHelp: "Gmail-oppdateringen sjekker bare tråder som allerede er knyttet til denne jobben, slik at ny korrespondanse kan vises her uten å importere hele tråden på nytt.", + correspondenceGmailConnected: "Gmail tilkoblet", + correspondenceGmailNotConnected: "Gmail ikke tilkoblet", + correspondenceLinkedThreads: "Tilknyttede tråder: {count}", + correspondenceNoLinkedThreads: "Ingen tilknyttede tråder ennå", + correspondenceManageThread: "Administrer {id}", + correspondenceLastSyncOk: "Siste Gmail-synkronisering ({mode}) var vellykket", + correspondenceLastSyncFailed: "Siste Gmail-synkronisering ({mode}) mislyktes", + correspondenceSync: "synkronisering", + correspondenceLastRefreshImportedMessage: "Siste oppdatering importerte {count} ny melding", + correspondenceLastRefreshImportedMessages: "Siste oppdatering importerte {count} nye meldinger", + correspondenceLastRefreshCheckedThread: "Siste oppdatering sjekket {count} tilknyttet tråd", + correspondenceLastRefreshCheckedThreads: "Siste oppdatering sjekket {count} tilknyttede tråder", + correspondenceNoRefreshHistory: "Ingen historikk for oppdatering av tilknyttede Gmail-tråder ennå", + correspondenceLatestSyncIssue: "Siste problem med Gmail-synkronisering: {error}", + correspondenceManageLinkedThread: "Administrer tilknyttet Gmail-tråd", + correspondenceManageLinkedThreadHelp: "Fjern trådens tilknytning til gjeldende jobb, eller flytt den til en annen eksisterende jobb.", + correspondenceReviewNote: "Kontrollnotat", + correspondenceReviewNotePlaceholder: "Hvorfor denne tråden bør forbli til kontroll eller flyttes til en annen jobb.", + correspondenceUnknownCompany: "Ukjent selskap", + correspondenceMoveToJob: "Flytt til jobb", + correspondenceSearchEveryJob: "Søk i alle jobber", + correspondenceUnlinkFromJob: "Fjern fra denne jobben", + correspondenceMoveThread: "Flytt tråd", + correspondenceMatchingAgainst: "Sammenligner med {company} / {role}", + correspondenceThisCompany: "dette selskapet", + correspondenceThisRole: "denne rollen", + correspondenceRefreshingLinkedThreads: "Oppdaterer tilknyttede tråder...", + correspondenceRefreshLinkedThreads: "Oppdater tilknyttede tråder", + correspondenceSyncChecked: "Synkronisering sjekket {date}", + correspondenceSyncIssue: "Synkroniseringsproblem: {error}", + correspondenceNoMatchesForOverride: "Ingen Gmail-treff for denne jobben og det egendefinerte søket ennå.", + correspondenceConfidenceScore: "{confidence} · poeng {score}", + correspondenceMessageCount: "{count} melding", + correspondenceMessagesCount: "{count} meldinger", + correspondenceAlreadyLinked: "Allerede tilknyttet", + gmailReviewLoadFailed: "Kunne ikke laste kandidater til Gmail-kontroll.", + gmailReviewLinked: "Tråden ble koblet til og importert.", + gmailReviewRejected: "Tråden ble avvist fra kontrollen.", + gmailReviewKeptSuggested: "Tråden ble beholdt som materiale til en foreslått jobb.", + gmailReviewReturned: "Tråden ble sendt tilbake til kontroll.", + gmailReviewSaveFailed: "Kunne ikke lagre avgjørelsen for Gmail-kontrollen.", + gmailReviewSyncFinished: "Manuell Gmail-synkronisering fullført: {linked} tråder tilknyttet, {review} til kontroll, {unmatched} uten treff.", + gmailReviewSyncFailed: "Kunne ikke kjøre manuell Gmail-synkronisering.", + gmailReviewUnknownCompany: "Ukjent selskap", + gmailReviewSuggestedRole: "Foreslått rolle", + gmailReviewCreatedImportedMessage: "Opprettet foreslått jobb og importerte {count} melding.", + gmailReviewCreatedImportedMessages: "Opprettet foreslått jobb og importerte {count} meldinger.", + gmailReviewCreateFailed: "Kunne ikke opprette den foreslåtte jobben.", + gmailReviewEmbeddedTitle: "Kontroll av rekrutteringsmeldinger", + gmailReviewTitle: "Kontrollkø for Gmail", + gmailReviewSubtitle: "Manuell synkronisering, automatisk tilknytning med høy sikkerhet, kontroll ved middels sikkerhet og foreslåtte jobber fra Gmail-tråder uten treff.", + gmailReviewSyncing: "Synkroniserer...", + gmailReviewRunSync: "Kjør manuell synkronisering", + gmailReviewBackToInbox: "Tilbake til innboksen", + gmailReviewFilter: "Filter: {value}", + gmailReviewRouteAll: "Alle", + gmailReviewRouteAutoLink: "Automatisk tilknytning", + gmailReviewRouteReview: "Til kontroll", + gmailReviewRouteUnmatched: "Uten treff", + gmailReviewRouteSuggested: "Foreslått", + gmailReviewRouteLinked: "Tilknyttet", + gmailReviewRouteRejected: "Avvist", + gmailReviewCandidateThreads: "{count} kandidattråder", + gmailReviewAutoLinkCount: "{count} automatisk tilknytning", + gmailReviewReviewCount: "{count} til kontroll", + gmailReviewUnmatchedCount: "{count} uten treff", + gmailReviewSuggestedJobsCount: "{count} foreslåtte jobber", + gmailReviewNoCandidates: "Ingen kandidater til Gmail-kontroll samsvarer med gjeldende filter.", + gmailReviewThreadSummary: "{count} meldinger · {route}", + gmailReviewHasImported: "Har importerte meldinger", + gmailReviewNotes: "Kontrollnotater", + gmailReviewNotesPlaceholder: "Hvorfor dette bør knyttes til, beholdes til kontroll eller bli en foreslått jobb.", + gmailReviewUnknownRole: "Ukjent rolle", + gmailReviewSuggestedJob: "Foreslått jobb: {company} · {role}", + gmailReviewOpenTopJob: "Åpne beste jobbtreff", + gmailReviewLinkTopJob: "Knytt til beste jobbtreff", + gmailReviewKeepInReview: "Behold til kontroll", + gmailReviewSuggestedJobAction: "Foreslått jobb", + gmailReviewCreating: "Oppretter...", + gmailReviewReject: "Avvis", attachmentsTitle: "Vedlegg ({count})", attachmentsSubtitle: "Last opp CV-er, søknadsbrev, porteføljer og støttedokumenter for denne søknaden.", attachmentsImages: "{count} bilder", diff --git a/job-tracker-ui/src/quick-capture.test.tsx b/job-tracker-ui/src/quick-capture.test.tsx index 468e05f..5ac9558 100644 --- a/job-tracker-ui/src/quick-capture.test.tsx +++ b/job-tracker-ui/src/quick-capture.test.tsx @@ -40,6 +40,7 @@ function renderModal(initialUrl?: string) { } beforeEach(() => { + window.localStorage.clear(); mockedApi.get.mockResolvedValue({ data: [] } as any); mockedApi.post.mockImplementation((url: string) => { if (url === '/jobimport/preview') { @@ -69,6 +70,15 @@ test('does not auto-import when no initialUrl is given', async () => { expect(mockedApi.post).not.toHaveBeenCalledWith('/jobimport/preview', expect.anything()); }); +test('localizes the job creation workflow in Norwegian Bokmål', async () => { + window.localStorage.setItem('uiLanguage', 'nb'); + renderModal(); + + expect(await screen.findByText('Start med jobbannonsen')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Skriv inn detaljer manuelt' })).toBeInTheDocument(); + expect(screen.getByText('Trinn 1 av 6: Legg til jobb')).toBeInTheDocument(); +}); + test('can generate a tailored CV after creating a job', async () => { mockedApi.post.mockImplementation((url: string) => { diff --git a/job-tracker-ui/src/views/GmailReviewPage.tsx b/job-tracker-ui/src/views/GmailReviewPage.tsx index d1799a0..11aa94a 100644 --- a/job-tracker-ui/src/views/GmailReviewPage.tsx +++ b/job-tracker-ui/src/views/GmailReviewPage.tsx @@ -4,9 +4,11 @@ import { api, getApiErrorMessage } from "../api"; import { CreatedSuggestedGmailJobResult, GmailManualSyncResult, GmailReviewQueueResponse, GmailSuggestedJobsResponse } from "../types"; import { useToast } from "../toast"; import { useNavigate } from "react-router-dom"; +import { useI18n } from "../i18n/I18nProvider"; export default function GmailReviewPage({ embedded = false }: { embedded?: boolean }) { const { toast } = useToast(); + const { t } = useI18n(); const navigate = useNavigate(); const [data, setData] = useState(null); const [suggestions, setSuggestions] = useState(null); @@ -16,6 +18,15 @@ export default function GmailReviewPage({ embedded = false }: { embedded?: boole const [creatingThreadId, setCreatingThreadId] = useState(null); const [routingFilter, setRoutingFilter] = useState<"all" | "auto-link" | "review" | "unmatched" | "suggested" | "linked" | "rejected">("all"); const [notes, setNotes] = useState>({}); + const routeLabel = (value: string) => { + if (value === "auto-link") return t("gmailReviewRouteAutoLink"); + if (value === "review") return t("gmailReviewRouteReview"); + if (value === "unmatched") return t("gmailReviewRouteUnmatched"); + if (value === "suggested") return t("gmailReviewRouteSuggested"); + if (value === "linked") return t("gmailReviewRouteLinked"); + if (value === "rejected") return t("gmailReviewRouteRejected"); + return t("gmailReviewRouteAll"); + }; const load = useCallback(async () => { setLoading(true); @@ -34,11 +45,11 @@ export default function GmailReviewPage({ embedded = false }: { embedded?: boole return next; }); } catch (error) { - toast(getApiErrorMessage(error, "Failed to load Gmail review candidates."), "error"); + toast(getApiErrorMessage(error, t("gmailReviewLoadFailed")), "error"); } finally { setLoading(false); } - }, [toast]); + }, [t, toast]); useEffect(() => { void load(); @@ -56,20 +67,20 @@ export default function GmailReviewPage({ embedded = false }: { embedded?: boole await load(); toast( decision === "linked" - ? "Thread linked and imported." + ? t("gmailReviewLinked") : decision === "rejected" - ? "Thread rejected from review." + ? t("gmailReviewRejected") : decision === "suggested" - ? "Thread kept as suggested job material." - : "Thread returned to review.", + ? t("gmailReviewKeptSuggested") + : t("gmailReviewReturned"), "success", ); } catch (error) { - toast(getApiErrorMessage(error, "Failed to save Gmail review decision."), "error"); + toast(getApiErrorMessage(error, t("gmailReviewSaveFailed")), "error"); } finally { setSavingThreadId(null); } - }, [load, notes, toast]); + }, [load, notes, t, toast]); const runManualSync = useCallback(async () => { setSyncing(true); @@ -82,15 +93,15 @@ export default function GmailReviewPage({ embedded = false }: { embedded?: boole }); await load(); toast( - `Manual Gmail sync finished: ${res.data.importedThreads} threads linked, ${res.data.reviewThreadCount} review, ${res.data.unmatchedThreadCount} unmatched.`, + t("gmailReviewSyncFinished", { linked: res.data.importedThreads, review: res.data.reviewThreadCount, unmatched: res.data.unmatchedThreadCount }), "success", ); } catch (error) { - toast(getApiErrorMessage(error, "Failed to run Gmail manual sync."), "error"); + toast(getApiErrorMessage(error, t("gmailReviewSyncFailed")), "error"); } finally { setSyncing(false); } - }, [load, toast]); + }, [load, t, toast]); const createSuggestedJob = useCallback(async (threadId: string) => { const suggestion = suggestions?.items.find((item) => item.threadId === threadId); @@ -100,22 +111,22 @@ export default function GmailReviewPage({ embedded = false }: { embedded?: boole try { const res = await api.post("/gmail/create-suggested-job", { threadId, - companyName: suggestion.companyName || "Unknown company", - jobTitle: suggestion.suggestedJobTitle || suggestion.subject || "Suggested role", + companyName: suggestion.companyName || t("gmailReviewUnknownCompany"), + jobTitle: suggestion.suggestedJobTitle || suggestion.subject || t("gmailReviewSuggestedRole"), recruiterName: suggestion.recruiterName || null, recruiterEmail: suggestion.recruiterEmail || null, notes: notes[threadId]?.trim() || suggestion.preview || null, status: "Applied", }); await load(); - toast(`Created suggested job and imported ${res.data.imported} message${res.data.imported === 1 ? "" : "s"}.`, "success"); + toast(t(res.data.imported === 1 ? "gmailReviewCreatedImportedMessage" : "gmailReviewCreatedImportedMessages", { count: res.data.imported }), "success"); navigate(`/jobs/${res.data.jobApplicationId}?section=communication`); } catch (error) { - toast(getApiErrorMessage(error, "Failed to create the suggested job."), "error"); + toast(getApiErrorMessage(error, t("gmailReviewCreateFailed")), "error"); } finally { setCreatingThreadId(null); } - }, [load, navigate, notes, suggestions?.items, toast]); + }, [load, navigate, notes, suggestions?.items, t, toast]); const filteredThreads = useMemo(() => { const threads = data?.threads ?? []; @@ -134,43 +145,43 @@ export default function GmailReviewPage({ embedded = false }: { embedded?: boole > - {embedded ? "Recruitment message review" : "Gmail review queue"} + {embedded ? t("gmailReviewEmbeddedTitle") : t("gmailReviewTitle")} - Manual sync, high-confidence auto-linking, medium-confidence review, and suggested jobs from unmatched Gmail threads. + {t("gmailReviewSubtitle")} - {!embedded ? : null} + {!embedded ? : null} - + {(["all", "auto-link", "review", "unmatched", "suggested", "linked", "rejected"] as const).map((value) => ( ))} {data ? ( - - - - - {suggestions?.count ? : null} + + + + + {suggestions?.count ? : null} ) : null} {loading ? : null} - {!loading && data && filteredThreads.length === 0 ? No Gmail review candidates match the current filter. : null} + {!loading && data && filteredThreads.length === 0 ? {t("gmailReviewNoCandidates")} : null} {filteredThreads.map((thread) => { @@ -181,16 +192,16 @@ export default function GmailReviewPage({ embedded = false }: { embedded?: boole {thread.subject} - {thread.messageCount} messages · {thread.routing} + {t("gmailReviewThreadSummary", { count: thread.messageCount, route: routeLabel(thread.routing) })} {thread.matchedQueries.slice(0, 3).map((query) => ( ))} - {thread.hasImportedMessages ? : null} + {thread.hasImportedMessages ? : null} setNotes((prev) => ({ ...prev, [thread.threadId]: event.target.value }))} size="small" @@ -198,11 +209,11 @@ export default function GmailReviewPage({ embedded = false }: { embedded?: boole multiline minRows={2} sx={{ mt: 1.25 }} - placeholder="Why this should link, stay in review, or become a suggested job." + placeholder={t("gmailReviewNotesPlaceholder")} /> {suggestion ? ( - Suggested job: {suggestion.companyName || "Unknown company"} · {suggestion.suggestedJobTitle || "Unknown role"} + {t("gmailReviewSuggestedJob", { company: suggestion.companyName || t("gmailReviewUnknownCompany"), role: suggestion.suggestedJobTitle || t("gmailReviewUnknownRole") })} ) : null} @@ -217,7 +228,7 @@ export default function GmailReviewPage({ embedded = false }: { embedded?: boole ))} {thread.jobCandidates[0] ? ( ) : null} {thread.jobCandidates[0] ? ( @@ -227,7 +238,7 @@ export default function GmailReviewPage({ embedded = false }: { embedded?: boole disabled={savingThreadId === thread.threadId} onClick={() => void saveDecision(thread.threadId, "linked", thread.jobCandidates[0].jobApplicationId)} > - Link top job + {t("gmailReviewLinkTopJob")} ) : null} {suggestion ? ( ) : null}