fix(i18n): sort discovery titles using selected UI locale

This commit is contained in:
cesnimda
2026-11-10 23:16:58 +01:00
parent 6c75e97bdb
commit ce7e48665c
7 changed files with 95 additions and 3 deletions
+15
View File
@@ -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.
@@ -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".
@@ -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.
@@ -0,0 +1,4 @@
> job-tracker-ui@0.1.0 typecheck
> tsc --noEmit --incremental false
+30
View File
@@ -97,6 +97,36 @@ test("sorts returned vacancies by title", async () => {
expect(screen.getAllByRole("heading", { level: 3 }).map((element) => element.textContent)).toEqual(["Analyst", "Zoologist"]); 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 () => { test("renders the discovery workflow in Norwegian Bokmål", async () => {
window.localStorage.setItem("uiLanguage", "nb"); window.localStorage.setItem("uiLanguage", "nb");
renderPage(); renderPage();
@@ -171,16 +171,17 @@ export default function JobDiscoveryPage() {
finally { pending.current = false; setMutating(false); } finally { pending.current = false; setMutating(false); }
}; };
const locale = language === "nb" ? "nb-NO" : "en";
const sortedJobs = useMemo(() => [...jobs].sort((left, right) => { 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") { if (sortOrder === "deadline") {
const leftDeadline = left.deadline ? new Date(left.deadline).getTime() : Number.MAX_SAFE_INTEGER; 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; const rightDeadline = right.deadline ? new Date(right.deadline).getTime() : Number.MAX_SAFE_INTEGER;
return leftDeadline - rightDeadline; return leftDeadline - rightDeadline;
} }
return new Date(right.modifiedAt || 0).getTime() - new Date(left.modifiedAt || 0).getTime(); return new Date(right.modifiedAt || 0).getTime() - new Date(left.modifiedAt || 0).getTime();
}), [jobs, sortOrder]); }), [jobs, sortOrder, locale]);
const formatDate = (value?: string) => value ? new Date(value).toLocaleDateString(language === "nb" ? "nb-NO" : "en") : null; const formatDate = (value?: string) => value ? new Date(value).toLocaleDateString(locale) : null;
return ( return (
<Stack spacing={3} sx={{ minWidth: 0 }}> <Stack spacing={3} sx={{ minWidth: 0 }}>