Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions static/app/utils/array/isNonEmptyArray.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
type NonEmptyArray<T> = [T, ...T[]];

/**
* Allows TypeScript to infer that arrays have at least one item so that
* expressions like `array[0]` typecheck naturally without needed non-null
* assertions. Unfortunately heavily limited to only work with `[0]` and not
* `.at(0)` or any other index.
Comment on lines +6 to +7

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

😭

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it be worth looking into introducing this package, and utilizing this function: https://remedajs.com/docs/#hasAtLeast ?

I know @TkDodo is a maintainer of it :o

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤷🏻‍♂️ I don't have a strong opinion! It seems like overkill for this case, but maybe it's worth it?

*/
export function isNonEmptyArray<T>(array: T[]): array is NonEmptyArray<T> {
return array.length > 0;
}
Original file line number Diff line number Diff line change
@@ -1,22 +1,6 @@
import {millisecondsToClosestInterval} from 'sentry/utils/duration/millisecondsToInterval';
import {closestIntervalToDuration} from 'sentry/utils/duration/closestIntervalToDuration';

const TEST_INTERVALS = [
'1m',
'2m',
'5m',
'10m',
'15m',
'30m',
'1h',
'2h',
'3h',
'4h',
'6h',
'12h',
'1d',
];

describe('millisecondsToClosestInterval()', () => {
describe('closestIntervalToDuration()', () => {
it.each([
[60_000, '1m'],
[2 * 60_000, '2m'],
Expand All @@ -28,7 +12,7 @@ describe('millisecondsToClosestInterval()', () => {
[6 * 3600_000, '6h'],
[24 * 3600_000, '1d'],
])('returns an exact string for valid granularity (%s)', (ms, expected) => {
expect(millisecondsToClosestInterval(ms, TEST_INTERVALS)).toBe(expected);
expect(closestIntervalToDuration(ms, TEST_INTERVALS)).toBe(expected);
});

it.each([
Expand All @@ -47,7 +31,7 @@ describe('millisecondsToClosestInterval()', () => {
])(
'rounds to the nearest interval when between two valid granularities (%s)',
(ms, expected) => {
expect(millisecondsToClosestInterval(ms, TEST_INTERVALS)).toBe(expected);
expect(closestIntervalToDuration(ms, TEST_INTERVALS)).toBe(expected);
}
);

Expand All @@ -57,7 +41,7 @@ describe('millisecondsToClosestInterval()', () => {
])(
'clamps to the smallest valid interval for values below the minimum (%s)',
(ms, expected) => {
expect(millisecondsToClosestInterval(ms, TEST_INTERVALS)).toBe(expected);
expect(closestIntervalToDuration(ms, TEST_INTERVALS)).toBe(expected);
}
);

Expand All @@ -67,16 +51,16 @@ describe('millisecondsToClosestInterval()', () => {
])(
'clamps to the largest valid interval for values above the maximum (%s)',
(ms, expected) => {
expect(millisecondsToClosestInterval(ms, TEST_INTERVALS)).toBe(expected);
expect(closestIntervalToDuration(ms, TEST_INTERVALS)).toBe(expected);
}
);

it.each([
[0, undefined],
[-60_000, undefined],
[Infinity, undefined],
])('returns undefined for invalid inputs (%s)', (ms, expected) => {
expect(millisecondsToClosestInterval(ms, TEST_INTERVALS)).toBe(expected);
[0, '1m'],
[-60_000, '1m'],
[Infinity, '1d'],
])('returns an interval for out-of-range inputs (%s)', (ms, expected) => {
expect(closestIntervalToDuration(ms, TEST_INTERVALS)).toBe(expected);
});

describe('less availableIntervals option', () => {
Expand All @@ -87,16 +71,16 @@ describe('millisecondsToClosestInterval()', () => {
[90_000, '1m'],
// 3m is equidistant between 1m and 5m — ties go to larger
[3 * 60_000, '5m'],
// exact match still works
// Exact match still works
[5 * 60_000, '5m'],
])('restricts selection to the provided available intervals (%s)', (ms, expected) => {
expect(millisecondsToClosestInterval(ms, availableIntervals)).toBe(expected);
expect(closestIntervalToDuration(ms, availableIntervals)).toBe(expected);
});

it.each([[1_000, '1m']])(
'clamps to the first available interval for values below the minimum (%s)',
(ms, expected) => {
expect(millisecondsToClosestInterval(ms, availableIntervals)).toBe(expected);
expect(closestIntervalToDuration(ms, availableIntervals)).toBe(expected);
}
);

Expand All @@ -106,8 +90,24 @@ describe('millisecondsToClosestInterval()', () => {
])(
'clamps to the last available interval for values above the maximum (%s)',
(ms, expected) => {
expect(millisecondsToClosestInterval(ms, availableIntervals)).toBe(expected);
expect(closestIntervalToDuration(ms, availableIntervals)).toBe(expected);
}
);
});
});

const TEST_INTERVALS = [
'1m',
'2m',
'5m',
'10m',
'15m',
'30m',
'1h',
'2h',
'3h',
'4h',
'6h',
'12h',
'1d',
];
Original file line number Diff line number Diff line change
@@ -1,28 +1,44 @@
import {isNonEmptyArray} from 'sentry/utils/array/isNonEmptyArray';
import {intervalToMilliseconds} from 'sentry/utils/duration/intervalToMilliseconds';
import {RangeMap, type Range} from 'sentry/utils/number/rangeMap';

/**
* Converts a millisecond value to the closest valid interval string.
* If the milliseconds value is not one of the exact valid interval durations,
* it will return the closest valid interval string (based on rounding rules).
* @param ms - The milliseconds value to convert.
* @param availableIntervals - Array of available interval strings (e.g. '1m', '5m', '1h') to choose from.
* @returns The closest valid interval string.
*/
export function millisecondsToClosestInterval(
ms: number,
export function closestIntervalToDuration(
duration: number,
availableIntervals: string[]
): string | undefined {
if (ms <= 0 || !Number.isFinite(ms)) {
return undefined;
): string | null {
if (!isNonEmptyArray(availableIntervals)) {
return null;
}

// sort the intervals in ascending order in case they are not in order already
const sortedIntervals = availableIntervals.sort(
(a, b) => intervalToMilliseconds(a) - intervalToMilliseconds(b)
);

// calculate the MIDPOINT value ranges to allow the interval to be chosen.
const shortestIntervalDuration = intervalToMilliseconds(sortedIntervals.at(0)!);
if (duration <= shortestIntervalDuration) {
// TypeScript correctly unpacks the tuple syntax here, so it knows that
// `[0]` must be defined. `.at(0)` doesn't have that benefit
return sortedIntervals[0];
}

const longestIntervalDuration = intervalToMilliseconds(sortedIntervals.at(-1)!);
if (duration >= longestIntervalDuration) {
// Due to how `noUncheckedIndexedAccess` works, TypeScript here doesn't know
// that the last element _also_ must exist. The non-null assertion is not
// avoidable
return sortedIntervals.at(-1)!;
}

if (!Number.isFinite(duration)) {
return null;
}

// Calculate the MIDPOINT value ranges to allow the interval to be chosen.
// For example if the available intervals are [1m, 5m, 1h, 4h, 6h, 1d], the valid interval range
// boundaries would be the numbers exactly in between the intervals.
// so for example:
Expand All @@ -33,16 +49,18 @@ export function millisecondsToClosestInterval(
// - anything from 5h -> 12h would give the 6h interval,
// - anything from 12h -> Infinity would give the 1d interval,
const intervalRanges: Array<Range<string>> = [];

for (let i = 0; i < sortedIntervals.length; i++) {
const range: Range<string> = {min: 0, max: 0, value: sortedIntervals[i]!};

if (i < sortedIntervals.length - 1) {
// min value should cover end of the previous interval (or 0 if there is no previous interval)
if (i === 0) {
range.min = 0;
} else {
range.min = intervalRanges[i - 1]!.max;
}
// max value should cover up until the value that is considered "closest" to the interval.
// Max value should cover up until the value that is considered "closest" to the interval.
// Any value up to halfway between the current and next interval would take the current interval.
const halfIntervalDifference = Math.round(
Math.abs(
Expand All @@ -53,7 +71,7 @@ export function millisecondsToClosestInterval(
range.max = intervalToMilliseconds(sortedIntervals[i]!) + halfIntervalDifference;
intervalRanges.push(range);
} else if (sortedIntervals.length > 1) {
// last interval should cover all values close to and greater than the last interval
// Last interval should cover all values close to and greater than the last interval
range.min = intervalRanges[i - 1]?.max ?? 0;
range.max = Infinity;
intervalRanges.push(range);
Expand All @@ -65,6 +83,7 @@ export function millisecondsToClosestInterval(
}

const intervalRangeMap = new RangeMap(intervalRanges ?? []);
const closestInterval = intervalRangeMap.get(ms);
const closestInterval = intervalRangeMap.get(duration)!;

return closestInterval;
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,11 @@ export const HEATMAP_COLORS = [
] as const;

/**
* Target width, in pixels, of a single heat map X-axis (time) bucket. The
* interval is chosen so that columns are roughly this wide for the rendered
* container width.
* Target size, in pixels, of a single heat map bucket along each axis. Both the
* X-axis (time) interval and the Y-axis bucket count are chosen so that cells
* are roughly this size, keeping them approximately square.
*/
export const PIXELS_PER_X_BUCKET = 15;
export const PIXELS_PER_BUCKET = 15;

/**
* Scale used for the heat map's Z axis (the cell color). A logarithmic scale
Expand Down
Loading
Loading