diff --git a/src/app/(home)/_components/dashboard/country-distribution.tsx b/src/app/(home)/_components/dashboard/country-distribution.tsx index b90ceda..81e2827 100644 --- a/src/app/(home)/_components/dashboard/country-distribution.tsx +++ b/src/app/(home)/_components/dashboard/country-distribution.tsx @@ -1,5 +1,6 @@ "use client"; +import { formatShortNumber } from "@/lib/format-number"; import { cn } from "@/lib/utils"; import gsap from "gsap"; import { useLayoutEffect, useMemo, useRef } from "react"; @@ -155,7 +156,7 @@ export function CountryDistribution({ countries, isLoading }: Props) { Total
- {isLoading ? "—" : totalShown} + {isLoading ? "—" : formatShortNumber(totalShown, { plus: true })}
diff --git a/src/app/(home)/_components/dashboard/notice-types.tsx b/src/app/(home)/_components/dashboard/notice-types.tsx index 3b7ff2c..bcfc66b 100644 --- a/src/app/(home)/_components/dashboard/notice-types.tsx +++ b/src/app/(home)/_components/dashboard/notice-types.tsx @@ -1,5 +1,6 @@ "use client"; +import { formatShortNumber } from "@/lib/format-number"; import gsap from "gsap"; import { useLayoutEffect, useMemo, useRef } from "react"; import { TenderIcon } from "./icons"; @@ -94,7 +95,9 @@ export function NoticeTypeBreakdown({ noticeTypes, isLoading }: Props) { {isLoading ? "Loading…" : labelize(n.type)} - {isLoading ? "—" : `${n.count} · ${pct}%`} + {isLoading + ? "—" + : `${formatShortNumber(n.count, { plus: true })} · ${pct}%`}
diff --git a/src/lib/format-number.ts b/src/lib/format-number.ts index a4267e5..e6a6c8e 100644 --- a/src/lib/format-number.ts +++ b/src/lib/format-number.ts @@ -1,10 +1,56 @@ -export function compactFormat(value: number) { - const formatter = new Intl.NumberFormat("en", { - notation: "compact", - compactDisplay: "short", - }); +export type FormatShortNumberOptions = { + /** Prefix positive values with "+" (e.g. +37k) */ + plus?: boolean; + /** Fraction digits for K / M / B suffixes (default 1) */ + maximumFractionDigits?: number; +}; - return formatter.format(value); +function trimTrailingZeros(value: string) { + return value.replace(/\.0+$/, "").replace(/(\.[0-9]*?)0+$/, "$1"); +} + +function formatScaled(value: number, maximumFractionDigits: number) { + if (Number.isInteger(value)) return String(value); + return trimTrailingZeros(value.toFixed(maximumFractionDigits)); +} + +/** + * Shortens large numbers for display: 37030 → "37k", 2500000 → "2.5M". + * Use `plus: true` for "+37k" style labels (e.g. chart totals). + */ +export function formatShortNumber( + value: number, + options?: FormatShortNumberOptions, +): string { + const { plus = false, maximumFractionDigits = 1 } = options ?? {}; + + if (!Number.isFinite(value)) return "—"; + + const sign = + value < 0 ? "-" : plus && value > 0 ? "+" : ""; + const abs = Math.abs(value); + + let body: string; + + if (abs < 1000) { + body = + abs % 1 === 0 + ? String(Math.round(abs)) + : trimTrailingZeros(abs.toFixed(maximumFractionDigits)); + } else if (abs < 1_000_000) { + body = `${formatScaled(abs / 1000, maximumFractionDigits)}k`; + } else if (abs < 1_000_000_000) { + body = `${formatScaled(abs / 1_000_000, maximumFractionDigits)}M`; + } else { + body = `${formatScaled(abs / 1_000_000_000, maximumFractionDigits)}B`; + } + + return `${sign}${body}`; +} + +/** @deprecated Prefer `formatShortNumber` — kept for existing imports */ +export function compactFormat(value: number) { + return formatShortNumber(value); } export function standardFormat(value: number) { @@ -12,4 +58,4 @@ export function standardFormat(value: number) { minimumFractionDigits: 2, maximumFractionDigits: 2, }); -} \ No newline at end of file +}