From b8292f6f7299daa704bbe41efa8081660c2b6e27 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Tue, 10 Nov 2026 22:44:24 +0100 Subject: [PATCH] perf(jobs): integrate locale-stable reads and salary formatting --- job-tracker-ui/src/components/JobTable.tsx | 6 +- job-tracker-ui/src/job-table-density.test.tsx | 60 +++++++++++++++++++ job-tracker-ui/src/landing-page.test.tsx | 2 +- job-tracker-ui/src/salary.ts | 19 +++--- 4 files changed, 75 insertions(+), 12 deletions(-) diff --git a/job-tracker-ui/src/components/JobTable.tsx b/job-tracker-ui/src/components/JobTable.tsx index 933aa9af..883aa218 100644 --- a/job-tracker-ui/src/components/JobTable.tsx +++ b/job-tracker-ui/src/components/JobTable.tsx @@ -324,8 +324,8 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col { initialData: { items: [], total: 0, page: 1, pageSize }, enabled: !boardView, - errorMessage: t("jobTableJobsLoadFailed"), - deps: [params, refreshToken, reloadToken, pageSize, t], + errorMessage: "", // ViewStateNotice renders the fallback in the current language. + deps: [params, refreshToken, reloadToken, pageSize], }, ); @@ -790,7 +790,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col {t("addJobModalSalary")} - {formatSalary(job) ?? "-"} + {formatSalary(job, language, t) ?? "-"} diff --git a/job-tracker-ui/src/job-table-density.test.tsx b/job-tracker-ui/src/job-table-density.test.tsx index f2688d28..155fecee 100644 --- a/job-tracker-ui/src/job-table-density.test.tsx +++ b/job-tracker-ui/src/job-table-density.test.tsx @@ -209,6 +209,66 @@ test("Bokmål display localizes standard statuses without changing API values or await waitFor(() => expect(mockedApi.patch).toHaveBeenCalledWith("/jobapplications/42/status", { status: "Offer" })); }); +test("mobile salary follows UI language and preserves imported periods and free text", async () => { + const previous = Object.getOwnPropertyDescriptor(window, "matchMedia"); + Object.defineProperty(window, "matchMedia", { configurable: true, value: (query: string) => ({ + matches: query === "(max-width:767.95px)", media: query, addEventListener: jest.fn(), removeEventListener: jest.fn(), + }) }); + const items = [ + { ...job, salaryMin: 800000.5, salaryMax: 900000, salaryCurrency: "NOK", salaryPeriod: "year" }, + { ...job, id: 43, jobTitle: "Second role", salaryMin: 0, salaryPeriod: "constructor" }, + { ...job, id: 44, jobTitle: "Third role", salary: "Lønn etter avtale" }, + ]; + mockedApi.get.mockImplementation((url: string) => Promise.resolve({ + data: url === "/companies" ? [job.company] : url === "/jobapplications" ? { items, total: items.length, page: 1, pageSize: 15 } : [], + } as any)); + try { + show(); + await screen.findByText(job.jobTitle); + const normalize = (value: string | null) => value?.replace(/\s/g, " "); + const salary = (locale: string, period: string) => { + const fmt = new Intl.NumberFormat(locale); + return normalize(`${fmt.format(800000.5)}–${fmt.format(900000)} NOK/${period}`); + }; + const card = (title: string) => screen.getByRole("link", { name: new RegExp(title, "i") }); + expect(normalize(card(job.jobTitle).textContent)).toContain(salary("en", translations.en.salaryPeriodYear)); + const params = lastParams(); + const readCount = calls().length; + await act(async () => { window.dispatchEvent(new StorageEvent("storage", { key: "uiLanguage", newValue: "nb" })); }); + expect(normalize(card(job.jobTitle).textContent)).toContain(salary("nb-NO", translations.nb.salaryPeriodYear)); + expect(within(card("Second role")).getByText("0/constructor")).toBeVisible(); + expect(within(card("Third role")).getByText("Lønn etter avtale")).toBeVisible(); + expect(lastParams()).toEqual(params); + expect(calls()).toHaveLength(readCount); + expect(mockedApi.put).not.toHaveBeenCalled(); + } finally { + if (previous) Object.defineProperty(window, "matchMedia", previous); + else Reflect.deleteProperty(window, "matchMedia"); + } +}); + +test.each(["jobs", "trash"] as const)("language changes localize the current %s read error without a new request", async (mode) => { + const title = mode === "jobs" ? "jobTableJobsLoadFailed" : "jobTableTrashLoadFailed"; + mockedApi.get.mockImplementation((url: string) => url === "/jobapplications" + ? Promise.reject({ response: { status: 503 } }) + : Promise.resolve({ data: [job.company] } as any)); + show(mode, "?q=platform"); + expect(await screen.findByRole("alert")).toHaveTextContent(translations.en[title]); + const readCount = calls().length; + const params = lastParams(); + await act(async () => { window.dispatchEvent(new StorageEvent("storage", { key: "uiLanguage", newValue: "nb" })); }); + expect(screen.getByRole("alert")).toHaveTextContent(translations.nb[title]); + expect(screen.queryByText(translations.en[title])).not.toBeInTheDocument(); + expect(calls()).toHaveLength(readCount); + expect(mockedApi.put).not.toHaveBeenCalled(); + + mockedApi.get.mockResolvedValue({ data: { items: [], total: 0, page: 1, pageSize: 15 } } as any); + fireEvent.click(screen.getByRole("button", { name: translations.nb.retry })); + await waitFor(() => expect(screen.queryByRole("alert")).not.toBeInTheDocument()); + expect(calls()).toHaveLength(readCount + 1); + expect(lastParams()).toEqual(params); +}); + test("partial bulk failure refreshes successful rows and retains only failed visible IDs", async () => { const other = { ...job, id: 43, jobTitle: "Second role" }; mockedApi.get.mockImplementation((url: string) => Promise.resolve({ data: url === "/companies" ? [job.company] : url === "/jobapplications" ? { items: [job, other], total: 2, page: 1, pageSize: 15 } : [] } as any)); diff --git a/job-tracker-ui/src/landing-page.test.tsx b/job-tracker-ui/src/landing-page.test.tsx index 3a255ad2..65a9859b 100644 --- a/job-tracker-ui/src/landing-page.test.tsx +++ b/job-tracker-ui/src/landing-page.test.tsx @@ -43,7 +43,7 @@ test("shows exactly Free and Pro without invented commercial terms", async () => test("switches the public landing page to Norwegian Bokmål", async () => { render(); - fireEvent.click(await screen.findByRole("button", { name: "Norsk" })); + fireEvent.click(await screen.findByRole("button", { name: "Norsk bokmål" })); expect(await screen.findByRole("heading", { name: "To tydelige alternativer" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Opprett en Free-konto" })).toBeInTheDocument(); }); diff --git a/job-tracker-ui/src/salary.ts b/job-tracker-ui/src/salary.ts index e5586ab5..628fac88 100644 --- a/job-tracker-ui/src/salary.ts +++ b/job-tracker-ui/src/salary.ts @@ -1,21 +1,24 @@ -import { JobApplication } from "./types"; +import type { TranslationKey, UiLanguage } from "./i18n/translations"; +import type { JobApplication } from "./types"; type SalaryFields = Pick; -const PERIOD_SUFFIX: Record = { year: "yr", month: "mo", hour: "hr" }; - -/** Structured salary when present ("60 000–70 000 NOK/yr"), otherwise the free-text field. */ -export function formatSalary(job: SalaryFields): string | null { +/** Localised structured salary, otherwise the original free-text field. */ +export function formatSalary(job: SalaryFields, language: UiLanguage, t: (key: TranslationKey) => string): string | null { const { salaryMin, salaryMax, salaryCurrency, salaryPeriod } = job; if (salaryMin == null && salaryMax == null) { return job.salary?.trim() || null; } - const fmt = (value: number) => value.toLocaleString(); + const fmt = (value: number) => value.toLocaleString(language === "nb" ? "nb-NO" : "en"); const range = salaryMin != null && salaryMax != null && salaryMin !== salaryMax ? `${fmt(salaryMin)}–${fmt(salaryMax)}` : fmt((salaryMin ?? salaryMax) as number); const currency = salaryCurrency ? ` ${salaryCurrency}` : ""; - const period = salaryPeriod ? `/${PERIOD_SUFFIX[salaryPeriod] ?? salaryPeriod}` : ""; - return `${range}${currency}${period}`; + const period = salaryPeriod?.toLowerCase(); + const periodLabel = period === "year" ? t("salaryPeriodYear") + : period === "month" ? t("salaryPeriodMonth") + : period === "hour" ? t("salaryPeriodHour") + : salaryPeriod; + return `${range}${currency}${periodLabel ? `/${periodLabel}` : ""}`; }