Browse Source

feat: Create generalized duration formatter (#57319)

There are many datetime & duration related utils scattered around the
sentry codebase. Overall I think it's fair to say that none of their
interfaces are compatible with each other: in terms of inputs and output
types (some return numbers, strings, react components, who knows).

I'd like to unify all the things under one folder:
`sentry/utils/duration/*` and create a sensible function interface,
that's not tied to any specific EventGroup or UserPreference, or other
entity in the repo. Those things should be building off a tested,
extensible, and opinionated set of primitives IMHO.

This is the first step, and is leveraged by
https://github.com/getsentry/sentry/pull/57323 to parse `statsPeriod` on
the client side, so we can do bounded infinite list loading (bounded
within the statsPeriod) and resume previous list positions.
Ryan Albrecht 1 year ago
parent
commit
9de88a3ad2

+ 12 - 0
static/app/utils/duration/durationUnitToSuffix.tsx

@@ -0,0 +1,12 @@
+import {Unit} from 'sentry/utils/duration/types';
+
+export default function durationUnitToSuffix(unit: Unit) {
+  return {
+    ms: 'ms',
+    sec: 's',
+    min: 'm',
+    hour: 'h',
+    day: 'd',
+    week: 'w',
+  }[unit];
+}

+ 110 - 0
static/app/utils/duration/formatDuration.spec.tsx

@@ -0,0 +1,110 @@
+import formatDuration from 'sentry/utils/duration/formatDuration';
+
+describe('formatDuration', () => {
+  describe('parsing', () => {
+    it.each([
+      {value: 60000, unit: 'ms' as const},
+      {value: 60, unit: 'sec' as const},
+      {value: 1, unit: 'min' as const},
+    ])('should convert "$value $unit" and return the count of ms', ({value, unit}) => {
+      expect(
+        formatDuration({style: 'count', precision: 'ms', timespan: [value, unit]})
+      ).toBe('60000');
+    });
+
+    it.each([
+      {value: 168, unit: 'hour' as const},
+      {value: 7, unit: 'day' as const},
+      {value: 1, unit: 'week' as const},
+    ])('should convert "$value $unit" and return the count of ms', ({value, unit}) => {
+      expect(
+        formatDuration({style: 'count', precision: 'ms', timespan: [value, unit]})
+      ).toBe('604800000');
+    });
+  });
+
+  describe('formatting', () => {
+    it.each([
+      {style: 'h:mm:ss' as const, expected: '8:20'},
+      {style: 'hh:mm:ss' as const, expected: '08:20'},
+      {style: 'h:mm:ss.sss' as const, expected: '8:20.012'},
+      {style: 'hh:mm:ss.sss' as const, expected: '08:20.012'},
+    ])('should format according to the selected style', ({style, expected}) => {
+      expect(
+        formatDuration({
+          style,
+          precision: 'sec',
+          timespan: [500.012, 'sec'],
+        })
+      ).toBe(expected);
+    });
+
+    it('should format the value into a locale specific number', () => {
+      expect(
+        formatDuration({
+          style: 'count-locale',
+          precision: 'ms',
+          timespan: [60, 'sec'],
+        })
+      ).toBe('60,000');
+    });
+
+    it('should format the value into a count, like statsPeriod', () => {
+      expect(
+        formatDuration({
+          style: 'count',
+          precision: 'ms',
+          timespan: [60, 'sec'],
+        })
+      ).toBe('60000');
+
+      expect(
+        formatDuration({
+          style: 'count',
+          precision: 'hour',
+          timespan: [45, 'min'],
+        })
+      ).toBe('0.75');
+    });
+
+    it('should format sec into hours, minutes, and seconds', () => {
+      expect(
+        formatDuration({
+          style: 'h:mm:ss',
+          precision: 'sec',
+          timespan: [500, 'sec'],
+        })
+      ).toBe('8:20');
+    });
+
+    it('should truncate ms when formatting as hours & minutes', () => {
+      expect(
+        formatDuration({
+          style: 'h:mm:ss',
+          precision: 'sec',
+          timespan: [500012, 'ms'],
+        })
+      ).toBe('8:20');
+    });
+
+    it('should add ms when format demands it', () => {
+      expect(
+        formatDuration({
+          style: 'h:mm:ss.sss',
+          precision: 'sec',
+          timespan: [500, 'sec'],
+        })
+      ).toBe('8:20.000');
+    });
+
+    it('should include ms when precision includes it', () => {
+      expect(
+        formatDuration({
+          style: 'h:mm:ss.sss',
+          precision: 'sec',
+          timespan: [500012, 'ms'],
+        })
+      ).toBe('8:20.012');
+    });
+  });
+});

+ 101 - 0
static/app/utils/duration/formatDuration.tsx

@@ -0,0 +1,101 @@
+import {Timespan, Unit} from 'sentry/utils/duration/types';
+import {formatSecondsToClock} from 'sentry/utils/formatters';
+
+type Format =
+  // example: `3,600`
+  | 'count-locale'
+  // example: `86400`
+  | 'count'
+  // example: `1:00:00.000`
+  | 'h:mm:ss.sss'
+  // example: `1:00:00`
+  | 'h:mm:ss'
+  // example: `01:00:00.000
+  | 'hh:mm:ss.sss'
+  // example: `01:00:00`
+  | 'hh:mm:ss';
+
+type Args = {
+  /**
+   * The precision of the output.
+   *
+   * If the output precision is more granular than the input precision you might
+   * find the output value is rounded down, because least-significant digits are
+   * simply chopped off.
+   * Alternativly, because of IEEE 754, converting from a granular precision to
+   * something less granular might, in some cases, change the least-significant
+   * digits of the final value.
+   */
+  precision: Unit;
+  /**
+   * The output style to use
+   *
+   * ie: 120 seconds formatted as "h:mm" results in "2:00"
+   * ie: 10500 formatted as "count" + "sec" results in "10.5"
+   */
+  style: Format;
+  /**
+   * The timespan/duration to be displayed
+   * ie: "1000 miliseconds" would have the same output as "1 second"
+   *
+   * If it's coming from javascript `new Date` then 'ms'
+   * If it's from an SDK event, probably 'sec'
+   */
+  timespan: Timespan;
+};
+
+const PRECISION_FACTORS: Record<Unit, number> = {
+  ms: 1,
+  sec: 1000,
+  min: 1000 * 60,
+  hour: 1000 * 60 * 60,
+  day: 1000 * 60 * 60 * 24,
+  week: 1000 * 60 * 60 * 24 * 7,
+};
+
+/**
+ * Format a timespan (aka duration) into a formatted string.
+ *
+ * A timespan is expressed a `number` and a `unit` pair -> [value, unit]
+ */
+export default function formatDuration({
+  precision,
+  style,
+  timespan: [value, unit],
+}: Args): string {
+  const ms = normalizeTimespanToMs(value, unit);
+  const valueInUnit = msToPrecision(ms, precision);
+
+  switch (style) {
+    case 'count-locale':
+      return valueInUnit.toLocaleString();
+    case 'count':
+      return String(valueInUnit);
+    case 'h:mm:ss': // fall-through
+    case 'hh:mm:ss': // fall-through
+    case 'h:mm:ss.sss': // fall-through
+    case 'hh:mm:ss.sss':
+      const includeMs = style.endsWith('.sss');
+      const valueInSec = msToPrecision(ms, 'sec');
+      const str = formatSecondsToClock(valueInSec, {
+        padAll: style.startsWith('hh:mm:ss'),
+      });
+      const [head, tail] = str.split('.');
+      return includeMs ? [head, tail ?? '000'].join('.') : String(head);
+    default:
+      throw new Error('Invalid style');
+  }
+}
+
+function normalizeTimespanToMs(value: number, unit: Unit): number {
+  const factor = PRECISION_FACTORS[unit];
+  return value * factor;
+}
+
+function msToPrecision(value: number, unit: Unit): number {
+  if (value === 0) {
+    return 0;
+  }
+  const factor = PRECISION_FACTORS[unit];
+  return value / factor;
+}

+ 3 - 0
static/app/utils/duration/types.tsx

@@ -0,0 +1,3 @@
+export type Unit = 'ms' | 'sec' | 'min' | 'hour' | 'day' | 'week';
+
+export type Timespan = [number, Unit];