Skip to content
Draft
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
2 changes: 1 addition & 1 deletion static/app/utils/dates.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export const DEFAULT_DAY_START_TIME = '00:00:00';
export const DEFAULT_DAY_END_TIME = '23:59:59';
const DATE_FORMAT_NO_TIMEZONE = 'YYYY/MM/DD HH:mm:ss';

export function getParser(local = false): typeof moment | typeof moment.utc {
function getParser(local = false): typeof moment | typeof moment.utc {
return local ? moment : moment.utc;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {Flex} from '@sentry/scraps/layout';

import {BaseChart} from 'sentry/components/charts/baseChart';
import {usePageFilters} from 'sentry/components/pageFilters/usePageFilters';
import {getUserTimezone} from 'sentry/utils/dates';
import {NO_PLOTTABLE_VALUES} from 'sentry/views/dashboards/widgets/common/settings';
import {plottablesCanBeVisualized} from 'sentry/views/dashboards/widgets/plottablesCanBeVisualized';
import {formatXAxisTimestamp} from 'sentry/views/dashboards/widgets/timeSeriesWidget/formatters/formatXAxisTimestamp';
Expand All @@ -32,6 +33,7 @@ export function HeatMapWidgetVisualization(props: HeatMapWidgetVisualizationProp

const pageFilters = usePageFilters();
const {start, end, period, utc} = pageFilters.selection.datetime;
const timezone = utc ? 'UTC' : getUserTimezone();

if (!plottablesCanBeVisualized(plottables)) {
throw new Error(NO_PLOTTABLE_VALUES);
Expand Down Expand Up @@ -74,9 +76,7 @@ export function HeatMapWidgetVisualization(props: HeatMapWidgetVisualizationProp
axisLabel: {
formatter: value => {
// NOTE: ECharts requires a `"category"` X-axis for heat maps, but we _know_ that we only support time as the X-axis. We need to parse the value here.
return formatXAxisTimestamp(parseFloat(value), {
utc: utc ?? undefined,
});
return formatXAxisTimestamp(parseFloat(value), timezone);
},
},
axisPointer: {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import type {TimeSeries} from 'sentry/views/dashboards/widgets/common/types';

/**
* Generate a TimeSeries for an arbitrary time range with a random-walk
* shape so charts look organic in stories.
*/
export function makeRandomWalkTimeSeries(
startMs: number,
endMs: number,
pointCount = 50
): TimeSeries {
const interval = Math.max(Math.floor((endMs - startMs) / pointCount), 1);
const values: Array<{timestamp: number; value: number}> = [];
let current = 100;

for (let ts = startMs; ts <= endMs; ts += interval) {
current += (Math.random() - 0.48) * 10;
current = Math.max(current, 1);
values.push({timestamp: ts, value: current});
}

return {
yAxis: 'count()',
meta: {valueType: 'number', valueUnit: null, interval},
values,
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,14 @@ import {formatXAxisTimestamp} from './formatXAxisTimestamp';
describe('formatXAxisTimestamp', () => {
it.each([
// Year starts
['2025-01-01T00:00:00', 'Jan 1st 2025'],
['2024-01-01T00:00:00', 'Jan 1st 2024'],
// // Month starts
['2025-01-01T00:00:00', '2025'],
['2024-01-01T00:00:00', '2024'],
// Month starts
['2025-02-01T00:00:00', 'Feb 1st'],
['2024-03-01T00:00:00', 'Mar 1st'],
// // Day starts
// Day starts
['2025-02-05T00:00:00', 'Feb 5th'],
// // Hour starts
// Hour starts
['2025-02-05T12:00:00', '12:00 PM'],
['2025-02-05T05:00:00', '5:00 AM'],
['2025-02-01T01:00:00', '1:00 AM'],
Expand All @@ -31,8 +31,8 @@ describe('formatXAxisTimestamp', () => {
user.options.clock24Hours = false;
ConfigStore.set('user', user);

const timestamp = moment(raw).unix() * 1000;
expect(formatXAxisTimestamp(timestamp)).toEqual(formatted);
const timestamp = moment.tz(raw, 'UTC').valueOf();
expect(formatXAxisTimestamp(timestamp, 'UTC')).toEqual(formatted);
});

it.each([
Expand All @@ -48,7 +48,7 @@ describe('formatXAxisTimestamp', () => {
user.options.clock24Hours = true;
ConfigStore.set('user', user);

const timestamp = moment(raw).unix() * 1000;
expect(formatXAxisTimestamp(timestamp)).toEqual(formatted);
const timestamp = moment.tz(raw, 'UTC').valueOf();
expect(formatXAxisTimestamp(timestamp, 'UTC')).toEqual(formatted);
});
});
Original file line number Diff line number Diff line change
@@ -1,29 +1,42 @@
import {getParser, getTimeFormat} from 'sentry/utils/dates';
import moment from 'moment-timezone';

import {getTimeFormat} from 'sentry/utils/dates';

/**
* A "cascading" formatter, based on the recommendations in [ECharts documentation](https://echarts.apache.org/en/option.html#xAxis.axisLabel.formatter). Given a timestamp of an X axis of type `"time"`, return a formatted string, to show under the axis tick.
* A cascading formatter for time axis labels. Given a tick timestamp, returns
* a formatted string whose granularity matches the tick's position. This
* works by inspecting what "round boundary" the tick falls on:
*
* - Midnight on Jan 1 → just the year: "2025"
* - Midnight on any day → date only: "Feb 3rd"
* - Any round minute → time only: "2:00 PM"
* - Otherwise → time with seconds: "2:00:30 PM"
*
* This approach is stateless — each tick is formatted independently, without
* knowledge of the other ticks. It relies on tick positions landing on round
* boundaries, which is guaranteed by both ECharts' built-in tick placement
* and by {@link generateTimezoneAlignedTicks} (which provides timezone-aware
* custom tick positions via `customValues`).
*
* The fidelity of the formatted value depends on the fidelity of the tick mark timestamp. ECharts will intelligently choose the location of tick marks based on the total time range, and any significant intervals inside. It always chooses tick marks that fall on a "round" time values (starts of days, starts of hours, 15 minute intervals, etc.). This formatter is called on the time stamps of the selected ticks. Here are some examples of output labels sets you can expect:
* The tick value is interpreted in the given timezone. This is important
* because tick positions from `generateTimezoneAlignedTicks` are at round
* boundaries in the user's timezone (e.g., midnight IST), not in the
* browser's local timezone. Without timezone-aware parsing, those ticks
* would be displayed as non-round browser-local times (e.g., "10:30 AM"
* PST instead of "12:00 AM" IST).
*
* ["Feb 1st", "Feb 2nd", "Feb 3rd"] when ECharts aligns ticks with days of the month
* ["11:00pm", "Feb 2nd", "1:00am"] when ECharts aligns ticks with hours across a day boundary
* ["Mar 1st", "Apr 1st", "May 1st"] when ECharts aligns ticks with starts of month
* ["Dec 1st", "Jan 1st 2025", "Feb 1st"] when ECharts aligns markers with starts of month across a year boundary
* ["12:00pm", "1:00am", "2:00am", "3:00am"] when ECharts aligns ticks with hours starts
* Example label sets for different time ranges:
*
* @param value
* @param options
* @returns Formatted X axis label string
* - Days: ["Feb 1st", "Feb 2nd", "Feb 3rd"]
* - Hours across a day boundary: ["11:00 PM", "Feb 2nd", "1:00 AM"]
* - Months: ["Mar 1st", "Apr 1st", "May 1st"]
* - Months across a year boundary: ["Dec 1st", "2025", "Feb 1st"]
* - Hours: ["12:00 PM", "1:00 AM", "2:00 AM", "3:00 AM"]
*/
export function formatXAxisTimestamp(
value: number,
options: {utc?: boolean} = {utc: false}
): string {
const parsed = getParser(!options.utc)(value);
export function formatXAxisTimestamp(value: number, timezone: string): string {
const parsed = moment.tz(value, timezone);

// Granularity-aware parsing, adjusts the format based on the
// granularity of the object This works well with ECharts since the
// parser is not aware of the other ticks
// Cascade from most specific to least specific boundary
let format = 'MMM Do';

if (
Expand All @@ -32,8 +45,8 @@ export function formatXAxisTimestamp(
parsed.minute() === 0 &&
parsed.second() === 0
) {
// Start of a year
format = 'MMM Do YYYY';
// Start of a year — just show the year (e.g., "2025")
format = 'YYYY';
} else if (
parsed.day() === 0 &&
parsed.hour() === 0 &&
Expand Down
Loading
Loading