fix(i18n): localise attachment size displays

This commit is contained in:
cesnimda
2026-11-03 19:22:07 +01:00
parent ca519cacbe
commit 1ce93ca0e3
8 changed files with 59 additions and 5 deletions
+17
View File
@@ -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.
+13
View File
@@ -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"
}
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -104,6 +104,18 @@ function changeLanguage(language: UiLanguage) {
act(() => { window.dispatchEvent(new StorageEvent("storage", { key: "uiLanguage", newValue: language })); }); 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>() { function deferred<T>() {
let resolve!: (value: T) => void; let resolve!: (value: T) => void;
let reject!: (reason: unknown) => void; let reject!: (reason: unknown) => void;
+9
View File
@@ -22,6 +22,15 @@ const mockedApi = api as jest.Mocked<typeof api>;
beforeEach(() => jest.clearAllMocks()); 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([ test.each([
['en', 'Preview: resume.pdf', 'Preview: resume.pdf'], ['en', 'Preview: resume.pdf', 'Preview: resume.pdf'],
['nb', 'Forhåndsvis: resume.pdf', 'Forhåndsvisning: resume.pdf'], ['nb', 'Forhåndsvis: resume.pdf', 'Forhåndsvisning: resume.pdf'],
@@ -357,7 +357,7 @@ export function SubmittedPackagesSection({ jobId, allowCapture = true }: { jobId
</Button> </Button>
{item.attachments.map((attachment) => ( {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" }}> <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> </Button>
))} ))}
</Stack> </Stack>
@@ -47,11 +47,12 @@ interface AttachmentItem {
useForAi: boolean; useForAi: boolean;
} }
function fmtSize(n: number) { function fmtSize(n: number, language: string) {
if (!n) return ""; if (!n) return "";
const kb = n / 1024; const kb = n / 1024;
if (kb < 1024) return `${kb.toFixed(0)} KB`; const digits = kb < 1024 ? 0 : 1;
return `${(kb / 1024).toFixed(1)} MB`; 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) { function isImageType(t: string) {
@@ -303,7 +304,7 @@ export default function Attachments({ jobId }: { jobId: number }) {
</TableCell> </TableCell>
<TableCell>{kind}</TableCell> <TableCell>{kind}</TableCell>
<TableCell sx={{ color: "text.secondary" }}>{a.fileType ? a.fileType.replace("application/", "") : ""}</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 sx={{ color: "text.secondary" }}>{a.uploadDate ? new Date(a.uploadDate).toLocaleString(language === "nb" ? "nb-NO" : "en") : ""}</TableCell>
<TableCell> <TableCell>
<FormControl size="small" fullWidth> <FormControl size="small" fullWidth>