Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 97d06180b7 | |||
| 4c12f7691f | |||
| af33e45542 | |||
| 1ce93ca0e3 |
@@ -0,0 +1,7 @@
|
||||
# Admin System metric formatting
|
||||
|
||||
Bounded I18N-02 display fix on `codex/i18n-admin-metrics`, based on the isolated salary checkpoint `af33e45542aea1e18088c44308161284fdca47a7`. The same unlocalised storage-size and benchmark-percentage formatting was present at A3 `49e79875`.
|
||||
|
||||
Database storage sizes and CV benchmark summary and entry percentages now format using the current English or Bokmål UI locale. Storage retains the existing B/KB/MB boundaries and one fractional digit for KB and MB. Percentages use locale-specific spacing and whole-number precision. Input metrics, missing-value fallbacks, threshold colours and diagnostic operation paths remain intact.
|
||||
|
||||
The existing mounted Bokmål diagnostic test reproduced the missing `2,0 KB` display before the fix and now checks that display and the localised `72 %` benchmark result. `npm test -- --runInBand --runTestsByPath src/admin-system-page.test.tsx src/translation-catalogue.test.ts` passed 23 tests across two suites. Full frontend typecheck, scoped ESLint with zero warnings and the Next static export passed. No browser/device or native-language review and no actual diagnostic or benchmark execution.
|
||||
@@ -0,0 +1,17 @@
|
||||
# Attachment size localisation
|
||||
|
||||
3 November 2026. Isolated `codex/i18n-attachment-sizes` from published `ca519cacbec6ee8e385927ad31356dacc18b2f2d`. Ordinary localisation work is authorised independently of the pending A3 increment gate. This branch does not replace the newer A3 application tree.
|
||||
|
||||
`Attachments` and the frozen submitted-package attachment list displayed decimal points through `toFixed`, even with Norwegian UI text. They now use `Intl.NumberFormat` with the existing UI language. The attachment table keeps its existing 1024-based KB/MB threshold, zero KB decimals and one MB decimal. Frozen package labels keep one KB decimal. No stored file size, filename, snapshot, route, API, revision, download or generation language changes.
|
||||
|
||||
Verification:
|
||||
|
||||
- Red regression: two Norwegian-format cases failed, one English case passed; 58 cases were filtered, not executed. Native `attachment-size-red.json`.
|
||||
- Full affected suites: **61 passed**, zero failed/skipped, two suites; `attachment-size-tests.json`. Includes EN/NB MB labels and changing the UI locale on a retained frozen package without a write.
|
||||
- Typecheck, scoped ESLint for the four affected files, static-export build and `git diff --check`: PASSED.
|
||||
- AST-only graph refresh PASSED:18,702 nodes/46,195 edges/1,119 communities. Optional SQL parser is absent and143 files yielded no symbols; these are graph coverage limits. Generated graph stays local.
|
||||
- Independent static review found no actionable issues; verification reruns were not added to the61 cases.
|
||||
|
||||
Rollback reverts these four source/test changes; no database rollback is required. The published branch remains separate until its narrow delta is reconciled against the newer A3 baseline. Existing physical PDF metrics and visual acceptance are unaffected; no renderer source changed.
|
||||
|
||||
NOT RUN: new browser/device or physical PDF checks, complete European localisation, native-language review, backend/provider/live-model or release qualification. Existing tests and code remain responsible for file permissions and downloads; this display-only change adds no new access path. The 188 historical dynamic-call reviews remain accounted for and are not expanded by these three numeric-format regressions.
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"base": "ca519cacbec6ee8e385927ad31356dacc18b2f2d",
|
||||
"policy": "LF-normalised text SHA256",
|
||||
"files": {
|
||||
"job-tracker-ui/src/components/Attachments.tsx": "02081be8282cb3d1a525b336bededb8aabe561cc3b73e1b98e924f9ef945add1",
|
||||
"job-tracker-ui/src/components/ApplicationAssets.tsx": "effcee6cfa0a43ebe0d73a0da625410dc98a6580fddda225b843e5e00d4e45f6",
|
||||
"job-tracker-ui/src/attachments.test.tsx": "89bb3e277ae7be652073eec6d3b6188bcbeaea8b29b07478ff5b21654be8e7c1",
|
||||
"job-tracker-ui/src/application-assets.test.tsx": "980a346cb4452ac4b7e6b689a963e5a23fc6d77410e47e66a422f9cd9c704e7b",
|
||||
"docs/i18n/ATTACHMENT_SIZE_FORMATTING.md": "4576ccfc3de3b72cd3e5591c40f697b827d724b8f4fe588b54567dbe75252332",
|
||||
"docs/i18n/attachment-size-red.json": "872882cb7d2937bf1df7f7a89810158c5de831381f56c6ae8bb0263d26b84d8e",
|
||||
"docs/i18n/attachment-size-tests.json": "8b8188d432b7788f951cf2c67f790c6cd4d24553ed9accbeb702645b3d4b2c6a"
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,11 @@
|
||||
# Current localisation continuation
|
||||
|
||||
Changing the Jobs or Trash display language keeps the existing query results and localises the current error notice. See [the validated request-count comparison](JOBS_LOCALE_READS.md).
|
||||
|
||||
Admin System database sizes and CV benchmark percentages now follow the EN/NB UI locale on the isolated admin-metrics branch. See [implementation and validation](ADMIN_METRIC_FORMATTING.md).
|
||||
|
||||
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.
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# Locale-only Jobs and Trash reads
|
||||
|
||||
Bounded PERF-08/PERF-13 and I18N-02 work on `codex/perf-jobs-locale-read`, based on `4c12f7691ff5e73f537135ffed60c0b599c7ce37`. The same Jobs read dependency on translated text was present in the inspected A3 branch.
|
||||
|
||||
Changing the UI language used to reload the same application-list query. The resource loader now tracks data inputs only. The existing error notice renders the current-language title and retains available server details. The shared resource and session hooks, query parameters, retry, data refresh and mutation paths remain intact. This change applies to Jobs and Trash. It does not affect saved language preference writes.
|
||||
|
||||
The three focused assertions reproduced the redundant request before the fix. Afterwards 34 tests passed across JobTable, Offer Comparison, Jobs view and the I18n provider. The tests cover already-loaded mobile salaries and current Jobs and Trash errors switching to Bokmål, followed by an explicit retry of the same query. Full frontend typecheck, scoped ESLint with zero warnings and the Next static export passed.
|
||||
|
||||
## Isolated browser measurement
|
||||
|
||||
Chromium 151.0.7922.34, 1366 × 900, fresh browser context per route, local Next 16.4.1/Turbopack static export, no throttle. Loopback serving disables HTTP caching. Requests to external hosts were blocked. Route interception supplied synthetic API responses, including 15 synthetic application rows and a fixture session. Resource Timing identified loaded JavaScript and CSS. The table reports file byte sizes and offline gzip-6 estimates, excluding HTTP headers and runtime-generated styles.
|
||||
|
||||
| Route | JS files before → after | Raw JS bytes before → after | Estimated gzip-6 JS bytes before → after |
|
||||
| --- | ---: | ---: | ---: |
|
||||
| Landing | 18 → 18 | 2,005,036 → 2,005,009 | 591,192 → 591,190 |
|
||||
| Login | 18 → 18 | 2,005,036 → 2,005,009 | 591,192 → 591,190 |
|
||||
| Jobs | 20 → 20 | 2,033,779 → 2,033,752 | 601,065 → 601,063 |
|
||||
|
||||
All three routes reached their content marker and reported no page errors in both runs. Each loaded one 293-byte CSS file. Jobs made one initial paginated GET in both runs. After switching from English to Bokmål via the storage event, the paginated GET count changed from **one extra request to zero**. The initial Jobs fetch, authentication and other startup requests still occurred. Timing samples were single unthrottled loopback observations, so this checkpoint claims no latency or Web Vitals improvement. Native device, A3 integration and release qualification remain open.
|
||||
@@ -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.
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -472,6 +472,8 @@ describe('AdminSystemPage', () => {
|
||||
expect(await screen.findByText(/Mistenkelig kontaktsted: san francisco, hobbies/i)).toBeTruthy();
|
||||
expect(screen.getByText('Production')).toBeTruthy();
|
||||
expect(screen.getByText(/Modell · qwen2.5:7b/i)).toBeTruthy();
|
||||
expect(screen.getByText(/2,0 KB/)).toBeVisible();
|
||||
expect(screen.getByText('72 %')).toBeVisible();
|
||||
fireEvent.click(screen.getByRole('tab', { name: /innstillinger/i }));
|
||||
expect(await screen.findByDisplayValue('Dette er en test-e-post fra systempanelet i Jobbjakt.')).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -104,6 +104,18 @@ function changeLanguage(language: UiLanguage) {
|
||||
act(() => { window.dispatchEvent(new StorageEvent("storage", { key: "uiLanguage", newValue: language })); });
|
||||
}
|
||||
|
||||
test('frozen attachment size follows UI language without changing stored package data', async () => {
|
||||
const attachment = { id: 81, fileName: 'recorded.pdf', fileSize: 1536, fileType: 'application/pdf' };
|
||||
const summary = { id: 91, version: 1, cvVariantId: 3, cvVariantName: 'Original CV', cvVariantVersion: 2, cvThemeId: 'nordic', hasCv: true, hasCoverLetter: true, createdAtUtc: '2026-07-19T10:00:00Z', attachments: [attachment] };
|
||||
routeGet({ submittedPackages: [summary] });
|
||||
render(<SubmittedPackagesSection jobId={7} />);
|
||||
expect(await screen.findByRole('button', { name: /recorded.pdf.*1.5 KB/ })).toBeInTheDocument();
|
||||
changeLanguage('nb');
|
||||
expect(screen.getByRole('button', { name: /recorded.pdf.*1,5 KB/ })).toBeInTheDocument();
|
||||
expect(summary.attachments[0]).toEqual(attachment);
|
||||
expect(mockedApi.post).not.toHaveBeenCalled(); expect(mockedApi.put).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason: unknown) => void;
|
||||
|
||||
@@ -22,6 +22,15 @@ const mockedApi = api as jest.Mocked<typeof api>;
|
||||
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
test.each([['en', '1.5 MB'], ['nb', '1,5 MB']])('attachment size follows the UI locale (%s)', async (language, size) => {
|
||||
const previous = localStorage.getItem('uiLanguage');
|
||||
localStorage.setItem('uiLanguage', language);
|
||||
mockedApi.get.mockResolvedValueOnce({ data: [{ id: 8, fileName: 'recorded.pdf', fileType: 'application/pdf', fileSize: 1572864, uploadDate: '2026-11-03T10:00:00Z' }] } as any);
|
||||
const view = render(<ToastProvider><I18nProvider><ConfirmProvider><PromptProvider><Attachments jobId={42} /></PromptProvider></ConfirmProvider></I18nProvider></ToastProvider>);
|
||||
try { expect(await screen.findByText(size)).toBeInTheDocument(); expect(mockedApi.post).not.toHaveBeenCalled(); }
|
||||
finally { view.unmount(); if (previous === null) localStorage.removeItem('uiLanguage'); else localStorage.setItem('uiLanguage', previous); }
|
||||
});
|
||||
|
||||
test.each([
|
||||
['en', 'Preview: resume.pdf', 'Preview: resume.pdf'],
|
||||
['nb', 'Forhåndsvis: resume.pdf', 'Forhåndsvisning: resume.pdf'],
|
||||
|
||||
@@ -357,7 +357,7 @@ export function SubmittedPackagesSection({ jobId, allowCapture = true }: { jobId
|
||||
</Button>
|
||||
{item.attachments.map((attachment) => (
|
||||
<Button key={attachment.id} size="small" variant="text" disabled={pending} startIcon={<DownloadOutlinedIcon />} onClick={() => void download(item, attachment)} sx={{ alignSelf: "flex-start", maxWidth: "100%", overflowWrap: "anywhere", textAlign: "left" }}>
|
||||
{attachment.fileName} · {(attachment.fileSize / 1024).toFixed(1)} KB
|
||||
{attachment.fileName} · {new Intl.NumberFormat(language === "nb" ? "nb-NO" : "en", { minimumFractionDigits: 1, maximumFractionDigits: 1 }).format(attachment.fileSize / 1024)} KB
|
||||
</Button>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
@@ -47,11 +47,12 @@ interface AttachmentItem {
|
||||
useForAi: boolean;
|
||||
}
|
||||
|
||||
function fmtSize(n: number) {
|
||||
function fmtSize(n: number, language: string) {
|
||||
if (!n) return "";
|
||||
const kb = n / 1024;
|
||||
if (kb < 1024) return `${kb.toFixed(0)} KB`;
|
||||
return `${(kb / 1024).toFixed(1)} MB`;
|
||||
const digits = kb < 1024 ? 0 : 1;
|
||||
const value = new Intl.NumberFormat(language === "nb" ? "nb-NO" : "en", { minimumFractionDigits: digits, maximumFractionDigits: digits }).format(kb < 1024 ? kb : kb / 1024);
|
||||
return `${value} ${kb < 1024 ? "KB" : "MB"}`;
|
||||
}
|
||||
|
||||
function isImageType(t: string) {
|
||||
@@ -303,7 +304,7 @@ export default function Attachments({ jobId }: { jobId: number }) {
|
||||
</TableCell>
|
||||
<TableCell>{kind}</TableCell>
|
||||
<TableCell sx={{ color: "text.secondary" }}>{a.fileType ? a.fileType.replace("application/", "") : ""}</TableCell>
|
||||
<TableCell sx={{ color: "text.secondary" }}>{a.fileSize ? fmtSize(a.fileSize) : ""}</TableCell>
|
||||
<TableCell sx={{ color: "text.secondary" }}>{a.fileSize ? fmtSize(a.fileSize, language) : ""}</TableCell>
|
||||
<TableCell sx={{ color: "text.secondary" }}>{a.uploadDate ? new Date(a.uploadDate).toLocaleString(language === "nb" ? "nb-NO" : "en") : ""}</TableCell>
|
||||
<TableCell>
|
||||
<FormControl size="small" fullWidth>
|
||||
|
||||
@@ -316,8 +316,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],
|
||||
},
|
||||
);
|
||||
|
||||
@@ -768,7 +768,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="overline" 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>
|
||||
|
||||
|
||||
@@ -146,6 +146,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,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}` : ""}`;
|
||||
}
|
||||
|
||||
@@ -167,11 +167,12 @@ type SystemStatus = {
|
||||
ai: AiServiceMetrics;
|
||||
};
|
||||
|
||||
function formatBytes(bytes?: number | null) {
|
||||
function formatBytes(bytes: number | null | undefined, locale: string) {
|
||||
if (bytes == null) return "-";
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
if (bytes < 1024) return `${bytes.toLocaleString(locale)} B`;
|
||||
const options = { minimumFractionDigits: 1, maximumFractionDigits: 1 };
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toLocaleString(locale, options)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toLocaleString(locale, options)} MB`;
|
||||
}
|
||||
|
||||
function displayMetadata(value?: string | null) {
|
||||
@@ -182,8 +183,8 @@ function formatDate(value?: string | null, locale?: string) {
|
||||
return value ? new Date(value).toLocaleString(locale) : "-";
|
||||
}
|
||||
|
||||
function formatPercent(value?: number | null) {
|
||||
return typeof value === "number" ? `${Math.round(value * 100)}%` : "-";
|
||||
function formatPercent(value: number | null | undefined, locale: string) {
|
||||
return typeof value === "number" ? value.toLocaleString(locale, { style: "percent", maximumFractionDigits: 0 }) : "-";
|
||||
}
|
||||
|
||||
function parseBenchmarkIndex(indexJson?: string | null): CvBenchmarkIndex | null {
|
||||
@@ -225,6 +226,7 @@ const actionErrorKeys: Record<ActionKind, TranslationKey> = { save: "adminSystem
|
||||
|
||||
export default function AdminSystemPage() {
|
||||
const { language, t } = useI18n();
|
||||
const locale = language === "nb" ? "nb-NO" : "en";
|
||||
const [tab, setTab] = useState(0);
|
||||
const [status, setStatus] = useState<SystemStatus | null>(null);
|
||||
const [emailSettings, setEmailSettings] = useState<EditableEmailSettings | null>(null);
|
||||
@@ -495,7 +497,7 @@ export default function AdminSystemPage() {
|
||||
<DetailRow label={t("adminSystemDataRoot")} value={status?.storage.dataRoot || "-"} />
|
||||
<DetailRow label={t("adminSystemDbPath")} value={status?.storage.dbPath || "-"} />
|
||||
<DetailRow label={t("adminSystemDbFileExists")} value={status?.storage.dbExists ? t("yes") : t("noWord")} />
|
||||
<DetailRow label={t("adminSystemDbSize")} value={formatBytes(status?.storage.dbSizeBytes)} />
|
||||
<DetailRow label={t("adminSystemDbSize")} value={formatBytes(status?.storage.dbSizeBytes, locale)} />
|
||||
<DetailRow label={t("companies")} value={status?.storage.companyCount ?? 0} />
|
||||
<DetailRow label={t("adminSystemJobs")} value={status?.storage.jobCount ?? 0} />
|
||||
<DetailRow label={t("adminSystemDeletedJobs")} value={status?.storage.deletedCount ?? 0} />
|
||||
@@ -612,9 +614,9 @@ export default function AdminSystemPage() {
|
||||
<>
|
||||
<Box sx={{ mt: 2, display: "grid", gridTemplateColumns: { xs: "1fr 1fr", md: "repeat(5, 1fr)" }, gap: 1.25 }}>
|
||||
<SummaryCard title={t("adminBenchmarkFiles")} value={String(benchmarkIndex.TotalFiles)} subtitle={t("adminBenchmarkCorpusInputs")} />
|
||||
<SummaryCard title={t("adminBenchmarkCoverage")} value={formatPercent(benchmarkIndex.AverageCoverage)} subtitle={t("adminBenchmarkCoverageHelp")} tone={benchmarkTone(benchmarkIndex.AverageCoverage)} />
|
||||
<SummaryCard title={t("adminBenchmarkConfidence")} value={formatPercent(benchmarkIndex.AverageConfidence)} subtitle={t("adminBenchmarkConfidenceHelp")} tone={benchmarkTone(benchmarkIndex.AverageConfidence)} />
|
||||
<SummaryCard title={t("adminBenchmarkConsistency")} value={formatPercent(benchmarkIndex.AverageConsistency)} subtitle={t("adminBenchmarkConsistencyHelp")} tone={benchmarkTone(benchmarkIndex.AverageConsistency)} />
|
||||
<SummaryCard title={t("adminBenchmarkCoverage")} value={formatPercent(benchmarkIndex.AverageCoverage, locale)} subtitle={t("adminBenchmarkCoverageHelp")} tone={benchmarkTone(benchmarkIndex.AverageCoverage)} />
|
||||
<SummaryCard title={t("adminBenchmarkConfidence")} value={formatPercent(benchmarkIndex.AverageConfidence, locale)} subtitle={t("adminBenchmarkConfidenceHelp")} tone={benchmarkTone(benchmarkIndex.AverageConfidence)} />
|
||||
<SummaryCard title={t("adminBenchmarkConsistency")} value={formatPercent(benchmarkIndex.AverageConsistency, locale)} subtitle={t("adminBenchmarkConsistencyHelp")} tone={benchmarkTone(benchmarkIndex.AverageConsistency)} />
|
||||
<SummaryCard title={t("adminBenchmarkMissingApproved")} value={String(benchmarkIndex.MissingApprovedFixtures)} subtitle={t("adminBenchmarkNeedsReview")} tone={benchmarkIndex.MissingApprovedFixtures > 0 ? "warning" : "success"} />
|
||||
</Box>
|
||||
|
||||
@@ -638,9 +640,9 @@ export default function AdminSystemPage() {
|
||||
<Box key={entry.Slug} sx={{ p: 1.25, borderRadius: 2, backgroundColor: "background.default", border: "none", boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)" }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 800 }}>{entry.FileName}</Typography>
|
||||
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap", mt: 0.75 }}>
|
||||
<Chip size="small" label={t("adminBenchmarkCoverageValue", { value: formatPercent(entry.CoverageScore) })} color={benchmarkTone(entry.CoverageScore)} />
|
||||
<Chip size="small" label={t("adminBenchmarkConfidenceValue", { value: formatPercent(entry.ConfidenceScore) })} color={benchmarkTone(entry.ConfidenceScore)} />
|
||||
<Chip size="small" label={t("adminBenchmarkConsistencyValue", { value: formatPercent(entry.ConsistencyScore) })} color={benchmarkTone(entry.ConsistencyScore)} />
|
||||
<Chip size="small" label={t("adminBenchmarkCoverageValue", { value: formatPercent(entry.CoverageScore, locale) })} color={benchmarkTone(entry.CoverageScore)} />
|
||||
<Chip size="small" label={t("adminBenchmarkConfidenceValue", { value: formatPercent(entry.ConfidenceScore, locale) })} color={benchmarkTone(entry.ConfidenceScore)} />
|
||||
<Chip size="small" label={t("adminBenchmarkConsistencyValue", { value: formatPercent(entry.ConsistencyScore, locale) })} color={benchmarkTone(entry.ConsistencyScore)} />
|
||||
</Box>
|
||||
<Typography variant="caption" sx={{ display: "block", color: "text.secondary", mt: 0.75 }}>{entry.DiffSummary || "-"}</Typography>
|
||||
</Box>
|
||||
|
||||
Reference in New Issue
Block a user