Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions components/DistributionPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"use client";

import { DIST_ACTIVE_COUNTS, DIST_ACTIVE_N, DIST_COUNTS, DIST_MIN, DIST_N } from "@/lib/distribution-data";
import { formatThousands } from "@/lib/format";
import { resolveResultTheme } from "./finishTheme";
import { Tip } from "./ScoutReport";
import type { Card } from "@/lib/scoring/types";
Expand Down Expand Up @@ -35,9 +36,13 @@ export default function DistributionPanel({ card }: { card: Card }) {
};
const all = top(DIST_COUNTS, DIST_N);
const act = top(DIST_ACTIVE_COUNTS, DIST_ACTIVE_N);
// Numbers in a <Tip> text node become part of the SSR HTML and are compared
// byte-for-byte at hydration — using formatThousands (locale-locked to en-US)
// prevents "5,000" vs "5.000" mismatches when the user's browser locale isn't
// the Node server's default.
const tipText =
`Higher than ${(DIST_N - all.atOrAbove).toLocaleString()} of ${DIST_N.toLocaleString()} randomly sampled GitHub users, ` +
`and ${(DIST_ACTIVE_N - act.atOrAbove).toLocaleString()} of the ${DIST_ACTIVE_N.toLocaleString()} who were active in the past year.`;
`Higher than ${formatThousands(DIST_N - all.atOrAbove)} of ${formatThousands(DIST_N)} randomly sampled GitHub users, ` +
`and ${formatThousands(DIST_ACTIVE_N - act.atOrAbove)} of the ${formatThousands(DIST_ACTIVE_N)} who were active in the past year.`;

return (
<section className="rounded-2xl border border-white/[0.06] bg-white/[0.02] p-[16px]">
Expand Down
7 changes: 7 additions & 0 deletions lib/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@ export const formatCount = (n: number): string => {
return String(Math.round(n));
};

// Thousands-grouped number for tooltips and copy (5000 → "5,000"). Locale-locked
// to en-US so Node SSR and the client's browser locale always agree — otherwise
// a 5,000-tip on a Spanish-locale browser renders as "5.000" and React throws a
// hydration mismatch on the very first paint of any scout report that crosses
// the thousands threshold in a <Tip> text node.
export const formatThousands = (n: number): string => n.toLocaleString("en-US");

// Fixed-precision rounding for deterministic geometry / imperative transforms —
// shared so radar, VS burst and tilt all round identically.
export const round2 = (n: number) => Math.round(n * 100) / 100;
Expand Down
32 changes: 31 additions & 1 deletion tests/format.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { formatCount, round1, round2 } from "@/lib/format";
import { formatCount, formatThousands, 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
Expand Down Expand Up @@ -36,6 +36,36 @@ describe("formatCount", () => {
});
});

// These strings get serialized into a hidden <Tip> text node on the scout
// report. SSR (Node) and CSR (browser) have to agree byte-for-byte on the very
// first paint, so we lock the locale — without "en-US", a Spanish-locale
// browser hydrates "5.000" against server-side "5,000" and React throws a
// hydration mismatch. formatCount was already locale-independent (toFixed +
// "k"); formatThousands carries the same guarantee, just for the
// un-abbreviated thousands-grouping copy we use in tooltips.
describe("formatThousands", () => {
it("renders single-digit numbers verbatim", () => {
expect(formatThousands(0)).toBe("0");
expect(formatThousands(7)).toBe("7");
});

it("renders three-digit numbers without grouping", () => {
expect(formatThousands(500)).toBe("500");
expect(formatThousands(999)).toBe("999");
});

it("groups thousands with en-US commas (locked locale)", () => {
expect(formatThousands(1000)).toBe("1,000");
expect(formatThousands(1234)).toBe("1,234");
expect(formatThousands(12345)).toBe("12,345");
expect(formatThousands(1234567)).toBe("1,234,567");
});

it("handles negative numbers consistently", () => {
expect(formatThousands(-1234)).toBe("-1,234");
});
});

describe("round1 / round2", () => {
it("rounds to fixed precision", () => {
expect(round2(1.005)).toBe(1);
Expand Down