mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +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
|
||||
|
||||
Reference in new issue
Block a user