From 3136e3590a3c98219aedffcd7b152aebe8100193 Mon Sep 17 00:00:00 2001 From: Francis Cao Date: Mon, 27 Jul 2026 10:32:28 -0700 Subject: [PATCH] Revert "Move timezone selection to query parameters" This reverts commit 4d7930060c6521006fdc1aac11fca197c7272b1f. --- .../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, 147 insertions(+), 129 deletions(-) create mode 100644 src/app/(main)/settings/preferences/TimezoneSetting.tsx delete 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 bdce36f47..55695946b 100644 --- a/src/app/(main)/settings/preferences/PreferenceSettings.tsx +++ b/src/app/(main)/settings/preferences/PreferenceSettings.tsx @@ -3,6 +3,7 @@ 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() { @@ -19,6 +20,10 @@ export function PreferenceSettings() { + + + + diff --git a/src/app/(main)/settings/preferences/TimezoneSetting.tsx b/src/app/(main)/settings/preferences/TimezoneSetting.tsx new file mode 100644 index 000000000..765d37608 --- /dev/null +++ b/src/app/(main)/settings/preferences/TimezoneSetting.tsx @@ -0,0 +1,45 @@ +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 72924a0d9..f5febf290 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 } from '@/components/hooks'; +import { useDateRange, useTimezone } from '@/components/hooks'; import { useRevenueChartQuery } from '@/components/hooks/queries/useRevenueChartQuery'; import { RevenueChart } from './RevenueChart'; import { useBoardRevenueCurrency } from './useBoardRevenueCurrency'; @@ -12,9 +12,10 @@ export function BoardRevenueChart({ currency?: string; }) { const currency = useBoardRevenueCurrency(selectedCurrency); + const { timezone } = useTimezone(); const { dateRange: { startDate, endDate, unit }, - } = useDateRange(); + } = useDateRange({ timezone }); 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 3e429c187..4dc19e012 100644 --- a/src/app/(main)/websites/[websiteId]/(reports)/revenue/RevenuePage.tsx +++ b/src/app/(main)/websites/[websiteId]/(reports)/revenue/RevenuePage.tsx @@ -1,13 +1,14 @@ 'use client'; import { Column } from '@umami/react-zen'; import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls'; -import { useDateRange } from '@/components/hooks'; +import { useDateRange, useTimezone } from '@/components/hooks'; import { Revenue } from './Revenue'; export function RevenuePage({ websiteId }: { websiteId: string }) { + const { timezone } = useTimezone(); const { dateRange: { startDate, endDate, unit }, - } = useDateRange(); + } = useDateRange({ timezone }); return ( diff --git a/src/app/(main)/websites/[websiteId]/WebsiteChart.tsx b/src/app/(main)/websites/[websiteId]/WebsiteChart.tsx index adc1d568a..896c733a8 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 } from '@/components/hooks'; +import { useDateRange, useTimezone } from '@/components/hooks'; import { useWebsitePageviewsQuery } from '@/components/hooks/queries/useWebsitePageviewsQuery'; import { PageviewsChart } from '@/components/metrics/PageviewsChart'; @@ -11,7 +11,8 @@ export function WebsiteChart({ websiteId: string; compareMode?: boolean; }) { - const { dateRange, dateCompare } = useDateRange(); + const { timezone } = useTimezone(); + const { dateRange, dateCompare } = useDateRange({ timezone: timezone }); 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 345ef83d9..642e9fe22 100644 --- a/src/app/(main)/websites/[websiteId]/WebsiteHeader.tsx +++ b/src/app/(main)/websites/[websiteId]/WebsiteHeader.tsx @@ -1,4 +1,4 @@ -import { Row } from '@umami/react-zen'; +import { Icon, Row, Text } 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, query } = useNavigation(); + const { renderUrl, pathname } = useNavigation(); const isSettings = pathname.endsWith('/settings'); const { t, labels } = useMessages(); @@ -28,14 +28,7 @@ export function WebsiteHeader({ } - titleHref={ - allowLink - ? renderUrl( - `/websites/${website.id}`, - query.timezone ? { timezone: query.timezone } : false, - ) - : undefined - } + titleHref={allowLink ? renderUrl(`/websites/${website.id}`, false) : undefined} > diff --git a/src/components/hooks/useDateParameters.ts b/src/components/hooks/useDateParameters.ts index d470f1fe8..a16949017 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, toUtc, canonicalizeTimezone } = useTimezone(); + const { timezone, localToUtc, canonicalizeTimezone } = useTimezone(); - const utcStart = toUtc(startDate); - const utcEnd = toUtc(endDate); + const utcStart = localToUtc(startDate); + const utcEnd = localToUtc(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 71755c06c..5090bd3d8 100644 --- a/src/components/hooks/useDateRange.ts +++ b/src/components/hooks/useDateRange.ts @@ -4,26 +4,24 @@ 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, - timezone, + options.timezone, ); - return !ignoreOffset && offset ? getOffsetDateRange(dateRangeObject, +offset) : dateRangeObject; - }, [date, unit, offset, locale, timezone, ignoreOffset]); + return !options.ignoreOffset && offset + ? getOffsetDateRange(dateRangeObject, +offset) + : dateRangeObject; + }, [date, unit, offset, options]); const dateCompare = getCompareDate(compare, dateRange.startDate, dateRange.endDate); diff --git a/src/components/hooks/useTimezone.ts b/src/components/hooks/useTimezone.ts index 024b51372..497c238b2 100644 --- a/src/components/hooks/useTimezone.ts +++ b/src/components/hooks/useTimezone.ts @@ -1,35 +1,22 @@ import { formatInTimeZone, fromZonedTime, toZonedTime } from 'date-fns-tz'; -import { useSyncExternalStore } from 'react'; -import { TIMEZONE_LEGACY } from '@/lib/constants'; -import { getTimezone, isValidTimezone, normalizeTimezone } from '@/lib/date'; +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 { useLocale } from './useLocale'; -import { useNavigation } from './useNavigation'; -function canonicalizeTimezone(timezone: string): string { - return TIMEZONE_LEGACY[timezone] ?? normalizeTimezone(timezone); -} - -function subscribe() { - return () => {}; -} - -function getServerTimezone() { - return 'UTC'; -} +const selector = (state: { timezone: string }) => state.timezone; export function useTimezone() { - 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 timezone = useApp(selector); + const localTimeZone = getTimezone(); 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) @@ -89,6 +76,10 @@ export function useTimezone() { return toZonedTime(date, localTimeZone); }; + const canonicalizeTimezone = (timezone: string): string => { + return TIMEZONE_LEGACY[timezone] ?? timezone; + }; + return { timezone, localTimeZone, @@ -96,6 +87,7 @@ 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 da6151e1a..4bff007ad 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, useTimezone } from '@/components/hooks'; +import { useMessages, useMobile } from '@/components/hooks'; import { DatePickerForm } from '@/components/metrics/DatePickerForm'; import { getMaxSelectableDate, parseDateRange } from '@/lib/date'; @@ -23,8 +23,7 @@ export function DateFilter({ }: DateFilterProps) { const { t, labels } = useMessages(); const [showPicker, setShowPicker] = useState(false); - const { timezone, fromUtc } = useTimezone(); - const { startDate, endDate } = parseDateRange(value, undefined, undefined, timezone) || {}; + const { startDate, endDate } = parseDateRange(value) || {}; const { isMobile } = useMobile(); const options = [ @@ -129,7 +128,7 @@ export function DateFilter({ startDate={startDate} endDate={endDate} minDate={new Date(2000, 0, 1)} - maxDate={getMaxSelectableDate(fromUtc(new Date()))} + maxDate={getMaxSelectableDate()} onChange={handlePickerChange} onClose={() => setShowPicker(false)} /> diff --git a/src/components/input/PreferencesButton.tsx b/src/components/input/PreferencesButton.tsx index b2c720971..aa1550ea2 100644 --- a/src/components/input/PreferencesButton.tsx +++ b/src/components/input/PreferencesButton.tsx @@ -1,5 +1,6 @@ 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'; @@ -16,6 +17,10 @@ export function PreferencesButton() { + + + + diff --git a/src/components/input/TimezoneFilter.tsx b/src/components/input/TimezoneFilter.tsx deleted file mode 100644 index c828819f3..000000000 --- a/src/components/input/TimezoneFilter.tsx +++ /dev/null @@ -1,62 +0,0 @@ -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 4fc419a31..1541bc6b4 100644 --- a/src/components/input/WebsiteFilterButton.tsx +++ b/src/components/input/WebsiteFilterButton.tsx @@ -4,7 +4,6 @@ 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, @@ -79,14 +78,17 @@ 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 a1e8286b5..81035cd65 100644 --- a/src/components/metrics/EventsChart.tsx +++ b/src/components/metrics/EventsChart.tsx @@ -2,7 +2,12 @@ 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, useWebsiteEventsSeriesQuery } from '@/components/hooks'; +import { + useDateRange, + useLocale, + useTimezone, + useWebsiteEventsSeriesQuery, +} from '@/components/hooks'; import { renderDateLabels } from '@/lib/charts'; import { hex6 } from '@/lib/colors'; import { CHART_COLORS } from '@/lib/constants'; @@ -15,9 +20,10 @@ export interface EventsChartProps extends BarChartProps { } export function EventsChart({ websiteId, focusLabel, limit }: EventsChartProps) { + const { timezone } = useTimezone(); const { dateRange: { startDate, endDate, unit }, - } = useDateRange(); + } = useDateRange({ timezone: timezone }); 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 ec12be1ce..886bd4775 100644 --- a/src/components/property-data/PropertyChart.tsx +++ b/src/components/property-data/PropertyChart.tsx @@ -11,6 +11,7 @@ import { useMessages, usePropertyArraySeriesQuery, usePropertySeriesQuery, + useTimezone, } from '@/components/hooks'; import type { PropertyDataSource } from '@/components/hooks/queries/usePropertyFieldsQuery'; import { ListTable } from '@/components/metrics/ListTable'; @@ -35,9 +36,10 @@ export function PropertyChart({ seriesType?: 'property' | 'array'; }) { const { t, labels } = useMessages(); + const { timezone } = useTimezone(); const { dateRange: { startDate, endDate, unit }, - } = useDateRange(); + } = useDateRange({ timezone }); 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 546659e84..9b177ddd6 100644 --- a/src/components/property-data/PropertyNumericChart.tsx +++ b/src/components/property-data/PropertyNumericChart.tsx @@ -9,6 +9,7 @@ import { useMessages, usePropertyNumericSeriesQuery, usePropertyNumericStatsQuery, + useTimezone, } from '@/components/hooks'; import type { PropertyDataSource } from '@/components/hooks/queries/usePropertyFieldsQuery'; import { MetricCard } from '@/components/metrics/MetricCard'; @@ -34,9 +35,10 @@ export function PropertyNumericChart({ }) { const { t, labels } = useMessages(); const { theme } = useTheme(); + const { timezone } = useTimezone(); const { dateRange: { startDate, endDate, unit }, - } = useDateRange(); + } = useDateRange({ timezone }); const { locale, dateLocale } = useLocale(); const { colors } = useMemo(() => getThemeColors(theme), [theme]); diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 1878b22a2..89e250e73 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -1,6 +1,7 @@ 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 23dce6210..dcefd9a53 100644 --- a/src/store/app.test.ts +++ b/src/store/app.test.ts @@ -1,5 +1,13 @@ import { beforeEach, describe, expect, test } from 'vitest'; -import { setConfig, setDateRangeValue, setLocale, setShareData, setUser, useApp } from './app'; +import { + setConfig, + setDateRangeValue, + setLocale, + setShareData, + setTimezone, + setUser, + useApp, +} from './app'; const initialState = { ...useApp.getState() }; @@ -7,6 +15,18 @@ 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 011710644..851332e26 100644 --- a/src/store/app.ts +++ b/src/store/app.ts @@ -6,12 +6,15 @@ 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, @@ -21,6 +24,10 @@ const initialState = { const store = create(() => ({ ...initialState })); +export function setTimezone(timezone: string) { + store.setState({ timezone }); +} + export function setLocale(locale: string) { store.setState({ locale }); }