From a57b2fe58c323391b70a43fc92973a4a9e9e55d2 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Fri, 28 Aug 2026 20:04:11 +0200 Subject: [PATCH] feat(i18n): localise application workspace --- .../application-workspace-overlay.test.tsx | 17 +- .../src/components/ApplicationAssets.tsx | 126 +++++----- .../components/ApplicationIntelligence.tsx | 82 ++++--- job-tracker-ui/src/i18n/translations.ts | 216 ++++++++++++++++++ .../src/views/ApplicationWorkspacePage.tsx | 55 ++--- 5 files changed, 372 insertions(+), 124 deletions(-) diff --git a/job-tracker-ui/src/application-workspace-overlay.test.tsx b/job-tracker-ui/src/application-workspace-overlay.test.tsx index 08bf2b6..7046eaf 100644 --- a/job-tracker-ui/src/application-workspace-overlay.test.tsx +++ b/job-tracker-ui/src/application-workspace-overlay.test.tsx @@ -149,6 +149,7 @@ function renderTable(path = "/jobs") { } beforeEach(() => { + window.localStorage.removeItem("uiLanguage"); mockedApi.get.mockImplementation((url: string) => { if (url === "/companies") return Promise.resolve({ data: [{ id: 1, name: "Acme" }] } as any); if (url === "/jobapplications") return Promise.resolve({ data: { items: [job], total: 1, page: 1, pageSize: 15 } } as any); @@ -158,7 +159,10 @@ beforeEach(() => { }); }); -afterEach(() => jest.clearAllMocks()); +afterEach(() => { + window.localStorage.removeItem("uiLanguage"); + jest.clearAllMocks(); +}); test("opens the dedicated workspace from the whole row and preserves list state on return", async () => { renderTable(); @@ -227,6 +231,17 @@ test("warns before section navigation would discard application edits", async () await waitFor(() => expect(screen.getByTestId("location")).toHaveTextContent("/jobs/42?section=analysis")); }); +test("renders shared workspace navigation and progress in Norwegian Bokmål", async () => { + window.localStorage.setItem("uiLanguage", "nb"); + renderTable("/jobs/42"); + + expect(await screen.findByRole("tab", { name: "Oversikt" })).toHaveAttribute("aria-selected", "true"); + expect(screen.getByRole("tab", { name: "Analyse" })).toBeInTheDocument(); + expect(screen.getByRole("tab", { name: "Søknadsbrev" })).toBeInTheDocument(); + expect(await screen.findByLabelText(/søknadsprogresjon/i)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /tilbake til søknader/i })).toBeInTheDocument(); +}); + test("hydrates list filters, sort and page from a shareable URL", async () => { renderTable("/jobs?q=backend&status=Interview&companyId=1&location=Oslo&needsFollowUp=1&readiness=interview&includeDeleted=1&sortBy=company&sortDir=asc&page=2"); diff --git a/job-tracker-ui/src/components/ApplicationAssets.tsx b/job-tracker-ui/src/components/ApplicationAssets.tsx index cf1fadb..7ec1d78 100644 --- a/job-tracker-ui/src/components/ApplicationAssets.tsx +++ b/job-tracker-ui/src/components/ApplicationAssets.tsx @@ -85,6 +85,7 @@ function Shell({ title, subtitle, loading, error, children }: { // ---------- CV ---------- export function ApplicationCvSection({ jobId }: { jobId: number }) { + const { t } = useI18n(); const { data, error, loading, setData, setError } = useAsset( () => applicationAssetsApi.cv(jobId), [jobId], @@ -122,29 +123,28 @@ export function ApplicationCvSection({ jobId }: { jobId: number }) { return ( {(data?.availableVariants.length ?? 0) === 0 ? ( - No CV variants yet. Build one in the CV builder — it starts from your master career - profile, so you never retype your history. + {t("assetsNoCvVariants")} ) : ( attach(e.target.value === "" ? null : Number(e.target.value))} - helperText="Changing this only re-points the application. The variant itself is untouched." + helperText={t("assetsAttachedCvHelp")} > - None + {t("assetsNone")} {(data?.availableVariants ?? []).map((v) => ( {v.name} · {v.themeId} · v{v.version} @@ -159,8 +159,8 @@ export function ApplicationCvSection({ jobId }: { jobId: number }) { {data.attachedVariantName} - Theme {data.attachedThemeId} · version {data.attachedVersion} - {data.attachedIsPublic ? " · public" : ""} + {t("assetsThemeVersion", { theme: data.attachedThemeId ?? "—", version: data.attachedVersion ?? "—" })} + {data.attachedIsPublic ? ` · ${t("assetsPublic")}` : ""} @@ -172,23 +172,23 @@ export function ApplicationCvSection({ jobId }: { jobId: number }) { // server-side, so there is no second CV loading path here. href={`/career/builder/${data.attachedVariantId}`} > - Edit, preview and export + {t("assetsEditPreviewExport")} ) : ( - No CV attached to this application yet. + {t("assetsNoCvAttached")} )} {data?.hasTailoredCvText && ( - This application also has legacy tailored CV text saved on it. A CV variant supersedes it. + {t("assetsLegacyCvText")} )} @@ -202,6 +202,7 @@ export function ApplicationCvSection({ jobId }: { jobId: number }) { // ---------- Tailoring ---------- export function ApplicationTailoringSection({ jobId }: { jobId: number }) { + const { t } = useI18n(); const { data, error, loading } = useAsset( () => applicationAssetsApi.tailoring(jobId), [jobId], @@ -209,26 +210,26 @@ export function ApplicationTailoringSection({ jobId }: { jobId: number }) { return ( {data && !data.hasCareerProfile && ( - Build your career profile to get experience and project suggestions. + {t("assetsBuildProfile")} )} {data && !data.hasJobDescription && ( - Paste the advert text to get keyword and requirement suggestions. + {t("assetsAddAdvert")} )} {(data?.suggestions.length ?? 0) === 0 ? ( - Nothing to suggest yet. + {t("assetsNoSuggestions")} ) : ( (data?.suggestions ?? []).map((s) => ( @@ -266,6 +267,7 @@ Kind regards, [Your name]`; export function ApplicationCoverLetterSection({ jobId, onDirtyChange }: { jobId: number; onDirtyChange?: (dirty: boolean) => void }) { + const { t } = useI18n(); const { data, error, loading, setData, setError } = useAsset( () => applicationAssetsApi.coverLetter(jobId), [jobId], @@ -315,36 +317,36 @@ export function ApplicationCoverLetterSection({ jobId, onDirtyChange }: { jobId: return ( {dirty && ( - + )} @@ -356,10 +358,10 @@ export function ApplicationCoverLetterSection({ jobId, onDirtyChange }: { jobId: onApply={(value, aiAction) => { setDraft(value); setDraftAiAction(aiAction); }} /> - + {(data?.versions.length ?? 0) === 0 ? ( - No versions yet. The first save starts the history. + {t("assetsNoVersions")} ) : ( @@ -375,19 +377,19 @@ export function ApplicationCoverLetterSection({ jobId, onDirtyChange }: { jobId: v{v.version} - {v.isCurrent && } + {v.isCurrent && } - {new Date(v.createdAtUtc).toLocaleString()} · {v.length} characters + {new Date(v.createdAtUtc).toLocaleString()} · {t("assetsCharacterCount", { count: v.length })} {!v.isCurrent && ( - + restore(v.version)} > @@ -405,15 +407,15 @@ export function ApplicationCoverLetterSection({ jobId, onDirtyChange }: { jobId: } const COVER_LETTER_ACTIONS = [ - { key: "generate", label: "Generate" }, - { key: "regenerate", label: "Fresh alternative" }, - { key: "improve", label: "Improve" }, - { key: "shorten", label: "Shorten" }, - { key: "expand", label: "Add detail" }, - { key: "professional", label: "More professional" }, - { key: "natural", label: "More natural" }, - { key: "grammar", label: "Fix grammar" }, - { key: "tailor", label: "Tailor more closely" }, + { key: "generate", labelKey: "coverActionGenerate" }, + { key: "regenerate", labelKey: "coverActionRegenerate" }, + { key: "improve", labelKey: "coverActionImprove" }, + { key: "shorten", labelKey: "coverActionShorten" }, + { key: "expand", labelKey: "coverActionExpand" }, + { key: "professional", labelKey: "coverActionProfessional" }, + { key: "natural", labelKey: "coverActionNatural" }, + { key: "grammar", labelKey: "coverActionGrammar" }, + { key: "tailor", labelKey: "coverActionTailor" }, ] as const; function CoverLetterAiAssistant({ jobId, currentText, onApply }: { jobId: number; currentText: string; onApply: (text: string, action: string) => void }) { @@ -466,17 +468,18 @@ function CoverLetterAiAssistant({ jobId, currentText, onApply }: { jobId: number )} - Action - setAction(event.target.value)}> + {COVER_LETTER_ACTIONS.map((item) => {t(item.labelKey)})} - Tone - setMode(event.target.value)}> {[ - "professional", "friendly", "short", "detailed", "modern", "traditional", - ].map((item) => {item[0].toUpperCase() + item.slice(1)})} + ["professional", "coverToneProfessional"], ["friendly", "coverToneFriendly"], ["short", "coverToneShort"], + ["detailed", "coverToneDetailed"], ["modern", "coverToneModern"], ["traditional", "coverToneTraditional"], + ].map(([value, key]) => {t(key as any)})} @@ -489,14 +492,14 @@ function CoverLetterAiAssistant({ jobId, currentText, onApply }: { jobId: number setInstructions(event.target.value)} multiline minRows={2} fullWidth /> - {error && void generate()}>Retry}>{error}} + {error && void generate()}>{t("retry")}}>{error}} {suggestion && ( @@ -512,7 +515,7 @@ function CoverLetterAiAssistant({ jobId, currentText, onApply }: { jobId: number {t("coverAiCurrent")} - {currentText || "No current draft"} + {currentText || t("coverAiNoDraft")} @@ -547,6 +550,7 @@ export function ApplicationPackageDraftsSection({ onSaved?: () => void; onDirtyChange?: (dirty: boolean) => void; }) { + const { t } = useI18n(); const initial = { applicationAnswer: initialApplicationAnswer, recruiterMessage: initialRecruiterMessage }; const [saved, setSaved] = useState(initial); const [draft, setDraft] = useState(null); @@ -588,36 +592,36 @@ export function ApplicationPackageDraftsSection({ return ( update({ applicationAnswer })} - placeholder="Draft an answer for motivation, suitability, or another application-form question." + placeholder={t("assetsApplicationAnswerPlaceholder")} /> update({ recruiterMessage })} - placeholder="Draft a concise message to the recruiter or hiring manager." + placeholder={t("assetsRecruiterMessagePlaceholder")} /> - {dirty && } + {dirty && } diff --git a/job-tracker-ui/src/components/ApplicationIntelligence.tsx b/job-tracker-ui/src/components/ApplicationIntelligence.tsx index d5e75e5..f6d1cb1 100644 --- a/job-tracker-ui/src/components/ApplicationIntelligence.tsx +++ b/job-tracker-ui/src/components/ApplicationIntelligence.tsx @@ -7,7 +7,7 @@ import { import { getApiErrorMessage } from "../api"; import { - CareerMatch, JobAnalysis, TIMELINE_CATEGORY_LABELS, Timeline, applicationIntelligenceApi, + CareerMatch, JobAnalysis, Timeline, applicationIntelligenceApi, } from "../applicationWorkspace"; import { useI18n } from "../i18n/I18nProvider"; @@ -110,6 +110,7 @@ function Bullets({ label, values }: { label: string; values: string[] }) { // ---------- Timeline ---------- export function ApplicationTimeline({ jobId }: { jobId: number }) { + const { t } = useI18n(); const [category, setCategory] = useState(""); const { data, error, loading } = useIntelligence( () => applicationIntelligenceApi.timeline(jobId, category || undefined), @@ -122,7 +123,7 @@ export function ApplicationTimeline({ jobId }: { jobId: number }) { {hasEvents && (data?.milestones.length ?? 0) > 0 && ( - Milestones + {t("intelligenceMilestones")} {(data?.milestones ?? []).map((m) => ( @@ -137,12 +138,12 @@ export function ApplicationTimeline({ jobId }: { jobId: number }) { )} {/* SectionShell takes children as a prop, so this JSX is built before it decides whether to render it — every access has to tolerate a null `data`. */} @@ -153,20 +154,20 @@ export function ApplicationTimeline({ jobId }: { jobId: number }) { exclusive value={category} onChange={(_e, next) => setCategory(next ?? "")} - aria-label="Filter timeline by category" + aria-label={t("intelligenceTimelineFilter")} sx={{ flexWrap: "wrap" }} > - All + {t("intelligenceAll")} {(data?.categories ?? []).map((c) => ( - - {TIMELINE_CATEGORY_LABELS[c] ?? c} + + {timelineCategoryLabel(t, c)} ))} )} {(data?.days.length ?? 0) === 0 ? ( - No events in this category. + {t("intelligenceNoCategoryEvents")} ) : ( (data?.days ?? []).map((day) => ( @@ -180,7 +181,7 @@ export function ApplicationTimeline({ jobId }: { jobId: number }) { {e.summary} - {e.isMilestone && } + {e.isMilestone && } {e.detail && ( {e.detail} @@ -200,6 +201,7 @@ export function ApplicationTimeline({ jobId }: { jobId: number }) { // ---------- Analysis ---------- export function ApplicationAnalysis({ jobId }: { jobId: number }) { + const { t } = useI18n(); const { data, error, loading } = useIntelligence( () => applicationIntelligenceApi.analysis(jobId), [jobId], @@ -207,27 +209,26 @@ export function ApplicationAnalysis({ jobId }: { jobId: number }) { const facts: [string, string | null][] = data ? [ - ["Role", data.role], - ["Company", data.company], - ["Location", data.location], - ["Employment type", data.employmentType], - ["Seniority", data.seniority], - ["Salary", data.salary], + [t("intelligenceRole"), data.role], + [t("company"), data.company], + [t("location"), data.location], + [t("intelligenceEmploymentType"), data.employmentType], + [t("intelligenceSeniority"), data.seniority], + [t("jobDetailsSalary"), data.salary], ] : []; return ( {data && !data.hasJobDescription && ( - No advert text saved yet. Paste it into the application to get requirements, technologies - and interview topics. + {t("intelligenceNoAdvert")} )} @@ -242,12 +243,12 @@ export function ApplicationAnalysis({ jobId }: { jobId: number }) { ))} - - - - - - + + + + + + ); @@ -278,7 +279,7 @@ export function ApplicationMatch({ jobId }: { jobId: number }) { ) : data && !data.hasCareerProfile ? ( - The linked CV cannot be matched until its career profile has content. + {t("intelligenceCvProfileEmpty")} ) : ( <> @@ -290,24 +291,24 @@ export function ApplicationMatch({ jobId }: { jobId: number }) { {data && !data.hasEnoughSignal && ( - The advert is too short to score reliably. Paste the full text for a real match. + {t("intelligenceAdvertTooShort")} )} - - + + {(data?.relevantExperience.length ?? 0) > 0 && ( - Relevant experience + {t("intelligenceRelevantExperience")} {(data?.relevantExperience ?? []).map((e, i) => ( @@ -326,7 +327,7 @@ export function ApplicationMatch({ jobId }: { jobId: number }) { {(data?.relevantProjects.length ?? 0) > 0 && ( - Relevant projects + {t("intelligenceRelevantProjects")} {(data?.relevantProjects ?? []).map((p, i) => ( @@ -344,8 +345,19 @@ export function ApplicationMatch({ jobId }: { jobId: number }) { )} - + ); } + +function timelineCategoryLabel(t: (key: any) => string, category: string): string { + const keys: Record = { + lifecycle: "intelligenceCategoryLifecycle", + stage: "intelligenceCategoryStage", + "follow-up": "intelligenceCategoryFollowUp", + communication: "intelligenceCategoryCommunication", + ai: "intelligenceCategoryAi", + }; + return keys[category] ? t(keys[category]) : category; +} diff --git a/job-tracker-ui/src/i18n/translations.ts b/job-tracker-ui/src/i18n/translations.ts index c4159eb..e1b877e 100644 --- a/job-tracker-ui/src/i18n/translations.ts +++ b/job-tracker-ui/src/i18n/translations.ts @@ -131,6 +131,114 @@ export const translations = { interviewFollowUpOpenTasks: "{count} open follow-up tasks on the checklist.", interviewFollowUpDate: "Follow up on", interviewFollowUpEmpty: "No follow-up scheduled. Applications without one can go quiet.", + intelligenceMilestones: "Milestones", + intelligenceTimeline: "Timeline", + intelligenceTimelineSubtitle: "Everything recorded for this application, newest first.", + intelligenceTimelineEmpty: "Nothing has happened yet. Activity appears here as the application moves forward.", + intelligenceTimelineFilter: "Filter timeline by category", + intelligenceAllEvents: "All events", + intelligenceAll: "All", + intelligenceNoCategoryEvents: "No events in this category.", + intelligenceMilestone: "Milestone", + intelligenceCategoryLifecycle: "Lifecycle", + intelligenceCategoryStage: "Stage", + intelligenceCategoryFollowUp: "Follow-up", + intelligenceCategoryCommunication: "Communication", + intelligenceCategoryAi: "AI", + intelligenceRole: "Role", + intelligenceEmploymentType: "Employment type", + intelligenceSeniority: "Seniority", + intelligenceAnalysisSubtitle: "Derived directly from the current advert — no repeated AI call when the page opens.", + intelligenceNoAdvert: "No advert text saved yet. Add it to detect requirements, technologies and likely interview topics.", + intelligenceTechnologies: "Technologies", + intelligenceSkills: "Skills", + intelligenceRequirements: "Important requirements", + intelligenceResponsibilities: "Responsibilities", + intelligenceInterviewTopics: "Likely interview topics", + intelligenceNotStated: "Not stated in the advert", + intelligenceCvProfileEmpty: "The linked CV cannot be matched until its career profile contains content.", + intelligenceMatchScore: "Career match score", + intelligenceAdvertTooShort: "The advert is too short to score reliably. Add the full text for a meaningful match.", + intelligenceMatched: "Matched", + intelligenceMissing: "Missing", + intelligenceRelevantExperience: "Relevant experience", + intelligenceRelevantProjects: "Relevant projects", + intelligenceSuggestions: "Suggestions", + assetsCvSubtitle: "The CV variant used for this application. Variants tailor the master career profile without duplicating it.", + assetsNoCvVariants: "No CV variants yet. Build one in the CV builder; it starts from the master career profile, so your history stays reusable.", + assetsAttachedCv: "Attached CV variant", + assetsAttachedCvHelp: "Changing this only changes which CV the application uses. The CV itself is untouched.", + assetsNone: "None", + assetsThemeVersion: "Theme {theme} · version {version}", + assetsPublic: "public", + assetsEditPreviewExport: "Edit, preview and export", + assetsDuplicateForJob: "Duplicate for this job", + assetsNoCvAttached: "No CV is attached to this application yet.", + assetsLegacyCvText: "This application also contains legacy tailored CV text. An attached CV variant takes precedence.", + assetsTailoring: "Tailoring", + assetsTailoringSubtitle: "What to emphasise for this advert. Suggestions never edit the profile or CV.", + assetsBuildProfile: "Build your career profile to get experience and project suggestions.", + assetsAddAdvert: "Add the advert text to get keyword and requirement suggestions.", + assetsNoSuggestions: "Nothing to suggest yet.", + assetsCoverSubtitle: "Every save creates a version, so manually edited text is never overwritten without review.", + assetsCoverPlaceholder: "Write it yourself, start from the template, or generate a tailored draft.", + assetsDiscardChanges: "Discard changes", + assetsStartTemplate: "Start from template", + assetsUnsavedChanges: "Unsaved changes", + assetsVersionHistory: "Version history", + assetsNoVersions: "No versions yet. The first save starts the history.", + assetsCurrent: "Current", + assetsCharacterCount: "{count} characters", + assetsRestoreVersion: "Restore this version", + assetsRestoreVersionNumber: "Restore version {version}", + coverAiAction: "Action", + coverAiTone: "Tone", + coverActionGenerate: "Generate", + coverActionRegenerate: "Fresh alternative", + coverActionImprove: "Improve", + coverActionShorten: "Shorten", + coverActionExpand: "Add detail", + coverActionProfessional: "More professional", + coverActionNatural: "More natural", + coverActionGrammar: "Fix grammar", + coverActionTailor: "Tailor more closely", + coverToneProfessional: "Professional", + coverToneFriendly: "Friendly", + coverToneShort: "Short", + coverToneDetailed: "Detailed", + coverToneModern: "Modern", + coverToneTraditional: "Traditional", + coverAiInstructionsPlaceholder: "For example: Focus on my .NET experience and keep it concise.", + coverAiNoDraft: "No current draft", + assetsApplicationDrafts: "Application answers and recruiter message", + assetsApplicationDraftsSubtitle: "Keep reusable application-form answers and a recruiter note with this application. Ordinary notes remain separate.", + assetsApplicationAnswer: "Application answer", + assetsApplicationAnswerPlaceholder: "Draft an answer about motivation, suitability, or another application-form question.", + assetsRecruiterMessage: "Recruiter message", + assetsRecruiterMessagePlaceholder: "Draft a concise message to the recruiter or hiring manager.", + assetsSaveDrafts: "Save application drafts", + workspaceTailoredText: "Tailored text", + workspaceNotPrepared: "Not prepared", + workspaceReady: "Ready", + workspaceNotWritten: "Not written", + workspaceAttachedCount: "{count} attached", + workspaceAiSuggestions: "AI suggestions", + workspaceSavedCount: "{count} saved", + workspaceNoneYet: "None yet", + workspaceDoneCount: "{completed}/{total} done", + workspaceCountry: "Country", + workspaceSource: "Source", + workspaceDiscovered: "Discovered", + workspaceApplied: "Applied", + workspaceNotScheduled: "Not scheduled", + workspaceApplicationInfo: "Application information", + workspaceEdit: "Edit", + workspaceNotes: "Notes", + workspaceJobDescription: "Job description", + workspaceAddAdvert: "Add advert", + workspaceNoJobDescription: "No advert text is saved. Analysis and matching need the job description.", + workspaceTranslatedAdvert: "Translated advert", + workspaceOriginalAdvert: "Original advert", notFoundTitle: "Page not found", notFoundBody: "The page you were looking for does not exist or may have moved.", appErrorTitle: "Something went wrong", @@ -1398,6 +1506,114 @@ export const translations = { interviewFollowUpOpenTasks: "{count} åpne oppfølgingsoppgaver i sjekklisten.", interviewFollowUpDate: "Følg opp den", interviewFollowUpEmpty: "Ingen oppfølging er planlagt. Søknader uten oppfølging kan bli stille.", + intelligenceMilestones: "Milepæler", + intelligenceTimeline: "Tidslinje", + intelligenceTimelineSubtitle: "Alt som er registrert for søknaden, nyeste først.", + intelligenceTimelineEmpty: "Ingenting har skjedd ennå. Aktivitet vises her når søknaden går videre.", + intelligenceTimelineFilter: "Filtrer tidslinjen etter kategori", + intelligenceAllEvents: "Alle hendelser", + intelligenceAll: "Alle", + intelligenceNoCategoryEvents: "Ingen hendelser i denne kategorien.", + intelligenceMilestone: "Milepæl", + intelligenceCategoryLifecycle: "Livssyklus", + intelligenceCategoryStage: "Stadium", + intelligenceCategoryFollowUp: "Oppfølging", + intelligenceCategoryCommunication: "Kommunikasjon", + intelligenceCategoryAi: "AI", + intelligenceRole: "Rolle", + intelligenceEmploymentType: "Ansettelsesform", + intelligenceSeniority: "Erfaringsnivå", + intelligenceAnalysisSubtitle: "Utledet direkte fra gjeldende annonse — ingen gjentatt AI-kall når siden åpnes.", + intelligenceNoAdvert: "Ingen annonsetekst er lagret ennå. Legg den til for å finne krav, teknologier og sannsynlige intervjutemaer.", + intelligenceTechnologies: "Teknologier", + intelligenceSkills: "Ferdigheter", + intelligenceRequirements: "Viktige krav", + intelligenceResponsibilities: "Arbeidsoppgaver", + intelligenceInterviewTopics: "Sannsynlige intervjutemaer", + intelligenceNotStated: "Ikke oppgitt i annonsen", + intelligenceCvProfileEmpty: "Den tilknyttede CV-en kan ikke sammenlignes før karriereprofilen inneholder data.", + intelligenceMatchScore: "Samsvar mellom CV og jobb", + intelligenceAdvertTooShort: "Annonsen er for kort til å gi en pålitelig vurdering. Legg til hele teksten for et meningsfylt samsvar.", + intelligenceMatched: "Samsvarer", + intelligenceMissing: "Mangler", + intelligenceRelevantExperience: "Relevant erfaring", + intelligenceRelevantProjects: "Relevante prosjekter", + intelligenceSuggestions: "Forslag", + assetsCvSubtitle: "CV-varianten som brukes for denne søknaden. Varianter tilpasser karriereprofilen uten å duplisere den.", + assetsNoCvVariants: "Ingen CV-varianter ennå. Bygg en i CV-byggeren; den starter fra karriereprofilen, slik at historikken kan gjenbrukes.", + assetsAttachedCv: "Tilknyttet CV-variant", + assetsAttachedCvHelp: "Dette endrer bare hvilken CV søknaden bruker. Selve CV-en forblir urørt.", + assetsNone: "Ingen", + assetsThemeVersion: "Tema {theme} · versjon {version}", + assetsPublic: "offentlig", + assetsEditPreviewExport: "Rediger, forhåndsvis og eksporter", + assetsDuplicateForJob: "Dupliser for denne jobben", + assetsNoCvAttached: "Ingen CV er tilknyttet søknaden ennå.", + assetsLegacyCvText: "Søknaden inneholder også eldre tilpasset CV-tekst. En tilknyttet CV-variant har prioritet.", + assetsTailoring: "Tilpasning", + assetsTailoringSubtitle: "Hva som bør fremheves for annonsen. Forslag redigerer aldri profilen eller CV-en.", + assetsBuildProfile: "Bygg karriereprofilen for å få forslag til erfaring og prosjekter.", + assetsAddAdvert: "Legg til annonseteksten for å få forslag til nøkkelord og krav.", + assetsNoSuggestions: "Ingen forslag ennå.", + assetsCoverSubtitle: "Hver lagring oppretter en versjon, slik at manuelt redigert tekst aldri overskrives uten gjennomgang.", + assetsCoverPlaceholder: "Skriv selv, start fra malen eller generer et tilpasset utkast.", + assetsDiscardChanges: "Forkast endringer", + assetsStartTemplate: "Start fra mal", + assetsUnsavedChanges: "Ulagrede endringer", + assetsVersionHistory: "Versjonshistorikk", + assetsNoVersions: "Ingen versjoner ennå. Første lagring starter historikken.", + assetsCurrent: "Gjeldende", + assetsCharacterCount: "{count} tegn", + assetsRestoreVersion: "Gjenopprett denne versjonen", + assetsRestoreVersionNumber: "Gjenopprett versjon {version}", + coverAiAction: "Handling", + coverAiTone: "Tone", + coverActionGenerate: "Generer", + coverActionRegenerate: "Nytt alternativ", + coverActionImprove: "Forbedre", + coverActionShorten: "Gjør kortere", + coverActionExpand: "Legg til detaljer", + coverActionProfessional: "Mer profesjonell", + coverActionNatural: "Mer naturlig", + coverActionGrammar: "Rett grammatikk", + coverActionTailor: "Tilpass tettere", + coverToneProfessional: "Profesjonell", + coverToneFriendly: "Vennlig", + coverToneShort: "Kort", + coverToneDetailed: "Detaljert", + coverToneModern: "Moderne", + coverToneTraditional: "Tradisjonell", + coverAiInstructionsPlaceholder: "For eksempel: Fokuser på .NET-erfaringen min og hold brevet kort.", + coverAiNoDraft: "Ingen gjeldende kladd", + assetsApplicationDrafts: "Søknadssvar og melding til rekrutterer", + assetsApplicationDraftsSubtitle: "Lagre gjenbrukbare søknadssvar og en rekrutterermelding med søknaden. Vanlige notater forblir separate.", + assetsApplicationAnswer: "Søknadssvar", + assetsApplicationAnswerPlaceholder: "Skriv et svar om motivasjon, egnethet eller et annet spørsmål i søknadsskjemaet.", + assetsRecruiterMessage: "Melding til rekrutterer", + assetsRecruiterMessagePlaceholder: "Skriv en kort melding til rekruttereren eller ansettelsesansvarlig.", + assetsSaveDrafts: "Lagre søknadsutkast", + workspaceTailoredText: "Tilpasset tekst", + workspaceNotPrepared: "Ikke forberedt", + workspaceReady: "Klar", + workspaceNotWritten: "Ikke skrevet", + workspaceAttachedCount: "{count} vedlagt", + workspaceAiSuggestions: "AI-forslag", + workspaceSavedCount: "{count} lagret", + workspaceNoneYet: "Ingen ennå", + workspaceDoneCount: "{completed}/{total} ferdig", + workspaceCountry: "Land", + workspaceSource: "Kilde", + workspaceDiscovered: "Oppdaget", + workspaceApplied: "Søkt", + workspaceNotScheduled: "Ikke planlagt", + workspaceApplicationInfo: "Søknadsinformasjon", + workspaceEdit: "Rediger", + workspaceNotes: "Notater", + workspaceJobDescription: "Stillingsbeskrivelse", + workspaceAddAdvert: "Legg til annonse", + workspaceNoJobDescription: "Ingen annonsetekst er lagret. Analyse og samsvar trenger stillingsbeskrivelsen.", + workspaceTranslatedAdvert: "Oversatt annonse", + workspaceOriginalAdvert: "Original annonse", notFoundTitle: "Siden ble ikke funnet", notFoundBody: "Siden du lette etter finnes ikke eller kan ha blitt flyttet.", appErrorTitle: "Noe gikk galt", diff --git a/job-tracker-ui/src/views/ApplicationWorkspacePage.tsx b/job-tracker-ui/src/views/ApplicationWorkspacePage.tsx index 0d56408..f032a89 100644 --- a/job-tracker-ui/src/views/ApplicationWorkspacePage.tsx +++ b/job-tracker-ui/src/views/ApplicationWorkspacePage.tsx @@ -302,14 +302,14 @@ function OverviewSection({ overview, onGo, onReload, onEdit }: { onReload: () => void; onEdit: () => void; }) { - const { t } = useI18n(); + const { language, t } = useI18n(); const stats = useMemo(() => overview ? [ - { icon: , label: "CV", value: overview.cv.variantName ?? (overview.cv.hasTailoredCvText ? "Tailored text" : "Not prepared"), ok: !!overview.cv.variantId || overview.cv.hasTailoredCvText, go: "cv" as const }, - { icon: , label: "Cover letter", value: overview.hasCoverLetter ? "Ready" : "Not written", ok: overview.hasCoverLetter, go: "cover-letter" as const }, - { icon: , label: "Documents", value: overview.documentCount ? `${overview.documentCount} attached` : "None", ok: overview.documentCount > 0, go: "overview" as const }, - { icon: , label: "AI suggestions", value: overview.aiInteractionCount ? `${overview.aiInteractionCount} saved` : "None yet", ok: overview.aiInteractionCount > 0, go: "analysis" as const }, - { icon: , label: "Checklist", value: overview.checklistProgress ? `${overview.checklistProgress.completed}/${overview.checklistProgress.total} done` : "—", ok: (overview.checklistProgress?.percent ?? 0) === 100, go: "overview" as const }, - ] : [], [overview]); + { icon: , label: t("workspaceCv"), value: overview.cv.variantName ?? (overview.cv.hasTailoredCvText ? t("workspaceTailoredText") : t("workspaceNotPrepared")), ok: !!overview.cv.variantId || overview.cv.hasTailoredCvText, go: "cv" as const }, + { icon: , label: t("jobDetailsCoverLetter"), value: overview.hasCoverLetter ? t("workspaceReady") : t("workspaceNotWritten"), ok: overview.hasCoverLetter, go: "cover-letter" as const }, + { icon: , label: t("workspaceDocuments"), value: overview.documentCount ? t("workspaceAttachedCount", { count: overview.documentCount }) : t("assetsNone"), ok: overview.documentCount > 0, go: "overview" as const }, + { icon: , label: t("workspaceAiSuggestions"), value: overview.aiInteractionCount ? t("workspaceSavedCount", { count: overview.aiInteractionCount }) : t("workspaceNoneYet"), ok: overview.aiInteractionCount > 0, go: "analysis" as const }, + { icon: , label: t("workspaceChecklist"), value: overview.checklistProgress ? t("workspaceDoneCount", { completed: overview.checklistProgress.completed, total: overview.checklistProgress.total }) : "—", ok: (overview.checklistProgress?.percent ?? 0) === 100, go: "overview" as const }, + ] : [], [overview, t]); if (!overview) { return {[0, 1].map((i) => )}; @@ -361,7 +361,7 @@ function OverviewSection({ overview, onGo, onReload, onEdit }: { {overview.recentActivity.map((a, i) => ( {a.type}{a.detail ? ` — ${a.detail}` : ""} - {new Date(a.at).toLocaleDateString()} + {new Date(a.at).toLocaleDateString(language === "nb" ? "nb-NO" : "en")} ))} @@ -408,26 +408,27 @@ function workspaceSectionLabel(t: (key: any) => string, section: WorkspaceSectio } function JobDetailsSection({ overview, onEdit }: { overview: WorkspaceOverview | null; onEdit: () => void }) { + const { language, t } = useI18n(); if (!overview) return ; const rows: [string, string][] = [ - ["Company", overview.company ?? "—"], - ["Location", overview.location ?? "—"], - ["Country", overview.countryCode ?? "—"], - ["Source", overview.source ?? "—"], - ["Salary", overview.salary ?? "—"], - ["Status", overview.status], - ["Discovered", overview.savedAt ? new Date(overview.savedAt).toLocaleDateString() : "—"], - ["Applied", overview.dateApplied ? new Date(overview.dateApplied).toLocaleDateString() : "—"], - ["Deadline", overview.deadline ? new Date(overview.deadline).toLocaleDateString() : "—"], - ["Follow-up", overview.followUpAt ? new Date(overview.followUpAt).toLocaleDateString() : "Not scheduled"], - ["Next action", overview.nextAction ?? "—"], + [t("company"), overview.company ?? "—"], + [t("location"), overview.location ?? "—"], + [t("workspaceCountry"), overview.countryCode ?? "—"], + [t("workspaceSource"), overview.source ?? "—"], + [t("jobDetailsSalary"), overview.salary ?? "—"], + [t("addJobModalStatus"), statusLabel(t, overview.status)], + [t("workspaceDiscovered"), overview.savedAt ? new Date(overview.savedAt).toLocaleDateString(language === "nb" ? "nb-NO" : "en") : "—"], + [t("workspaceApplied"), overview.dateApplied ? new Date(overview.dateApplied).toLocaleDateString(language === "nb" ? "nb-NO" : "en") : "—"], + [t("jobDetailsDeadline"), overview.deadline ? new Date(overview.deadline).toLocaleDateString(language === "nb" ? "nb-NO" : "en") : "—"], + [t("intelligenceCategoryFollowUp"), overview.followUpAt ? new Date(overview.followUpAt).toLocaleDateString(language === "nb" ? "nb-NO" : "en") : t("workspaceNotScheduled")], + [t("workspaceNextAction"), overview.nextAction ?? "—"], ]; return ( - Application information - + {t("workspaceApplicationInfo")} + {rows.map(([k, v]) => ( @@ -444,29 +445,29 @@ function JobDetailsSection({ overview, onEdit }: { overview: WorkspaceOverview | ) : null} {overview.notes ? ( - Notes + {t("workspaceNotes")} {overview.notes} ) : null} - Job description + {t("workspaceJobDescription")} {!overview.hasJobDescription ? ( - Add advert}> - No advert text saved. Analysis and matching need the job description. + {t("workspaceAddAdvert")}}> + {t("workspaceNoJobDescription")} ) : ( {overview.translatedDescription ? ( - Translated advert + {t("workspaceTranslatedAdvert")} {overview.translatedDescription} ) : null} {overview.description ? ( - {overview.translatedDescription ? Original advert{overview.descriptionLanguage ? ` · ${overview.descriptionLanguage.toUpperCase()}` : ""} : null} + {overview.translatedDescription ? {t("workspaceOriginalAdvert")}{overview.descriptionLanguage ? ` · ${overview.descriptionLanguage.toUpperCase()}` : ""} : null} {overview.description} ) : null}