Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4c12f7691f | |||
| af33e45542 | |||
| 1ce93ca0e3 | |||
| ca519cacbe |
@@ -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
|
||||
|
||||
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.
|
||||
|
||||
2 November 2026. Historical 188 dynamic-call census finite key-domain review COMPLETE; full I18N-00 / European locale release acceptance remains INCOMPLETE. Original source-bound snapshots are retained under 00/. Machine register schema 2 binds inventory indices, source hashes and selected owner dependencies; 108 original plus 80 supplemental calls cover all 188 indices without overlap. Static review does not establish all translator sensitivity/context, every visible/backend string, grammar/plural quality, runtime coverage or native review.
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
# Historical direct catalogue access review
|
||||
|
||||
3 November 2026. All four entries in the frozen 00/DIRECT_CATALOGUE_ACCESS.json are in I18nProvider.tsx. Reviewed actual current callers and source, not re-extracted as a claim of complete current coverage.
|
||||
|
||||
| Historical expression | Context and disposition |
|
||||
|---|---|
|
||||
| translations[l].settingsLanguageSaveFailed | Public static account-preference error copy, no runtime private value. Uses requested language, guarded by save sequence/current language before showing feedback. Deliberate provider-internal access; calling the context hook inside its owner is inappropriate. Retain. |
|
||||
| translations[l].retry | Static retry action label in the same guarded feedback; no interpolation/private data. Retain. |
|
||||
| translations[language][key] | Central typed message lookup inside t, passed to interpolation; all callers use this message owner. React callers render text; this review does not certify every sink. Retain. |
|
||||
| translations.en[key] | Deterministic English fallback for the central lookup. No country/document-language coupling. Retain. |
|
||||
|
||||
The provider's TranslationKey/UiLanguage contracts and catalogue completeness tests constrain these accesses; sequence guards prevent obsolete-save feedback/retries. No new catalogue bypass or runtime change was required. Existing provider/catalogue tests pass within the40-case document-date qualification; this is not native linguistic or full security acceptance.
|
||||
|
||||
Original inventory/hash/line records are immutable historical evidence. Current source identity is bound in document-date SOURCE_MANIFEST.json. This closes contextual classification of the four historical direct accesses only. Current full re-extraction, non-catalogue/backend literal inventory and every-string sensitivity/caller review remain incomplete. No runtime user content was exported or translated externally.
|
||||
@@ -0,0 +1,11 @@
|
||||
# Document upload wall-clock date correction
|
||||
|
||||
3 November 2026. Independent ordinary application fix, branch `codex/i18n-document-dates` from published d4b938fa. Not integrated into A3 yet; no wholesale merge of this older integration-derived tree.
|
||||
|
||||
Reproduced: the document index accepted an ISO prefix and let JavaScript normalise invalid wall-clock components. `2026-02-30T09:15:00`, hour24, minute61 and trailing junk displayed plausible timestamps. Eight EN/NB regression cases failed before the fix; two valid fractional-second cases passed. These failures are retained in evidence/document-dates-red.json;21 unrelated cases were skipped in that focused red execution.
|
||||
|
||||
The existing uploaded-local branch now accepts the complete unzoned .NET-style timestamp (optional1–7 fractional digits) and round-trips all calendar/time fields before formatting. Invalid input uses the existing localised Date not recorded message and emits no fabricated time element. Valid wall-clock components remain unchanged across time zones using the existing UTC formatting carrier. The raw datetime attribute remains unchanged. No API date-kind semantics, server data, UTC-history formatting, document content, download behavior, revisions or navigation changes. No new locale key/dependency/schema.
|
||||
|
||||
Final validation: **40 tests /3 suites PASSED**, documents31 + provider6 + catalogue3. Tests include metadata filters/paging/history, source links, all seven resource kinds, download/focus/cancellation, ownership-safe read behavior, both languages, invalid dates and valid .NET fractional seconds. All document tests assert no POST/PUT/PATCH/DELETE. Typecheck, scoped zero-warning lint, production build and diff whitespace check PASSED. An intermediate command used a nonexistent catalogue-test filename:37 cases passed but that execution failed one suite; it is not counted as passing. Corrected final run uses translation-catalogue.test.ts. No fresh browser/backend/native/provider qualification.
|
||||
|
||||
Rollback: revert this bounded view/parser and test commit; no data rollback. Broader document UTC date validation, partial-date semantics outside this surface, DST/native reviews and full European locale architecture remain open. No release or production qualification inferred.
|
||||
@@ -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
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"base": "d4b938fafbf10541b62134293cea8299f25c4031",
|
||||
"recordedAtUtc": "2026-11-03T08:50:34.776817+00:00",
|
||||
"policy": "LF-normalised SHA256 text",
|
||||
"files": {
|
||||
"job-tracker-ui/src/views/DocumentsPage.tsx": "2a3187938d7f61cc1d723bae5b784f09707713606b523db176d6df6d05a91c3d",
|
||||
"job-tracker-ui/src/documents-page.test.tsx": "abfa8c4bef73499c0546f7e9b8a091793b8c273f829b51159ae115fc54aa4ed3",
|
||||
"job-tracker-ui/src/i18n/I18nProvider.tsx": "d22b5d033d0e405ffb5d581c752d61a24056d1e496f5f6a2f1baecef500f2113",
|
||||
"docs/i18n/00/DIRECT_CATALOGUE_ACCESS.json": "c470c52c500d79f51c4cedd073c2a8474ae73b832e531df3c0c38369993cae3f",
|
||||
"docs/i18n/DOCUMENT_DATE_VALIDATION.md": "602cd31d84eed1e59ed000159bdc8b998b0dbdc5b7c965ad51831d26a16b31d6",
|
||||
"docs/i18n/DIRECT_CATALOGUE_REVIEW.md": "4eb88d2d1da2ed0cd52d49fc8dc7f11d1943d169c240e56ae3562d298bc6eb6a",
|
||||
"docs/i18n/evidence/document-dates-tests.json": "227187b5a02473f442c8600b434e5f0922fa63ba13bfb9355254b9a19b7d4e81",
|
||||
"docs/i18n/evidence/document-dates-red.json": "430ec89f685c2558d443677c596a12b587829a9a45e3f29dc03e042d3685a074"
|
||||
}
|
||||
}
|
||||
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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -38,6 +38,27 @@ function deferred<T>() {
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
describe.each(['en', 'nb'] as const)('%s uploaded wall-clock dates', locale => {
|
||||
test.each(['2026-02-30T09:15:00', '2026-02-12T24:15:00', '2026-02-12T09:61:00', '2026-02-12T09:15:00junk'])('does not invent a timestamp from %s', async recordedAt => {
|
||||
localStorage.setItem('uiLanguage', locale);
|
||||
mockedApi.get.mockResolvedValue({ data: page([item({ recordedAt, dateKind: 'uploaded-local' })]) } as never);
|
||||
renderPage();
|
||||
expect(await screen.findByText(copy('documentsDateUnknown', {}, locale))).toBeVisible();
|
||||
expect(document.querySelector('time')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('preserves fractional-second wall-clock metadata without timezone conversion', async () => {
|
||||
localStorage.setItem('uiLanguage', locale);
|
||||
const recordedAt = '2026-02-12T09:15:00.1234567';
|
||||
mockedApi.get.mockResolvedValue({ data: page([item({ recordedAt, dateKind: 'uploaded-local' })]) } as never);
|
||||
renderPage();
|
||||
await screen.findByText('Metadata CV ÆØÅ', { exact: true });
|
||||
const time = document.querySelector('time');
|
||||
expect(time).toHaveAttribute('datetime', recordedAt);
|
||||
expect(time).toHaveTextContent(new Intl.DateTimeFormat(locale === 'nb' ? 'nb-NO' : 'en-GB', { dateStyle: 'medium', timeStyle: 'short', timeZone: 'UTC' }).format(new Date('2026-02-12T09:15:00Z')));
|
||||
});
|
||||
});
|
||||
|
||||
function Probe() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -214,9 +214,16 @@ export default function DocumentsPage() {
|
||||
const label: TranslationKey = item.dateKind === "uploaded-local" ? "documentsUploadedLocal" : item.dateKind === "captured-utc" ? "documentsCaptured" : item.dateKind === "history-recorded-utc" ? "documentsHistoryRecorded" : "documentsUpdated";
|
||||
let value: Date | null = null;
|
||||
if (item.recordedAt && item.dateKind === "uploaded-local") {
|
||||
const local = /^(\d{4})-(\d\d)-(\d\d)T(\d\d):(\d\d):(\d\d)/.exec(item.recordedAt);
|
||||
const local = /^(\d{4})-(\d\d)-(\d\d)T(\d\d):(\d\d):(\d\d)(?:\.\d{1,7})?$/.exec(item.recordedAt);
|
||||
// UTC is only a formatting carrier for the unzoned wall-clock components.
|
||||
if (local) value = new Date(Date.UTC(+local[1], +local[2] - 1, +local[3], +local[4], +local[5], +local[6]));
|
||||
if (local) {
|
||||
const parts = local.slice(1).map(Number);
|
||||
const candidate = new Date(0);
|
||||
candidate.setUTCFullYear(parts[0], parts[1] - 1, parts[2]);
|
||||
candidate.setUTCHours(parts[3], parts[4], parts[5], 0);
|
||||
// Date normalises impossible fields; show unknown rather than inventing a date.
|
||||
if ([candidate.getUTCFullYear(), candidate.getUTCMonth() + 1, candidate.getUTCDate(), candidate.getUTCHours(), candidate.getUTCMinutes(), candidate.getUTCSeconds()].every((part, index) => part === parts[index])) value = candidate;
|
||||
}
|
||||
} else if (item.recordedAt && ["updated-utc", "captured-utc", "history-recorded-utc"].includes(item.dateKind)) value = new Date(item.recordedAt);
|
||||
if (!value || Number.isNaN(value.valueOf())) return <Typography variant="body2" color="text.secondary">{t("documentsDateUnknown")}</Typography>;
|
||||
return <Box><Typography variant="caption" color="text.secondary">{t(label)}</Typography><Typography variant="body2" component="time" dateTime={item.recordedAt!} sx={{ display: "block" }}>{value.toLocaleString(locale, { dateStyle: "medium", timeStyle: "short", ...(item.dateKind === "uploaded-local" ? { timeZone: "UTC" } : {}) })}</Typography></Box>;
|
||||
|
||||
Reference in New Issue
Block a user