Skip to content

Commit 7a6ff5f

Browse files
authored
feat(releases): timezone selection updates filtering and dateTime display on release overview (#7854)
1 parent 07c92c7 commit 7a6ff5f

File tree

4 files changed

+178
-24
lines changed

4 files changed

+178
-24
lines changed

packages/sanity/src/core/releases/tool/overview/ReleasesOverview.tsx

+40-17
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import {AddIcon, ChevronDownIcon, CloseIcon, EarthGlobeIcon} from '@sanity/icons'
22
import {Box, type ButtonMode, Card, Container, Flex, Stack, Text} from '@sanity/ui'
33
import {endOfDay, format, isSameDay, startOfDay} from 'date-fns'
4-
import {zonedTimeToUtc} from 'date-fns-tz'
54
import {AnimatePresence, motion} from 'framer-motion'
65
import {type MouseEventHandler, useCallback, useEffect, useMemo, useRef, useState} from 'react'
76
import {type RouterContextValue, type SearchParam, useRouter} from 'sanity/router'
@@ -13,6 +12,7 @@ import {
1312
type CalendarProps,
1413
} from '../../../../ui-components/inputs/DateFilters/calendar/CalendarFilter'
1514
import {useTranslation} from '../../../i18n'
15+
import useDialogTimeZone from '../../../scheduledPublishing/hooks/useDialogTimeZone'
1616
import useTimeZone from '../../../scheduledPublishing/hooks/useTimeZone'
1717
import {CreateReleaseDialog} from '../../components/dialog/CreateReleaseDialog'
1818
import {usePerspective} from '../../hooks/usePerspective'
@@ -74,18 +74,22 @@ export interface TableRelease extends ReleaseDocument {
7474
isDeleted?: boolean
7575
}
7676

77-
// TODO: use the selected timezone rather than client
78-
const getTimezoneAdjustedDateTimeRange = (date: Date) => {
79-
const {timeZone} = Intl.DateTimeFormat().resolvedOptions()
77+
const useTimezoneAdjustedDateTimeRange = () => {
78+
const {zoneDateToUtc} = useTimeZone()
8079

81-
return [startOfDay(date), endOfDay(date)].map((time) => zonedTimeToUtc(time, timeZone))
80+
return useCallback(
81+
(date: Date) => [startOfDay(date), endOfDay(date)].map(zoneDateToUtc),
82+
[zoneDateToUtc],
83+
)
8284
}
8385

8486
const ReleaseCalendarDay: CalendarProps['renderCalendarDay'] = (props) => {
8587
const {data: releases} = useReleases()
88+
const getTimezoneAdjustedDateTimeRange = useTimezoneAdjustedDateTimeRange()
89+
8690
const {date} = props
8791

88-
const [startOfDayUTC, endOfDayUTC] = getTimezoneAdjustedDateTimeRange(date)
92+
const [startOfDayForTimeZone, endOfDayForTimeZone] = getTimezoneAdjustedDateTimeRange(date)
8993

9094
const dayHasReleases = releases?.some((release) => {
9195
const releasePublishAt = release.publishAt || release.metadata.intendedPublishAt
@@ -95,8 +99,8 @@ const ReleaseCalendarDay: CalendarProps['renderCalendarDay'] = (props) => {
9599

96100
return (
97101
release.metadata.releaseType === 'scheduled' &&
98-
publishDateUTC >= startOfDayUTC &&
99-
publishDateUTC <= endOfDayUTC
102+
publishDateUTC >= startOfDayForTimeZone &&
103+
publishDateUTC <= endOfDayForTimeZone
100104
)
101105
})
102106

@@ -135,8 +139,10 @@ export function ReleasesOverview() {
135139
const loadingTableData = loading || (!releasesMetadata && Boolean(releaseIds.length))
136140
const {t} = useTranslation(releasesLocaleNamespace)
137141
const {t: tCore} = useTranslation()
138-
const {timeZone} = useTimeZone()
142+
const {timeZone, utcToCurrentZoneDate} = useTimeZone()
139143
const {currentGlobalBundleId} = usePerspective()
144+
const {DialogTimeZone, dialogProps, dialogTimeZoneShow} = useDialogTimeZone()
145+
const getTimezoneAdjustedDateTimeRange = useTimezoneAdjustedDateTimeRange()
140146

141147
const getRowProps = useCallback(
142148
(datum: TableRelease): Partial<TableRowProps> =>
@@ -179,11 +185,19 @@ export function ReleasesOverview() {
179185
[],
180186
)
181187

182-
const handleSelectFilterDate = useCallback((date?: Date) => {
183-
setReleaseFilterDate((prevFilterDate) =>
184-
prevFilterDate && date && isSameDay(prevFilterDate, date) ? undefined : date,
185-
)
186-
}, [])
188+
const handleSelectFilterDate = useCallback(
189+
(date?: Date) =>
190+
setReleaseFilterDate((prevFilterDate) => {
191+
if (!date) return undefined
192+
193+
const timeZoneAdjustedDate = utcToCurrentZoneDate(date)
194+
195+
return prevFilterDate && isSameDay(prevFilterDate, timeZoneAdjustedDate)
196+
? undefined
197+
: timeZoneAdjustedDate
198+
}),
199+
[utcToCurrentZoneDate],
200+
)
187201

188202
const clearFilterDate = useCallback(() => {
189203
setReleaseFilterDate(undefined)
@@ -301,15 +315,22 @@ export function ReleasesOverview() {
301315
const filteredReleases = useMemo(() => {
302316
if (!releaseFilterDate) return releaseGroupMode === 'open' ? tableReleases : archivedReleases
303317

304-
const [startOfDayUTC, endOfDayUTC] = getTimezoneAdjustedDateTimeRange(releaseFilterDate)
318+
const [startOfDayForTimeZone, endOfDayForTimeZone] =
319+
getTimezoneAdjustedDateTimeRange(releaseFilterDate)
305320

306321
return tableReleases.filter((release) => {
307322
if (!release.publishAt || release.metadata.releaseType !== 'scheduled') return false
308323

309324
const publishDateUTC = new Date(release.publishAt)
310-
return publishDateUTC >= startOfDayUTC && publishDateUTC <= endOfDayUTC
325+
return publishDateUTC >= startOfDayForTimeZone && publishDateUTC <= endOfDayForTimeZone
311326
})
312-
}, [releaseFilterDate, releaseGroupMode, tableReleases, archivedReleases])
327+
}, [
328+
releaseFilterDate,
329+
releaseGroupMode,
330+
tableReleases,
331+
archivedReleases,
332+
getTimezoneAdjustedDateTimeRange,
333+
])
313334

314335
return (
315336
<Flex direction="row" flex={1} style={{height: '100%'}}>
@@ -347,7 +368,9 @@ export function ReleasesOverview() {
347368
mode="bleed"
348369
padding={2}
349370
text={`${timeZone.abbreviation} (${timeZone.namePretty})`}
371+
onClick={dialogTimeZoneShow}
350372
/>
373+
{DialogTimeZone && <DialogTimeZone {...dialogProps} />}
351374
{loadingOrHasReleases && createReleaseButton}
352375
</Flex>
353376
</Flex>

packages/sanity/src/core/releases/tool/overview/ReleasesOverviewColumnDefs.tsx

+17-5
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,13 @@ import {LockIcon, PinFilledIcon, PinIcon} from '@sanity/icons'
22
import {Box, Card, Flex, Stack, Text} from '@sanity/ui'
33
import {format} from 'date-fns'
44
import {type TFunction} from 'i18next'
5-
import {useCallback} from 'react'
5+
import {useCallback, useMemo} from 'react'
66
import {useRouter} from 'sanity/router'
77

88
import {Button, Tooltip} from '../../../../ui-components'
99
import {RelativeTime} from '../../../components'
1010
import {Translate, useTranslation} from '../../../i18n'
11+
import useTimeZone, {getLocalTimeZone} from '../../../scheduledPublishing/hooks/useTimeZone'
1112
import {ReleaseAvatar} from '../../components/ReleaseAvatar'
1213
import {usePerspective} from '../../hooks/usePerspective'
1314
import {releasesLocaleNamespace} from '../../i18n'
@@ -22,9 +23,18 @@ import {type TableRelease} from './ReleasesOverview'
2223

2324
const ReleaseTime = ({release}: {release: TableRelease}) => {
2425
const {t} = useTranslation()
26+
const {timeZone, utcToCurrentZoneDate} = useTimeZone()
27+
const {abbreviation: localeTimeZoneAbbreviation} = getLocalTimeZone()
28+
2529
const {metadata} = release
2630

27-
const getTimeString = () => {
31+
const getTimezoneAbbreviation = useCallback(
32+
() =>
33+
timeZone.abbreviation === localeTimeZoneAbbreviation ? '' : `(${timeZone.abbreviation})`,
34+
[localeTimeZoneAbbreviation, timeZone.abbreviation],
35+
)
36+
37+
const timeString = useMemo(() => {
2838
if (metadata.releaseType === 'asap') {
2939
return t('release.type.asap')
3040
}
@@ -34,12 +44,14 @@ const ReleaseTime = ({release}: {release: TableRelease}) => {
3444

3545
const publishDate = getPublishDateFromRelease(release)
3646

37-
return publishDate ? format(new Date(publishDate), 'PPpp') : null
38-
}
47+
return publishDate
48+
? `${format(utcToCurrentZoneDate(publishDate), 'PPpp')} ${getTimezoneAbbreviation()}`
49+
: null
50+
}, [metadata.releaseType, release, utcToCurrentZoneDate, getTimezoneAbbreviation, t])
3951

4052
return (
4153
<Text muted size={1}>
42-
{getTimeString()}
54+
{timeString}
4355
</Text>
4456
)
4557
}

packages/sanity/src/core/releases/tool/overview/__tests__/ReleasesOverview.test.tsx

+90-2
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,16 @@
11
import {act, fireEvent, render, screen, waitFor, within} from '@testing-library/react'
2+
import {format, set} from 'date-fns'
23
import {useRouter} from 'sanity/router'
34
import {beforeEach, describe, expect, it, vi} from 'vitest'
45

56
import {getByDataUi, queryByDataUi} from '../../../../../../test/setup/customQueries'
67
import {createTestProvider} from '../../../../../../test/testUtils/TestProvider'
8+
import {
9+
getLocalTimeZoneMockReturn,
10+
mockGetLocaleTimeZone,
11+
mockUseTimeZone,
12+
useTimeZoneMockReturn,
13+
} from '../../../../scheduledPublishing/hooks/__tests__/__mocks__/useTimeZone.mock'
714
import {
815
activeASAPRelease,
916
activeScheduledRelease,
@@ -30,7 +37,12 @@ import {type ReleasesMetadata} from '../../../store/useReleasesMetadata'
3037
import {useBundleDocumentsMockReturnWithResults} from '../../detail/__tests__/__mocks__/useBundleDocuments.mock'
3138
import {ReleasesOverview} from '../ReleasesOverview'
3239

33-
const TODAY = new Date()
40+
const TODAY = set(new Date(), {
41+
hours: 22,
42+
minutes: 0,
43+
seconds: 0,
44+
milliseconds: 0,
45+
})
3446

3547
vi.mock('sanity', () => ({
3648
SANITY_VERSION: '0.0.0',
@@ -59,6 +71,12 @@ vi.mock('../../../hooks/usePerspective', () => ({
5971
usePerspective: vi.fn(() => usePerspectiveMockReturn),
6072
}))
6173

74+
vi.mock('../../../../scheduledPublishing/hooks/useTimeZone', async (importOriginal) => ({
75+
...(await importOriginal()),
76+
getLocalTimeZone: vi.fn(() => getLocalTimeZoneMockReturn),
77+
default: vi.fn(() => useTimeZoneMockReturn),
78+
}))
79+
6280
describe('ReleasesOverview', () => {
6381
beforeEach(() => {
6482
mockUseReleases.mockRestore()
@@ -132,7 +150,10 @@ describe('ReleasesOverview', () => {
132150
const releases: ReleaseDocument[] = [
133151
{
134152
...activeScheduledRelease,
135-
metadata: {...activeScheduledRelease.metadata, intendedPublishAt: TODAY.toISOString()},
153+
metadata: {
154+
...activeScheduledRelease.metadata,
155+
intendedPublishAt: TODAY.toISOString(),
156+
},
136157
},
137158
activeASAPRelease,
138159
activeUndecidedRelease,
@@ -142,6 +163,7 @@ describe('ReleasesOverview', () => {
142163
let activeRender: ReturnType<typeof render>
143164

144165
beforeEach(async () => {
166+
mockUseTimeZone.mockRestore()
145167
mockUseReleases.mockReturnValue({
146168
...useReleasesMockReturn,
147169
archivedReleases: [archivedScheduledRelease, publishedASAPRelease],
@@ -191,6 +213,13 @@ describe('ReleasesOverview', () => {
191213
within(asapReleaseRow).getByText('Undecided')
192214
})
193215

216+
it('shows time for scheduled releases', () => {
217+
const scheduledReleaseRow = screen.getAllByTestId('table-row')[2]
218+
219+
const date = format(TODAY, 'MMM d, yyyy')
220+
within(scheduledReleaseRow).getByText(`${date}, 10:00:00 PM`)
221+
})
222+
194223
it('has release menu actions for each release', () => {
195224
const releaseRows = screen.getAllByTestId('table-row')
196225
releaseRows.forEach((row) => {
@@ -283,6 +312,65 @@ describe('ReleasesOverview', () => {
283312
})
284313
})
285314

315+
describe('timezone selection', () => {
316+
it('shows the selected timezone', () => {
317+
screen.getByText('SCT (Sanity/Oslo)')
318+
})
319+
320+
it('opens the timezone selector', () => {
321+
fireEvent.click(screen.getByText('SCT (Sanity/Oslo)'))
322+
323+
within(getByDataUi(document.body, 'DialogCard')).getByText('Select time zone')
324+
})
325+
326+
it('shows dates with timezone abbreviation when it is not the locale', () => {
327+
mockGetLocaleTimeZone.mockReturnValue({
328+
abbreviation: 'NST', // Not Sanity Time
329+
namePretty: 'Not Sanity Time',
330+
offset: '+00:00',
331+
name: 'NST',
332+
alternativeName: 'Not Sanity Time',
333+
mainCities: 'Not Sanity City',
334+
value: 'Not Sanity Time',
335+
})
336+
337+
activeRender.rerender(<ReleasesOverview />)
338+
339+
const scheduledReleaseRow = screen.getAllByTestId('table-row')[2]
340+
341+
const date = format(TODAY, 'MMM d, yyyy')
342+
within(scheduledReleaseRow).getByText(`${date}, 10:00:00 PM (SCT)`)
343+
})
344+
345+
describe('when a different timezone is selected', () => {
346+
beforeEach(() => {
347+
mockUseTimeZone.mockReturnValue({
348+
...useTimeZoneMockReturn,
349+
// spoof a timezone that is 8 hours ahead of UTC
350+
zoneDateToUtc: vi.fn((date) => set(date, {hours: new Date(date).getHours() - 8})),
351+
})
352+
353+
activeRender.rerender(<ReleasesOverview />)
354+
})
355+
356+
it('shows today as having no releases', () => {
357+
const todayTile = within(getByDataUi(document.body, 'Calendar')).getByText(
358+
TODAY.getDate(),
359+
)
360+
expect(todayTile.parentNode).not.toHaveStyle('font-weight: 700')
361+
})
362+
363+
it('shows no releases when filtered by today', () => {
364+
const todayTile = within(getByDataUi(document.body, 'Calendar')).getByText(
365+
TODAY.getDate(),
366+
)
367+
fireEvent.click(todayTile)
368+
369+
expect(screen.queryAllByTestId('table-row')).toHaveLength(0)
370+
})
371+
})
372+
})
373+
286374
describe('archived releases', () => {
287375
beforeEach(() => {
288376
fireEvent.click(screen.getByText('Archived'))
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import {type Mock, type Mocked, vi} from 'vitest'
2+
3+
import {type NormalizedTimeZone} from '../../../types'
4+
import useTimeZone, {getLocalTimeZone} from '../../useTimeZone'
5+
6+
const mockTimeZone: NormalizedTimeZone = {
7+
abbreviation: 'SCT', // Sanity Central Time :)
8+
namePretty: 'Sanity/Oslo',
9+
offset: '+00:00',
10+
name: 'SCT',
11+
alternativeName: 'Sanity/Oslo',
12+
mainCities: 'Oslo',
13+
value: 'SCT',
14+
}
15+
16+
// default export
17+
export const useTimeZoneMockReturn: Mocked<ReturnType<typeof useTimeZone>> = {
18+
zoneDateToUtc: vi.fn((date) => date),
19+
utcToCurrentZoneDate: vi.fn((date) => date),
20+
getCurrentZoneDate: vi.fn(() => new Date()),
21+
timeZone: mockTimeZone,
22+
setTimeZone: vi.fn(),
23+
formatDateTz: vi.fn(),
24+
}
25+
26+
export const getLocalTimeZoneMockReturn: Mocked<ReturnType<typeof getLocalTimeZone>> = mockTimeZone
27+
28+
// default export
29+
export const mockUseTimeZone = useTimeZone as Mock<typeof useTimeZone>
30+
31+
export const mockGetLocaleTimeZone = getLocalTimeZone as Mock<typeof getLocalTimeZone>

0 commit comments

Comments
 (0)