add better support for timezones

This commit is contained in:
Carl-Gerhard Lindesvärd
2024-05-02 21:16:31 +02:00
parent cff7822f12
commit 33e92a0da0
9 changed files with 152 additions and 189 deletions

View File

@@ -56,8 +56,8 @@ const startServer = async () => {
onError(error: unknown) { onError(error: unknown) {
if (error instanceof Error) { if (error instanceof Error) {
logger.error(error, error.message); logger.error(error, error.message);
} else { } else if (error && typeof error === 'object' && 'error' in error) {
logger.error(error, 'Unknown error trpc error'); logger.error(error.error, 'Unknown error trpc error');
} }
}, },
} satisfies FastifyTRPCPluginOptions<AppRouter>['trpcOptions'], } satisfies FastifyTRPCPluginOptions<AppRouter>['trpcOptions'],

View File

@@ -2,6 +2,7 @@
import { useOverviewOptions } from '@/components/overview/useOverviewOptions'; import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
import { TimeWindowPicker } from '@/components/time-window-picker'; import { TimeWindowPicker } from '@/components/time-window-picker';
import { endOfDay, formatISO, startOfDay } from 'date-fns';
export function OverviewReportRange() { export function OverviewReportRange() {
const { range, setRange, setStartDate, setEndDate, endDate, startDate } = const { range, setRange, setStartDate, setEndDate, endDate, startDate } =
@@ -11,8 +12,14 @@ export function OverviewReportRange() {
<TimeWindowPicker <TimeWindowPicker
onChange={setRange} onChange={setRange}
value={range} value={range}
onStartDateChange={(date) => setStartDate(date)} onStartDateChange={(date) => {
onEndDateChange={(date) => setEndDate(date)} const d = formatISO(startOfDay(new Date(date)));
setStartDate(d);
}}
onEndDateChange={(date) => {
const d = formatISO(endOfDay(new Date(date)));
setEndDate(d);
}}
endDate={endDate} endDate={endDate}
startDate={startDate} startDate={startDate}
/> />

View File

@@ -1,3 +0,0 @@
import FullPageLoadingState from '@/components/full-page-loading-state';
export default FullPageLoadingState;

View File

@@ -1,6 +1,12 @@
import { createSlice } from '@reduxjs/toolkit'; import { createSlice } from '@reduxjs/toolkit';
import type { PayloadAction } from '@reduxjs/toolkit'; import type { PayloadAction } from '@reduxjs/toolkit';
import { isSameDay, isSameMonth } from 'date-fns'; import {
endOfDay,
formatISO,
isSameDay,
isSameMonth,
startOfDay,
} from 'date-fns';
import { import {
alphabetIds, alphabetIds,
@@ -188,8 +194,8 @@ export const reportSlice = createSlice({
}> }>
) => { ) => {
state.dirty = true; state.dirty = true;
state.startDate = action.payload.startDate; state.startDate = formatISO(startOfDay(action.payload.startDate));
state.endDate = action.payload.endDate; state.endDate = formatISO(endOfDay(action.payload.endDate));
if (isSameDay(state.startDate, state.endDate)) { if (isSameDay(state.startDate, state.endDate)) {
state.interval = 'hour'; state.interval = 'hour';
@@ -203,7 +209,7 @@ export const reportSlice = createSlice({
// Date range // Date range
changeStartDate: (state, action: PayloadAction<string>) => { changeStartDate: (state, action: PayloadAction<string>) => {
state.dirty = true; state.dirty = true;
state.startDate = action.payload; state.startDate = formatISO(startOfDay(action.payload));
const interval = getDefaultIntervalByDates( const interval = getDefaultIntervalByDates(
state.startDate, state.startDate,
@@ -217,7 +223,7 @@ export const reportSlice = createSlice({
// Date range // Date range
changeEndDate: (state, action: PayloadAction<string>) => { changeEndDate: (state, action: PayloadAction<string>) => {
state.dirty = true; state.dirty = true;
state.endDate = action.payload; state.endDate = formatISO(endOfDay(action.payload));
const interval = getDefaultIntervalByDates( const interval = getDefaultIntervalByDates(
state.startDate, state.startDate,

View File

@@ -5,3 +5,41 @@ export function getTime(date: string | number | Date) {
export function toISOString(date: string | number | Date) { export function toISOString(date: string | number | Date) {
return new Date(date).toISOString(); return new Date(date).toISOString();
} }
export function getTimezoneFromDateString(_date: string) {
const mapper: Record<string, string> = {
'+00:00': 'UTC',
'+01:00': 'Europe/Paris',
'+02:00': 'Europe/Stockholm',
'+03:00': 'Europe/Moscow',
'+04:00': 'Asia/Dubai',
'+05:00': 'Asia/Karachi',
'+06:00': 'Asia/Dhaka',
'+07:00': 'Asia/Bangkok',
'+08:00': 'Asia/Shanghai',
'+09:00': 'Asia/Tokyo',
'+10:00': 'Australia/Sydney',
'+11:00': 'Pacific/Noumea',
'+12:00': 'Pacific/Fiji',
'-02:00': 'America/Noronha',
'-03:00': 'America/Sao_Paulo',
'-04:00': 'America/Santiago',
'-05:00': 'America/Bogota',
'-06:00': 'America/Mexico_City',
'-07:00': 'America/Phoenix',
'-08:00': 'America/Los_Angeles',
'-09:00': 'America/Anchorage',
'-10:00': 'Pacific/Honolulu',
'-11:00': 'Pacific/Midway',
'-12:00': 'Pacific/Tarawa',
};
const defaultTimezone = 'UTC';
const match = _date.match(/([+-][0-9]{2}):([0-9]{2})$/)?.[0];
if (match) {
return mapper[match] ?? defaultTimezone;
}
return defaultTimezone;
}

View File

@@ -15,7 +15,7 @@ export const timeWindows = {
}, },
today: { today: {
key: 'today', key: 'today',
label: 'Today', label: '24 hours',
shortcut: 'D', shortcut: 'D',
}, },
'7d': { '7d': {

View File

@@ -15,6 +15,7 @@ export const ch = createClient({
export async function chQueryWithMeta<T extends Record<string, any>>( export async function chQueryWithMeta<T extends Record<string, any>>(
query: string query: string
): Promise<ResponseJSON<T>> { ): Promise<ResponseJSON<T>> {
console.log('Query:', query);
const start = Date.now(); const start = Date.now();
const res = await ch.query({ const res = await ch.query({
query, query,
@@ -39,7 +40,6 @@ export async function chQueryWithMeta<T extends Record<string, any>>(
console.log(`Clickhouse query took ${response.statistics?.elapsed}ms`); console.log(`Clickhouse query took ${response.statistics?.elapsed}ms`);
console.log(`chQuery took ${Date.now() - start}ms`); console.log(`chQuery took ${Date.now() - start}ms`);
console.log('Query:', query);
return response; return response;
} }
@@ -52,7 +52,11 @@ export async function chQuery<T extends Record<string, any>>(
export function formatClickhouseDate(_date: Date | string) { export function formatClickhouseDate(_date: Date | string) {
const date = typeof _date === 'string' ? new Date(_date) : _date; const date = typeof _date === 'string' ? new Date(_date) : _date;
return date.toISOString().replace('T', ' ').replace(/Z+$/, ''); return date
.toISOString()
.replace('T', ' ')
.replace(/Z+$/, '')
.replace(/\.[0-9]+$/, '');
} }
export function convertClickhouseDateToJs(date: string) { export function convertClickhouseDateToJs(date: string) {

View File

@@ -1,5 +1,6 @@
import { escape } from 'sqlstring'; import { escape } from 'sqlstring';
import { getTimezoneFromDateString } from '@openpanel/common';
import type { import type {
IChartEventFilter, IChartEventFilter,
IGetChartDataInput, IGetChartDataInput,
@@ -21,32 +22,40 @@ export function getChartSql({
sb.where = getEventFiltersWhereClause(event.filters); sb.where = getEventFiltersWhereClause(event.filters);
sb.where.projectId = `project_id = ${escape(projectId)}`; sb.where.projectId = `project_id = ${escape(projectId)}`;
let labelValue = escape('*');
if (event.name !== '*') { if (event.name !== '*') {
sb.select.label = `${escape(event.name)} as label`; labelValue = `${escape(event.name)}`;
sb.where.eventName = `name = ${escape(event.name)}`; sb.select.label = `${labelValue} as label`;
sb.where.eventName = `name = ${labelValue}`;
} else {
sb.select.label = `${labelValue} as label`;
} }
sb.select.count = `count(*) as count`; sb.select.count = `count(*) as count`;
switch (interval) { switch (interval) {
case 'minute': { case 'minute': {
sb.select.date = `toStartOfMinute(created_at) as date`; sb.select.date = `toStartOfMinute(toTimeZone(created_at, '${getTimezoneFromDateString(startDate)}')) as date`;
sb.orderBy.date = `date ASC WITH FILL FROM toStartOfMinute(toTimeZone(toDateTime('${formatClickhouseDate(startDate)}'), '${getTimezoneFromDateString(startDate)}')) TO toStartOfMinute(toTimeZone(toDateTime('${formatClickhouseDate(endDate)}'), '${getTimezoneFromDateString(startDate)}')) STEP toIntervalMinute(1) INTERPOLATE ( label as ${labelValue} )`;
break; break;
} }
case 'hour': { case 'hour': {
sb.select.date = `toStartOfHour(created_at) as date`; sb.select.date = `toStartOfHour(toTimeZone(created_at, '${getTimezoneFromDateString(startDate)}')) as date`;
sb.orderBy.date = `date ASC WITH FILL FROM toStartOfHour(toTimeZone(toDateTime('${formatClickhouseDate(startDate)}'), '${getTimezoneFromDateString(startDate)}')) TO toStartOfHour(toTimeZone(toDateTime('${formatClickhouseDate(endDate)}'), '${getTimezoneFromDateString(startDate)}')) STEP toIntervalHour(1) INTERPOLATE ( label as ${labelValue} )`;
break; break;
} }
case 'day': { case 'day': {
sb.select.date = `toStartOfDay(created_at) as date`; sb.select.date = `toStartOfDay(toTimeZone(created_at, '${getTimezoneFromDateString(startDate)}')) as date`;
sb.orderBy.date = `date ASC WITH FILL FROM toStartOfDay(toTimeZone(toDateTime('${formatClickhouseDate(startDate)}'), '${getTimezoneFromDateString(startDate)}')) TO toStartOfDay(toTimeZone(toDateTime('${formatClickhouseDate(endDate)}'), '${getTimezoneFromDateString(startDate)}')) STEP toIntervalDay(1) INTERPOLATE ( label as ${labelValue} )`;
break; break;
} }
case 'month': { case 'month': {
sb.select.date = `toStartOfMonth(created_at) as date`; sb.select.date = `toStartOfMonth(toTimeZone(created_at, '${getTimezoneFromDateString(startDate)}')) as date`;
sb.orderBy.date = `date ASC WITH FILL FROM toStartOfMonth(toTimeZone(toDateTime('${formatClickhouseDate(startDate)}'), '${getTimezoneFromDateString(startDate)}')) TO toStartOfMonth(toTimeZone(toDateTime('${formatClickhouseDate(endDate)}'), '${getTimezoneFromDateString(startDate)}')) STEP toIntervalMonth(1) INTERPOLATE ( label as ${labelValue} )`;
break; break;
} }
} }
sb.groupBy.date = 'date'; sb.groupBy.date = 'date';
sb.orderBy.date = 'date ASC';
if (startDate) { if (startDate) {
sb.where.startDate = `created_at >= '${formatClickhouseDate(startDate)}'`; sb.where.startDate = `created_at >= '${formatClickhouseDate(startDate)}'`;

View File

@@ -1,10 +1,14 @@
import { import {
differenceInMilliseconds,
endOfDay, endOfDay,
endOfMonth,
endOfYear, endOfYear,
formatISO,
startOfDay, startOfDay,
startOfMonth, startOfMonth,
startOfYear, startOfYear,
subDays, subDays,
subMilliseconds,
subMinutes, subMinutes,
subMonths, subMonths,
subYears, subYears,
@@ -43,129 +47,6 @@ function getEventLegend(event: IChartEvent) {
return event.displayName ?? `${event.name} (${event.id})`; return event.displayName ?? `${event.name} (${event.id})`;
} }
function fillEmptySpotsInTimeline(
items: ResultItem[],
interval: IInterval,
startDate: string,
endDate: string
) {
const result = [];
const clonedStartDate = new Date(startDate);
const clonedEndDate = new Date(endDate);
const today = new Date();
if (interval === 'minute') {
clonedStartDate.setUTCSeconds(0, 0);
clonedEndDate.setUTCMinutes(clonedEndDate.getUTCMinutes() + 1, 0, 0);
} else if (interval === 'hour') {
clonedStartDate.setUTCMinutes(0, 0, 0);
clonedEndDate.setUTCMinutes(0, 0, 0);
} else {
clonedStartDate.setUTCHours(0, 0, 0, 0);
clonedEndDate.setUTCHours(0, 0, 0, 0);
}
if (interval === 'month') {
clonedStartDate.setUTCDate(1);
clonedEndDate.setUTCDate(1);
}
// Force if interval is month and the start date is the same month as today
const shouldForce = () =>
interval === 'month' &&
clonedStartDate.getUTCFullYear() === today.getUTCFullYear() &&
clonedStartDate.getUTCMonth() === today.getUTCMonth();
let prev = undefined;
while (
shouldForce() ||
clonedStartDate.getTime() <= clonedEndDate.getTime()
) {
if (prev === clonedStartDate.getTime()) {
break;
}
prev = clonedStartDate.getTime();
const getYear = (date: Date) => date.getUTCFullYear();
const getMonth = (date: Date) => date.getUTCMonth();
const getDay = (date: Date) => date.getUTCDate();
const getHour = (date: Date) => date.getUTCHours();
const getMinute = (date: Date) => date.getUTCMinutes();
const item = items.find((item) => {
const date = convertClickhouseDateToJs(item.date);
if (interval === 'month') {
return (
getYear(date) === getYear(clonedStartDate) &&
getMonth(date) === getMonth(clonedStartDate)
);
}
if (interval === 'day') {
return (
getYear(date) === getYear(clonedStartDate) &&
getMonth(date) === getMonth(clonedStartDate) &&
getDay(date) === getDay(clonedStartDate)
);
}
if (interval === 'hour') {
return (
getYear(date) === getYear(clonedStartDate) &&
getMonth(date) === getMonth(clonedStartDate) &&
getDay(date) === getDay(clonedStartDate) &&
getHour(date) === getHour(clonedStartDate)
);
}
if (interval === 'minute') {
return (
getYear(date) === getYear(clonedStartDate) &&
getMonth(date) === getMonth(clonedStartDate) &&
getDay(date) === getDay(clonedStartDate) &&
getHour(date) === getHour(clonedStartDate) &&
getMinute(date) === getMinute(clonedStartDate)
);
}
return false;
});
if (item) {
result.push({
...item,
date: clonedStartDate.toISOString(),
});
} else {
result.push({
date: clonedStartDate.toISOString(),
count: 0,
label: null,
});
}
switch (interval) {
case 'day': {
clonedStartDate.setUTCDate(clonedStartDate.getUTCDate() + 1);
break;
}
case 'hour': {
clonedStartDate.setUTCHours(clonedStartDate.getUTCHours() + 1);
break;
}
case 'minute': {
clonedStartDate.setUTCMinutes(clonedStartDate.getUTCMinutes() + 1);
break;
}
case 'month': {
clonedStartDate.setUTCMonth(clonedStartDate.getUTCMonth() + 1);
break;
}
}
}
return sort(function (a, b) {
return new Date(a.date).getTime() - new Date(b.date).getTime();
}, result);
}
export function withFormula( export function withFormula(
{ formula, events }: IChartInput, { formula, events }: IChartInput,
series: GetChartDataResult series: GetChartDataResult
@@ -235,6 +116,26 @@ export function withFormula(
]; ];
} }
const toDynamicISODateWithTZ = (
date: string,
blueprint: string,
interval: IInterval
) => {
// If we have a space in the date we know it's a date with time
if (date.includes(' ')) {
// If interval is minutes we need to convert the timezone to what timezone is used (either on client or the server)
// - We use timezone from server if its a predefined range (yearToDate, lastYear, etc.)
// - We use timezone from client if its a custom range
if (interval === 'minute' || interval === 'hour') {
return date.replace(' ', 'T') + blueprint.slice(-6);
}
// Otherwise we just return without the timezone
// It will be converted to the correct timezone on the client
return date.replace(' ', 'T');
}
return `${date}T00:00:00`;
};
export async function getChartData(payload: IGetChartDataInput) { export async function getChartData(payload: IGetChartDataInput) {
let result = await chQuery<ResultItem>(getChartSql(payload)); let result = await chQuery<ResultItem>(getChartSql(payload));
@@ -250,9 +151,15 @@ export async function getChartData(payload: IGetChartDataInput) {
// group by sql label // group by sql label
const series = result.reduce( const series = result.reduce(
(acc, item) => { (acc, item) => {
// If we fill empty spots in the timeline (clickhouse) we wont get a label back
// take the event name as label
if (!item.label && item.count === 0) {
item.label = payload.event.name;
}
const label = item.label?.trim() || NOT_SET_VALUE;
// item.label can be null when using breakdowns on a property // item.label can be null when using breakdowns on a property
// that doesn't exist on all events // that doesn't exist on all events
const label = item.label?.trim() || NOT_SET_VALUE;
if (label) { if (label) {
if (acc[label]) { if (acc[label]) {
acc[label]?.push(item); acc[label]?.push(item);
@@ -281,22 +188,25 @@ export async function getChartData(payload: IGetChartDataInput) {
payload.chartType === 'metric' || payload.chartType === 'metric' ||
payload.chartType === 'pie' || payload.chartType === 'pie' ||
payload.chartType === 'bar' payload.chartType === 'bar'
? fillEmptySpotsInTimeline( ? (series[key] ?? []).map((item) => {
series[key] ?? [],
payload.interval,
payload.startDate,
payload.endDate
).map((item) => {
return { return {
label: serieName, label: serieName,
count: item.count ? round(item.count) : null, count: item.count ? round(item.count) : null,
date: new Date(item.date).toISOString(), date: toDynamicISODateWithTZ(
item.date,
payload.startDate,
payload.interval
),
}; };
}) })
: (series[key] ?? []).map((item) => ({ : (series[key] ?? []).map((item) => ({
label: item.label, label: item.label,
count: item.count ? round(item.count) : null, count: item.count ? round(item.count) : null,
date: new Date(item.date).toISOString(), date: toDynamicISODateWithTZ(
item.date,
payload.startDate,
payload.interval
),
})); }));
return { return {
@@ -310,8 +220,8 @@ export async function getChartData(payload: IGetChartDataInput) {
export function getDatesFromRange(range: IChartRange) { export function getDatesFromRange(range: IChartRange) {
if (range === '30min' || range === 'lastHour') { if (range === '30min' || range === 'lastHour') {
const minutes = range === '30min' ? 30 : 60; const minutes = range === '30min' ? 30 : 60;
const startDate = subMinutes(new Date(), minutes).toUTCString(); const startDate = formatISO(subMinutes(new Date(), minutes));
const endDate = new Date().toUTCString(); const endDate = formatISO(new Date());
return { return {
startDate, startDate,
@@ -320,18 +230,22 @@ export function getDatesFromRange(range: IChartRange) {
} }
if (range === 'today') { if (range === 'today') {
const startDate = startOfDay(new Date()); // This is last 24 hours instead
const endDate = endOfDay(new Date()); // Makes it easier to handle timezones
// const startDate = startOfDay(new Date());
// const endDate = endOfDay(new Date());
const startDate = subDays(new Date(), 1);
const endDate = new Date();
return { return {
startDate: startDate.toUTCString(), startDate: formatISO(startDate),
endDate: endDate.toUTCString(), endDate: formatISO(endDate),
}; };
} }
if (range === '7d') { if (range === '7d') {
const startDate = subDays(new Date(), 7).toUTCString(); const startDate = formatISO(subDays(new Date(), 7));
const endDate = new Date().toUTCString(); const endDate = formatISO(new Date());
return { return {
startDate, startDate,
@@ -340,8 +254,8 @@ export function getDatesFromRange(range: IChartRange) {
} }
if (range === '30d') { if (range === '30d') {
const startDate = subDays(new Date(), 30).toUTCString(); const startDate = formatISO(subDays(new Date(), 30));
const endDate = new Date().toUTCString(); const endDate = formatISO(new Date());
return { return {
startDate, startDate,
@@ -350,8 +264,8 @@ export function getDatesFromRange(range: IChartRange) {
} }
if (range === 'monthToDate') { if (range === 'monthToDate') {
const startDate = startOfMonth(new Date()).toUTCString(); const startDate = formatISO(startOfMonth(new Date()));
const endDate = new Date().toUTCString(); const endDate = formatISO(new Date());
return { return {
startDate, startDate,
@@ -361,8 +275,8 @@ export function getDatesFromRange(range: IChartRange) {
if (range === 'lastMonth') { if (range === 'lastMonth') {
const month = subMonths(new Date(), 1); const month = subMonths(new Date(), 1);
const startDate = startOfMonth(month).toUTCString(); const startDate = formatISO(startOfMonth(month));
const endDate = endOfDay(month).toUTCString(); const endDate = formatISO(endOfMonth(month));
return { return {
startDate, startDate,
@@ -371,8 +285,8 @@ export function getDatesFromRange(range: IChartRange) {
} }
if (range === 'yearToDate') { if (range === 'yearToDate') {
const startDate = startOfYear(new Date()).toUTCString(); const startDate = formatISO(startOfYear(new Date()));
const endDate = new Date().toUTCString(); const endDate = formatISO(new Date());
return { return {
startDate, startDate,
@@ -382,8 +296,8 @@ export function getDatesFromRange(range: IChartRange) {
if (range === 'lastYear') { if (range === 'lastYear') {
const year = subYears(new Date(), 1); const year = subYears(new Date(), 1);
const startDate = startOfYear(year).toUTCString(); const startDate = formatISO(startOfYear(year));
const endDate = endOfYear(year).toUTCString(); const endDate = formatISO(endOfYear(year));
return { return {
startDate, startDate,
@@ -391,20 +305,9 @@ export function getDatesFromRange(range: IChartRange) {
}; };
} }
console.log('-------------------------------');
console.log('-------------------------------');
console.log('-------------------------------');
console.log('-------------------------------');
console.log('-------------------------------');
console.log('-------------------------------');
console.log('-------------------------------');
console.log('-------------------------------');
console.log('-------------------------------');
console.log('-------------------------------');
console.log('-------------------------------');
return { return {
startDate: subDays(new Date(), 30).toISOString(), startDate: formatISO(subDays(new Date(), 30)),
endDate: new Date().toISOString(), endDate: formatISO(new Date()),
}; };
} }
@@ -421,16 +324,15 @@ export function getChartStartEndDate({
export function getChartPrevStartEndDate({ export function getChartPrevStartEndDate({
startDate, startDate,
endDate, endDate,
range,
}: { }: {
startDate: string; startDate: string;
endDate: string; endDate: string;
range: IChartRange; range: IChartRange;
}) { }) {
const diff = new Date(endDate).getTime() - new Date(startDate).getTime(); const diff = differenceInMilliseconds(new Date(endDate), new Date(startDate));
return { return {
startDate: new Date(new Date(startDate).getTime() - diff).toISOString(), startDate: formatISO(subMilliseconds(new Date(startDate), diff - 1)),
endDate: new Date(new Date(endDate).getTime() - diff).toISOString(), endDate: formatISO(subMilliseconds(new Date(endDate), diff - 1)),
}; };
} }