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]}`; }