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 ? : <>
@@ -622,7 +624,7 @@ export default function Correspondence({ jobId, jobContext }: { jobId: number; j
<>
{linkedThreadIds.length > 0 ? (
void refreshLinkedThreads("manual")} disabled={linkedThreadRefreshLoading}>
- {linkedThreadRefreshLoading ? "Refreshing linked threads..." : "Refresh linked threads"}
+ {linkedThreadRefreshLoading ? t("correspondenceRefreshingLinkedThreads") : t("correspondenceRefreshLinkedThreads")}
) : null}
void loadGmailMatches(gmailQuery)} disabled={gmailMatchesLoading}>{t("correspondenceRefresh")}
@@ -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")}
void runManualSync()} disabled={syncing}>
- {syncing ? "Syncing..." : "Run manual sync"}
+ {syncing ? t("gmailReviewSyncing") : t("gmailReviewRunSync")}
void load()} disabled={loading || syncing}>
- {loading ? "Loading..." : "Refresh"}
+ {loading ? t("loading") : t("correspondenceRefresh")}
- {!embedded ? navigate("/correspondence")}>Back to inbox : null}
+ {!embedded ? navigate("/correspondence")}>{t("gmailReviewBackToInbox")} : null}
-
+
{(["all", "auto-link", "review", "unmatched", "suggested", "linked", "rejected"] as const).map((value) => (
setRoutingFilter(value)}>
- {value}
+ {routeLabel(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] ? (
navigate(`/jobs/${thread.jobCandidates[0].jobApplicationId}?section=communication`)}>
- Open top job
+ {t("gmailReviewOpenTopJob")}
) : 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}
void saveDecision(thread.threadId, "review")}
>
- Keep in review
+ {t("gmailReviewKeepInReview")}
void saveDecision(thread.threadId, "suggested")}
>
- Suggested job
+ {t("gmailReviewSuggestedJobAction")}
{suggestion ? (
void createSuggestedJob(thread.threadId)}
>
- {creatingThreadId === thread.threadId ? "Creating..." : "Create job"}
+ {creatingThreadId === thread.threadId ? t("gmailReviewCreating") : t("createJob")}
) : null}
void saveDecision(thread.threadId, "rejected")}
>
- Reject
+ {t("gmailReviewReject")}