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 });
}