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.
This commit is contained in:
Marc-André Bombeck committed 2026-10-20 12:55:06 +02:00
1 parent 67cc56f707
commit 56daa57350
12 files changed
+84 -70

No files matched your search

+3 -3
View File
@@ -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() {
</Badge>
</td>
<td className="px-3 py-2 text-right font-mono text-xs tabular-nums">
{formatBytes(row.sizeBytes, fmt)}
{formatBytes(row.sizeBytes, locale)}
</td>
<td className="text-muted-foreground px-3 py-2 text-right text-xs whitespace-nowrap">
{fmt.dateTime(row.createdAt)}
@@ -1394,7 +1394,7 @@ export function BackupsSection() {
</div>
<p className="text-muted-foreground text-xs">
{t("admin.section.backups.colSize")}:{" "}
{formatBytes(row.sizeBytes, fmt)} ·{" "}
{formatBytes(row.sizeBytes, locale)} ·{" "}
{fmt.dateTime(row.createdAt)}
</p>
<div className="mt-2 flex flex-wrap items-center gap-2">
@@ -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<typeof useFormatters>,
): 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() {
<div className="grid gap-3 sm:grid-cols-2">
<StatTile
label={t("admin.section.maintenance.tableSize")}
value={sizeLabel(s.sizes.tableBytes, fmt)}
value={formatBytes(s.sizes.tableBytes, locale)}
/>
<StatTile
label={t("admin.section.maintenance.indexSize")}
value={sizeLabel(s.sizes.indexBytes, fmt)}
value={formatBytes(s.sizes.indexBytes, locale)}
/>
</div>
) : null}
@@ -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,
})}`}
+1 -1
View File
@@ -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";
@@ -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({
+2 -5
View File
@@ -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;
@@ -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 = {
@@ -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,
+1 -1
View File
@@ -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;
-15
View File
@@ -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