Compare commits

...

2 commits

Author SHA1 Message Date
Mike Cao
5ded9abbfe Added data-fetch-credentials attribute. Closes #3644
Some checks are pending
Node.js CI / build (postgresql, 18.18, 10) (push) Waiting to run
2025-11-13 19:42:04 -08:00
Francis Cao
6751bf88bb fix chart and timezone issues, pass consistent dates to DB.
Some checks failed
Create docker images (cloud) / Build, push, and deploy (push) Waiting to run
Node.js CI / build (postgresql, 18.18, 10) (push) Has been cancelled
Closes #3700
2025-11-13 15:52:24 -08:00
13 changed files with 168 additions and 87 deletions

View file

@ -1,6 +1,6 @@
{
"name": "umami",
"version": "3.0.0",
"version": "3.0.1",
"description": "A modern, privacy-focused alternative to Google Analytics.",
"author": "Umami Software, Inc. <hello@umami.is>",
"license": "MIT",
@ -78,7 +78,7 @@
"@react-spring/web": "^10.0.3",
"@svgr/cli": "^8.1.0",
"@tanstack/react-query": "^5.90.5",
"@umami/react-zen": "^0.206.0",
"@umami/react-zen": "^0.207.0",
"@umami/redis-client": "^0.29.0",
"bcryptjs": "^3.0.2",
"chalk": "^5.6.2",

10
pnpm-lock.yaml generated
View file

@ -45,8 +45,8 @@ importers:
specifier: ^5.90.5
version: 5.90.5(react@19.2.0)
'@umami/react-zen':
specifier: ^0.206.0
version: 0.206.0(@babel/core@7.28.3)(@types/react@19.2.2)(babel-plugin-react-compiler@19.1.0-rc.2)(immer@10.2.0)(use-sync-external-store@1.6.0(react@19.2.0))
specifier: ^0.207.0
version: 0.207.0(@babel/core@7.28.3)(@types/react@19.2.2)(babel-plugin-react-compiler@19.1.0-rc.2)(immer@10.2.0)(use-sync-external-store@1.6.0(react@19.2.0))
'@umami/redis-client':
specifier: ^0.29.0
version: 0.29.0
@ -2935,8 +2935,8 @@ packages:
resolution: {integrity: sha512-tUFMXI4gxzzMXt4xpGJEsBsTox0XbNQ1y94EwlD/CuZwFcQP79xfQqMhau9HsRc/J0cAPA/HZt1dZPtGn9V/7w==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@umami/react-zen@0.206.0':
resolution: {integrity: sha512-9XM3Oj1akdyuwkMT1SldrJOyrMACP9TLJApZ/9ocmPuET4B7vpPxRoxv8OpEVlBaDw5nmlJfIvefsNMBLt1OQg==}
'@umami/react-zen@0.207.0':
resolution: {integrity: sha512-TUllF5mKQ+IBepIgT0xvJo/bGBEdPDfGiATyjuuxe2/SwO3LDINbmPUSFpWrjxtUaigiEI0JaQsklSLkirEKPg==}
'@umami/redis-client@0.29.0':
resolution: {integrity: sha512-Jaqh++jskqDB7ny75pfC02OvKp1JTS4asGDsFrRL3qy8sxL3PAl9+/mybCJe4/6vWrXDJKqpgkSfUDJq2bFjyw==}
@ -10697,7 +10697,7 @@ snapshots:
'@typescript-eslint/types': 8.46.2
eslint-visitor-keys: 4.2.1
'@umami/react-zen@0.206.0(@babel/core@7.28.3)(@types/react@19.2.2)(babel-plugin-react-compiler@19.1.0-rc.2)(immer@10.2.0)(use-sync-external-store@1.6.0(react@19.2.0))':
'@umami/react-zen@0.207.0(@babel/core@7.28.3)(@types/react@19.2.2)(babel-plugin-react-compiler@19.1.0-rc.2)(immer@10.2.0)(use-sync-external-store@1.6.0(react@19.2.0))':
dependencies:
'@fontsource/jetbrains-mono': 5.2.8
'@internationalized/date': 3.10.0

View file

@ -1,5 +1,5 @@
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';
import { useMemo } from 'react';
@ -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,

View file

@ -5,13 +5,13 @@ export function useDateParameters() {
const {
dateRange: { startDate, endDate, unit },
} = useDateRange();
const { timezone, toUtc, canonicalizeTimezone } = useTimezone();
const { timezone, localToUtc, canonicalizeTimezone } = useTimezone();
return {
startAt: +toUtc(startDate),
endAt: +toUtc(endDate),
startDate: toUtc(startDate).toISOString(),
endDate: toUtc(endDate).toISOString(),
startAt: +localToUtc(startDate),
endAt: +localToUtc(endDate),
startDate: localToUtc(startDate).toISOString(),
endDate: localToUtc(endDate).toISOString(),
unit,
timezone: canonicalizeTimezone(timezone),
};

View file

@ -5,7 +5,7 @@ import { useLocale } from '@/components/hooks/useLocale';
import { DATE_RANGE_CONFIG, DEFAULT_DATE_RANGE_VALUE } from '@/lib/constants';
import { getItem } from '@/lib/storage';
export function useDateRange(options: { ignoreOffset?: boolean } = {}) {
export function useDateRange(options: { ignoreOffset?: boolean; timezone?: string } = {}) {
const {
query: { date = '', offset = 0, compare = 'prev' },
} = useNavigation();
@ -15,6 +15,7 @@ export function useDateRange(options: { ignoreOffset?: boolean } = {}) {
const dateRangeObject = parseDateRange(
date || getItem(DATE_RANGE_CONFIG) || DEFAULT_DATE_RANGE_VALUE,
locale,
options.timezone,
);
return !options.ignoreOffset && offset

View file

@ -3,11 +3,13 @@ import { TIMEZONE_CONFIG, TIMEZONE_LEGACY } from '@/lib/constants';
import { formatInTimeZone, zonedTimeToUtc, utcToZonedTime } from 'date-fns-tz';
import { useApp, setTimezone } from '@/store/app';
import { useLocale } from './useLocale';
import { getTimezone } from '@/lib/date';
const selector = (state: { timezone: string }) => state.timezone;
export function useTimezone() {
const timezone = useApp(selector);
const localTimeZone = getTimezone();
const { dateLocale } = useLocale();
const saveTimezone = (value: string) => {
@ -26,6 +28,38 @@ export function useTimezone() {
);
};
const formatSeriesTimezone = (data: any, column: string, timezone: string) => {
return data.map(item => {
const date = new Date(item[column]);
const format = new Intl.DateTimeFormat('en-US', {
timeZone: timezone,
hour12: false,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
const parts = format.formatToParts(date);
const get = type => parts.find(p => p.type === type)?.value;
const year = get('year');
const month = get('month');
const day = get('day');
const hour = get('hour');
const minute = get('minute');
const second = get('second');
return {
...item,
[column]: `${year}-${month}-${day} ${hour}:${minute}:${second}`,
};
});
};
const toUtc = (date: Date | string | number) => {
return zonedTimeToUtc(date, timezone);
};
@ -34,16 +68,28 @@ export function useTimezone() {
return utcToZonedTime(date, timezone);
};
const localToUtc = (date: Date | string | number) => {
return zonedTimeToUtc(date, localTimeZone);
};
const localFromUtc = (date: Date | string | number) => {
return utcToZonedTime(date, localTimeZone);
};
const canonicalizeTimezone = (timezone: string): string => {
return TIMEZONE_LEGACY[timezone] ?? timezone;
};
return {
timezone,
saveTimezone,
formatTimezoneDate,
localTimeZone,
toUtc,
fromUtc,
localToUtc,
localFromUtc,
saveTimezone,
formatTimezoneDate,
formatSeriesTimezone,
canonicalizeTimezone,
};
}

View file

@ -1,6 +1,11 @@
import { BarChart, 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 { CHART_COLORS } from '@/lib/constants';
import { generateTimeSeries } from '@/lib/date';
@ -13,9 +18,10 @@ export interface EventsChartProps extends BarChartProps {
}
export function EventsChart({ websiteId, focusLabel }: EventsChartProps) {
const { timezone } = useTimezone();
const {
dateRange: { startDate, endDate, unit },
} = useDateRange();
} = useDateRange({ timezone: timezone });
const { locale, dateLocale } = useLocale();
const { data, isLoading, error } = useWebsiteEventsSeriesQuery(websiteId);
const [label, setLabel] = useState<string>(focusLabel);
@ -33,20 +39,32 @@ export function EventsChart({ websiteId, focusLabel }: EventsChartProps) {
return obj;
}, {});
return {
datasets: Object.keys(map).map((key, index) => {
const color = colord(CHART_COLORS[index % CHART_COLORS.length]);
return {
label: key,
data: generateTimeSeries(map[key], startDate, endDate, unit, dateLocale),
lineTension: 0,
backgroundColor: color.alpha(0.6).toRgbString(),
borderColor: color.alpha(0.7).toRgbString(),
borderWidth: 1,
};
}),
focusLabel,
};
if (!map || Object.keys(map).length === 0) {
return {
datasets: [
{
data: generateTimeSeries([], startDate, endDate, unit, dateLocale),
lineTension: 0,
borderWidth: 1,
},
],
};
} else {
return {
datasets: Object.keys(map).map((key, index) => {
const color = colord(CHART_COLORS[index % CHART_COLORS.length]);
return {
label: key,
data: generateTimeSeries(map[key], startDate, endDate, unit, dateLocale),
lineTension: 0,
backgroundColor: color.alpha(0.6).toRgbString(),
borderColor: color.alpha(0.7).toRgbString(),
borderWidth: 1,
};
}),
focusLabel,
};
}
}, [data, startDate, endDate, unit, focusLabel]);
useEffect(() => {

View file

@ -3,6 +3,7 @@ import { startOfMinute, subMinutes, isBefore } from 'date-fns';
import { PageviewsChart } from './PageviewsChart';
import { DEFAULT_ANIMATION_DURATION, REALTIME_RANGE } from '@/lib/constants';
import { RealtimeData } from '@/lib/types';
import { useTimezone } from '@/components/hooks';
export interface RealtimeChartProps {
data: RealtimeData;
@ -11,6 +12,7 @@ export interface RealtimeChartProps {
}
export function RealtimeChart({ data, unit, ...props }: RealtimeChartProps) {
const { formatSeriesTimezone, fromUtc, timezone } = useTimezone();
const endDate = startOfMinute(new Date());
const startDate = subMinutes(endDate, REALTIME_RANGE);
const prevEndDate = useRef(endDate);
@ -21,8 +23,8 @@ export function RealtimeChart({ data, unit, ...props }: RealtimeChartProps) {
}
return {
pageviews: data.series.views,
sessions: data.series.visitors,
pageviews: formatSeriesTimezone(data.series.views, 'x', timezone),
sessions: formatSeriesTimezone(data.series.visitors, 'x', timezone),
};
}, [data, startDate, endDate, unit]);
@ -38,8 +40,8 @@ export function RealtimeChart({ data, unit, ...props }: RealtimeChartProps) {
return (
<PageviewsChart
{...props}
minDate={startDate}
maxDate={endDate}
minDate={fromUtc(startDate)}
maxDate={fromUtc(endDate)}
unit={unit}
data={chartData}
animationDuration={animationDuration}

View file

@ -51,12 +51,12 @@ function getUTCString(date?: Date | string | number) {
return formatInTimeZone(date || new Date(), 'UTC', 'yyyy-MM-dd HH:mm:ss');
}
function getDateStringSQL(data: any, unit: string = 'utc', timezone?: string) {
function getDateStringSQL(field: string, unit: string, timezone?: string) {
if (timezone) {
return `formatDateTime(${data}, '${CLICKHOUSE_DATE_FORMATS[unit]}', '${timezone}')`;
return `formatDateTime(${field}, '${CLICKHOUSE_DATE_FORMATS[unit]}', '${timezone}')`;
}
return `formatDateTime(${data}, '${CLICKHOUSE_DATE_FORMATS[unit]}')`;
return `formatDateTime(${field}, '${CLICKHOUSE_DATE_FORMATS[unit]}')`;
}
function getDateSQL(field: string, unit: string, timezone?: string) {

View file

@ -1,44 +1,45 @@
import {
addMinutes,
addHours,
addDays,
addMonths,
addYears,
subMinutes,
subHours,
subDays,
subMonths,
subYears,
startOfMinute,
startOfHour,
startOfDay,
startOfWeek,
startOfMonth,
startOfYear,
endOfHour,
endOfDay,
endOfWeek,
endOfMonth,
endOfYear,
differenceInMinutes,
differenceInHours,
differenceInCalendarDays,
differenceInCalendarWeeks,
differenceInCalendarMonths,
differenceInCalendarYears,
format,
max,
min,
isDate,
addWeeks,
subWeeks,
endOfMinute,
isSameDay,
isBefore,
isEqual,
} from 'date-fns';
import { getDateLocale } from '@/lib/lang';
import { DateRange } from '@/lib/types';
import {
addDays,
addHours,
addMinutes,
addMonths,
addWeeks,
addYears,
differenceInCalendarDays,
differenceInCalendarMonths,
differenceInCalendarWeeks,
differenceInCalendarYears,
differenceInHours,
differenceInMinutes,
endOfDay,
endOfHour,
endOfMinute,
endOfMonth,
endOfWeek,
endOfYear,
format,
isBefore,
isDate,
isEqual,
isSameDay,
max,
min,
startOfDay,
startOfHour,
startOfMinute,
startOfMonth,
startOfWeek,
startOfYear,
subDays,
subHours,
subMinutes,
subMonths,
subWeeks,
subYears,
} from 'date-fns';
import { utcToZonedTime } from 'date-fns-tz';
export const TIME_UNIT = {
minute: 'minute',
@ -135,7 +136,7 @@ export function parseDateValue(value: string) {
return { num: +num, unit };
}
export function parseDateRange(value: string, locale = 'en-US'): DateRange {
export function parseDateRange(value: string, locale = 'en-US', timezone?: string): DateRange {
if (typeof value !== 'string') {
return null;
}
@ -156,7 +157,8 @@ export function parseDateRange(value: string, locale = 'en-US'): DateRange {
};
}
const now = new Date();
const date = new Date();
const now = timezone ? utcToZonedTime(date, timezone) : date;
const dateLocale = getDateLocale(locale);
const { num = 1, unit } = parseDateValue(value);

View file

@ -27,6 +27,14 @@ const DATE_FORMATS = {
year: 'YYYY-01-01 HH24:00:00',
};
const DATE_FORMATS_UTC = {
minute: 'YYYY-MM-DD"T"HH24:MI:00"Z"',
hour: 'YYYY-MM-DD"T"HH24:00:00"Z"',
day: 'YYYY-MM-DD"T"HH24:00:00"Z"',
month: 'YYYY-MM-01"T"HH24:00:00"Z"',
year: 'YYYY-01-01"T"HH24:00:00"Z"',
};
function getAddIntervalQuery(field: string, interval: string): string {
return `${field} + interval '${interval}'`;
}
@ -40,11 +48,11 @@ function getCastColumnQuery(field: string, type: string): string {
}
function getDateSQL(field: string, unit: string, timezone?: string): string {
if (timezone) {
if (timezone && timezone !== 'utc') {
return `to_char(date_trunc('${unit}', ${field} at time zone '${timezone}'), '${DATE_FORMATS[unit]}')`;
}
return `to_char(date_trunc('${unit}', ${field}), '${DATE_FORMATS[unit]}')`;
return `to_char(date_trunc('${unit}', ${field}), '${DATE_FORMATS_UTC[unit]}')`;
}
function getDateWeeklySQL(field: string, timezone?: string) {

View file

@ -1,7 +1,7 @@
import { getPageviewStats } from '@/queries/sql/pageviews/getPageviewStats';
import { getRealtimeActivity } from '@/queries/sql/getRealtimeActivity';
import { getSessionStats } from '@/queries/sql/sessions/getSessionStats';
import { QueryFilters } from '@/lib/types';
import { getRealtimeActivity } from '@/queries/sql/getRealtimeActivity';
import { getPageviewStats } from '@/queries/sql/pageviews/getPageviewStats';
import { getSessionStats } from '@/queries/sql/sessions/getSessionStats';
function increment(data: object, key: string) {
if (key) {

View file

@ -18,6 +18,7 @@
const _false = 'false';
const _true = 'true';
const attr = currentScript.getAttribute.bind(currentScript);
const website = attr(_data + 'website-id');
const hostUrl = attr(_data + 'host-url');
const beforeSend = attr(_data + 'before-send');
@ -27,6 +28,8 @@
const excludeSearch = attr(_data + 'exclude-search') === _true;
const excludeHash = attr(_data + 'exclude-hash') === _true;
const domain = attr(_data + 'domains') || '';
const credentials = attr(_data + 'fetch-credentials') || 'omit';
const domains = domain.split(',').map(n => n.trim());
const host =
hostUrl || '__COLLECT_API_HOST__' || currentScript.src.split('/').slice(0, -1).join('/');
@ -165,7 +168,7 @@
'Content-Type': 'application/json',
...(typeof cache !== 'undefined' && { 'x-umami-cache': cache }),
},
credentials: 'omit',
credentials,
});
const data = await res.json();