mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
1 parent
67cc56f707
commit
56daa57350
12 files changed
+84
-70
No files matched your search
@@ -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,
|
||||
})}`}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
+17
-16
@@ -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<typeof useFormatters>,
|
||||
): 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]}`;
|
||||
}
|
||||
Reference in new issue
Block a user