fix: handle custom time ranges in timezones correctly (#10094)

* feat: handle custom time ranges in timezones correctly

* fix: improve timezone UI

* fix: prettier issues

* fix: show time in user selected timezone, overflow issue (#10096)

* fix: show time in user selected timezone, overflow issue, metadata loading

* fix: add gap to popover row

* fix: use inline conditional render for startTimeMs

* fix: remove unused variable in CustomTimePicker

* fix: remove unused variable in CustomTimePicker, TraceMetadata

* fix: update timezone mock in SpanHoverCard test case
This commit is contained in:
Yunus M
2026-01-28 10:30:40 +05:30
committed by GitHub
parent 54b80c3949
commit 9d700563c1
13 changed files with 225 additions and 117 deletions

View File

@@ -131,6 +131,45 @@
border-top: 1px solid var(--bg-ink-200);
padding: 8px 14px;
.timezone-container {
display: flex;
align-items: center;
justify-content: space-between;
&__left {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
.timezone__name {
font-size: 12px;
line-height: 16px;
color: var(--bg-robin-400);
font-weight: 500;
}
.timezone__separator {
font-size: 12px;
line-height: 16px;
color: var(--bg-robin-300);
font-weight: 500;
}
.timezone__offset {
font-size: 11px;
line-height: 14px;
color: var(--bg-robin-400);
font-weight: 500;
}
}
&__right {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
}
&,
.timezone {
font-family: Inter;
@@ -138,6 +177,7 @@
line-height: 16px;
letter-spacing: -0.06px;
}
display: flex;
align-items: center;
color: var(--bg-vanilla-400);
@@ -156,18 +196,21 @@
}
}
}
.timezone-badge {
display: flex;
align-items: center;
justify-content: center;
padding: 0 4px;
border-radius: 2px;
background: rgba(171, 189, 255, 0.04);
color: var(--bg-vanilla-100);
font-size: 12px;
color: var(--bg-vanilla-400);
background-color: var(--bg-ink-200);
font-size: 9px;
font-weight: 400;
line-height: 16px;
letter-spacing: -0.06px;
line-height: 12px;
letter-spacing: -0.045px;
margin-right: 4px;
width: 72px;
cursor: pointer;
}
@@ -183,6 +226,7 @@
font-size: 11px;
color: var(--bg-vanilla-400);
background-color: var(--bg-ink-200);
cursor: pointer;
&.is-live {
background-color: transparent;
@@ -238,11 +282,10 @@
.date-time-popover__footer {
border-color: var(--bg-vanilla-400);
}
.timezone-container {
color: var(--bg-ink-400);
&__clock-icon {
stroke: var(--bg-ink-400);
}
.timezone {
color: var(--bg-ink-100);
background: rgb(179 179 179 / 15%);

View File

@@ -4,7 +4,6 @@
import './CustomTimePicker.styles.scss';
import { Input, InputRef, Popover, Tooltip } from 'antd';
import logEvent from 'api/common/logEvent';
import cx from 'classnames';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { DateTimeRangeType } from 'container/TopNav/CustomDateTimeModal';
@@ -22,9 +21,7 @@ import {
ChangeEvent,
Dispatch,
SetStateAction,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
@@ -113,22 +110,8 @@ function CustomTimePicker({
const [activeView, setActiveView] = useState<ViewType>(DEFAULT_VIEW);
const { timezone, browserTimezone } = useTimezone();
const { timezone } = useTimezone();
const activeTimezoneOffset = timezone.offset;
const isTimezoneOverridden = useMemo(
() => timezone.offset !== browserTimezone.offset,
[timezone, browserTimezone],
);
const handleViewChange = useCallback(
(newView: 'timezone' | 'datetime'): void => {
if (activeView !== newView) {
setActiveView(newView);
}
setOpen(true);
},
[activeView, setOpen],
);
const [isOpenedFromFooter, setIsOpenedFromFooter] = useState(false);
@@ -371,6 +354,7 @@ function CustomTimePicker({
startTime,
endTime,
DATE_TIME_FORMATS.UK_DATETIME_SECONDS,
timezone.value,
);
if (!isValidTimeRange) {
@@ -422,8 +406,8 @@ function CustomTimePicker({
</div>
);
const handleOpen = (e: React.SyntheticEvent): void => {
e.stopPropagation();
const handleOpen = (e?: React.SyntheticEvent): void => {
e?.stopPropagation?.();
if (showLiveLogs) {
setOpen(true);
@@ -436,12 +420,12 @@ function CustomTimePicker({
// reset the input status and error message as we reset the time to previous correct value
resetErrorStatus();
const startTime = dayjs(minTime / 1000_000).format(
DATE_TIME_FORMATS.UK_DATETIME_SECONDS,
);
const endTime = dayjs(maxTime / 1000_000).format(
DATE_TIME_FORMATS.UK_DATETIME_SECONDS,
);
const startTime = dayjs(minTime / 1000_000)
.tz(timezone.value)
.format(DATE_TIME_FORMATS.UK_DATETIME_SECONDS);
const endTime = dayjs(maxTime / 1000_000)
.tz(timezone.value)
.format(DATE_TIME_FORMATS.UK_DATETIME_SECONDS);
setInputValue(`${startTime} - ${endTime}`);
};
@@ -468,18 +452,6 @@ function CustomTimePicker({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [location.pathname]);
const handleTimezoneHintClick = (e: React.MouseEvent): void => {
e.stopPropagation();
handleViewChange('timezone');
setIsOpenedFromFooter(false);
logEvent(
'DateTimePicker: Timezone picker opened from time range input badge',
{
page: location.pathname,
},
);
};
const handleInputBlur = (): void => {
resetErrorStatus();
};
@@ -498,9 +470,7 @@ function CustomTimePicker({
return '';
};
// Focus and select input text when popover opens
useEffect(() => {
if (open && inputRef.current) {
const focusInput = (): void => {
// Use setTimeout to wait for React to update the DOM and make input editable
setTimeout(() => {
const inputElement = inputRef.current?.input;
@@ -508,10 +478,20 @@ function CustomTimePicker({
inputElement.focus();
inputElement.select();
}
}, 0);
}, 100);
};
// Focus and select input text when popover opens
useEffect(() => {
if (open && inputRef.current) {
focusInput();
}
}, [open]);
const handleTimezoneChange = (): void => {
focusInput();
};
return (
<div className="custom-time-picker">
<Tooltip title={getTooltipTitle()} placement="top">
@@ -532,6 +512,7 @@ function CustomTimePicker({
customDateTimeVisible={defaultTo(customDateTimeVisible, false)}
onCustomDateHandler={defaultTo(onCustomDateHandler, noop)}
onSelectHandler={handleSelect}
onTimezoneChange={handleTimezoneChange}
onGoLive={defaultTo(onGoLive, noop)}
onExitLiveLogs={defaultTo(onExitLiveLogs, noop)}
options={items}
@@ -583,8 +564,8 @@ function CustomTimePicker({
prefix={getInputPrefix()}
suffix={
<div className="time-input-suffix">
{!!isTimezoneOverridden && activeTimezoneOffset && (
<div className="timezone-badge" onClick={handleTimezoneHintClick}>
{activeTimezoneOffset && (
<div className="timezone-badge">
<span>{activeTimezoneOffset}</span>
</div>
)}

View File

@@ -31,6 +31,7 @@ import { TimeRangeValidationResult } from 'utils/timeUtils';
import CalendarContainer from './CalendarContainer';
import { CustomTimePickerInputStatus } from './CustomTimePicker';
import TimezonePicker from './TimezonePicker';
import { Timezone } from './timezoneUtils';
const TO_MILLISECONDS_FACTOR = 1000_000;
@@ -52,6 +53,7 @@ interface CustomTimePickerPopoverContentProps {
lexicalContext?: LexicalContext,
) => void;
onSelectHandler: (label: string, value: string) => void;
onTimezoneChange: (timezone: Timezone) => void;
onGoLive: () => void;
selectedTime: string;
activeView: 'datetime' | 'timezone';
@@ -101,6 +103,7 @@ function CustomTimePickerPopoverContent({
setCustomDTPickerVisible,
onCustomDateHandler,
onSelectHandler,
onTimezoneChange,
onGoLive,
selectedTime,
activeView,
@@ -208,6 +211,7 @@ function CustomTimePickerPopoverContent({
setActiveView={setActiveView}
setIsOpen={setIsOpen}
isOpenedFromFooter={isOpenedFromFooter}
onTimezoneSelect={onTimezoneChange}
/>
</div>
);
@@ -352,26 +356,30 @@ function CustomTimePickerPopoverContent({
<div className="date-time-popover__footer">
<div className="timezone-container">
<div className="timezone-container__left">
<Clock
color={Color.BG_VANILLA_400}
color={Color.BG_ROBIN_400}
className="timezone-container__clock-icon"
height={12}
width={12}
/>
<span className="timezone__icon">Current timezone</span>
<div></div>
<button
type="button"
className="timezone"
<span className="timezone__name">{timezone.name}</span>
<span className="timezone__separator"></span>
<span className="timezone__offset">{activeTimezoneOffset}</span>
</div>
<div className="timezone-container__right">
<Button
type="text"
size="small"
className="periscope-btn text timezone-change-button"
onClick={handleTimezoneHintClick}
icon={<PenLine size={10} />}
>
<span>{activeTimezoneOffset}</span>
<PenLine
color={Color.BG_VANILLA_100}
className="timezone__icon"
size={10}
/>
</button>
Change Timezone
</Button>
</div>
</div>
</div>
</>

View File

@@ -121,12 +121,14 @@ interface TimezonePickerProps {
setActiveView: Dispatch<SetStateAction<'datetime' | 'timezone'>>;
setIsOpen: Dispatch<SetStateAction<boolean>>;
isOpenedFromFooter: boolean;
onTimezoneSelect: (timezone: Timezone) => void;
}
function TimezonePicker({
setActiveView,
setIsOpen,
isOpenedFromFooter,
onTimezoneSelect,
}: TimezonePickerProps): JSX.Element {
const [searchTerm, setSearchTerm] = useState('');
const { timezone, updateTimezone } = useTimezone();
@@ -153,11 +155,11 @@ function TimezonePicker({
}, [isOpenedFromFooter, setActiveView, setIsOpen]);
const handleTimezoneSelect = useCallback(
(timezone: Timezone) => {
(timezone: Timezone): void => {
setSelectedTimezone(timezone.name);
updateTimezone(timezone);
onTimezoneSelect(timezone);
handleCloseTimezonePicker();
setIsOpen(false);
logEvent('DateTimePicker: New Timezone Selected', {
timezone: {
name: timezone.name,
@@ -165,7 +167,7 @@ function TimezonePicker({
},
});
},
[handleCloseTimezonePicker, setIsOpen, updateTimezone],
[handleCloseTimezonePicker, updateTimezone, onTimezoneSelect],
);
// Register keyboard shortcuts
@@ -194,7 +196,7 @@ function TimezonePicker({
<div className="timezone-picker__list">
{getFilteredTimezones(searchTerm).map((timezone) => (
<TimezoneItem
key={timezone.value}
key={`${timezone.value}-${timezone.name}`}
timezone={timezone}
isSelected={timezone.name === selectedTimezone}
onClick={(): void => handleTimezoneSelect(timezone)}

View File

@@ -1,6 +1,4 @@
.span-hover-card {
width: 206px;
.ant-popover-inner {
background: linear-gradient(
139deg,
@@ -60,8 +58,8 @@
display: flex;
justify-content: space-between;
align-items: center;
max-width: 174px;
margin-top: 8px;
gap: 16px;
}
&__label {

View File

@@ -4,6 +4,7 @@ import { Popover, Typography } from 'antd';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { convertTimeToRelevantUnit } from 'container/TraceDetail/utils';
import dayjs from 'dayjs';
import { useTimezone } from 'providers/Timezone';
import { ReactNode } from 'react';
import { Span } from 'types/api/trace/getTraceV2';
import { toFixed } from 'utils/toFixed';
@@ -29,6 +30,8 @@ function SpanHoverCard({
duration,
);
const { timezone } = useTimezone();
// Calculate relative start time from trace start
const relativeStartTime = span.timestamp - traceMetadata.startTime;
const {
@@ -37,9 +40,9 @@ function SpanHoverCard({
} = convertTimeToRelevantUnit(relativeStartTime);
// Format absolute start time
const startTimeFormatted = dayjs(span.timestamp).format(
DATE_TIME_FORMATS.SPAN_POPOVER_DATE,
);
const startTimeFormatted = dayjs(span.timestamp)
.tz(timezone.value)
.format(DATE_TIME_FORMATS.DD_MMM_YYYY_HH_MM_SS);
const getContent = (): JSX.Element => (
<div className="span-hover-card">
@@ -87,7 +90,7 @@ function SpanHoverCard({
</Typography.Text>
</div>
}
mouseEnterDelay={0.5}
mouseEnterDelay={0.2}
content={getContent()}
trigger="hover"
rootClassName="span-hover-card"

View File

@@ -2,21 +2,54 @@ import { act, fireEvent, render, screen } from '@testing-library/react';
import { Span } from 'types/api/trace/getTraceV2';
import SpanHoverCard from '../SpanHoverCard';
import { TimezoneContextType } from 'providers/Timezone';
// Mock dayjs completely for testing
jest.mock('dayjs', () => {
const mockDayjs = jest.fn(() => ({
format: jest.fn((formatString: string) => {
if (formatString === 'D/M/YY - HH:mm:ss') {
return '15/3/24 - 14:23:45';
}
return 'mock-date';
// Mock timezone provider so SpanHoverCard can use useTimezone without a real context
jest.mock('providers/Timezone', () => ({
__esModule: true,
useTimezone: (): TimezoneContextType => ({
timezone: {
name: 'Coordinated Universal Time — UTC, GMT',
value: 'UTC',
offset: 'UTC',
searchIndex: 'UTC',
},
browserTimezone: {
name: 'Coordinated Universal Time — UTC, GMT',
value: 'UTC',
offset: 'UTC',
searchIndex: 'UTC',
},
updateTimezone: jest.fn(),
formatTimezoneAdjustedTimestamp: jest.fn(() => 'mock-date'),
isAdaptationEnabled: true,
setIsAdaptationEnabled: jest.fn(),
}),
}));
// Mock dayjs for testing, including timezone helpers used in timezoneUtils
jest.mock('dayjs', () => {
const mockDayjsInstance: any = {};
mockDayjsInstance.format = jest.fn((formatString: string) =>
// Match the DD_MMM_YYYY_HH_MM_SS format: 'DD MMM YYYY, HH:mm:ss'
formatString === 'DD MMM YYYY, HH:mm:ss'
? '15 Mar 2024, 14:23:45'
: 'mock-date',
);
// Support chaining: dayjs().tz(timezone).format(...) and dayjs().tz(timezone).utcOffset()
mockDayjsInstance.tz = jest.fn(() => mockDayjsInstance);
mockDayjsInstance.utcOffset = jest.fn(() => 0);
const mockDayjs = jest.fn(() => mockDayjsInstance);
Object.assign(mockDayjs, {
extend: jest.fn(),
// Support dayjs.tz.guess()
tz: { guess: jest.fn(() => 'UTC') },
});
return mockDayjs;
});
@@ -84,7 +117,7 @@ describe('SpanHoverCard', () => {
expect(screen.getByText('Hover me')).toBeInTheDocument();
});
it('shows popover after 0.5 second delay on hover', async () => {
it('shows popover after 0.2 second delay on hover', async () => {
render(
<SpanHoverCard span={mockSpan} traceMetadata={mockTraceMetadata}>
<div data-testid={HOVER_ELEMENT_ID}>Hover for details</div>
@@ -101,7 +134,7 @@ describe('SpanHoverCard', () => {
// Advance time by 0.5 seconds
act(() => {
jest.advanceTimersByTime(500);
jest.advanceTimersByTime(200);
});
// Now popover should appear
@@ -117,10 +150,10 @@ describe('SpanHoverCard', () => {
const hoverElement = screen.getByTestId(HOVER_ELEMENT_ID);
// Quick hover and unhover
// Quick hover and unhover (less than the 0.2s delay)
fireEvent.mouseEnter(hoverElement);
act(() => {
jest.advanceTimersByTime(200); // Only 0.2 seconds
jest.advanceTimersByTime(100); // Only 0.1 seconds
});
fireEvent.mouseLeave(hoverElement);
@@ -163,7 +196,7 @@ describe('SpanHoverCard', () => {
expect(screen.getByText('Start time:')).toBeInTheDocument();
});
it('displays new date format with seconds', async () => {
it('displays date in DD MMM YYYY, HH:mm:ss format with seconds', async () => {
render(
<SpanHoverCard span={mockSpan} traceMetadata={mockTraceMetadata}>
<div data-testid={HOVER_ELEMENT_ID}>Date format test</div>
@@ -178,8 +211,8 @@ describe('SpanHoverCard', () => {
jest.advanceTimersByTime(500);
});
// Verify the new date format is displayed
expect(screen.getByText('15/3/24 - 14:23:45')).toBeInTheDocument();
// Verify the DD MMM YYYY, HH:mm:ss format is displayed
expect(screen.getByText('15 Mar 2024, 14:23:45')).toBeInTheDocument();
});
it('displays relative time information', async () => {

View File

@@ -42,7 +42,7 @@
.trace-id {
color: #fff;
font-family: Inter;
font-size: 14px;
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: 18px; /* 128.571% */
@@ -59,7 +59,7 @@
background: var(--bg-slate-400);
color: var(--Vanilla-400, #c0c1c3);
font-family: Inter;
font-size: 14px;
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: 18px; /* 128.571% */
@@ -83,7 +83,7 @@
.text {
color: var(--bg-vanilla-400);
font-family: Inter;
font-size: 14px;
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
@@ -110,7 +110,7 @@
.text {
color: var(--bg-vanilla-400);
font-family: Inter;
font-size: 14px;
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
@@ -127,7 +127,7 @@
.text {
color: var(--bg-vanilla-400);
font-family: Inter;
font-size: 14px;
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
@@ -156,7 +156,7 @@
color: var(--bg-vanilla-400);
text-align: center;
font-family: Inter;
font-size: 14px;
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: 18px; /* 128.571% */

View File

@@ -1,8 +1,10 @@
import './TraceMetadata.styles.scss';
import { Button, Tooltip, Typography } from 'antd';
import { Button, Skeleton, Tooltip, Typography } from 'antd';
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import ROUTES from 'constants/routes';
import dayjs from 'dayjs';
import history from 'lib/history';
import {
ArrowLeft,
@@ -11,7 +13,8 @@ import {
DraftingCompass,
Timer,
} from 'lucide-react';
import { formatEpochTimestamp } from 'utils/timeUtils';
import { useTimezone } from 'providers/Timezone';
import { useMemo } from 'react';
export interface ITraceMetadataProps {
traceID: string;
@@ -22,6 +25,7 @@ export interface ITraceMetadataProps {
totalSpans: number;
totalErrorSpans: number;
notFound: boolean;
isDataLoading: boolean;
}
function TraceMetadata(props: ITraceMetadataProps): JSX.Element {
@@ -34,8 +38,19 @@ function TraceMetadata(props: ITraceMetadataProps): JSX.Element {
totalErrorSpans,
totalSpans,
notFound,
isDataLoading,
} = props;
const { timezone } = useTimezone();
const startTimeInMs = useMemo(
() =>
dayjs(startTime * 1e3)
.tz(timezone.value)
.format(DATE_TIME_FORMATS.DD_MMM_YYYY_HH_MM_SS),
[startTime, timezone.value],
);
const handlePreviousBtnClick = (): void => {
if (window.history.length > 1) {
history.goBack();
@@ -57,7 +72,18 @@ function TraceMetadata(props: ITraceMetadataProps): JSX.Element {
</div>
<Typography.Text className="trace-id-value">{traceID}</Typography.Text>
</div>
{!notFound && (
{isDataLoading && (
<div className="second-row">
<div className="service-entry-info">
<BetweenHorizonalStart size={14} />
<Skeleton.Input active className="skeleton-input" size="small" />
<Skeleton.Input active className="skeleton-input" size="small" />
<Skeleton.Input active className="skeleton-input" size="small" />
</div>
</div>
)}
{!isDataLoading && !notFound && (
<div className="second-row">
<div className="service-entry-info">
<BetweenHorizonalStart size={14} />
@@ -79,8 +105,9 @@ function TraceMetadata(props: ITraceMetadataProps): JSX.Element {
<Tooltip title="Start timestamp">
<CalendarClock size={14} />
</Tooltip>
<Typography.Text className="text">
{formatEpochTimestamp(startTime * 1000)}
{startTimeInMs || 'N/A'}
</Typography.Text>
</div>
</div>

View File

@@ -135,6 +135,7 @@ function TraceDetailsV2(): JSX.Element {
<ResizablePanel minSize={20} maxSize={80} className="trace-left-content">
<TraceMetadata
traceID={traceId}
isDataLoading={isFetchingTraceData}
duration={
(traceData?.payload?.endTimestampMillis || 0) -
(traceData?.payload?.startTimestampMillis || 0)

View File

@@ -198,6 +198,14 @@
border-color: var(--bg-vanilla-300);
background: var(--bg-vanilla-100);
color: var(--bg-ink-200);
&.text {
color: var(--bg-ink-200) !important;
&:hover {
color: var(--bg-ink-300) !important;
}
}
}
.periscope-input-with-label {

View File

@@ -18,7 +18,7 @@ import React, {
useState,
} from 'react';
interface TimezoneContextType {
export interface TimezoneContextType {
timezone: Timezone;
browserTimezone: Timezone;
updateTimezone: (timezone: Timezone) => void;

View File

@@ -2,10 +2,13 @@ import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import dayjs from 'dayjs';
import customParseFormat from 'dayjs/plugin/customParseFormat';
import duration from 'dayjs/plugin/duration';
import timezone from 'dayjs/plugin/timezone';
import utc from 'dayjs/plugin/utc';
dayjs.extend(utc);
dayjs.extend(customParseFormat);
dayjs.extend(duration);
dayjs.extend(timezone);
export function toUTCEpoch(time: number): number {
const x = new Date();
@@ -213,10 +216,11 @@ export const validateTimeRange = (
startTime: string,
endTime: string,
format: string,
timezone: string,
): TimeRangeValidationResult => {
const start = dayjs(startTime, format, true);
const end = dayjs(endTime, format, true);
const now = dayjs();
const start = dayjs.tz(startTime, format, timezone);
const end = dayjs.tz(endTime, format, timezone);
const now = dayjs().tz(timezone);
const startTimeMs = start.valueOf();
const endTimeMs = end.valueOf();