From ce7e48665ca681e1c059cb576a068957a8d562d5 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Tue, 10 Nov 2026 23:16:58 +0100 Subject: [PATCH] fix(i18n): sort discovery titles using selected UI locale --- docs/i18n/DISCOVERY_TITLE_ORDER.md | 15 +++++++++ .../evidence/discovery-title-order/LINT.txt | 0 .../evidence/discovery-title-order/RED.txt | 33 +++++++++++++++++++ .../evidence/discovery-title-order/TESTS.txt | 9 +++++ .../evidence/discovery-title-order/TYPES.txt | 4 +++ job-tracker-ui/src/job-discovery.test.tsx | 30 +++++++++++++++++ job-tracker-ui/src/views/JobDiscoveryPage.tsx | 7 ++-- 7 files changed, 95 insertions(+), 3 deletions(-) create mode 100644 docs/i18n/DISCOVERY_TITLE_ORDER.md create mode 100644 docs/i18n/evidence/discovery-title-order/LINT.txt create mode 100644 docs/i18n/evidence/discovery-title-order/RED.txt create mode 100644 docs/i18n/evidence/discovery-title-order/TESTS.txt create mode 100644 docs/i18n/evidence/discovery-title-order/TYPES.txt diff --git a/docs/i18n/DISCOVERY_TITLE_ORDER.md b/docs/i18n/DISCOVERY_TITLE_ORDER.md new file mode 100644 index 00000000..cb64f56d --- /dev/null +++ b/docs/i18n/DISCOVERY_TITLE_ORDER.md @@ -0,0 +1,15 @@ +# Discovery title order follows UI language + +10 November 2026. Baseline `aac0f3b3a9b452b48126ddaf26e1837eeadd2f6d`, branch `codex/i18n-discovery-order`. + +The title sort used the browser's default collation and its memo omitted the UI language. Switching English to Bokmål left Å-prefixed titles before Z. The focused regression reproduced this on the unchanged baseline. + +`JobDiscoveryPage` now passes its existing English/Bokmål display locale to `localeCompare` and includes that locale in the memo dependencies. The same locale still formats recorded timestamps. The change reaches ordinary and saved-search result arrays through their shared title sort. + +The regression switches English → Bokmål → English with Å/Z fixtures, verifies each order, and checks that the role, municipality and title sort remain selected. Switching adds zero API reads and triggers no writes. Existing submitted search and provider contracts are retained. + +24 tests in `job-discovery.test.tsx`, TypeScript without emit and ESLint on the two changed files passed. The initial red run selected one test and skipped the other 23. It is retained separately from the final suite result in `evidence/discovery-title-order/`. + +Export build, rendered browser, native language/device and live-provider verification were not run in this isolated increment. Existing whole-application, European locale and final acceptance gates remain open. The root integration owner will review and publish this branch separately. + +Rollback is the inverse of the two source/test changes. There are no schema, dependency or persisted-data changes. diff --git a/docs/i18n/evidence/discovery-title-order/LINT.txt b/docs/i18n/evidence/discovery-title-order/LINT.txt new file mode 100644 index 00000000..e69de29b diff --git a/docs/i18n/evidence/discovery-title-order/RED.txt b/docs/i18n/evidence/discovery-title-order/RED.txt new file mode 100644 index 00000000..1eec6661 --- /dev/null +++ b/docs/i18n/evidence/discovery-title-order/RED.txt @@ -0,0 +1,33 @@ + +> job-tracker-ui@0.1.0 test +> jest --runInBand job-discovery.test.tsx -t title sorting follows UI language changes + +FAIL src/job-discovery.test.tsx + ● title sorting follows UI language changes without refetching or changing discovery filters + + expect(received).toEqual(expected) // deep equality + + - Expected - 1 + + Received + 1 + + Array [ + - "Zoologist", + "Åker agronom", + + "Zoologist", + ] + + 115 | + 116 | act(() => { window.dispatchEvent(new StorageEvent("storage", { key: "uiLanguage", newValue: "nb" })); }); + > 117 | expect(titles()).toEqual(["Zoologist", "Åker agronom"]); + | ^ + 118 | expect(screen.getByLabelText("Rolle eller bedrift")).toHaveValue("science"); + 119 | expect(screen.getByLabelText("Kommune")).toHaveValue("Oslo"); + 120 | expect(screen.getByRole("combobox", { name: translations.nb.jobDiscoverySort })).toHaveTextContent(translations.nb.jobDiscoverySortTitle); + + at Object.toEqual (src/job-discovery.test.tsx:117:20) + +Test Suites: 1 failed, 1 total +Tests: 1 failed, 23 skipped, 24 total +Snapshots: 0 total +Time: 5.888 s +Ran all test suites matching job-discovery.test.tsx with tests matching "title sorting follows UI language changes". diff --git a/docs/i18n/evidence/discovery-title-order/TESTS.txt b/docs/i18n/evidence/discovery-title-order/TESTS.txt new file mode 100644 index 00000000..9d3a22fe --- /dev/null +++ b/docs/i18n/evidence/discovery-title-order/TESTS.txt @@ -0,0 +1,9 @@ + +> job-tracker-ui@0.1.0 test +> jest --runInBand job-discovery.test.tsx + +Test Suites: 1 passed, 1 total +Tests: 24 passed, 24 total +Snapshots: 0 total +Time: 8.499 s +Ran all test suites matching job-discovery.test.tsx. diff --git a/docs/i18n/evidence/discovery-title-order/TYPES.txt b/docs/i18n/evidence/discovery-title-order/TYPES.txt new file mode 100644 index 00000000..3c414e0a --- /dev/null +++ b/docs/i18n/evidence/discovery-title-order/TYPES.txt @@ -0,0 +1,4 @@ + +> job-tracker-ui@0.1.0 typecheck +> tsc --noEmit --incremental false + diff --git a/job-tracker-ui/src/job-discovery.test.tsx b/job-tracker-ui/src/job-discovery.test.tsx index 9966026a..d7cf9456 100644 --- a/job-tracker-ui/src/job-discovery.test.tsx +++ b/job-tracker-ui/src/job-discovery.test.tsx @@ -97,6 +97,36 @@ test("sorts returned vacancies by title", async () => { expect(screen.getAllByRole("heading", { level: 3 }).map((element) => element.textContent)).toEqual(["Analyst", "Zoologist"]); }); +test("title sorting follows UI language changes without refetching or changing discovery filters", async () => { + searchResponse([ + { id: "1", title: "Åker agronom", url: "https://example.test/1", source: "nav", countryCode: "NO" }, + { id: "2", title: "Zoologist", url: "https://example.test/2", source: "nav", countryCode: "NO" }, + ]); + renderPage(); + fireEvent.change(screen.getByLabelText("Role or company"), { target: { value: "science" } }); + fireEvent.change(screen.getByLabelText("Municipality"), { target: { value: "Oslo" } }); + fireEvent.click(screen.getByRole("button", { name: "Search NAV" })); + await screen.findByText("2 vacancies found"); + fireEvent.mouseDown(screen.getByRole("combobox", { name: "Sort results" })); + fireEvent.click(await screen.findByRole("option", { name: "Title A–Z" })); + const titles = () => screen.getAllByRole("heading", { level: 3 }).map((element) => element.textContent); + expect(titles()).toEqual(["Åker agronom", "Zoologist"]); + const reads = mockedApi.get.mock.calls.length; + + act(() => { window.dispatchEvent(new StorageEvent("storage", { key: "uiLanguage", newValue: "nb" })); }); + expect(titles()).toEqual(["Zoologist", "Åker agronom"]); + expect(screen.getByLabelText("Rolle eller bedrift")).toHaveValue("science"); + expect(screen.getByLabelText("Kommune")).toHaveValue("Oslo"); + expect(screen.getByRole("combobox", { name: translations.nb.jobDiscoverySort })).toHaveTextContent(translations.nb.jobDiscoverySortTitle); + + act(() => { window.dispatchEvent(new StorageEvent("storage", { key: "uiLanguage", newValue: "en" })); }); + expect(titles()).toEqual(["Åker agronom", "Zoologist"]); + expect(mockedApi.get).toHaveBeenCalledTimes(reads); + expect(mockedApi.post).not.toHaveBeenCalled(); + expect(mockedApi.patch).not.toHaveBeenCalled(); + expect(mockedApi.delete).not.toHaveBeenCalled(); +}); + test("renders the discovery workflow in Norwegian Bokmål", async () => { window.localStorage.setItem("uiLanguage", "nb"); renderPage(); diff --git a/job-tracker-ui/src/views/JobDiscoveryPage.tsx b/job-tracker-ui/src/views/JobDiscoveryPage.tsx index 58ad10b4..c99ba2ba 100644 --- a/job-tracker-ui/src/views/JobDiscoveryPage.tsx +++ b/job-tracker-ui/src/views/JobDiscoveryPage.tsx @@ -171,16 +171,17 @@ export default function JobDiscoveryPage() { finally { pending.current = false; setMutating(false); } }; + const locale = language === "nb" ? "nb-NO" : "en"; const sortedJobs = useMemo(() => [...jobs].sort((left, right) => { - if (sortOrder === "title") return left.title.localeCompare(right.title); + if (sortOrder === "title") return left.title.localeCompare(right.title, locale); if (sortOrder === "deadline") { const leftDeadline = left.deadline ? new Date(left.deadline).getTime() : Number.MAX_SAFE_INTEGER; const rightDeadline = right.deadline ? new Date(right.deadline).getTime() : Number.MAX_SAFE_INTEGER; return leftDeadline - rightDeadline; } return new Date(right.modifiedAt || 0).getTime() - new Date(left.modifiedAt || 0).getTime(); - }), [jobs, sortOrder]); - const formatDate = (value?: string) => value ? new Date(value).toLocaleDateString(language === "nb" ? "nb-NO" : "en") : null; + }), [jobs, sortOrder, locale]); + const formatDate = (value?: string) => value ? new Date(value).toLocaleDateString(locale) : null; return (