diff --git a/docs/i18n/CURRENT_STATUS.md b/docs/i18n/CURRENT_STATUS.md
index cdb9435b..b8130368 100644
--- a/docs/i18n/CURRENT_STATUS.md
+++ b/docs/i18n/CURRENT_STATUS.md
@@ -1,5 +1,7 @@
# Current localisation continuation
+Mobile application cards now use the selected EN/NB locale and existing salary-period labels on the isolated mobile-salary branch. See [the bounded implementation and validation](MOBILE_SALARY_FORMATTING.md). Full I18N-02 surface coverage and European locale acceptance remain open.
+
3 November 2026 — independent document-date fix: invalid uploaded wall-clock dates/times/trailing junk now show the existing EN/NB unknown-date message instead of JavaScript rollover.40 tests/3 suites, typecheck, scoped lint and build pass; see DOCUMENT_DATE_VALIDATION.md. All four historical direct-catalogue accesses have contextual dispositions in DIRECT_CATALOGUE_REVIEW.md; current full extraction/backend literals/every-string triage remain open. No new locales or release approval. This branch is not yet integrated into A3 dashboard/application work.
Register portability correction: schema 3 explicitly normalises CRLF to LF when hashing reviewed TS/TSX/JSON text and the original inventory; historicalSourceSha256 retains the original inventory's raw-hash meaning. This avoids treating Git checkout line-ending conversion as a source edit. Integration initially detected the raw inventory mismatch; original snapshot contents and historical source hashes were preserved, and both worktrees are revalidated with the declared encoding. This is evidence portability, not renewed runtime execution.
diff --git a/docs/i18n/MOBILE_SALARY_FORMATTING.md b/docs/i18n/MOBILE_SALARY_FORMATTING.md
new file mode 100644
index 00000000..0efef5cb
--- /dev/null
+++ b/docs/i18n/MOBILE_SALARY_FORMATTING.md
@@ -0,0 +1,9 @@
+# Mobile application salary formatting
+
+Scope: bounded I18N-02 display fix on `codex/i18n-mobile-salary` from `1ce93ca0e3a906673e636e56f1b910756ca78bb2`. The same unlocalised formatter and mobile caller were also present at A3 `49e79875`. The 188 historical dynamic-call reviews remain accounted for.
+
+The mobile Jobs and Trash cards use the selected EN/NB UI locale for numeric salary formatting and the existing Year, Month and Hour labels. Recognised period codes match case-insensitively. Other imported period text stays literal, including values that would otherwise collide with inherited JavaScript properties. Currency, saved amounts and the existing free-text fallback are retained. Offer Comparison keeps its own existing formatting contract.
+
+The new mounted-card test failed on the previous English `yr` suffix. It then verified English and Bokmål grouping and decimals after a live UI-language change, preservation of zero, an unknown imported period and free text, unchanged application query parameters and no preference write from the synthetic storage event. The page's existing language-change refresh can repeat the same read. This checkpoint did not change that behaviour.
+
+Validation from `job-tracker-ui`: `npm test -- --runInBand --runTestsByPath src/job-table-density.test.tsx src/offer-comparison.test.tsx` passed 20 tests across two suites. `npm run typecheck`, targeted ESLint with zero warnings and `npm run build` passed. The build generated three static pages. Browser/device and native-language reviews remain open. No new locale or release qualification is claimed.
diff --git a/job-tracker-ui/src/components/JobTable.tsx b/job-tracker-ui/src/components/JobTable.tsx
index 94503417..e2f1d49a 100644
--- a/job-tracker-ui/src/components/JobTable.tsx
+++ b/job-tracker-ui/src/components/JobTable.tsx
@@ -768,7 +768,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 ffbe7b17..d36658bc 100644
--- a/job-tracker-ui/src/job-table-density.test.tsx
+++ b/job-tracker-ui/src/job-table-density.test.tsx
@@ -146,6 +146,42 @@ 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();
+ 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(mockedApi.put).not.toHaveBeenCalled();
+ } finally {
+ if (previous) Object.defineProperty(window, "matchMedia", previous);
+ else Reflect.deleteProperty(window, "matchMedia");
+ }
+});
+
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/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}` : ""}`;
}