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