format.ts 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215
  1. // Number, percent and duration formatting as the classic UI does it
  2. // (BxAnalyticsTemplate::formatValue and js/dashboard.js formatMetric).
  3. // Hand-written instead of Intl, so web, iOS and Android print the same.
  4. import type { AnalyticsStrings } from './analytics.types';
  5. type DurationStrings = Pick<AnalyticsStrings, 'duration_h' | 'duration_m' | 'duration_s'>;
  6. const COMPACT: ReadonlyArray<readonly [number, string]> = [
  7. [1e9, 'B'],
  8. [1e6, 'M'],
  9. [1e3, 'K'],
  10. ];
  11. function groupThousands(value: number | string): string {
  12. return String(value).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
  13. }
  14. function trimZeros(value: string): string {
  15. return value.includes('.') ? value.replace(/\.?0+$/, '') : value;
  16. }
  17. /** 950, 9.5K, 95K, 1.2M */
  18. export function formatNumber(value: unknown): string {
  19. const n = Math.trunc(Number(value) || 0);
  20. for (const [size, suffix] of COMPACT) {
  21. if (n >= size) {
  22. const f = n / size;
  23. return (f < 10 ? trimZeros(f.toFixed(1)) : groupThousands(Math.floor(f))) + suffix;
  24. }
  25. }
  26. return String(n);
  27. }
  28. export function formatPercent(value: unknown): string {
  29. const n = Number(value) || 0;
  30. return (Math.abs(n) < 10 && Math.round(n) !== n ? n.toFixed(1) : String(Math.round(n))) + '%';
  31. }
  32. export function formatDuration(value: unknown, strings: DurationStrings): string {
  33. const s = Math.round(Number(value) || 0);
  34. if (s < 60) return s + strings.duration_s;
  35. if (s < 3600) return Math.floor(s / 60) + strings.duration_m + ' ' + (s % 60) + strings.duration_s;
  36. return Math.floor(s / 3600) + strings.duration_h + ' ' + Math.floor((s % 3600) / 60) + strings.duration_m;
  37. }
  38. /** A metric value for a key figure or a list. */
  39. export function formatMetric(metric: string, value: unknown, strings: DurationStrings): string {
  40. if (value === null || value === undefined || value === '') return '';
  41. switch (metric) {
  42. case 'bounce_rate':
  43. return formatPercent(value);
  44. case 'pages_per_session':
  45. return (Number(value) || 0).toFixed(1);
  46. case 'duration':
  47. return formatDuration(value, strings);
  48. default:
  49. return formatNumber(value);
  50. }
  51. }
  52. /** A chart axis tick (the classic chart rounds rates to whole percent). */
  53. export function formatAxis(metric: string, value: number, strings: DurationStrings): string {
  54. if (metric === 'bounce_rate') return Math.round(value) + '%';
  55. return formatMetric(metric, value, strings);
  56. }
  57. /** +12%, -3.4%, '' when there is nothing to compare with. */
  58. export function formatChange(change: number | null | undefined): string {
  59. if (change === null || change === undefined) return '';
  60. const small = Math.abs(change) < 10;
  61. // halves away from zero, as PHP's round() in the classic UI: -15.5% is -16%, not -15%
  62. const text = small ? change.toFixed(1) : groupThousands(Math.round(Math.abs(change)));
  63. const sign = change > 0 ? '+' : (!small && change < 0 ? '-' : '');
  64. return sign + text + '%';
  65. }
  66. export type Trend = 'good' | 'bad' | 'flat';
  67. /** Metrics where a rise is bad news (BxAnalyticsTemplate::_trend). */
  68. const DOWN_IS_GOOD = new Set(['bounce_rate', 'errors', 'refunds', 'cancelled', 'churn']);
  69. /** Down is good for the bounce rate, errors, refunds, cancellations and churn. */
  70. export function getTrend(metric: string, change: number | null | undefined): Trend {
  71. if (change === null || change === undefined || change === 0) return 'flat';
  72. const up = change > 0;
  73. if (DOWN_IS_GOOD.has(metric)) return up ? 'bad' : 'good';
  74. return up ? 'good' : 'bad';
  75. }
  76. const moneyFormatters = new Map<string, Intl.NumberFormat | null>();
  77. /** How an amount is printed: with cents, whole, or compact ($12K). */
  78. type MoneyStyle = 'cents' | 'whole' | 'compact';
  79. function getMoneyFormatter(currency: string, locale: string, style: MoneyStyle): Intl.NumberFormat | null {
  80. const key = `${locale}|${currency}|${style}`;
  81. if (moneyFormatters.has(key)) return moneyFormatters.get(key) ?? null;
  82. let formatter: Intl.NumberFormat | null = null;
  83. const base: Intl.NumberFormatOptions = style === 'whole'
  84. ? { style: 'currency', currency, maximumFractionDigits: 0, minimumFractionDigits: 0 }
  85. : style === 'compact'
  86. ? { style: 'currency', currency, notation: 'compact', maximumFractionDigits: 1, minimumFractionDigits: 0 }
  87. : { style: 'currency', currency };
  88. // $ rather than US$ in every locale, as UNA prints it: a report has one currency
  89. const attempts: Array<[string | undefined, Intl.NumberFormatOptions]> = [
  90. [locale || undefined, { ...base, currencyDisplay: 'narrowSymbol' }],
  91. [locale || undefined, base],
  92. [undefined, base],
  93. ];
  94. for (const [tag, options] of attempts) {
  95. try {
  96. formatter = new Intl.NumberFormat(tag, options);
  97. break;
  98. } catch {
  99. // an unknown locale tag or display option; then an unknown currency code or no Intl
  100. }
  101. }
  102. moneyFormatters.set(key, formatter);
  103. return formatter;
  104. }
  105. /** Signs of common currencies, as UNA prints them. */
  106. const CURRENCY_SIGNS: Record<string, string> = {
  107. USD: '$', EUR: '€', GBP: '£', JPY: '¥', CNY: '¥', RUB: '₽', INR: '₹', KRW: '₩', UAH: '₴', TRY: '₺', ILS: '₪', NGN: '₦', PHP: '₱', VND: '₫', PLN: 'zł', BRL: 'R$',
  108. };
  109. /**
  110. * Hermes on iOS accepts currencyDisplay narrowSymbol but still prints the code (USD 183.15):
  111. * put the sign in its place when we know it, so NEO matches UNA's own amounts.
  112. */
  113. function withSign(text: string, code: string): string {
  114. const sign = CURRENCY_SIGNS[code];
  115. if (!sign || !text.includes(code)) return text;
  116. return text
  117. .replace(new RegExp(`${code}[\\s\\u00a0]?-`), `-${sign}`)
  118. .replace(new RegExp(`${code}[\\s\\u00a0]?(?=\\d)`), sign)
  119. .replace(new RegExp(`(\\d)[\\s\\u00a0]?${code}`), `$1 ${sign}`);
  120. }
  121. /**
  122. * An amount in the report's currency: $1,234.50. `whole` drops the cents (axis ticks).
  123. * Falls back to `1,234.50 USD` when the runtime can't format the currency.
  124. */
  125. export function formatMoney(value: unknown, currency: string | undefined, locale: string, whole = false): string {
  126. const n = Number(value) || 0;
  127. const code = (currency || '').toUpperCase();
  128. const formatter = /^[A-Z]{3}$/.test(code) ? getMoneyFormatter(code, locale, whole ? 'whole' : 'cents') : null;
  129. if (formatter) return withSign(formatter.format(n), code);
  130. const fixed = whole ? String(Math.round(Math.abs(n))) : Math.abs(n).toFixed(2);
  131. const [int = '0', frac] = fixed.split('.');
  132. const text = (n < 0 ? '-' : '') + groupThousands(int) + (frac ? '.' + frac : '');
  133. return code ? `${text} ${code}` : text;
  134. }
  135. /** Credits: up to two decimals, no trailing zeros, thousands grouped. */
  136. export function formatCredits(value: unknown): string {
  137. const [int = '0', frac] = (Number(value) || 0).toFixed(2).split('.');
  138. return trimZeros(groupThousands(int) + '.' + (frac ?? '00'));
  139. }
  140. /** A typed report value (BxAnalyticsTemplate::formatValue), '' for no value. */
  141. export function formatReportValue(type: string | undefined, value: unknown, currency: string | undefined, locale: string): string {
  142. if (value === null || value === undefined || value === '') return '';
  143. switch (type) {
  144. case 'money':
  145. return formatMoney(value, currency, locale);
  146. case 'credits':
  147. return formatCredits(value);
  148. case 'percent':
  149. case 'rate':
  150. return formatPercent(value);
  151. case 'decimal':
  152. return (Number(value) || 0).toFixed(1);
  153. case 'text':
  154. case 'label':
  155. return String(value);
  156. default:
  157. return formatNumber(value);
  158. }
  159. }
  160. /**
  161. * A chart axis tick: compact numbers, money in the viewer's locale ($12K from 10,000).
  162. * `decimals` is the tick step's: money ticks keep their cents when the step has them.
  163. */
  164. export function formatReportAxis(type: string | undefined, value: number, currency: string | undefined, locale: string, decimals = 0): string {
  165. if (type === 'money') {
  166. // $12K rather than $12,000 once the gutter would get too wide
  167. if (Math.abs(value) >= 10000) {
  168. const code = (currency || '').toUpperCase();
  169. const formatter = /^[A-Z]{3}$/.test(code) ? getMoneyFormatter(code, locale, 'compact') : null;
  170. if (formatter) return withSign(formatter.format(value), code);
  171. const compact = (value < 0 ? '-' : '') + formatNumber(Math.abs(value));
  172. return code ? `${compact} ${code}` : compact;
  173. }
  174. return formatMoney(value, currency, locale, decimals <= 0);
  175. }
  176. if (type === 'credits') return (value < 0 ? '-' : '') + formatNumber(Math.abs(value));
  177. if (type === 'number' || !type) return (value < 0 ? '-' : '') + formatNumber(Math.abs(value));
  178. return formatReportValue(type, value, currency, locale);
  179. }
  180. /** Fill UNA's `{0}` placeholder (a replacer function, so `$&` in a page title stays as is). */
  181. export function fillPlaceholder(text: string, value: string | number): string {
  182. return text.replace('{0}', () => String(value));
  183. }
  184. /** The device's IANA timezone, '' when the runtime can't tell (UNA then uses UTC). */
  185. export function getDeviceTimezone(): string {
  186. try {
  187. return Intl.DateTimeFormat().resolvedOptions().timeZone || '';
  188. } catch {
  189. return '';
  190. }
  191. }