From 5805c1a62168fc42beddadf233799c3a0900b0cd Mon Sep 17 00:00:00 2001 From: cesnimda Date: Fri, 28 Aug 2026 12:44:02 +0200 Subject: [PATCH] feat(i18n): persist Bokmal preference globally --- .../AuthAndSystemControllerTests.cs | 29 ++++++ JobTrackerApi.Tests/MigrationChainTests.cs | 4 + JobTrackerApi/Controllers/AuthController.cs | 31 ++++++- JobTrackerApi/Data/JobTrackerContext.cs | 4 + .../20260828090000_AddUiLanguagePreference.cs | 27 ++++++ .../JobTrackerContextModelSnapshot.cs | 4 + JobTrackerApi/Models/ApplicationUser.cs | 2 + .../StartupInitializationExtensions.cs | 2 + job-tracker-ui/src/App.tsx | 9 +- .../src/application-assets.test.tsx | 5 +- .../src/application-intelligence.test.tsx | 5 +- job-tracker-ui/src/components/AddJobModal.tsx | 2 +- .../src/components/ApplicationAssets.tsx | 26 +++--- .../components/ApplicationIntelligence.tsx | 10 ++- .../src/components/SettingsView.tsx | 4 +- job-tracker-ui/src/i18n-provider.test.tsx | 46 ++++++++++ job-tracker-ui/src/i18n/I18nProvider.tsx | 31 ++++++- job-tracker-ui/src/i18n/translations.ts | 88 ++++++++++++++++++- job-tracker-ui/src/layout/AppShell.tsx | 16 +++- .../src/views/ApplicationWorkspacePage.tsx | 78 +++++++++------- job-tracker-ui/src/views/CvBuilderEditor.tsx | 2 +- 21 files changed, 359 insertions(+), 66 deletions(-) create mode 100644 JobTrackerApi/Migrations/20260828090000_AddUiLanguagePreference.cs create mode 100644 job-tracker-ui/src/i18n-provider.test.tsx diff --git a/JobTrackerApi.Tests/AuthAndSystemControllerTests.cs b/JobTrackerApi.Tests/AuthAndSystemControllerTests.cs index 8054e78..773c610 100644 --- a/JobTrackerApi.Tests/AuthAndSystemControllerTests.cs +++ b/JobTrackerApi.Tests/AuthAndSystemControllerTests.cs @@ -426,6 +426,35 @@ public sealed class AuthAndSystemControllerTests Assert.Equal("Ada L.", user.DisplayName); } + [Fact] + public async Task Language_preference_normalizes_and_persists_norwegian_bokmal() + { + var user = new ApplicationUser { Id = "user-1", UiLanguage = "en" }; + var users = CreateUserManager(); + users.Setup(x => x.GetUserAsync(It.IsAny())).ReturnsAsync(user); + users.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success); + var controller = new AuthController(BuildConfig(), users.Object, Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of(), NullLogger.Instance, Mock.Of(), TestHostFactory.CreateInMemoryDb()); + + var result = await controller.UpdateLanguagePreference(new AuthController.UpdateLanguagePreferenceRequest("nb")); + + Assert.IsType(result); + Assert.Equal("nb-NO", user.UiLanguage); + } + + [Fact] + public async Task Language_preference_rejects_an_unsupported_locale() + { + var user = new ApplicationUser { Id = "user-1", UiLanguage = "en" }; + var users = CreateUserManager(); + users.Setup(x => x.GetUserAsync(It.IsAny())).ReturnsAsync(user); + var controller = new AuthController(BuildConfig(), users.Object, Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of(), NullLogger.Instance, Mock.Of(), TestHostFactory.CreateInMemoryDb()); + + var result = await controller.UpdateLanguagePreference(new AuthController.UpdateLanguagePreferenceRequest("sv-SE")); + + Assert.IsType(result); + users.Verify(x => x.UpdateAsync(It.IsAny()), Times.Never); + } + [Fact] public async Task Request_email_change_keeps_current_email_and_sends_confirmation_to_new_address() { diff --git a/JobTrackerApi.Tests/MigrationChainTests.cs b/JobTrackerApi.Tests/MigrationChainTests.cs index 1d0c120..5803380 100644 --- a/JobTrackerApi.Tests/MigrationChainTests.cs +++ b/JobTrackerApi.Tests/MigrationChainTests.cs @@ -40,6 +40,9 @@ public sealed class MigrationChainTests SELECT COUNT(*) FROM pragma_table_info('AspNetUsers') WHERE name = 'EmailFollowUpRemindersEnabled' AND dflt_value IN ('1', 'true'); """)); + Assert.Equal(1, await ScalarAsync(connection, """ + SELECT COUNT(*) FROM pragma_table_info('AspNetUsers') WHERE name = 'UiLanguage'; + """)); } [Fact] @@ -93,6 +96,7 @@ public sealed class MigrationChainTests Assert.Contains("CONSTRAINT `FK_AccountDeletionFiles_Request`", script, StringComparison.Ordinal); Assert.Contains("CREATE TABLE IF NOT EXISTS `AspNetUsers`", script, StringComparison.Ordinal); Assert.Contains("CREATE TABLE IF NOT EXISTS `AiInteractions`", script, StringComparison.Ordinal); + Assert.Contains("`UiLanguage` varchar(16)", script, StringComparison.Ordinal); Assert.All( Regex.Matches(script, "CONSTRAINT `([^`]+)`").Select(match => match.Groups[1].Value), identifier => Assert.True(identifier.Length <= 64, $"MariaDB constraint identifier exceeds 64 characters: {identifier}")); diff --git a/JobTrackerApi/Controllers/AuthController.cs b/JobTrackerApi/Controllers/AuthController.cs index a80da45..c650a52 100644 --- a/JobTrackerApi/Controllers/AuthController.cs +++ b/JobTrackerApi/Controllers/AuthController.cs @@ -96,6 +96,7 @@ public sealed class AuthController : ControllerBase { public string AppVersion { get; init; } = "unknown"; public string? AppCommitSha { get; init; } + public string UiLanguage { get; init; } = "en"; } public sealed record PendingEmailChangeResult(string? PendingEmail, DateTimeOffset? RequestedAtUtc); private const int MaxAvatarBytes = 1_000_000; @@ -539,6 +540,21 @@ public sealed class AuthController : ControllerBase return NoContent(); } + public sealed record UpdateLanguagePreferenceRequest(string? Language); + + [HttpPut("preferences/language")] + [Authorize(AuthenticationSchemes = "local")] + public async Task UpdateLanguagePreference([FromBody] UpdateLanguagePreferenceRequest request) + { + var user = await _users.GetUserAsync(User); + if (user is null) return Unauthorized(); + var language = NormalizeUiLanguage(request?.Language); + if (language is null) return BadRequest("Supported languages are en and nb-NO."); + user.UiLanguage = language; + var result = await _users.UpdateAsync(user); + return result.Succeeded ? NoContent() : BadRequest(string.Join("; ", result.Errors.Select(error => error.Description))); + } + public sealed record RequestEmailChangeRequest(string Email, string CurrentPassword); public sealed record ConfirmEmailChangeRequest(string UserId, string Email, string Token); public sealed record CancelEmailChangeRequest(string CurrentPassword); @@ -1200,7 +1216,20 @@ public sealed class AuthController : ControllerBase MicrosoftLink: new MicrosoftLinkDto( Linked: !string.IsNullOrWhiteSpace(user.MicrosoftTenantId) && !string.IsNullOrWhiteSpace(user.MicrosoftObjectId), Email: user.MicrosoftEmail, - LinkedAt: user.MicrosoftLinkedAt)); + LinkedAt: user.MicrosoftLinkedAt)) + { + UiLanguage = NormalizeUiLanguage(user.UiLanguage) ?? "en", + }; + } + + private static string? NormalizeUiLanguage(string? language) + { + var value = language?.Trim(); + if (string.Equals(value, "en", StringComparison.OrdinalIgnoreCase)) return "en"; + if (string.Equals(value, "nb", StringComparison.OrdinalIgnoreCase) + || string.Equals(value, "nb-NO", StringComparison.OrdinalIgnoreCase) + || string.Equals(value, "no", StringComparison.OrdinalIgnoreCase)) return "nb-NO"; + return null; } private MeResult WithBuildMetadata(MeResult result, bool include) diff --git a/JobTrackerApi/Data/JobTrackerContext.cs b/JobTrackerApi/Data/JobTrackerContext.cs index f6c97aa..0cec0f7 100644 --- a/JobTrackerApi/Data/JobTrackerContext.cs +++ b/JobTrackerApi/Data/JobTrackerContext.cs @@ -92,6 +92,10 @@ namespace JobTrackerApi.Data .Property(x => x.EmailFollowUpRemindersEnabled) .HasDefaultValue(true); + modelBuilder.Entity() + .Property(x => x.UiLanguage) + .HasMaxLength(16); + modelBuilder.Entity().Property(x => x.OwnerUserId).HasMaxLength(255); modelBuilder.Entity().Property(x => x.OwnerKey).HasMaxLength(64); modelBuilder.Entity().Property(x => x.RequestedByUserId).HasMaxLength(255); diff --git a/JobTrackerApi/Migrations/20260828090000_AddUiLanguagePreference.cs b/JobTrackerApi/Migrations/20260828090000_AddUiLanguagePreference.cs new file mode 100644 index 0000000..00f6a1d --- /dev/null +++ b/JobTrackerApi/Migrations/20260828090000_AddUiLanguagePreference.cs @@ -0,0 +1,27 @@ +using JobTrackerApi.Data; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace JobTrackerApi.Migrations; + +[DbContext(typeof(JobTrackerContext))] +[Migration("20260828090000_AddUiLanguagePreference")] +public partial class AddUiLanguagePreference : Migration +{ + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "UiLanguage", + table: "AspNetUsers", + type: ActiveProvider.Contains("MySql", StringComparison.OrdinalIgnoreCase) ? "varchar(16)" : "TEXT", + maxLength: 16, + nullable: true); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn(name: "UiLanguage", table: "AspNetUsers"); + } +} diff --git a/JobTrackerApi/Migrations/JobTrackerContextModelSnapshot.cs b/JobTrackerApi/Migrations/JobTrackerContextModelSnapshot.cs index 89c1964..b415f76 100644 --- a/JobTrackerApi/Migrations/JobTrackerContextModelSnapshot.cs +++ b/JobTrackerApi/Migrations/JobTrackerContextModelSnapshot.cs @@ -481,6 +481,10 @@ namespace JobTrackerApi.Migrations b.Property("StripeSubscriptionStatus") .HasColumnType("TEXT"); + b.Property("UiLanguage") + .HasMaxLength(16) + .HasColumnType("TEXT"); + b.Property("TotpEnabledAtUtc") .HasColumnType("TEXT"); diff --git a/JobTrackerApi/Models/ApplicationUser.cs b/JobTrackerApi/Models/ApplicationUser.cs index ae25732..3f38250 100644 --- a/JobTrackerApi/Models/ApplicationUser.cs +++ b/JobTrackerApi/Models/ApplicationUser.cs @@ -33,6 +33,8 @@ public sealed class ApplicationUser : IdentityUser public bool AiEnabled { get; set; } = true; public bool ExternalAiProcessingAllowed { get; set; } public bool EmailFollowUpRemindersEnabled { get; set; } = true; + // BCP 47 application UI preference. Null/legacy values safely fall back to English. + public string? UiLanguage { get; set; } public string DeletionStatus { get; set; } = AccountDeletionStatuses.Active; public DateTimeOffset? DeletionRequestedAtUtc { get; set; } } diff --git a/JobTrackerApi/Services/StartupInitializationExtensions.cs b/JobTrackerApi/Services/StartupInitializationExtensions.cs index 8f61769..92f51ab 100644 --- a/JobTrackerApi/Services/StartupInitializationExtensions.cs +++ b/JobTrackerApi/Services/StartupInitializationExtensions.cs @@ -533,6 +533,7 @@ public static class StartupInitializationExtensions EnsureColumn(conn, "AspNetUsers", "StripeSubscriptionId", "ALTER TABLE AspNetUsers ADD COLUMN StripeSubscriptionId TEXT NULL;"); EnsureColumn(conn, "AspNetUsers", "StripeSubscriptionStatus", "ALTER TABLE AspNetUsers ADD COLUMN StripeSubscriptionStatus TEXT NULL;"); EnsureColumn(conn, "AspNetUsers", "StripeLastEventCreatedUtc", "ALTER TABLE AspNetUsers ADD COLUMN StripeLastEventCreatedUtc TEXT NULL;"); + EnsureColumn(conn, "AspNetUsers", "UiLanguage", "ALTER TABLE AspNetUsers ADD COLUMN UiLanguage TEXT NULL;"); static void EnsureUserRuleSettingsTable(DbConnection c) { @@ -1435,6 +1436,7 @@ public static class StartupInitializationExtensions EnsureMySqlColumn(conn, "AspNetUsers", "StripeSubscriptionId", "ALTER TABLE `AspNetUsers` ADD COLUMN `StripeSubscriptionId` varchar(255) NULL;"); EnsureMySqlColumn(conn, "AspNetUsers", "StripeSubscriptionStatus", "ALTER TABLE `AspNetUsers` ADD COLUMN `StripeSubscriptionStatus` varchar(64) NULL;"); EnsureMySqlColumn(conn, "AspNetUsers", "StripeLastEventCreatedUtc", "ALTER TABLE `AspNetUsers` ADD COLUMN `StripeLastEventCreatedUtc` datetime(6) NULL;"); + EnsureMySqlColumn(conn, "AspNetUsers", "UiLanguage", "ALTER TABLE `AspNetUsers` ADD COLUMN `UiLanguage` varchar(16) NULL;"); // RuleSettings is MIGRATION-owned — the initial migration creates it. The reconciler // used to create it too, which made a clean install fail with "Table 'RuleSettings' diff --git a/job-tracker-ui/src/App.tsx b/job-tracker-ui/src/App.tsx index b526a5a..263fc8a 100644 --- a/job-tracker-ui/src/App.tsx +++ b/job-tracker-ui/src/App.tsx @@ -81,6 +81,7 @@ type MeResponse = { entitlements?: { ai?: boolean; proThemes?: boolean }; appVersion?: string; appCommitSha?: string; + uiLanguage?: "en" | "nb-NO"; }; function breadcrumbsFor(path: string, t: (k: any) => string): string[] { @@ -154,7 +155,7 @@ function LegacyApplicationRedirect() { function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMode, onThemeModeChange }: { jobPageSize: 15 | 20 | 25; setJobPageSize: (n: 15 | 20 | 25) => void; jobColumns: JobTableColumns; setJobColumns: (c: JobTableColumns) => void; themeMode: ThemeModePref; onThemeModeChange: (v: ThemeModePref) => void; }) { const location = useLocation(); const navigate = useNavigate(); - const { t } = useI18n(); + const { t, hydrateLanguage } = useI18n(); const compactHeaderActions = useMediaQuery("(max-width:767.95px)"); const [addOpen, setAddOpen] = useState(false); @@ -198,6 +199,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo .then((r) => { if (!active) return; setMe(r.data); + hydrateLanguage(r.data?.uiLanguage); setIsAdmin(Boolean(r.data?.roles?.includes("Admin"))); setAuthUserKey(r.data?.id || r.data?.email || r.data?.userName || null, false); }) @@ -213,7 +215,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo return () => { active = false; }; - }, []); + }, [hydrateLanguage]); useEffect(() => { const load = () => { api.get("/jobapplications/reminders", { params: { upcomingDays: 14 } }).then((r) => setReminderCount(Array.isArray(r.data) ? r.data.length : 0)).catch(() => setReminderCount(0)); @@ -240,6 +242,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo api.get("/auth/me") .then((r) => { setMe(r.data); + hydrateLanguage(r.data?.uiLanguage); setIsAdmin(Boolean(r.data?.roles?.includes("Admin"))); setAuthUserKey(r.data?.id || r.data?.email || r.data?.userName || null, false); }) @@ -253,7 +256,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo window.addEventListener("auth-changed", onAuthChanged); return () => window.removeEventListener("auth-changed", onAuthChanged); - }, []); + }, [hydrateLanguage]); useEffect(() => { const onKeyDown = (e: KeyboardEvent) => { diff --git a/job-tracker-ui/src/application-assets.test.tsx b/job-tracker-ui/src/application-assets.test.tsx index 254c92f..74325b4 100644 --- a/job-tracker-ui/src/application-assets.test.tsx +++ b/job-tracker-ui/src/application-assets.test.tsx @@ -1,11 +1,14 @@ import React from "react"; import "@testing-library/jest-dom"; -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { fireEvent, render as rtlRender, screen, waitFor } from "@testing-library/react"; import { ApplicationCoverLetterSection, ApplicationCvSection, ApplicationPackageDraftsSection, } from "./components/ApplicationAssets"; import { api } from "./api"; +import { I18nProvider } from "./i18n/I18nProvider"; + +const render = (ui: React.ReactElement) => rtlRender({ui}); jest.mock("./api", () => ({ api: { diff --git a/job-tracker-ui/src/application-intelligence.test.tsx b/job-tracker-ui/src/application-intelligence.test.tsx index efdef05..4cf3136 100644 --- a/job-tracker-ui/src/application-intelligence.test.tsx +++ b/job-tracker-ui/src/application-intelligence.test.tsx @@ -1,11 +1,14 @@ import React from "react"; import "@testing-library/jest-dom"; -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { fireEvent, render as rtlRender, screen, waitFor } from "@testing-library/react"; import { ApplicationAnalysis, ApplicationMatch, ApplicationTimeline, } from "./components/ApplicationIntelligence"; import { api } from "./api"; +import { I18nProvider } from "./i18n/I18nProvider"; + +const render = (ui: React.ReactElement) => rtlRender({ui}); jest.mock("./api", () => ({ api: { diff --git a/job-tracker-ui/src/components/AddJobModal.tsx b/job-tracker-ui/src/components/AddJobModal.tsx index e1d71d2..7535b4b 100644 --- a/job-tracker-ui/src/components/AddJobModal.tsx +++ b/job-tracker-ui/src/components/AddJobModal.tsx @@ -100,7 +100,7 @@ function normalizeLanguage(value?: string | null) { const raw = (value || "").trim().toLowerCase(); if (!raw) return ""; if (["en", "eng", "english"].includes(raw)) return "en"; - if (["no", "nb", "nn", "norwegian", "norwegian bokmål", "bokmal", "bokmål"].includes(raw)) return "no"; + if (["no", "nb", "nb-no", "nn", "norwegian", "norwegian bokmål", "bokmal", "bokmål"].includes(raw)) return "nb"; return raw; } diff --git a/job-tracker-ui/src/components/ApplicationAssets.tsx b/job-tracker-ui/src/components/ApplicationAssets.tsx index cdea7d4..cf1fadb 100644 --- a/job-tracker-ui/src/components/ApplicationAssets.tsx +++ b/job-tracker-ui/src/components/ApplicationAssets.tsx @@ -16,6 +16,7 @@ import { import { cvBuilderApi } from "../cvBuilder"; import { aiWorkspaceApi } from "../aiWorkspace"; import { useAccountPlan } from "../accountPlan"; +import { useI18n } from "../i18n/I18nProvider"; // Phase 5.4 — Application Assets sections for the workspace. // @@ -417,10 +418,11 @@ const COVER_LETTER_ACTIONS = [ function CoverLetterAiAssistant({ jobId, currentText, onApply }: { jobId: number; currentText: string; onApply: (text: string, action: string) => void }) { const { canUseAi } = useAccountPlan(); + const { language: uiLanguage, t } = useI18n(); const { data: cv, loading: loadingCv } = useAsset(() => applicationAssetsApi.cv(jobId), [jobId]); const [action, setAction] = useState("generate"); const [mode, setMode] = useState("professional"); - const [language, setLanguage] = useState<"en" | "nb-NO">("en"); + const [language, setLanguage] = useState<"en" | "nb-NO">(() => uiLanguage === "nb" ? "nb-NO" : "en"); const [instructions, setInstructions] = useState(""); const [suggestion, setSuggestion] = useState(""); const [busy, setBusy] = useState(false); @@ -449,17 +451,17 @@ function CoverLetterAiAssistant({ jobId, currentText, onApply }: { jobId: number const hasCv = !!cv?.attachedVariantId; return ( {!hasCv ? ( - Select a CV before generating a tailored cover letter. + {t("coverAiSelectCv")} ) : ( - Using {cv?.attachedVariantName} and this application's full job advert and analysis. + {t("coverAiUsing")} {cv?.attachedVariantName} )} @@ -478,15 +480,15 @@ function CoverLetterAiAssistant({ jobId, currentText, onApply }: { jobId: number - Document language - setLanguage(event.target.value as "en" | "nb-NO")}> English Norsk bokmål setInstructions(event.target.value)} @@ -508,17 +510,17 @@ function CoverLetterAiAssistant({ jobId, currentText, onApply }: { jobId: number {suggestion && ( - Current + {t("coverAiCurrent")} {currentText || "No current draft"} - Suggestion + {t("coverAiSuggestion")} {suggestion} - - + + diff --git a/job-tracker-ui/src/components/ApplicationIntelligence.tsx b/job-tracker-ui/src/components/ApplicationIntelligence.tsx index 8bcdb55..5dba521 100644 --- a/job-tracker-ui/src/components/ApplicationIntelligence.tsx +++ b/job-tracker-ui/src/components/ApplicationIntelligence.tsx @@ -9,6 +9,7 @@ import { getApiErrorMessage } from "../api"; import { CareerMatch, JobAnalysis, TIMELINE_CATEGORY_LABELS, Timeline, applicationIntelligenceApi, } from "../applicationWorkspace"; +import { useI18n } from "../i18n/I18nProvider"; // Phase 5.3 — Application Intelligence sections for the workspace. // @@ -255,6 +256,7 @@ export function ApplicationAnalysis({ jobId }: { jobId: number }) { // ---------- Match ---------- export function ApplicationMatch({ jobId }: { jobId: number }) { + const { t } = useI18n(); const { data, error, loading } = useIntelligence( () => applicationIntelligenceApi.match(jobId), [jobId], @@ -262,17 +264,17 @@ export function ApplicationMatch({ jobId }: { jobId: number }) { return ( {data && !data.hasSelectedCv ? ( - Select a CV on the CV tab first. The application will only analyse the document you explicitly link. + {t("workspaceSelectCvFirst")} ) : data && !data.hasCareerProfile ? ( diff --git a/job-tracker-ui/src/components/SettingsView.tsx b/job-tracker-ui/src/components/SettingsView.tsx index 1b23396..e66d186 100644 --- a/job-tracker-ui/src/components/SettingsView.tsx +++ b/job-tracker-ui/src/components/SettingsView.tsx @@ -138,10 +138,10 @@ export default function SettingsView({ labelId="language-label" value={language} label={t("settingsPreferredLanguage")} - onChange={(e) => setLanguage(e.target.value as "en" | "no")} + onChange={(e) => setLanguage(e.target.value as "en" | "nb")} > {t("settingsEnglish")} - {t("settingsNorwegian")} + {t("settingsNorwegian")} diff --git a/job-tracker-ui/src/i18n-provider.test.tsx b/job-tracker-ui/src/i18n-provider.test.tsx new file mode 100644 index 0000000..5737a4c --- /dev/null +++ b/job-tracker-ui/src/i18n-provider.test.tsx @@ -0,0 +1,46 @@ +import React from "react"; +import "@testing-library/jest-dom"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; + +import { api } from "./api"; +import { I18nProvider, useI18n } from "./i18n/I18nProvider"; + +function Probe() { + const { language, setLanguage, hydrateLanguage, t } = useI18n(); + return ( +
+ {language} + {t("settings")} + + + +
+ ); +} + +beforeEach(() => { + jest.clearAllMocks(); + window.localStorage.clear(); +}); + +test("migrates the legacy Norwegian code and persists Bokmål using nb-NO", async () => { + window.localStorage.setItem("uiLanguage", "no"); + render(); + + expect(screen.getByLabelText("language")).toHaveTextContent("nb"); + expect(screen.getByLabelText("settings-label")).toHaveTextContent("Innstillinger"); + expect(document.documentElement.lang).toBe("nb-NO"); + + fireEvent.click(screen.getByRole("button", { name: "English" })); + expect(screen.getByLabelText("settings-label")).toHaveTextContent("Settings"); + await waitFor(() => expect(api.put).toHaveBeenCalledWith("/auth/preferences/language", { language: "en" })); +}); + +test("hydrates an authenticated server preference without writing it back", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: /Restore server preference/i })); + + expect(screen.getByLabelText("language")).toHaveTextContent("nb"); + expect(window.localStorage.getItem("uiLanguage")).toBe("nb"); + expect(api.put).not.toHaveBeenCalled(); +}); diff --git a/job-tracker-ui/src/i18n/I18nProvider.tsx b/job-tracker-ui/src/i18n/I18nProvider.tsx index 1bf814d..51afdd1 100644 --- a/job-tracker-ui/src/i18n/I18nProvider.tsx +++ b/job-tracker-ui/src/i18n/I18nProvider.tsx @@ -1,11 +1,13 @@ -import React, { createContext, useContext, useState } from "react"; +import React, { createContext, useCallback, useContext, useEffect, useState } from "react"; import { translations, TranslationKey, UiLanguage } from "./translations"; +import { api } from "../api"; type TranslationParams = Record; type Ctx = { language: UiLanguage; setLanguage: (l: UiLanguage) => void; + hydrateLanguage: (language?: string | null) => void; t: (key: TranslationKey, params?: TranslationParams) => string; }; @@ -19,17 +21,38 @@ function interpolate(template: string, params?: TranslationParams) { export function I18nProvider({ children }: { children: React.ReactNode }) { const [language, setLanguageState] = useState(() => { const raw = window.localStorage.getItem("uiLanguage"); - return raw === "no" ? "no" : "en"; + return raw === "nb" || raw === "nb-NO" || raw === "no" ? "nb" : "en"; }); - const setLanguage = (l: UiLanguage) => { + const applyLanguage = useCallback((l: UiLanguage) => { setLanguageState(l); window.localStorage.setItem("uiLanguage", l); + document.documentElement.lang = l === "nb" ? "nb-NO" : "en"; + }, []); + + const setLanguage = (l: UiLanguage) => { + applyLanguage(l); + void api.put("/auth/preferences/language", { language: l === "nb" ? "nb-NO" : "en" }).catch(() => undefined); }; + const hydrateLanguage = useCallback((value?: string | null) => { + if (!value) return; + applyLanguage(value === "nb" || value === "nb-NO" || value === "no" ? "nb" : "en"); + }, [applyLanguage]); + + useEffect(() => { + document.documentElement.lang = language === "nb" ? "nb-NO" : "en"; + const onStorage = (event: StorageEvent) => { + if (event.key !== "uiLanguage") return; + applyLanguage(event.newValue === "nb" || event.newValue === "nb-NO" || event.newValue === "no" ? "nb" : "en"); + }; + window.addEventListener("storage", onStorage); + return () => window.removeEventListener("storage", onStorage); + }, [applyLanguage, language]); + const t = (key: TranslationKey, params?: TranslationParams) => interpolate(translations[language][key] ?? translations.en[key], params); - return {children}; + return {children}; } export function useI18n() { diff --git a/job-tracker-ui/src/i18n/translations.ts b/job-tracker-ui/src/i18n/translations.ts index c3e5316..d517571 100644 --- a/job-tracker-ui/src/i18n/translations.ts +++ b/job-tracker-ui/src/i18n/translations.ts @@ -1,4 +1,4 @@ -export type UiLanguage = "en" | "no"; +export type UiLanguage = "en" | "nb"; export const translations = { en: { @@ -1170,8 +1170,50 @@ export const translations = { rulesSaving: "Saving...", rulesSave: "Save Rules", rulesSaveFailed: "Failed to save rules.", + workspace: "Workspace", + workspaceBack: "Back to applications", + workspaceOpenFull: "Open full-page workspace", + workspaceSections: "Workspace sections", + workspaceOverview: "Overview", + workspaceAnalysis: "Analysis", + workspaceCv: "CV", + workspaceCoverLetter: "Cover Letter", + workspaceInterviewPrep: "Interview Prep", + workspaceInvalidLink: "This application link is invalid.", + workspaceLoadFailed: "Could not open this application.", + workspaceUnsavedTitle: "Unsaved application changes", + workspaceUnsavedMessage: "Leaving this section will discard changes that have not been saved.", + workspaceDiscardLeave: "Discard and leave", + workspaceKeepEditing: "Keep editing", + workspaceEditApplication: "Edit application", + workspaceOpenAdvert: "Open original advert", + workspaceProgress: "Application progress", + workspaceNextAction: "Next recommended action", + workspaceNothingOutstanding: "Nothing outstanding — this application is fully prepared.", + workspaceRecentActivity: "Recent activity", + workspaceRefresh: "Refresh", + workspaceNoActivity: "No activity recorded yet.", + workspaceJobDetails: "Job details", + workspaceChecklist: "Next actions and checklist", + workspaceActivityHistory: "Activity history", + workspaceDocuments: "Documents", + workspaceCommunication: "Communication", + workspaceCvMatch: "CV Match", + workspaceSelectCvMatch: "Choose the CV intended for this application before comparing it with the advert.", + workspaceComparingCv: "{name} compared with this job advert.", + workspaceSelectCvFirst: "Select a CV on the CV tab first. The application will only analyse the document you explicitly link.", + coverAiTitle: "AI writing assistant", + coverAiSubtitle: "Uses this job and its linked CV. Suggestions never overwrite your document.", + coverAiSelectCv: "Select a CV before generating a tailored cover letter.", + coverAiUsing: "Using {name} and this application's full job advert and analysis.", + coverAiDocumentLanguage: "Document language", + coverAiAdditionalInstructions: "Additional instructions", + coverAiCurrent: "Current", + coverAiSuggestion: "Suggestion", + coverAiApply: "Apply to editor", + coverAiReject: "Reject", }, - no: { + nb: { appTitle: "Jobbjakt", appTagline: "Hold oversikt over jobbsøkingen", dashboard: "Dashboard", @@ -2340,6 +2382,48 @@ export const translations = { rulesSaving: "Lagrer...", rulesSave: "Lagre regler", rulesSaveFailed: "Kunne ikke lagre regler.", + workspace: "Arbeidsområde", + workspaceBack: "Tilbake til søknader", + workspaceOpenFull: "Åpne arbeidsområdet på helside", + workspaceSections: "Deler av arbeidsområdet", + workspaceOverview: "Oversikt", + workspaceAnalysis: "Analyse", + workspaceCv: "CV", + workspaceCoverLetter: "Søknadsbrev", + workspaceInterviewPrep: "Intervjuforberedelse", + workspaceInvalidLink: "Denne søknadslenken er ugyldig.", + workspaceLoadFailed: "Kunne ikke åpne denne søknaden.", + workspaceUnsavedTitle: "Ulagrede søknadsendringer", + workspaceUnsavedMessage: "Hvis du forlater denne delen, forkastes endringer som ikke er lagret.", + workspaceDiscardLeave: "Forkast og forlat", + workspaceKeepEditing: "Fortsett å redigere", + workspaceEditApplication: "Rediger søknad", + workspaceOpenAdvert: "Åpne den opprinnelige annonsen", + workspaceProgress: "Søknadsprogresjon", + workspaceNextAction: "Neste anbefalte handling", + workspaceNothingOutstanding: "Ingenting gjenstår — denne søknaden er ferdig forberedt.", + workspaceRecentActivity: "Nylig aktivitet", + workspaceRefresh: "Oppdater", + workspaceNoActivity: "Ingen aktivitet er registrert ennå.", + workspaceJobDetails: "Stillingsdetaljer", + workspaceChecklist: "Neste handlinger og sjekkliste", + workspaceActivityHistory: "Aktivitetshistorikk", + workspaceDocuments: "Dokumenter", + workspaceCommunication: "Kommunikasjon", + workspaceCvMatch: "CV-samsvar", + workspaceSelectCvMatch: "Velg CV-en som skal brukes i søknaden før den sammenlignes med annonsen.", + workspaceComparingCv: "{name} sammenlignes med denne stillingsannonsen.", + workspaceSelectCvFirst: "Velg først en CV under CV-fanen. Søknaden analyserer bare dokumentet du kobler til eksplisitt.", + coverAiTitle: "AI-skriveassistent", + coverAiSubtitle: "Bruker denne stillingen og den tilknyttede CV-en. Forslag overskriver aldri dokumentet ditt.", + coverAiSelectCv: "Velg en CV før du genererer et skreddersydd søknadsbrev.", + coverAiUsing: "Bruker {name} samt hele stillingsannonsen og analysen for denne søknaden.", + coverAiDocumentLanguage: "Dokumentspråk", + coverAiAdditionalInstructions: "Tilleggsinstruksjoner", + coverAiCurrent: "Nåværende", + coverAiSuggestion: "Forslag", + coverAiApply: "Bruk i redigeringsfeltet", + coverAiReject: "Avvis", }, } as const; diff --git a/job-tracker-ui/src/layout/AppShell.tsx b/job-tracker-ui/src/layout/AppShell.tsx index 12cd135..f1b082c 100644 --- a/job-tracker-ui/src/layout/AppShell.tsx +++ b/job-tracker-ui/src/layout/AppShell.tsx @@ -6,6 +6,8 @@ import { Badge, Box, Breadcrumbs, + Button, + ButtonGroup, Chip, Divider, Drawer, @@ -112,7 +114,7 @@ export default function AppShell({ rightActions?: React.ReactNode; children: React.ReactNode; }) { - const { t } = useI18n(); + const { language, setLanguage, t } = useI18n(); const isMobile = useMediaQuery("(max-width:767.95px)"); const [desktopNavCollapsed, setDesktopNavCollapsed] = useState(() => { try { @@ -309,6 +311,7 @@ export default function AppShell({ {buildBadge} + {user ? ( {buildBadge} + )} - ); } + +function StackLanguageToggle({ language, setLanguage }: { language: "en" | "nb"; setLanguage: (language: "en" | "nb") => void }) { + return ( + + + + + ); +} diff --git a/job-tracker-ui/src/views/ApplicationWorkspacePage.tsx b/job-tracker-ui/src/views/ApplicationWorkspacePage.tsx index ad94150..b2383be 100644 --- a/job-tracker-ui/src/views/ApplicationWorkspacePage.tsx +++ b/job-tracker-ui/src/views/ApplicationWorkspacePage.tsx @@ -66,6 +66,7 @@ export function ApplicationWorkspace({ const jobId = jobIdOverride ?? Number(id); const location = useLocation(); const navigate = useNavigate(); + const { t } = useI18n(); const { confirm } = useConfirm(); const [params, setParams] = useSearchParams(); const section = sectionOverride ?? workspaceSection(params.get("section")); @@ -96,10 +97,10 @@ export function ApplicationWorkspace({ const blockedNavigation = blocker; let active = true; void confirm({ - title: "Unsaved application changes", - message: "Leaving this section will discard changes that have not been saved.", - confirmLabel: "Discard and leave", - cancelLabel: "Keep editing", + title: t("workspaceUnsavedTitle"), + message: t("workspaceUnsavedMessage"), + confirmLabel: t("workspaceDiscardLeave"), + cancelLabel: t("workspaceKeepEditing"), destructive: true, }).then((approved) => { if (!active) return; @@ -107,21 +108,21 @@ export function ApplicationWorkspace({ else blockedNavigation.reset(); }); return () => { active = false; }; - }, [blocker, confirm]); + }, [blocker, confirm, t]); const load = useCallback(async () => { if (!Number.isInteger(jobId) || jobId <= 0) { setOverview(null); - setError("This application link is invalid."); + setError(t("workspaceInvalidLink")); return; } try { setError(null); setOverview(await applicationWorkspaceApi.overview(jobId)); } catch (err) { - setError(getApiErrorMessage(err, "Could not open this application.")); + setError(getApiErrorMessage(err, t("workspaceLoadFailed"))); } - }, [jobId]); + }, [jobId, t]); useEffect(() => { load(); @@ -144,7 +145,7 @@ export function ApplicationWorkspace({ if (error) { return ( - + {error} ); @@ -154,23 +155,23 @@ export function ApplicationWorkspace({ - - + + - Workspace + {t("workspace")} {fullPageHref ? ( - + @@ -184,10 +185,10 @@ export function ApplicationWorkspace({ onChange={(_, value: WorkspaceSectionKey) => go(value)} variant="scrollable" scrollButtons="auto" - aria-label="Workspace sections" + aria-label={t("workspaceSections")} sx={{ px: { xs: 0.5, sm: 1.5 }, borderTop: 1, borderColor: "divider", minHeight: 46 }} > - {WORKSPACE_SECTIONS.map((s) => )} + {WORKSPACE_SECTIONS.map((s) => )} @@ -238,16 +239,16 @@ function WorkspaceHeader({ overview, onEdit }: { overview: WorkspaceOverview | n - - + + {overview.source ? : null} {overview.jobUrl && ( - - + + @@ -269,9 +270,9 @@ function ApplicationProgress({ status }: { status: string }) { : PIPELINE_STATUSES.filter((stage) => !["Rejected", "Ghosted", "Withdrawn"].includes(stage)); return ( - + - Application progress + {t("workspaceProgress")} {stages.map((stage, index) => { @@ -301,6 +302,7 @@ function OverviewSection({ overview, onGo, onReload, onEdit }: { onReload: () => void; onEdit: () => void; }) { + const { 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 }, @@ -317,7 +319,7 @@ function OverviewSection({ overview, onGo, onReload, onEdit }: { {overview.nextStep ? ( - Next recommended action + {t("workspaceNextAction")} {overview.nextStep.label} {overview.nextStep.reason} + {t("workspaceRecentActivity")} + {overview.recentActivity.length === 0 ? ( - No activity recorded yet. + {t("workspaceNoActivity")} ) : ( {overview.recentActivity.map((a, i) => ( @@ -372,12 +374,13 @@ function OverviewSection({ overview, onGo, onReload, onEdit }: { } function OverviewDetails({ jobId, overview, onReload, onEdit }: { jobId: number; overview: WorkspaceOverview; onReload: () => void; onEdit: () => void }) { + const { t } = useI18n(); const panels = [ - { id: "details", title: "Job details", content: }, - { id: "tasks", title: "Next actions and checklist", content: }, - { id: "timeline", title: "Activity history", content: }, - { id: "documents", title: "Documents", content: }, - { id: "communication", title: "Communication", content: }, + { id: "details", title: t("workspaceJobDetails"), content: }, + { id: "tasks", title: t("workspaceChecklist"), content: }, + { id: "timeline", title: t("workspaceActivityHistory"), content: }, + { id: "documents", title: t("workspaceDocuments"), content: }, + { id: "communication", title: t("workspaceCommunication"), content: }, ]; return ( @@ -393,6 +396,17 @@ function OverviewDetails({ jobId, overview, onReload, onEdit }: { jobId: number; ); } +function workspaceSectionLabel(t: (key: any) => string, section: WorkspaceSectionKey): string { + const keys: Record = { + overview: "workspaceOverview", + analysis: "workspaceAnalysis", + cv: "workspaceCv", + "cover-letter": "workspaceCoverLetter", + interview: "workspaceInterviewPrep", + }; + return t(keys[section]); +} + function JobDetailsSection({ overview, onEdit }: { overview: WorkspaceOverview | null; onEdit: () => void }) { if (!overview) return ; const rows: [string, string][] = [ diff --git a/job-tracker-ui/src/views/CvBuilderEditor.tsx b/job-tracker-ui/src/views/CvBuilderEditor.tsx index d3558f7..dd39aca 100644 --- a/job-tracker-ui/src/views/CvBuilderEditor.tsx +++ b/job-tracker-ui/src/views/CvBuilderEditor.tsx @@ -923,7 +923,7 @@ function CustomizeTab({ mode, settings, update, themes }: { Page size Density } - Language + Language Date format {supports("layout") && Columns}