Skip to content

Commit f98ab33

Browse files
evanpurkhiserandrewshie-sentry
authored andcommitted
feat(js): Add timezone providers (#91805)
This implements a new top-level provider that provides the timezone to components like `<DateTime />`. This provider exposes two provider components and one hook. By default the provider uses the browsers timezone. - `useTimezone` - Get the currently set timezone by the user - `TimezoneProvider` - The simplest provider that provides the timezone using the interface ```tsx <TimezoneProvider timezone="America/Los_Angeles"> ... </TimezoneProvider> ``` - `UserTimezoneProvider` - Provides the timezone from the `ConfigStore` user. Reacts to changes to the user.
1 parent 6e545c5 commit f98ab33

File tree

7 files changed

+177
-49
lines changed

7 files changed

+177
-49
lines changed

static/app/components/dateTime.spec.tsx

Lines changed: 23 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,21 @@
1-
import {ConfigFixture} from 'sentry-fixture/config';
21
import {UserFixture} from 'sentry-fixture/user';
32

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

6-
import {DateTime} from 'sentry/components/dateTime';
75
import ConfigStore from 'sentry/stores/configStore';
86

9-
describe('DateTime', () => {
10-
const user = UserFixture({
11-
options: {
12-
...UserFixture().options,
13-
clock24Hours: false,
14-
timezone: 'America/Los_Angeles',
15-
},
16-
});
7+
import {DateTime} from './dateTime';
8+
import {TimezoneProvider} from './timezoneProvider';
179

18-
beforeAll(() => {
19-
ConfigStore.loadInitialData(ConfigFixture({user}));
20-
});
10+
describe('DateTime', () => {
11+
function renderPDT(child: React.ReactElement) {
12+
return render(
13+
<TimezoneProvider timezone="America/Los_Angeles">{child}</TimezoneProvider>
14+
);
15+
}
2116

2217
it('renders a date', () => {
23-
render(<DateTime date={new Date()} />);
18+
renderPDT(<DateTime date={new Date()} />);
2419
expect(screen.getByText('Oct 16, 7:41 PM')).toBeInTheDocument();
2520
});
2621

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

33-
render(<DateTime date={date} />);
28+
renderPDT(<DateTime date={date} />);
3429
expect(screen.getByText('Dec 31, 2016 7:41 PM')).toBeInTheDocument();
3530
});
3631

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

4237
it('renders only the date', () => {
43-
render(<DateTime date={new Date()} dateOnly />);
38+
renderPDT(<DateTime date={new Date()} dateOnly />);
4439
expect(screen.getByText('Oct 16')).toBeInTheDocument();
4540
});
4641

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

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

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

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

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

7267
describe('24 Hours', () => {
7368
beforeAll(() => {
69+
const user = UserFixture();
7470
user.options.clock24Hours = true;
7571
ConfigStore.set('user', user);
7672
});
7773

7874
afterAll(() => {
75+
const user = UserFixture();
7976
user.options.clock24Hours = false;
8077
ConfigStore.set('user', user);
8178
});
8279

8380
it('renders a date', () => {
84-
render(<DateTime date={new Date()} />);
81+
renderPDT(<DateTime date={new Date()} />);
8582
expect(screen.getByText('Oct 16, 19:41')).toBeInTheDocument();
8683
});
8784

8885
it('renders only the time', () => {
89-
render(<DateTime date={new Date()} timeOnly />);
86+
renderPDT(<DateTime date={new Date()} timeOnly />);
9087
expect(screen.getByText('19:41')).toBeInTheDocument();
9188
});
9289

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

9895
it('renders date with forced timezone', () => {
99-
render(<DateTime date={new Date()} forcedTimezone="America/Toronto" />);
96+
renderPDT(<DateTime date={new Date()} forcedTimezone="America/Toronto" />);
10097
expect(screen.getByText('Oct 16, 22:41')).toBeInTheDocument();
10198
});
10299
});

static/app/components/dateTime.tsx

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import moment from 'moment-timezone';
22

3+
import {useTimezone} from 'sentry/components/timezoneProvider';
34
import {getFormat} from 'sentry/utils/dates';
45
import {useUser} from 'sentry/utils/useUser';
56

@@ -60,7 +61,9 @@ export function DateTime({
6061
...props
6162
}: DateTimeProps) {
6263
const user = useUser();
63-
const options = user?.options;
64+
const currentTimezone = useTimezone();
65+
66+
const tz = forcedTimezone ?? currentTimezone;
6467

6568
const formatString =
6669
format ??
@@ -69,19 +72,19 @@ export function DateTime({
6972
timeOnly,
7073
// If the year prop is defined, then use it. Otherwise only show the year if `date`
7174
// is in the current year.
72-
year: year ?? moment().year() !== moment(date).year(),
75+
year: year ?? moment.tz(tz).year() !== moment.tz(date, tz).year(),
7376
// If timeZone is defined, use it. Otherwise only show the time zone if we're using
7477
// UTC time.
7578
timeZone: timeZone ?? utc,
7679
seconds,
77-
...options,
80+
clock24Hours: user?.options.clock24Hours,
7881
});
7982

8083
return (
8184
<time {...props}>
8285
{utc
8386
? moment.utc(date).format(formatString)
84-
: moment.tz(date, forcedTimezone ?? options?.timezone ?? '').format(formatString)}
87+
: moment.tz(date, tz).format(formatString)}
8588
</time>
8689
);
8790
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import {UserFixture} from 'sentry-fixture/user';
2+
3+
import {act, render, screen} from 'sentry-test/reactTestingLibrary';
4+
5+
import ConfigStore from 'sentry/stores/configStore';
6+
7+
import {TimezoneProvider, UserTimezoneProvider, useTimezone} from './timezoneProvider';
8+
9+
describe('timezoneProvider', function () {
10+
function setConfigStoreTimezone(tz: string) {
11+
const user = UserFixture();
12+
user.options.timezone = tz;
13+
act(() => ConfigStore.set('user', user));
14+
}
15+
16+
function ShowTimezone(props: React.ComponentProps<'div'>) {
17+
const timezone = useTimezone();
18+
return <div {...props}>{timezone}</div>;
19+
}
20+
21+
function ChangeUserTimezone({tz}: {tz: string}) {
22+
return <button onClick={() => setConfigStoreTimezone(tz)}>Change Timezone</button>;
23+
}
24+
25+
beforeEach(() => setConfigStoreTimezone('America/New_York'));
26+
27+
describe('TimezoneProvider', function () {
28+
it('provides the timezone value', function () {
29+
render(
30+
<TimezoneProvider timezone="America/Halifax">
31+
<ShowTimezone data-test-id="tz" />
32+
</TimezoneProvider>
33+
);
34+
35+
expect(screen.getByTestId('tz')).toHaveTextContent('America/Halifax');
36+
});
37+
});
38+
39+
describe('UserTimezoneProvider', function () {
40+
it('provides timezone for the user', function () {
41+
render(
42+
<UserTimezoneProvider>
43+
<ShowTimezone data-test-id="tz" />
44+
</UserTimezoneProvider>
45+
);
46+
47+
expect(screen.getByTestId('tz')).toHaveTextContent('America/New_York');
48+
});
49+
50+
it('updates when the user timezone changes', function () {
51+
render(
52+
<UserTimezoneProvider>
53+
<ShowTimezone data-test-id="tz" />
54+
<ChangeUserTimezone tz="America/Los_Angeles" />
55+
</UserTimezoneProvider>
56+
);
57+
58+
expect(screen.getByTestId('tz')).toHaveTextContent('America/New_York');
59+
60+
screen.getByRole('button', {name: 'Change Timezone'}).click();
61+
expect(screen.getByTestId('tz')).toHaveTextContent('America/Los_Angeles');
62+
});
63+
});
64+
});
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import {createContext, useContext, useMemo} from 'react';
2+
3+
import {useUser} from 'sentry/utils/useUser';
4+
5+
interface TimezoneProviderValue {
6+
timezone: string;
7+
setOverride?: (timezone: string | null) => void;
8+
}
9+
10+
interface CommonProps {
11+
children: NonNullable<React.ReactNode>;
12+
}
13+
14+
const browserTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
15+
16+
const Provider = createContext<TimezoneProviderValue>({timezone: browserTimezone});
17+
18+
interface TimezoneProviderProps {
19+
children: NonNullable<React.ReactNode>;
20+
timezone: string;
21+
}
22+
23+
/**
24+
* Provide the specified timezone to components that useTimezone.
25+
*
26+
* See OverrideTimezoneProvider for a implementation of the timezone provider
27+
* that allows for overriding the timezone using hooks.
28+
*/
29+
export function TimezoneProvider({children, timezone}: TimezoneProviderProps) {
30+
const value = useMemo(() => ({timezone}), [timezone]);
31+
32+
return <Provider value={value}>{children}</Provider>;
33+
}
34+
35+
/**
36+
* Provides the user's configured timezone to components that use useTimezone.
37+
*/
38+
export function UserTimezoneProvider({children}: CommonProps) {
39+
const user = useUser();
40+
const timezone = user?.options.timezone ?? browserTimezone;
41+
42+
return <TimezoneProvider timezone={timezone}>{children}</TimezoneProvider>;
43+
}
44+
45+
/**
46+
* Get the currently configured timezone.
47+
*/
48+
export function useTimezone() {
49+
return useContext(Provider).timezone;
50+
}

static/app/utils/profiling/hooks/useRelativeDateTime.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import {useMemo} from 'react';
22

3+
import {useTimezone} from 'sentry/components/timezoneProvider';
34
import type {PageFilters} from 'sentry/types/core';
4-
import {getUserTimezone} from 'sentry/utils/dates';
55

66
const DAY = 24 * 60 * 60 * 1000;
77

@@ -16,6 +16,8 @@ export function useRelativeDateTime({
1616
relativeDays,
1717
retentionDays,
1818
}: UseRelativeDateTimeOptions): PageFilters['datetime'] {
19+
const timezone = useTimezone();
20+
1921
const anchorTime = anchor * 1000;
2022

2123
// Make sure to memo this. Otherwise, each re-render will have
@@ -34,7 +36,7 @@ export function useRelativeDateTime({
3436
return {
3537
start: beforeDateTime,
3638
end: afterDateTime,
37-
utc: getUserTimezone() === 'UTC',
39+
utc: timezone.includes('UTC'),
3840
period: null,
3941
};
4042
}

static/app/views/app/index.tsx

Lines changed: 20 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import GlobalModal from 'sentry/components/globalModal';
1414
import {useGlobalModal} from 'sentry/components/globalModal/useGlobalModal';
1515
import Hook from 'sentry/components/hook';
1616
import Indicators from 'sentry/components/indicators';
17+
import {UserTimezoneProvider} from 'sentry/components/timezoneProvider';
1718
import {DEPLOY_PREVIEW_CONFIG, EXPERIMENTAL_SPA} from 'sentry/constants';
1819
import AlertStore from 'sentry/stores/alertStore';
1920
import ConfigStore from 'sentry/stores/configStore';
@@ -252,23 +253,25 @@ function App({children, params}: Props) {
252253

253254
return (
254255
<Profiler id="App" onRender={onRenderCallback}>
255-
<LastKnownRouteContextProvider>
256-
<RouteAnalyticsContextProvider>
257-
{renderOrganizationContextProvider(
258-
<AsyncSDKIntegrationContextProvider>
259-
<GlobalFeedbackForm>
260-
<MainContainer tabIndex={-1} ref={mainContainerRef}>
261-
<DemoToursProvider>
262-
<GlobalModal onClose={handleModalClose} />
263-
<Indicators className="indicators-container" />
264-
<ErrorBoundary>{renderBody()}</ErrorBoundary>
265-
</DemoToursProvider>
266-
</MainContainer>
267-
</GlobalFeedbackForm>
268-
</AsyncSDKIntegrationContextProvider>
269-
)}
270-
</RouteAnalyticsContextProvider>
271-
</LastKnownRouteContextProvider>
256+
<UserTimezoneProvider>
257+
<LastKnownRouteContextProvider>
258+
<RouteAnalyticsContextProvider>
259+
{renderOrganizationContextProvider(
260+
<AsyncSDKIntegrationContextProvider>
261+
<GlobalFeedbackForm>
262+
<MainContainer tabIndex={-1} ref={mainContainerRef}>
263+
<DemoToursProvider>
264+
<GlobalModal onClose={handleModalClose} />
265+
<Indicators className="indicators-container" />
266+
<ErrorBoundary>{renderBody()}</ErrorBoundary>
267+
</DemoToursProvider>
268+
</MainContainer>
269+
</GlobalFeedbackForm>
270+
</AsyncSDKIntegrationContextProvider>
271+
)}
272+
</RouteAnalyticsContextProvider>
273+
</LastKnownRouteContextProvider>
274+
</UserTimezoneProvider>
272275
</Profiler>
273276
);
274277
}

tests/js/setup.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,15 @@ jest.mock('@sentry/react', function sentryReact() {
154154

155155
ConfigStore.loadInitialData(ConfigFixture());
156156

157+
// Default browser timezone to UTC
158+
jest.spyOn(Intl.DateTimeFormat.prototype, 'resolvedOptions').mockImplementation(() => ({
159+
locale: 'en-US',
160+
calendar: 'gregory',
161+
numberingSystem: 'latn',
162+
timeZone: 'UTC',
163+
timeZoneName: 'short',
164+
}));
165+
157166
/**
158167
* Test Globals
159168
*/

0 commit comments

Comments
 (0)