From 56daa573505338b2ade5b6dc7d8d2938b32fc7e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 20 Oct 2026 12:55:06 +0200 Subject: [PATCH] refactor(format): one byte formatter for every surface The document vault and the admin area each carried a formatBytes; the same backup read 1.25 MB in one and 1.2 MB in the other, and only the vault knew gigabytes. The vault rule (one decimal below 100, up to GB, digits in the reader's locale) now lives in src/lib/format/bytes.ts and takes the locale, so the server-rendered share page uses it too. A test holds the definition to that one module. --- src/components/admin/backups-section.tsx | 6 +-- .../admin/measurement-maintenance-card.tsx | 18 ++----- .../admin/offhost-backups-section.tsx | 4 +- src/components/clinician/documents-list.tsx | 2 +- .../documents/__tests__/vault-utils.test.ts | 10 ---- src/components/documents/document-card.tsx | 7 +-- .../documents/document-detail-sheet.tsx | 2 +- .../sources/document-source-picker.tsx | 2 +- src/components/documents/upload-zone.tsx | 2 +- src/components/documents/vault-utils.ts | 15 ------ src/lib/format/__tests__/bytes.test.ts | 53 +++++++++++++++++++ src/lib/format/bytes.ts | 33 ++++++------ 12 files changed, 84 insertions(+), 70 deletions(-) create mode 100644 src/lib/format/__tests__/bytes.test.ts diff --git a/src/components/admin/backups-section.tsx b/src/components/admin/backups-section.tsx index 16b6e5ed4..2f3ab6759 100644 --- a/src/components/admin/backups-section.tsx +++ b/src/components/admin/backups-section.tsx @@ -843,7 +843,7 @@ async function uploadOnce(file: File) { } export function BackupsSection() { - const { t } = useTranslations(); + const { t, locale } = useTranslations(); const fmt = useFormatters(); const queryClient = useQueryClient(); @@ -1327,7 +1327,7 @@ export function BackupsSection() { - {formatBytes(row.sizeBytes, fmt)} + {formatBytes(row.sizeBytes, locale)} {fmt.dateTime(row.createdAt)} @@ -1394,7 +1394,7 @@ export function BackupsSection() {

{t("admin.section.backups.colSize")}:{" "} - {formatBytes(row.sizeBytes, fmt)} ·{" "} + {formatBytes(row.sizeBytes, locale)} ·{" "} {fmt.dateTime(row.createdAt)}

diff --git a/src/components/admin/measurement-maintenance-card.tsx b/src/components/admin/measurement-maintenance-card.tsx index 6109f5a89..3569c0e64 100644 --- a/src/components/admin/measurement-maintenance-card.tsx +++ b/src/components/admin/measurement-maintenance-card.tsx @@ -45,20 +45,8 @@ const ENDPOINT = "/api/admin/maintenance/measurements"; /** How often the card asks while a run is waiting or working. */ const POLL_MS = 10_000; -const GIB = 1024 * 1024 * 1024; - -function sizeLabel( - bytes: number, - fmt: ReturnType, -): string { - // The shared formatter stops at MB; this table is routinely gigabytes. - return bytes >= GIB - ? `${fmt.number(bytes / GIB, 2)} GB` - : formatBytes(bytes, fmt); -} - export function MeasurementMaintenanceCard() { - const { t } = useTranslations(); + const { t, locale } = useTranslations(); const fmt = useFormatters(); const queryClient = useQueryClient(); @@ -132,11 +120,11 @@ export function MeasurementMaintenanceCard() {
) : null} diff --git a/src/components/admin/offhost-backups-section.tsx b/src/components/admin/offhost-backups-section.tsx index 99ffb6af2..3e65f6402 100644 --- a/src/components/admin/offhost-backups-section.tsx +++ b/src/components/admin/offhost-backups-section.tsx @@ -84,7 +84,7 @@ function freshnessLabel( } function AccountRow({ row }: { row: OffhostAccountRow }) { - const { t } = useTranslations(); + const { t, locale } = useTranslations(); const fmt = useFormatters(); return ( @@ -128,7 +128,7 @@ function AccountRow({ row }: { row: OffhostAccountRow }) { }) : `${fmt.dateTime(row.lastSuccessAt)} · ${formatBytes( row.sizeBytes, - fmt, + locale, )} · ${t("admin.section.backups.offhost.age", { hours: row.ageHours ?? 0, })}`} diff --git a/src/components/clinician/documents-list.tsx b/src/components/clinician/documents-list.tsx index b5850b2dd..d9bbc1317 100644 --- a/src/components/clinician/documents-list.tsx +++ b/src/components/clinician/documents-list.tsx @@ -11,7 +11,7 @@ import { Download } from "lucide-react"; import { DOCUMENT_KIND_ICONS } from "@/components/documents/document-kind-meta"; -import { formatBytes } from "@/components/documents/vault-utils"; +import { formatBytes } from "@/lib/format/bytes"; import type { ShareViewDocument } from "@/lib/clinician-share/share-view-data"; import type { InboundDocumentKindValue } from "@/lib/validations/inbound-documents"; diff --git a/src/components/documents/__tests__/vault-utils.test.ts b/src/components/documents/__tests__/vault-utils.test.ts index f368012c5..d881f7a5e 100644 --- a/src/components/documents/__tests__/vault-utils.test.ts +++ b/src/components/documents/__tests__/vault-utils.test.ts @@ -11,7 +11,6 @@ import { documentCardKeyAction, documentDateKey, expandRangeSelection, - formatBytes, formatMonthLabel, hasProcessingDocument, isDocumentProcessing, @@ -216,15 +215,6 @@ describe("timeline items", () => { }); }); -describe("byte formatting", () => { - it("scales units and keeps one decimal below 10", () => { - expect(formatBytes(0, "en")).toBe("0 B"); - expect(formatBytes(512, "en")).toBe("512 B"); - expect(formatBytes(26_214_400, "en")).toBe("25 MB"); - expect(formatBytes(1_288_490_189, "en")).toBe("1.2 GB"); - }); -}); - describe("upload response contract (§3.2)", () => { it("HTTP 200 + meta.duplicate returns the EXISTING row as a duplicate", () => { const body = JSON.stringify({ diff --git a/src/components/documents/document-card.tsx b/src/components/documents/document-card.tsx index d40019b92..807e551b0 100644 --- a/src/components/documents/document-card.tsx +++ b/src/components/documents/document-card.tsx @@ -31,11 +31,8 @@ import { cn } from "@/lib/utils"; import type { InboundDocumentDto } from "@/lib/validations/inbound-documents"; import { DOCUMENT_KIND_ICONS } from "./document-kind-meta"; import type { UploadQueueItem } from "./use-document-upload"; -import { - documentCardKeyAction, - formatBytes, - isDocumentProcessing, -} from "./vault-utils"; +import { formatBytes } from "@/lib/format/bytes"; +import { documentCardKeyAction, isDocumentProcessing } from "./vault-utils"; /** How long the "Ready" chip stays up after a card's index lands. */ const READY_FLASH_MS = 3000; diff --git a/src/components/documents/document-detail-sheet.tsx b/src/components/documents/document-detail-sheet.tsx index c5594528f..1426fcb91 100644 --- a/src/components/documents/document-detail-sheet.tsx +++ b/src/components/documents/document-detail-sheet.tsx @@ -102,7 +102,7 @@ import { useDocumentSummary, useSuggestDetails, } from "./use-document-assist"; -import { formatBytes } from "./vault-utils"; +import { formatBytes } from "@/lib/format/bytes"; import { QueryErrorRow } from "@/components/ui/query-error-row"; type PatchInput = { diff --git a/src/components/documents/sources/document-source-picker.tsx b/src/components/documents/sources/document-source-picker.tsx index 1ae4fc11d..ba50192d0 100644 --- a/src/components/documents/sources/document-source-picker.tsx +++ b/src/components/documents/sources/document-source-picker.tsx @@ -75,7 +75,7 @@ import type { InboundDocumentKindValue } from "@/lib/validations/inbound-documen import { WrittenOutcomeLine } from "@/components/outcome/written-outcome-line"; import type { WrittenOutcome } from "@/lib/outcome/written-outcome"; -import { formatBytes } from "../vault-utils"; +import { formatBytes } from "@/lib/format/bytes"; import { errorCodeOf, isStoppingError, diff --git a/src/components/documents/upload-zone.tsx b/src/components/documents/upload-zone.tsx index c94863234..40386a4ba 100644 --- a/src/components/documents/upload-zone.tsx +++ b/src/components/documents/upload-zone.tsx @@ -19,7 +19,7 @@ import { type RefObject } from "react"; import { Progress } from "@/components/ui/progress"; import { useTranslations } from "@/lib/i18n/context"; import type { DocumentUsageDto } from "@/lib/validations/inbound-documents"; -import { formatBytes } from "./vault-utils"; +import { formatBytes } from "@/lib/format/bytes"; /** Usage fraction above which the quota bar appears. */ const QUOTA_BAR_THRESHOLD = 0.8; diff --git a/src/components/documents/vault-utils.ts b/src/components/documents/vault-utils.ts index 3e4c12175..e7c0f3653 100644 --- a/src/components/documents/vault-utils.ts +++ b/src/components/documents/vault-utils.ts @@ -302,21 +302,6 @@ export function formatMonthLabel(key: string, locale: string): string { }).format(new Date(`${key}-15T12:00:00.000Z`)); } -/** Locale byte formatting: 0 B / 412 KB / 1.2 MB / 2.4 GB. */ -export function formatBytes(bytes: number, locale: string): string { - const units = ["B", "KB", "MB", "GB"] as const; - let value = Math.max(0, bytes); - let unit = 0; - while (value >= 1024 && unit < units.length - 1) { - value /= 1024; - unit += 1; - } - const digits = unit === 0 || value >= 100 ? 0 : value >= 10 ? 1 : 1; - return `${new Intl.NumberFormat(locale, { - maximumFractionDigits: digits, - }).format(value)} ${units[unit]}`; -} - /** * Shift-click range selection over the timeline's visual order. Selects * every id between the last plainly-toggled anchor and the target diff --git a/src/lib/format/__tests__/bytes.test.ts b/src/lib/format/__tests__/bytes.test.ts new file mode 100644 index 000000000..0413d42c8 --- /dev/null +++ b/src/lib/format/__tests__/bytes.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import { readFileSync, readdirSync, statSync } from "node:fs"; +import { join } from "node:path"; + +import { formatBytes } from "../bytes"; + +describe("formatBytes", () => { + it("scales units and keeps one decimal below 100", () => { + expect(formatBytes(0, "en")).toBe("0 B"); + expect(formatBytes(512, "en")).toBe("512 B"); + expect(formatBytes(1_310_720, "en")).toBe("1.3 MB"); + expect(formatBytes(26_214_400, "en")).toBe("25 MB"); + expect(formatBytes(1_288_490_189, "en")).toBe("1.2 GB"); + expect(formatBytes(150 * 1024 * 1024, "en")).toBe("150 MB"); + }); + + it("prints the digits in the reader's locale", () => { + expect(formatBytes(1_288_490_189, "de")).toBe("1,2 GB"); + expect(formatBytes(1_288_490_189, "de-DE")).toBe("1,2 GB"); + expect(formatBytes(1_310_720, "fr")).toBe("1,3 MB"); + }); + + it("never prints a negative size", () => { + expect(formatBytes(-5, "en")).toBe("0 B"); + }); +}); + +/** + * One byte formatter. A second definition is how the admin area and the + * document vault came to print the same size two ways. + */ +describe("a single byte formatter", () => { + it("is defined only in src/lib/format/bytes.ts", () => { + const root = join(__dirname, "../../.."); + const hits: string[] = []; + const walk = (dir: string) => { + for (const name of readdirSync(dir)) { + if (name === "node_modules" || name === "generated") continue; + const path = join(dir, name); + if (statSync(path).isDirectory()) { + walk(path); + } else if (/\.tsx?$/.test(name) && !/\.test\.tsx?$/.test(name)) { + const source = readFileSync(path, "utf8"); + if (/function\s+formatBytes\b|const\s+formatBytes\s*=/.test(source)) { + hits.push(path.slice(root.length + 1)); + } + } + } + }; + walk(root); + expect(hits).toEqual(["lib/format/bytes.ts"]); + }); +}); diff --git a/src/lib/format/bytes.ts b/src/lib/format/bytes.ts index c59732ce2..7fd753fbe 100644 --- a/src/lib/format/bytes.ts +++ b/src/lib/format/bytes.ts @@ -1,22 +1,23 @@ -import type { useFormatters } from "@/lib/i18n/context"; +import { getNumberFormat } from "@/lib/intl/formatter-cache"; /** - * One spelling of "1.25 MB" for every surface that shows a byte count from - * the admin backups response. + * One spelling of a byte count for every surface: 0 B / 412 KB / 1.2 MB / + * 2.4 GB, digits in the reader's locale ("1,2 MB" for a German reader). * - * It lives here rather than beside the card that happened to need it first: - * the second reader was importing a formatter out of a thousand-line client - * component, which is a dependency on a page's worth of code for four lines of - * arithmetic. Both cards render on the same route today, so it costs nothing - * today — and it becomes a cost the moment either one moves. + * There used to be two: this module printed megabytes with two decimals and + * no gigabyte step, the document vault printed one decimal up to gigabytes, so + * the same backup read "1.25 MB" in the admin area and "1.2 MB" beside a + * document. It takes the locale rather than a formatter object so the share + * page, which renders on the server without the formatter hook, uses it too. */ -export function formatBytes( - bytes: number, - fmt: ReturnType, -): string { - if (bytes < 1024) return `${bytes} B`; - if (bytes < 1024 * 1024) { - return `${fmt.integer(Math.round(bytes / 1024))} KB`; +export function formatBytes(bytes: number, locale: string): string { + const units = ["B", "KB", "MB", "GB"] as const; + let value = Math.max(0, bytes); + let unit = 0; + while (value >= 1024 && unit < units.length - 1) { + value /= 1024; + unit += 1; } - return `${fmt.number(bytes / 1024 / 1024, 2)} MB`; + const digits = unit === 0 || value >= 100 ? 0 : 1; + return `${getNumberFormat(locale, { maximumFractionDigits: digits }).format(value)} ${units[unit]}`; }