diff --git a/lib/format.ts b/lib/format.ts index d5f1c08..84b55da 100644 --- a/lib/format.ts +++ b/lib/format.ts @@ -1,6 +1,20 @@ -// Compact number formatting for display (1240 → "1.2k", 248723 → "249k"). -export const formatCount = (n: number): string => - n >= 1000 ? (n / 1000).toFixed(n >= 10000 ? 0 : 1).replace(/\.0$/, "") + "k" : String(Math.round(n)); +// Compact number formatting for display (1240 → "1.2k", 248723 → "249k", 1250000 → "1.3M"). +// One decimal below 10 units ("1.2k"), none above ("249k"), so the string stays short. +const compact = (n: number, unit: number, suffix: string): string => { + const scaled = n / unit; + return scaled.toFixed(scaled >= 10 ? 0 : 1).replace(/\.0$/, "") + suffix; +}; + +export const formatCount = (n: number): string => { + if (n >= 1_000_000) return compact(n, 1_000_000, "M"); + if (n >= 1000) { + const thousands = compact(n, 1000, "k"); + // 999_950+ rounds up to "1000k"; roll over to the next unit rather than + // printing a four-digit thousands value. + return thousands === "1000k" ? compact(n, 1_000_000, "M") : thousands; + } + return String(Math.round(n)); +}; // Fixed-precision rounding for deterministic geometry / imperative transforms — // shared so radar, VS burst and tilt all round identically. diff --git a/tests/format.test.ts b/tests/format.test.ts new file mode 100644 index 0000000..973e47e --- /dev/null +++ b/tests/format.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import { formatCount, round1, round2 } from "@/lib/format"; + +// formatCount feeds the card, the scout report and the duel bars, so the exact +// strings matter: they have to stay short enough for the fixed-width slots and +// never show a four-digit thousands value like "1200k". + +describe("formatCount", () => { + it("prints small counts verbatim", () => { + expect(formatCount(0)).toBe("0"); + expect(formatCount(7)).toBe("7"); + expect(formatCount(999)).toBe("999"); + }); + + it("switches to thousands at 1000", () => { + expect(formatCount(1000)).toBe("1k"); + expect(formatCount(1240)).toBe("1.2k"); + expect(formatCount(9999)).toBe("10k"); + }); + + it("drops the decimal once there are ten or more thousands", () => { + expect(formatCount(10000)).toBe("10k"); + expect(formatCount(248723)).toBe("249k"); + }); + + it("switches to millions instead of a four-digit thousands value", () => { + // Top accounts clear a million stars once organization-owned repos count, + // and "1200k" is neither compact nor readable. + expect(formatCount(1_000_000)).toBe("1M"); + expect(formatCount(1_250_000)).toBe("1.3M"); + expect(formatCount(12_300_000)).toBe("12M"); + }); + + it("rolls over rather than rounding up to 1000k", () => { + expect(formatCount(999_999)).toBe("1M"); + }); +}); + +describe("round1 / round2", () => { + it("rounds to fixed precision", () => { + expect(round2(1.005)).toBe(1); + expect(round2(1.239)).toBe(1.24); + expect(round1(1.24)).toBe(1.2); + expect(round1(1.25)).toBe(1.3); + }); + + it("is stable for values already at precision", () => { + expect(round2(2.5)).toBe(2.5); + expect(round1(2.5)).toBe(2.5); + }); +});