formatters.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434
  1. import round from 'lodash/round';
  2. import {t, tn} from 'sentry/locale';
  3. import type {CommitAuthor, User} from 'sentry/types';
  4. import {RATE_UNIT_LABELS, RateUnit} from 'sentry/utils/discover/fields';
  5. import {formatFloat} from 'sentry/utils/number/formatFloat';
  6. export function userDisplayName(user: User | CommitAuthor, includeEmail = true): string {
  7. let displayName = String(user?.name ?? t('Unknown author')).trim();
  8. if (displayName.length <= 0) {
  9. displayName = t('Unknown author');
  10. }
  11. const email = String(user?.email ?? '').trim();
  12. if (email.length > 0 && email !== displayName && includeEmail) {
  13. displayName += ' (' + email + ')';
  14. }
  15. return displayName;
  16. }
  17. // in milliseconds
  18. export const MONTH = 2629800000;
  19. export const WEEK = 604800000;
  20. export const DAY = 86400000;
  21. export const HOUR = 3600000;
  22. export const MINUTE = 60000;
  23. export const SECOND = 1000;
  24. export const MILLISECOND = 1;
  25. export const MICROSECOND = 0.001;
  26. export const NANOSECOND = 0.000001;
  27. const SUFFIX_ABBR = {
  28. years: t('yr'),
  29. weeks: t('wk'),
  30. days: t('d'),
  31. hours: t('hr'),
  32. minutes: t('min'),
  33. seconds: t('s'),
  34. milliseconds: t('ms'),
  35. };
  36. /**
  37. * Returns a human readable exact duration.
  38. * 'precision' arg will truncate the results to the specified suffix
  39. *
  40. * e.g. 1 hour 25 minutes 15 seconds
  41. */
  42. export function getExactDuration(
  43. seconds: number,
  44. abbreviation: boolean = false,
  45. precision: keyof typeof SUFFIX_ABBR = 'milliseconds'
  46. ) {
  47. const minSuffix = ` ${precision}`;
  48. const convertDuration = (secs: number, abbr: boolean): string => {
  49. // value in milliseconds
  50. const msValue = round(secs * 1000);
  51. const value = round(Math.abs(secs * 1000));
  52. const divideBy = (time: number) => {
  53. return {
  54. quotient: msValue < 0 ? Math.ceil(msValue / time) : Math.floor(msValue / time),
  55. remainder: msValue % time,
  56. };
  57. };
  58. if (value >= WEEK || (value && minSuffix === ' weeks')) {
  59. const {quotient, remainder} = divideBy(WEEK);
  60. const suffix = abbr ? t('wk') : ` ${tn('week', 'weeks', quotient)}`;
  61. return `${quotient}${suffix} ${
  62. minSuffix === suffix ? '' : convertDuration(remainder / 1000, abbr)
  63. }`;
  64. }
  65. if (value >= DAY || (value && minSuffix === ' days')) {
  66. const {quotient, remainder} = divideBy(DAY);
  67. const suffix = abbr ? t('d') : ` ${tn('day', 'days', quotient)}`;
  68. return `${quotient}${suffix} ${
  69. minSuffix === suffix ? '' : convertDuration(remainder / 1000, abbr)
  70. }`;
  71. }
  72. if (value >= HOUR || (value && minSuffix === ' hours')) {
  73. const {quotient, remainder} = divideBy(HOUR);
  74. const suffix = abbr ? t('hr') : ` ${tn('hour', 'hours', quotient)}`;
  75. return `${quotient}${suffix} ${
  76. minSuffix === suffix ? '' : convertDuration(remainder / 1000, abbr)
  77. }`;
  78. }
  79. if (value >= MINUTE || (value && minSuffix === ' minutes')) {
  80. const {quotient, remainder} = divideBy(MINUTE);
  81. const suffix = abbr ? t('min') : ` ${tn('minute', 'minutes', quotient)}`;
  82. return `${quotient}${suffix} ${
  83. minSuffix === suffix ? '' : convertDuration(remainder / 1000, abbr)
  84. }`;
  85. }
  86. if (value >= SECOND || (value && minSuffix === ' seconds')) {
  87. const {quotient, remainder} = divideBy(SECOND);
  88. const suffix = abbr ? t('s') : ` ${tn('second', 'seconds', quotient)}`;
  89. return `${quotient}${suffix} ${
  90. minSuffix === suffix ? '' : convertDuration(remainder / 1000, abbr)
  91. }`;
  92. }
  93. if (value === 0) {
  94. return '';
  95. }
  96. const suffix = abbr ? t('ms') : ` ${tn('millisecond', 'milliseconds', value)}`;
  97. return `${msValue}${suffix}`;
  98. };
  99. const result = convertDuration(seconds, abbreviation).trim();
  100. if (result.length) {
  101. return result;
  102. }
  103. return `0${abbreviation ? SUFFIX_ABBR[precision] : minSuffix}`;
  104. }
  105. export function formatSecondsToClock(
  106. seconds: number,
  107. {padAll}: {padAll: boolean} = {padAll: true}
  108. ) {
  109. if (seconds === 0 || isNaN(seconds)) {
  110. return padAll ? '00:00' : '0:00';
  111. }
  112. const divideBy = (msValue: number, time: number) => {
  113. return {
  114. quotient: msValue < 0 ? Math.ceil(msValue / time) : Math.floor(msValue / time),
  115. remainder: msValue % time,
  116. };
  117. };
  118. // value in milliseconds
  119. const absMSValue = round(Math.abs(seconds * 1000));
  120. const {quotient: hours, remainder: rMins} = divideBy(absMSValue, HOUR);
  121. const {quotient: minutes, remainder: rSeconds} = divideBy(rMins, MINUTE);
  122. const {quotient: secs, remainder: milliseconds} = divideBy(rSeconds, SECOND);
  123. const fill = (num: number) => (num < 10 ? `0${num}` : String(num));
  124. const parts = hours
  125. ? [padAll ? fill(hours) : hours, fill(minutes), fill(secs)]
  126. : [padAll ? fill(minutes) : minutes, fill(secs)];
  127. const ms = `000${milliseconds}`.slice(-3);
  128. return milliseconds ? `${parts.join(':')}.${ms}` : parts.join(':');
  129. }
  130. export function parseClockToSeconds(clock: string) {
  131. const [rest, milliseconds] = clock.split('.');
  132. const parts = rest.split(':');
  133. let seconds = 0;
  134. const progression = [MONTH, WEEK, DAY, HOUR, MINUTE, SECOND].slice(parts.length * -1);
  135. for (let i = 0; i < parts.length; i++) {
  136. const num = Number(parts[i]) || 0;
  137. const time = progression[i] / 1000;
  138. seconds += num * time;
  139. }
  140. const ms = Number(milliseconds) || 0;
  141. return seconds + ms / 1000;
  142. }
  143. /**
  144. * Format a value between 0 and 1 as a percentage
  145. */
  146. export function formatPercentage(
  147. value: number,
  148. places: number = 2,
  149. options: {
  150. minimumValue?: number;
  151. } = {}
  152. ) {
  153. if (value === 0) {
  154. return '0%';
  155. }
  156. const minimumValue = options.minimumValue ?? 0;
  157. if (Math.abs(value) <= minimumValue) {
  158. return `<${minimumValue * 100}%`;
  159. }
  160. return (
  161. round(value * 100, places).toLocaleString(undefined, {
  162. maximumFractionDigits: places,
  163. }) + '%'
  164. );
  165. }
  166. const numberFormatSteps = [
  167. [1_000_000_000, 'b'],
  168. [1_000_000, 'm'],
  169. [1_000, 'k'],
  170. ] as const;
  171. /**
  172. * Formats a number with an abbreviation e.g. 1000 -> 1k.
  173. *
  174. * @param number the number to format
  175. * @param maximumSignificantDigits the number of significant digits to include
  176. * @param includeDecimals when true, formatted number will always include non trailing zero decimal places
  177. */
  178. export function formatAbbreviatedNumber(
  179. number: number | string,
  180. maximumSignificantDigits?: number,
  181. includeDecimals?: boolean
  182. ): string {
  183. number = Number(number);
  184. const prefix = number < 0 ? '-' : '';
  185. const numAbsValue = Math.abs(number);
  186. for (const step of numberFormatSteps) {
  187. const [suffixNum, suffix] = step;
  188. const shortValue = Math.floor(numAbsValue / suffixNum);
  189. const fitsBound = numAbsValue % suffixNum === 0;
  190. if (shortValue <= 0) {
  191. continue;
  192. }
  193. const useShortValue = !includeDecimals && (shortValue > 10 || fitsBound);
  194. if (useShortValue) {
  195. if (maximumSignificantDigits === undefined) {
  196. return `${prefix}${shortValue}${suffix}`;
  197. }
  198. const formattedNumber = parseFloat(
  199. shortValue.toPrecision(maximumSignificantDigits)
  200. ).toString();
  201. return `${prefix}${formattedNumber}${suffix}`;
  202. }
  203. const formattedNumber = formatFloat(
  204. numAbsValue / suffixNum,
  205. maximumSignificantDigits || 1
  206. ).toLocaleString(undefined, {
  207. maximumSignificantDigits,
  208. });
  209. return `${prefix}${formattedNumber}${suffix}`;
  210. }
  211. return number.toLocaleString(undefined, {maximumSignificantDigits});
  212. }
  213. /**
  214. * Formats a number with an abbreviation and rounds to 2
  215. * decimal digits without forcing trailing zeros.
  216. * e. g. 1000 -> 1k, 1234 -> 1.23k
  217. */
  218. export function formatAbbreviatedNumberWithDynamicPrecision(
  219. value: number | string
  220. ): string {
  221. const number = Number(value);
  222. if (number === 0) {
  223. return '0';
  224. }
  225. const log10 = Math.log10(Math.abs(number));
  226. // numbers less than 1 will have a negative log10
  227. const numOfDigits = log10 < 0 ? 1 : Math.floor(log10) + 1;
  228. const maxStep = numberFormatSteps[0][0];
  229. // if the number is larger than the largest step, we determine the number of digits
  230. // by dividing the number by the largest step, otherwise the number of formatted
  231. // digits is the number of digits in the number modulo 3 (the number of zeroes between steps)
  232. const numOfFormattedDigits =
  233. number > maxStep
  234. ? Math.floor(Math.log10(number / maxStep))
  235. : Math.max(numOfDigits % 3 === 0 ? 3 : numOfDigits % 3, 0);
  236. const maximumSignificantDigits = numOfFormattedDigits + 2;
  237. return formatAbbreviatedNumber(value, maximumSignificantDigits, true);
  238. }
  239. /**
  240. * Rounds to specified number of decimal digits (defaults to 2) without forcing trailing zeros
  241. * Will preserve significant decimals for very small numbers
  242. * e.g. 0.0001234 -> 0.00012
  243. * @param value number to format
  244. */
  245. export function formatNumberWithDynamicDecimalPoints(
  246. value: number,
  247. maxFractionDigits = 2
  248. ): string {
  249. if ([0, Infinity, -Infinity, NaN].includes(value)) {
  250. return value.toLocaleString();
  251. }
  252. const exponent = Math.floor(Math.log10(Math.abs(value)));
  253. const maximumFractionDigits =
  254. exponent >= 0 ? maxFractionDigits : Math.abs(exponent) + 1;
  255. const numberFormat = {
  256. maximumFractionDigits,
  257. minimumFractionDigits: 0,
  258. };
  259. return value.toLocaleString(undefined, numberFormat);
  260. }
  261. export function formatRate(
  262. value: number,
  263. unit: RateUnit = RateUnit.PER_SECOND,
  264. options: {
  265. minimumValue?: number;
  266. significantDigits?: number;
  267. } = {}
  268. ) {
  269. // NOTE: `Intl` doesn't support unitless-per-unit formats (i.e.,
  270. // `"-per-minute"` is not valid) so we have to concatenate the unit manually, since our rates are usually just "/min" or "/s".
  271. // Because of this, the unit is not internationalized.
  272. // 0 is special!
  273. if (value === 0) {
  274. return `${0}${RATE_UNIT_LABELS[unit]}`;
  275. }
  276. const minimumValue = options.minimumValue ?? 0;
  277. const significantDigits = options.significantDigits ?? 3;
  278. const numberFormatOptions: ConstructorParameters<typeof Intl.NumberFormat>[1] = {
  279. notation: 'compact',
  280. compactDisplay: 'short',
  281. minimumSignificantDigits: significantDigits,
  282. maximumSignificantDigits: significantDigits,
  283. };
  284. if (value <= minimumValue) {
  285. return `<${minimumValue}${RATE_UNIT_LABELS[unit]}`;
  286. }
  287. return `${value.toLocaleString(undefined, numberFormatOptions)}${
  288. RATE_UNIT_LABELS[unit]
  289. }`;
  290. }
  291. export function formatSpanOperation(
  292. operation?: string,
  293. length: 'short' | 'long' = 'short'
  294. ) {
  295. if (length === 'long') {
  296. return getLongSpanOperationDescription(operation);
  297. }
  298. return getShortSpanOperationDescription(operation);
  299. }
  300. function getLongSpanOperationDescription(operation?: string) {
  301. if (operation?.startsWith('http')) {
  302. return t('URL request');
  303. }
  304. if (operation === 'db.redis') {
  305. return t('cache query');
  306. }
  307. if (operation?.startsWith('db')) {
  308. return t('database query');
  309. }
  310. if (operation?.startsWith('task')) {
  311. return t('application task');
  312. }
  313. if (operation?.startsWith('serialize')) {
  314. return t('serializer');
  315. }
  316. if (operation?.startsWith('middleware')) {
  317. return t('middleware');
  318. }
  319. if (operation === 'resource') {
  320. return t('resource');
  321. }
  322. if (operation === 'resource.script') {
  323. return t('JavaScript file');
  324. }
  325. if (operation === 'resource.css') {
  326. return t('stylesheet');
  327. }
  328. if (operation === 'resource.img') {
  329. return t('image');
  330. }
  331. return t('span');
  332. }
  333. function getShortSpanOperationDescription(operation?: string) {
  334. if (operation?.startsWith('http')) {
  335. return t('request');
  336. }
  337. if (operation?.startsWith('db')) {
  338. return t('query');
  339. }
  340. if (operation?.startsWith('task')) {
  341. return t('task');
  342. }
  343. if (operation?.startsWith('serialize')) {
  344. return t('serializer');
  345. }
  346. if (operation?.startsWith('middleware')) {
  347. return t('middleware');
  348. }
  349. if (operation?.startsWith('resource')) {
  350. return t('resource');
  351. }
  352. return t('span');
  353. }