diff --git a/src/lib/format.test.ts b/src/lib/format.test.ts index 0392f0c..10f1b89 100644 --- a/src/lib/format.test.ts +++ b/src/lib/format.test.ts @@ -4,8 +4,8 @@ // // Nothing imports it, so the bundle never includes it. import { - axisBytes, axisTop, bytes, cpuName, cycle, daysUntil, despike, osName, pair, percent, quarters, RATE_FLOOR, - rateAxis, tickClock, timeTicks, uptime, windows, withGaps, + axisBytes, axisTop, bytes, cpuName, cycle, daysUntil, despike, money, osName, pair, percent, quarters, + RATE_FLOOR, rateAxis, tickClock, timeTicks, uptime, windows, withGaps, } from "./format.ts" let failed = 0 @@ -173,6 +173,15 @@ eq(uptime(2 * 86400 + 5 * 3600), "2 天 5 小时", "超过一天不再写分钟" // 旧 hub 存名称,新 hub 把其余长度存成 `m`;两种写法同一个长度读法一致。 eq(["yearly", "12m", "60m", "18m", "once", "weekly"].map(cycle), ["年付", "年付", "5 年付", "18 个月付", "一次性", "weekly"], "付款周期") +// money: zh-CN whatever the browser's language, so US$ stands apart from HK$ +// and JP¥ from ¥; a code Intl refuses is shown rather than thrown. +eq(money(100, "CNY"), "¥100.00", "人民币") +eq(money(100, "USD"), "US$100.00", "美元与港币等其它元区分开") +eq(money(100, "HKD"), "HK$100.00", "港币同样符号在前") +eq(money(1200, "JPY"), "JP¥1,200", "日元不带小数,与人民币分开") +eq(money(1200.5, "JPY"), "JP¥1,200.5", "日元填了小数照实显示,不取整") +eq(money(100, "港币"), "港币 100.00", "旧 hub 存下的非法代码不抛错") + eq(osName("Debian GNU/Linux 12 (bookworm)"), "Debian 12", "发行版名去掉代号") eq(cpuName("Intel(R) Xeon(R) CPU E5-2680 8-Core Processor"), "Intel Xeon E5-2680", "CPU 名去掉商标和核数") diff --git a/src/lib/format.ts b/src/lib/format.ts index 9c2a4a0..dc40680 100644 --- a/src/lib/format.ts +++ b/src/lib/format.ts @@ -78,10 +78,30 @@ export function daysUntil(date?: string | null): number | null { */ export const FOREVER = "∞" -const SYMBOLS: Record = { USD: "$", CNY: "¥", EUR: "€", GBP: "£", JPY: "¥" } +const MONEY = new Map() +/** + * A price as zh-CN writes it: ¥12.00, US$12.00, HK$12.00, JP¥1,200, and the code + * ahead of the amount where the locale has no symbol, as in SGD 12.00. The + * locale is fixed so the figure does not vary with the browser's language, and + * so JPY reads JP¥, apart from CNY. Fractions stop at two places, the precision + * the price is entered in, not at the currency's minor unit: rounding to whole + * yen would show a price of 0.4 as JP¥0. A formatter costs about 100 µs to + * build, hence one per currency. + */ export function money(amount: number, currency: string): string { - return `${SYMBOLS[currency] ?? ""}${amount.toFixed(2)}${SYMBOLS[currency] ? "" : ` ${currency}`}` + try { + let format = MONEY.get(currency) + if (!format) { + format = new Intl.NumberFormat("zh-CN", { style: "currency", currency, maximumFractionDigits: 2 }) + MONEY.set(currency, format) + } + return format.format(amount) + } catch { + // Intl throws on anything but three letters, which hubs before 1.3.1 stored + // unchecked when written through the API. + return `${currency} ${amount.toFixed(2)}`.trim() + } } // Hub 1.3.0 and earlier store only these names; later hubs store any other