From 4d7930060c6521006fdc1aac11fca197c7272b1f Mon Sep 17 00:00:00 2001 From: Mike Cao Date: Mon, 27 Jul 2026 02:24:59 -0700 Subject: [PATCH] Move timezone selection to query parameters --- .../preferences/PreferenceSettings.tsx | 5 -- .../settings/preferences/TimezoneSetting.tsx | 45 -------------- .../(reports)/revenue/BoardRevenueChart.tsx | 5 +- .../(reports)/revenue/RevenuePage.tsx | 5 +- .../websites/[websiteId]/WebsiteChart.tsx | 5 +- .../websites/[websiteId]/WebsiteHeader.tsx | 13 +++- src/components/hooks/useDateParameters.ts | 6 +- src/components/hooks/useDateRange.ts | 12 ++-- src/components/hooks/useTimezone.ts | 42 ++++++++----- src/components/input/DateFilter.tsx | 7 ++- src/components/input/PreferencesButton.tsx | 5 -- src/components/input/TimezoneFilter.tsx | 62 +++++++++++++++++++ src/components/input/WebsiteFilterButton.tsx | 16 +++-- src/components/metrics/EventsChart.tsx | 10 +-- .../property-data/PropertyChart.tsx | 4 +- .../property-data/PropertyNumericChart.tsx | 4 +- src/lib/constants.ts | 1 - src/store/app.test.ts | 22 +------ src/store/app.ts | 7 --- 19 files changed, 129 insertions(+), 147 deletions(-) delete mode 100644 src/app/(main)/settings/preferences/TimezoneSetting.tsx create mode 100644 src/components/input/TimezoneFilter.tsx diff --git a/src/app/(main)/settings/preferences/PreferenceSettings.tsx b/src/app/(main)/settings/preferences/PreferenceSettings.tsx index 55695946b..bdce36f47 100644 --- a/src/app/(main)/settings/preferences/PreferenceSettings.tsx +++ b/src/app/(main)/settings/preferences/PreferenceSettings.tsx @@ -3,7 +3,6 @@ import { useLoginQuery, useMessages } from '@/components/hooks'; import { DateRangeSetting } from './DateRangeSetting'; import { LanguageSetting } from './LanguageSetting'; import { ThemeSetting } from './ThemeSetting'; -import { TimezoneSetting } from './TimezoneSetting'; import { VersionSetting } from './VersionSetting'; export function PreferenceSettings() { @@ -20,10 +19,6 @@ export function PreferenceSettings() { - - - - diff --git a/src/app/(main)/settings/preferences/TimezoneSetting.tsx b/src/app/(main)/settings/preferences/TimezoneSetting.tsx deleted file mode 100644 index 765d37608..000000000 --- a/src/app/(main)/settings/preferences/TimezoneSetting.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { Button, ListItem, Row, Select } from '@umami/react-zen'; -import { useState } from 'react'; -import { useMessages, useTimezone } from '@/components/hooks'; -import { getTimezone } from '@/lib/date'; - -const timezones = Intl.supportedValuesOf('timeZone'); - -export function TimezoneSetting() { - const [search, setSearch] = useState(''); - const { t, labels } = useMessages(); - const { timezone, saveTimezone } = useTimezone(); - const items = search - ? timezones.filter(n => n.toLowerCase().includes(search.toLowerCase())) - : timezones; - - const handleReset = () => saveTimezone(getTimezone()); - - const handleOpen = isOpen => { - if (isOpen) { - setSearch(''); - } - }; - - return ( - - - - - ); -} diff --git a/src/app/(main)/websites/[websiteId]/(reports)/revenue/BoardRevenueChart.tsx b/src/app/(main)/websites/[websiteId]/(reports)/revenue/BoardRevenueChart.tsx index f5febf290..72924a0d9 100644 --- a/src/app/(main)/websites/[websiteId]/(reports)/revenue/BoardRevenueChart.tsx +++ b/src/app/(main)/websites/[websiteId]/(reports)/revenue/BoardRevenueChart.tsx @@ -1,5 +1,5 @@ import { LoadingPanel } from '@/components/common/LoadingPanel'; -import { useDateRange, useTimezone } from '@/components/hooks'; +import { useDateRange } from '@/components/hooks'; import { useRevenueChartQuery } from '@/components/hooks/queries/useRevenueChartQuery'; import { RevenueChart } from './RevenueChart'; import { useBoardRevenueCurrency } from './useBoardRevenueCurrency'; @@ -12,10 +12,9 @@ export function BoardRevenueChart({ currency?: string; }) { const currency = useBoardRevenueCurrency(selectedCurrency); - const { timezone } = useTimezone(); const { dateRange: { startDate, endDate, unit }, - } = useDateRange({ timezone }); + } = useDateRange(); const { data, isLoading, isFetching, error } = useRevenueChartQuery(websiteId, currency); return ( diff --git a/src/app/(main)/websites/[websiteId]/(reports)/revenue/RevenuePage.tsx b/src/app/(main)/websites/[websiteId]/(reports)/revenue/RevenuePage.tsx index 4dc19e012..3e429c187 100644 --- a/src/app/(main)/websites/[websiteId]/(reports)/revenue/RevenuePage.tsx +++ b/src/app/(main)/websites/[websiteId]/(reports)/revenue/RevenuePage.tsx @@ -1,14 +1,13 @@ 'use client'; import { Column } from '@umami/react-zen'; import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls'; -import { useDateRange, useTimezone } from '@/components/hooks'; +import { useDateRange } from '@/components/hooks'; import { Revenue } from './Revenue'; export function RevenuePage({ websiteId }: { websiteId: string }) { - const { timezone } = useTimezone(); const { dateRange: { startDate, endDate, unit }, - } = useDateRange({ timezone }); + } = useDateRange(); return ( diff --git a/src/app/(main)/websites/[websiteId]/WebsiteChart.tsx b/src/app/(main)/websites/[websiteId]/WebsiteChart.tsx index 896c733a8..adc1d568a 100644 --- a/src/app/(main)/websites/[websiteId]/WebsiteChart.tsx +++ b/src/app/(main)/websites/[websiteId]/WebsiteChart.tsx @@ -1,6 +1,6 @@ import { useMemo } from 'react'; import { LoadingPanel } from '@/components/common/LoadingPanel'; -import { useDateRange, useTimezone } from '@/components/hooks'; +import { useDateRange } from '@/components/hooks'; import { useWebsitePageviewsQuery } from '@/components/hooks/queries/useWebsitePageviewsQuery'; import { PageviewsChart } from '@/components/metrics/PageviewsChart'; @@ -11,8 +11,7 @@ export function WebsiteChart({ websiteId: string; compareMode?: boolean; }) { - const { timezone } = useTimezone(); - const { dateRange, dateCompare } = useDateRange({ timezone: timezone }); + const { dateRange, dateCompare } = useDateRange(); const { startDate, endDate, unit, value } = dateRange; const { data, isLoading, isFetching, error } = useWebsitePageviewsQuery({ websiteId, diff --git a/src/app/(main)/websites/[websiteId]/WebsiteHeader.tsx b/src/app/(main)/websites/[websiteId]/WebsiteHeader.tsx index 642e9fe22..345ef83d9 100644 --- a/src/app/(main)/websites/[websiteId]/WebsiteHeader.tsx +++ b/src/app/(main)/websites/[websiteId]/WebsiteHeader.tsx @@ -1,4 +1,4 @@ -import { Icon, Row, Text } from '@umami/react-zen'; +import { Row } from '@umami/react-zen'; import { Favicon } from '@/components/common/Favicon'; import { IconLabel } from '@/components/common/IconLabel'; import { LinkButton } from '@/components/common/LinkButton'; @@ -15,7 +15,7 @@ export function WebsiteHeader({ allowLink?: boolean; }) { const website = useWebsite(); - const { renderUrl, pathname } = useNavigation(); + const { renderUrl, pathname, query } = useNavigation(); const isSettings = pathname.endsWith('/settings'); const { t, labels } = useMessages(); @@ -28,7 +28,14 @@ export function WebsiteHeader({ } - titleHref={allowLink ? renderUrl(`/websites/${website.id}`, false) : undefined} + titleHref={ + allowLink + ? renderUrl( + `/websites/${website.id}`, + query.timezone ? { timezone: query.timezone } : false, + ) + : undefined + } > diff --git a/src/components/hooks/useDateParameters.ts b/src/components/hooks/useDateParameters.ts index a16949017..d470f1fe8 100644 --- a/src/components/hooks/useDateParameters.ts +++ b/src/components/hooks/useDateParameters.ts @@ -13,10 +13,10 @@ export function useDateParameters() { const { dateRange: { startDate, endDate, unit }, } = useDateRange(); - const { timezone, localToUtc, canonicalizeTimezone } = useTimezone(); + const { timezone, toUtc, canonicalizeTimezone } = useTimezone(); - const utcStart = localToUtc(startDate); - const utcEnd = localToUtc(endDate); + const utcStart = toUtc(startDate); + const utcEnd = toUtc(endDate); const startAtValue = +utcStart; const endAtValue = +utcEnd; const isStartValid = Number.isFinite(startAtValue); diff --git a/src/components/hooks/useDateRange.ts b/src/components/hooks/useDateRange.ts index 5090bd3d8..71755c06c 100644 --- a/src/components/hooks/useDateRange.ts +++ b/src/components/hooks/useDateRange.ts @@ -4,24 +4,26 @@ import { useNavigation } from '@/components/hooks/useNavigation'; import { DATE_RANGE_CONFIG, DEFAULT_DATE_RANGE_VALUE } from '@/lib/constants'; import { getCompareDate, getOffsetDateRange, parseDateRange } from '@/lib/date'; import { getItem } from '@/lib/storage'; +import { useTimezone } from './useTimezone'; export function useDateRange(options: { ignoreOffset?: boolean; timezone?: string } = {}) { const { query: { date = '', unit = '', offset = 0, compare = 'prev' }, } = useNavigation(); const { locale } = useLocale(); + const { timezone: defaultTimezone } = useTimezone(); + const timezone = options.timezone ?? defaultTimezone; + const ignoreOffset = options.ignoreOffset; const dateRange = useMemo(() => { const dateRangeObject = parseDateRange( date || getItem(DATE_RANGE_CONFIG) || DEFAULT_DATE_RANGE_VALUE, unit, locale, - options.timezone, + timezone, ); - return !options.ignoreOffset && offset - ? getOffsetDateRange(dateRangeObject, +offset) - : dateRangeObject; - }, [date, unit, offset, options]); + return !ignoreOffset && offset ? getOffsetDateRange(dateRangeObject, +offset) : dateRangeObject; + }, [date, unit, offset, locale, timezone, ignoreOffset]); const dateCompare = getCompareDate(compare, dateRange.startDate, dateRange.endDate); diff --git a/src/components/hooks/useTimezone.ts b/src/components/hooks/useTimezone.ts index 497c238b2..024b51372 100644 --- a/src/components/hooks/useTimezone.ts +++ b/src/components/hooks/useTimezone.ts @@ -1,22 +1,35 @@ import { formatInTimeZone, fromZonedTime, toZonedTime } from 'date-fns-tz'; -import { TIMEZONE_CONFIG, TIMEZONE_LEGACY } from '@/lib/constants'; -import { getTimezone } from '@/lib/date'; -import { setItem } from '@/lib/storage'; -import { setTimezone, useApp } from '@/store/app'; +import { useSyncExternalStore } from 'react'; +import { TIMEZONE_LEGACY } from '@/lib/constants'; +import { getTimezone, isValidTimezone, normalizeTimezone } from '@/lib/date'; import { useLocale } from './useLocale'; +import { useNavigation } from './useNavigation'; -const selector = (state: { timezone: string }) => state.timezone; +function canonicalizeTimezone(timezone: string): string { + return TIMEZONE_LEGACY[timezone] ?? normalizeTimezone(timezone); +} + +function subscribe() { + return () => {}; +} + +function getServerTimezone() { + return 'UTC'; +} export function useTimezone() { - const timezone = useApp(selector); - const localTimeZone = getTimezone(); + const { + query: { timezone: queryTimezone }, + } = useNavigation(); + const localTimeZone = canonicalizeTimezone( + useSyncExternalStore(subscribe, getTimezone, getServerTimezone), + ); + const requestedTimezone = + typeof queryTimezone === 'string' ? canonicalizeTimezone(queryTimezone) : undefined; + const timezone = + requestedTimezone && isValidTimezone(requestedTimezone) ? requestedTimezone : localTimeZone; const { dateLocale } = useLocale(); - const saveTimezone = (value: string) => { - setItem(TIMEZONE_CONFIG, value); - setTimezone(value); - }; - const formatTimezoneDate = (date: string, pattern: string) => { return formatInTimeZone( /^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]{3})?Z$/.test(date) @@ -76,10 +89,6 @@ export function useTimezone() { return toZonedTime(date, localTimeZone); }; - const canonicalizeTimezone = (timezone: string): string => { - return TIMEZONE_LEGACY[timezone] ?? timezone; - }; - return { timezone, localTimeZone, @@ -87,7 +96,6 @@ export function useTimezone() { fromUtc, localToUtc, localFromUtc, - saveTimezone, formatTimezoneDate, formatSeriesTimezone, canonicalizeTimezone, diff --git a/src/components/input/DateFilter.tsx b/src/components/input/DateFilter.tsx index 4bff007ad..da6151e1a 100644 --- a/src/components/input/DateFilter.tsx +++ b/src/components/input/DateFilter.tsx @@ -1,7 +1,7 @@ import { Dialog, ListItem, ListSeparator, Modal, Select, type SelectProps } from '@umami/react-zen'; import { Fragment, type Key, useState } from 'react'; import { DateDisplay } from '@/components/common/DateDisplay'; -import { useMessages, useMobile } from '@/components/hooks'; +import { useMessages, useMobile, useTimezone } from '@/components/hooks'; import { DatePickerForm } from '@/components/metrics/DatePickerForm'; import { getMaxSelectableDate, parseDateRange } from '@/lib/date'; @@ -23,7 +23,8 @@ export function DateFilter({ }: DateFilterProps) { const { t, labels } = useMessages(); const [showPicker, setShowPicker] = useState(false); - const { startDate, endDate } = parseDateRange(value) || {}; + const { timezone, fromUtc } = useTimezone(); + const { startDate, endDate } = parseDateRange(value, undefined, undefined, timezone) || {}; const { isMobile } = useMobile(); const options = [ @@ -128,7 +129,7 @@ export function DateFilter({ startDate={startDate} endDate={endDate} minDate={new Date(2000, 0, 1)} - maxDate={getMaxSelectableDate()} + maxDate={getMaxSelectableDate(fromUtc(new Date()))} onChange={handlePickerChange} onClose={() => setShowPicker(false)} /> diff --git a/src/components/input/PreferencesButton.tsx b/src/components/input/PreferencesButton.tsx index aa1550ea2..b2c720971 100644 --- a/src/components/input/PreferencesButton.tsx +++ b/src/components/input/PreferencesButton.tsx @@ -1,6 +1,5 @@ import { Button, Column, DialogTrigger, Icon, Label, Popover } from '@umami/react-zen'; import { DateRangeSetting } from '@/app/(main)/settings/preferences/DateRangeSetting'; -import { TimezoneSetting } from '@/app/(main)/settings/preferences/TimezoneSetting'; import { Panel } from '@/components/common/Panel'; import { useMessages } from '@/components/hooks'; import { Settings } from '@/components/icons'; @@ -17,10 +16,6 @@ export function PreferencesButton() { - - - - diff --git a/src/components/input/TimezoneFilter.tsx b/src/components/input/TimezoneFilter.tsx new file mode 100644 index 000000000..c828819f3 --- /dev/null +++ b/src/components/input/TimezoneFilter.tsx @@ -0,0 +1,62 @@ +import { ListItem, ListSeparator, Select } from '@umami/react-zen'; +import { type Key, useState } from 'react'; +import { useMessages, useNavigation, useTimezone } from '@/components/hooks'; +import { isValidTimezone } from '@/lib/date'; + +const CURRENT_TIMEZONE = 'current'; +const timezones = [ + 'UTC', + ...(Intl.supportedValuesOf?.('timeZone') ?? []).filter(value => value !== 'UTC'), +]; + +export function TimezoneFilter() { + const [search, setSearch] = useState(''); + const { t, labels } = useMessages(); + const { router, query, updateParams } = useNavigation(); + const { timezone, localTimeZone, canonicalizeTimezone } = useTimezone(); + const hasOverride = + typeof query.timezone === 'string' && isValidTimezone(canonicalizeTimezone(query.timezone)); + const selectedTimezone = hasOverride ? timezone : CURRENT_TIMEZONE; + const normalizedSearch = search.trim().toLowerCase(); + const items = normalizedSearch + ? timezones.filter(value => value.toLowerCase().includes(normalizedSearch)) + : timezones; + + const handleChange = (value: Key) => { + router.push( + updateParams({ + timezone: value === CURRENT_TIMEZONE ? undefined : value.toString(), + page: 1, + }), + ); + }; + + const handleOpen = (isOpen: boolean) => { + if (isOpen) { + setSearch(''); + } + }; + + return ( + + ); +} diff --git a/src/components/input/WebsiteFilterButton.tsx b/src/components/input/WebsiteFilterButton.tsx index 1541bc6b4..4fc419a31 100644 --- a/src/components/input/WebsiteFilterButton.tsx +++ b/src/components/input/WebsiteFilterButton.tsx @@ -4,6 +4,7 @@ import { useFilters, useMessages, useNavigation } from '@/components/hooks'; import { ListFilter } from '@/components/icons'; import { DialogButton } from '@/components/input/DialogButton'; import { FilterEditForm } from '@/components/input/FilterEditForm'; +import { TimezoneFilter } from '@/components/input/TimezoneFilter'; import { filtersArrayToObject, serializeSessionPropertyFilters, @@ -78,17 +79,14 @@ export function WebsiteFilterButton({ {({ close }) => { return ( <> - {(isOverview || allowBounceFilter) && ( - - + + {(isOverview || allowBounceFilter) && ( + {t(labels.excludeBounce)} - - )} + )} + + ); diff --git a/src/components/metrics/EventsChart.tsx b/src/components/metrics/EventsChart.tsx index 81035cd65..a1e8286b5 100644 --- a/src/components/metrics/EventsChart.tsx +++ b/src/components/metrics/EventsChart.tsx @@ -2,12 +2,7 @@ import { colord } from 'colord'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { BarChart, type BarChartProps } from '@/components/charts/BarChart'; import { LoadingPanel } from '@/components/common/LoadingPanel'; -import { - useDateRange, - useLocale, - useTimezone, - useWebsiteEventsSeriesQuery, -} from '@/components/hooks'; +import { useDateRange, useLocale, useWebsiteEventsSeriesQuery } from '@/components/hooks'; import { renderDateLabels } from '@/lib/charts'; import { hex6 } from '@/lib/colors'; import { CHART_COLORS } from '@/lib/constants'; @@ -20,10 +15,9 @@ export interface EventsChartProps extends BarChartProps { } export function EventsChart({ websiteId, focusLabel, limit }: EventsChartProps) { - const { timezone } = useTimezone(); const { dateRange: { startDate, endDate, unit }, - } = useDateRange({ timezone: timezone }); + } = useDateRange(); const { locale, dateLocale } = useLocale(); const { data, isLoading, error } = useWebsiteEventsSeriesQuery(websiteId, { limit }); const [label, setLabel] = useState(focusLabel); diff --git a/src/components/property-data/PropertyChart.tsx b/src/components/property-data/PropertyChart.tsx index 886bd4775..ec12be1ce 100644 --- a/src/components/property-data/PropertyChart.tsx +++ b/src/components/property-data/PropertyChart.tsx @@ -11,7 +11,6 @@ import { useMessages, usePropertyArraySeriesQuery, usePropertySeriesQuery, - useTimezone, } from '@/components/hooks'; import type { PropertyDataSource } from '@/components/hooks/queries/usePropertyFieldsQuery'; import { ListTable } from '@/components/metrics/ListTable'; @@ -36,10 +35,9 @@ export function PropertyChart({ seriesType?: 'property' | 'array'; }) { const { t, labels } = useMessages(); - const { timezone } = useTimezone(); const { dateRange: { startDate, endDate, unit }, - } = useDateRange({ timezone }); + } = useDateRange(); const { locale, dateLocale } = useLocale(); const propertySeriesQuery = usePropertySeriesQuery( source, diff --git a/src/components/property-data/PropertyNumericChart.tsx b/src/components/property-data/PropertyNumericChart.tsx index 9b177ddd6..546659e84 100644 --- a/src/components/property-data/PropertyNumericChart.tsx +++ b/src/components/property-data/PropertyNumericChart.tsx @@ -9,7 +9,6 @@ import { useMessages, usePropertyNumericSeriesQuery, usePropertyNumericStatsQuery, - useTimezone, } from '@/components/hooks'; import type { PropertyDataSource } from '@/components/hooks/queries/usePropertyFieldsQuery'; import { MetricCard } from '@/components/metrics/MetricCard'; @@ -35,10 +34,9 @@ export function PropertyNumericChart({ }) { const { t, labels } = useMessages(); const { theme } = useTheme(); - const { timezone } = useTimezone(); const { dateRange: { startDate, endDate, unit }, - } = useDateRange({ timezone }); + } = useDateRange(); const { locale, dateLocale } = useLocale(); const { colors } = useMemo(() => getThemeColors(theme), [theme]); diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 89e250e73..1878b22a2 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -1,7 +1,6 @@ export const CURRENT_VERSION = process.env.currentVersion; export const AUTH_TOKEN = 'umami.auth'; export const LOCALE_CONFIG = 'umami.locale'; -export const TIMEZONE_CONFIG = 'umami.timezone'; export const DATE_RANGE_CONFIG = 'umami.date-range'; export const THEME_CONFIG = 'umami.theme'; export const CURRENCY_CONFIG = 'umami.currency'; diff --git a/src/store/app.test.ts b/src/store/app.test.ts index dcefd9a53..23dce6210 100644 --- a/src/store/app.test.ts +++ b/src/store/app.test.ts @@ -1,13 +1,5 @@ import { beforeEach, describe, expect, test } from 'vitest'; -import { - setConfig, - setDateRangeValue, - setLocale, - setShareData, - setTimezone, - setUser, - useApp, -} from './app'; +import { setConfig, setDateRangeValue, setLocale, setShareData, setUser, useApp } from './app'; const initialState = { ...useApp.getState() }; @@ -15,18 +7,6 @@ beforeEach(() => { useApp.setState({ ...initialState }, true); }); -describe('setTimezone', () => { - test('updates the timezone', () => { - setTimezone('America/New_York'); - expect(useApp.getState().timezone).toBe('America/New_York'); - }); - - test('leaves other fields untouched', () => { - setTimezone('UTC'); - expect(useApp.getState().locale).toBe(initialState.locale); - }); -}); - describe('setLocale', () => { test('updates the locale', () => { setLocale('fr-FR'); diff --git a/src/store/app.ts b/src/store/app.ts index 851332e26..011710644 100644 --- a/src/store/app.ts +++ b/src/store/app.ts @@ -6,15 +6,12 @@ import { DEFAULT_THEME, LOCALE_CONFIG, THEME_CONFIG, - TIMEZONE_CONFIG, } from '@/lib/constants'; -import { getTimezone } from '@/lib/date'; import { getItem } from '@/lib/storage'; const initialState = { locale: getItem(LOCALE_CONFIG) || process.env.defaultLocale || DEFAULT_LOCALE, theme: getItem(THEME_CONFIG) || DEFAULT_THEME, - timezone: getItem(TIMEZONE_CONFIG) || getTimezone(), dateRangeValue: getItem(DATE_RANGE_CONFIG) || DEFAULT_DATE_RANGE_VALUE, share: null, shareToken: null, @@ -24,10 +21,6 @@ const initialState = { const store = create(() => ({ ...initialState })); -export function setTimezone(timezone: string) { - store.setState({ timezone }); -} - export function setLocale(locale: string) { store.setState({ locale }); }