perf(jobs): integrate locale-stable reads and salary formatting

This commit is contained in:
cesnimda
2026-11-10 22:44:24 +01:00
parent 6bfdbd9db7
commit b8292f6f72
4 changed files with 75 additions and 12 deletions
+3 -3
View File
@@ -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
</Box>
<Box sx={{ display: "flex", flexWrap: "wrap", columnGap: 0.5, alignItems: "baseline" }}>
<Typography variant="caption" sx={{ color: "text.secondary" }}>{t("addJobModalSalary")}</Typography>
<Typography variant="body2" sx={{ fontWeight: 600, fontVariantNumeric: "tabular-nums" }}>{formatSalary(job) ?? "-"}</Typography>
<Typography variant="body2" sx={{ fontWeight: 600, fontVariantNumeric: "tabular-nums" }}>{formatSalary(job, language, t) ?? "-"}</Typography>
</Box>
</Box>
@@ -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));
+1 -1
View File
@@ -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(<MemoryRouter><I18nProvider><LandingPage /></I18nProvider></MemoryRouter>);
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();
});
+11 -8
View File
@@ -1,21 +1,24 @@
import { JobApplication } from "./types";
import type { TranslationKey, UiLanguage } from "./i18n/translations";
import type { JobApplication } from "./types";
type SalaryFields = Pick<JobApplication, "salary" | "salaryMin" | "salaryMax" | "salaryCurrency" | "salaryPeriod">;
const PERIOD_SUFFIX: Record<string, string> = { 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}` : ""}`;
}