Skip to content

feat(js): Add timezone providers #91805

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
May 16, 2025
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
49 changes: 23 additions & 26 deletions static/app/components/dateTime.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,26 +1,21 @@
import {ConfigFixture} from 'sentry-fixture/config';
import {UserFixture} from 'sentry-fixture/user';

import {render, screen} from 'sentry-test/reactTestingLibrary';

import {DateTime} from 'sentry/components/dateTime';
import ConfigStore from 'sentry/stores/configStore';

describe('DateTime', () => {
const user = UserFixture({
options: {
...UserFixture().options,
clock24Hours: false,
timezone: 'America/Los_Angeles',
},
});
import {DateTime} from './dateTime';
import {TimezoneProvider} from './timezoneProvider';

beforeAll(() => {
ConfigStore.loadInitialData(ConfigFixture({user}));
});
describe('DateTime', () => {
function renderPDT(child: React.ReactElement) {
return render(
<TimezoneProvider timezone="America/Los_Angeles">{child}</TimezoneProvider>
);
}

it('renders a date', () => {
render(<DateTime date={new Date()} />);
renderPDT(<DateTime date={new Date()} />);
expect(screen.getByText('Oct 16, 7:41 PM')).toBeInTheDocument();
});

Expand All @@ -30,73 +25,75 @@ describe('DateTime', () => {
date.setMonth(11);
date.setDate(31);

render(<DateTime date={date} />);
renderPDT(<DateTime date={date} />);
expect(screen.getByText('Dec 31, 2016 7:41 PM')).toBeInTheDocument();
});

it('renders only the time', () => {
render(<DateTime date={new Date()} timeOnly />);
renderPDT(<DateTime date={new Date()} timeOnly />);
expect(screen.getByText('7:41 PM')).toBeInTheDocument();
});

it('renders only the date', () => {
render(<DateTime date={new Date()} dateOnly />);
renderPDT(<DateTime date={new Date()} dateOnly />);
expect(screen.getByText('Oct 16')).toBeInTheDocument();
});

it('renders a date with year', () => {
render(<DateTime date={new Date()} year />);
renderPDT(<DateTime date={new Date()} year />);
expect(screen.getByText('Oct 16, 2017 7:41 PM')).toBeInTheDocument();
});

it('renders a date with seconds', () => {
render(<DateTime date={new Date()} seconds />);
renderPDT(<DateTime date={new Date()} seconds />);
expect(screen.getByText('Oct 16, 7:41:20 PM')).toBeInTheDocument();
});

it('renders a date with the time zone', () => {
render(<DateTime date={new Date()} timeZone />);
renderPDT(<DateTime date={new Date()} timeZone />);
expect(screen.getByText('Oct 16, 7:41 PM PDT')).toBeInTheDocument();
});

it('renders date with forced utc', () => {
render(<DateTime date={new Date()} utc />);
renderPDT(<DateTime date={new Date()} utc />);
expect(screen.getByText('Oct 17, 2:41 AM UTC')).toBeInTheDocument();
});

it('renders date with forced timezone', () => {
render(<DateTime date={new Date()} forcedTimezone="America/Toronto" />);
renderPDT(<DateTime date={new Date()} forcedTimezone="America/Toronto" />);
expect(screen.getByText('Oct 16, 10:41 PM')).toBeInTheDocument();
});

describe('24 Hours', () => {
beforeAll(() => {
const user = UserFixture();
user.options.clock24Hours = true;
ConfigStore.set('user', user);
});

afterAll(() => {
const user = UserFixture();
user.options.clock24Hours = false;
ConfigStore.set('user', user);
});

it('renders a date', () => {
render(<DateTime date={new Date()} />);
renderPDT(<DateTime date={new Date()} />);
expect(screen.getByText('Oct 16, 19:41')).toBeInTheDocument();
});

it('renders only the time', () => {
render(<DateTime date={new Date()} timeOnly />);
renderPDT(<DateTime date={new Date()} timeOnly />);
expect(screen.getByText('19:41')).toBeInTheDocument();
});

it('renders date with forced utc', () => {
render(<DateTime date={new Date()} utc />);
renderPDT(<DateTime date={new Date()} utc />);
expect(screen.getByText('Oct 17, 02:41 UTC')).toBeInTheDocument();
});

it('renders date with forced timezone', () => {
render(<DateTime date={new Date()} forcedTimezone="America/Toronto" />);
renderPDT(<DateTime date={new Date()} forcedTimezone="America/Toronto" />);
expect(screen.getByText('Oct 16, 22:41')).toBeInTheDocument();
});
});
Expand Down
11 changes: 7 additions & 4 deletions static/app/components/dateTime.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import moment from 'moment-timezone';

import {useTimezone} from 'sentry/components/timezoneProvider';
import {getFormat} from 'sentry/utils/dates';
import {useUser} from 'sentry/utils/useUser';

Expand Down Expand Up @@ -60,7 +61,9 @@ export function DateTime({
...props
}: DateTimeProps) {
const user = useUser();
const options = user?.options;
const currentTimezone = useTimezone();

const tz = forcedTimezone ?? currentTimezone;

const formatString =
format ??
Expand All @@ -69,19 +72,19 @@ export function DateTime({
timeOnly,
// If the year prop is defined, then use it. Otherwise only show the year if `date`
// is in the current year.
year: year ?? moment().year() !== moment(date).year(),
year: year ?? moment.tz(tz).year() !== moment.tz(date, tz).year(),
// If timeZone is defined, use it. Otherwise only show the time zone if we're using
// UTC time.
timeZone: timeZone ?? utc,
seconds,
...options,
Copy link
Member Author

Choose a reason for hiding this comment

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

This was a mistake before. We were spreading the entire user.options object into the getFormat function.

clock24Hours: user?.options.clock24Hours,
});

return (
<time {...props}>
{utc
? moment.utc(date).format(formatString)
: moment.tz(date, forcedTimezone ?? options?.timezone ?? '').format(formatString)}
: moment.tz(date, tz).format(formatString)}
</time>
);
}
64 changes: 64 additions & 0 deletions static/app/components/timezoneProvider.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import {UserFixture} from 'sentry-fixture/user';

import {act, render, screen} from 'sentry-test/reactTestingLibrary';

import ConfigStore from 'sentry/stores/configStore';

import {TimezoneProvider, UserTimezoneProvider, useTimezone} from './timezoneProvider';

describe('timezoneProvider', function () {
function setConfigStoreTimezone(tz: string) {
const user = UserFixture();
user.options.timezone = tz;
act(() => ConfigStore.set('user', user));
}

function ShowTimezone(props: React.ComponentProps<'div'>) {
const timezone = useTimezone();
return <div {...props}>{timezone}</div>;
}

function ChangeUserTimezone({tz}: {tz: string}) {
return <button onClick={() => setConfigStoreTimezone(tz)}>Change Timezone</button>;
}

beforeEach(() => setConfigStoreTimezone('America/New_York'));

describe('TimezoneProvider', function () {
it('provides the timezone value', function () {
render(
<TimezoneProvider timezone="America/Halifax">
<ShowTimezone data-test-id="tz" />
</TimezoneProvider>
);

expect(screen.getByTestId('tz')).toHaveTextContent('America/Halifax');
});
});

describe('UserTimezoneProvider', function () {
it('provides timezone for the user', function () {
render(
<UserTimezoneProvider>
<ShowTimezone data-test-id="tz" />
</UserTimezoneProvider>
);

expect(screen.getByTestId('tz')).toHaveTextContent('America/New_York');
});

it('updates when the user timezone changes', function () {
render(
<UserTimezoneProvider>
<ShowTimezone data-test-id="tz" />
<ChangeUserTimezone tz="America/Los_Angeles" />
</UserTimezoneProvider>
);

expect(screen.getByTestId('tz')).toHaveTextContent('America/New_York');

screen.getByRole('button', {name: 'Change Timezone'}).click();
expect(screen.getByTestId('tz')).toHaveTextContent('America/Los_Angeles');
});
});
});
50 changes: 50 additions & 0 deletions static/app/components/timezoneProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import {createContext, useContext, useMemo} from 'react';

import {useUser} from 'sentry/utils/useUser';

interface TimezoneProviderValue {
timezone: string;
setOverride?: (timezone: string | null) => void;
}

interface CommonProps {
children: NonNullable<React.ReactNode>;
}

const browserTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;

const Provider = createContext<TimezoneProviderValue>({timezone: browserTimezone});

interface TimezoneProviderProps {
children: NonNullable<React.ReactNode>;
timezone: string;
}

/**
* Provide the specified timezone to components that useTimezone.
*
* See OverrideTimezoneProvider for a implementation of the timezone provider
* that allows for overriding the timezone using hooks.
*/
export function TimezoneProvider({children, timezone}: TimezoneProviderProps) {
const value = useMemo(() => ({timezone}), [timezone]);

return <Provider value={value}>{children}</Provider>;
}

/**
* Provides the user's configured timezone to components that use useTimezone.
*/
export function UserTimezoneProvider({children}: CommonProps) {
const user = useUser();
const timezone = user?.options.timezone ?? browserTimezone;

return <TimezoneProvider timezone={timezone}>{children}</TimezoneProvider>;
}

/**
* Get the currently configured timezone.
*/
export function useTimezone() {
return useContext(Provider).timezone;
}
6 changes: 4 additions & 2 deletions static/app/utils/profiling/hooks/useRelativeDateTime.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import {useMemo} from 'react';

import {useTimezone} from 'sentry/components/timezoneProvider';
import type {PageFilters} from 'sentry/types/core';
import {getUserTimezone} from 'sentry/utils/dates';

const DAY = 24 * 60 * 60 * 1000;

Expand All @@ -16,6 +16,8 @@ export function useRelativeDateTime({
relativeDays,
retentionDays,
}: UseRelativeDateTimeOptions): PageFilters['datetime'] {
const timezone = useTimezone();

const anchorTime = anchor * 1000;

// Make sure to memo this. Otherwise, each re-render will have
Expand All @@ -34,7 +36,7 @@ export function useRelativeDateTime({
return {
start: beforeDateTime,
end: afterDateTime,
utc: getUserTimezone() === 'UTC',
utc: timezone.includes('UTC'),
period: null,
};
}
37 changes: 20 additions & 17 deletions static/app/views/app/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import GlobalModal from 'sentry/components/globalModal';
import {useGlobalModal} from 'sentry/components/globalModal/useGlobalModal';
import Hook from 'sentry/components/hook';
import Indicators from 'sentry/components/indicators';
import {UserTimezoneProvider} from 'sentry/components/timezoneProvider';
import {DEPLOY_PREVIEW_CONFIG, EXPERIMENTAL_SPA} from 'sentry/constants';
import AlertStore from 'sentry/stores/alertStore';
import ConfigStore from 'sentry/stores/configStore';
Expand Down Expand Up @@ -252,23 +253,25 @@ function App({children, params}: Props) {

return (
<Profiler id="App" onRender={onRenderCallback}>
<LastKnownRouteContextProvider>
<RouteAnalyticsContextProvider>
{renderOrganizationContextProvider(
<AsyncSDKIntegrationContextProvider>
<GlobalFeedbackForm>
<MainContainer tabIndex={-1} ref={mainContainerRef}>
<DemoToursProvider>
<GlobalModal onClose={handleModalClose} />
<Indicators className="indicators-container" />
<ErrorBoundary>{renderBody()}</ErrorBoundary>
</DemoToursProvider>
</MainContainer>
</GlobalFeedbackForm>
</AsyncSDKIntegrationContextProvider>
)}
</RouteAnalyticsContextProvider>
</LastKnownRouteContextProvider>
<UserTimezoneProvider>
<LastKnownRouteContextProvider>
<RouteAnalyticsContextProvider>
{renderOrganizationContextProvider(
<AsyncSDKIntegrationContextProvider>
<GlobalFeedbackForm>
<MainContainer tabIndex={-1} ref={mainContainerRef}>
<DemoToursProvider>
<GlobalModal onClose={handleModalClose} />
<Indicators className="indicators-container" />
<ErrorBoundary>{renderBody()}</ErrorBoundary>
</DemoToursProvider>
</MainContainer>
</GlobalFeedbackForm>
</AsyncSDKIntegrationContextProvider>
)}
</RouteAnalyticsContextProvider>
</LastKnownRouteContextProvider>
</UserTimezoneProvider>
</Profiler>
);
}
Expand Down
9 changes: 9 additions & 0 deletions tests/js/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,15 @@ jest.mock('@sentry/react', function sentryReact() {

ConfigStore.loadInitialData(ConfigFixture());

// Default browser timezone to UTC
jest.spyOn(Intl.DateTimeFormat.prototype, 'resolvedOptions').mockImplementation(() => ({
locale: 'en-US',
calendar: 'gregory',
numberingSystem: 'latn',
timeZone: 'UTC',
timeZoneName: 'short',
}));

/**
* Test Globals
*/
Expand Down
Loading