fix: default last/first seen broken when clickhouse defaults to 1970
This commit is contained in:
@@ -1,23 +1,16 @@
|
|||||||
import { fancyMinutes, useNumber } from '@/hooks/use-numer-formatter';
|
|
||||||
import { cn } from '@/utils/cn';
|
|
||||||
import AutoSizer from 'react-virtualized-auto-sizer';
|
|
||||||
import { Area, AreaChart, Bar, BarChart, Tooltip } from 'recharts';
|
|
||||||
|
|
||||||
import { formatDate, timeAgo } from '@/utils/date';
|
|
||||||
import { getChartColor } from '@/utils/theme';
|
|
||||||
import { getPreviousMetric } from '@openpanel/common';
|
import { getPreviousMetric } from '@openpanel/common';
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||||
|
import { Bar, BarChart, Tooltip } from 'recharts';
|
||||||
import {
|
import {
|
||||||
ChartTooltipContainer,
|
|
||||||
ChartTooltipHeader,
|
|
||||||
ChartTooltipItem,
|
|
||||||
} from '../charts/chart-tooltip';
|
|
||||||
import {
|
|
||||||
PreviousDiffIndicatorPure,
|
|
||||||
getDiffIndicator,
|
getDiffIndicator,
|
||||||
|
PreviousDiffIndicatorPure,
|
||||||
} from '../report-chart/common/previous-diff-indicator';
|
} from '../report-chart/common/previous-diff-indicator';
|
||||||
import { Skeleton } from '../skeleton';
|
import { Skeleton } from '../skeleton';
|
||||||
import { Tooltiper } from '../ui/tooltip';
|
import { Tooltiper } from '../ui/tooltip';
|
||||||
|
import { fancyMinutes, useNumber } from '@/hooks/use-numer-formatter';
|
||||||
|
import { cn } from '@/utils/cn';
|
||||||
|
import { formatDate, timeAgo } from '@/utils/date';
|
||||||
|
|
||||||
interface MetricCardProps {
|
interface MetricCardProps {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -78,6 +71,9 @@ export function OverviewMetricCard({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (unit === 'timeAgo') {
|
if (unit === 'timeAgo') {
|
||||||
|
if (!value) {
|
||||||
|
return <>{'N/A'}</>;
|
||||||
|
}
|
||||||
return <>{timeAgo(new Date(value))}</>;
|
return <>{timeAgo(new Date(value))}</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,7 +99,7 @@ export function OverviewMetricCard({
|
|||||||
getPreviousMetric(current, previous)?.state,
|
getPreviousMetric(current, previous)?.state,
|
||||||
'#6ee7b7', // green
|
'#6ee7b7', // green
|
||||||
'#fda4af', // red
|
'#fda4af', // red
|
||||||
'#93c5fd', // blue
|
'#93c5fd' // blue
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderTooltip = () => {
|
const renderTooltip = () => {
|
||||||
@@ -115,7 +111,7 @@ export function OverviewMetricCard({
|
|||||||
{renderValue(
|
{renderValue(
|
||||||
data[currentIndex].current,
|
data[currentIndex].current,
|
||||||
'ml-1 font-light text-xl',
|
'ml-1 font-light text-xl',
|
||||||
false,
|
false
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
@@ -132,60 +128,60 @@ export function OverviewMetricCard({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<Tooltiper content={renderTooltip()} asChild sideOffset={-20}>
|
<Tooltiper asChild content={renderTooltip()} sideOffset={-20}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
|
||||||
className={cn(
|
className={cn(
|
||||||
'col-span-2 flex-1 shadow-[0_0_0_0.5px] shadow-border md:col-span-1',
|
'col-span-2 flex-1 shadow-[0_0_0_0.5px] shadow-border md:col-span-1',
|
||||||
active && 'bg-def-100',
|
active && 'bg-def-100'
|
||||||
)}
|
)}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
|
type="button"
|
||||||
>
|
>
|
||||||
<div className={cn('group relative p-4')}>
|
<div className={cn('group relative p-4')}>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'absolute left-4 right-4 bottom-0 z-0 opacity-50 transition-opacity duration-300 group-hover:opacity-100',
|
'absolute right-4 bottom-0 left-4 z-0 opacity-50 transition-opacity duration-300 group-hover:opacity-100'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<AutoSizer style={{ height: 20 }}>
|
<AutoSizer style={{ height: 20 }}>
|
||||||
{({ width }) => (
|
{({ width }) => (
|
||||||
<BarChart
|
<BarChart
|
||||||
width={width}
|
|
||||||
height={20}
|
|
||||||
data={data}
|
data={data}
|
||||||
style={{
|
height={20}
|
||||||
background: 'transparent',
|
margin={{ top: 0, right: 0, left: 0, bottom: 0 }}
|
||||||
}}
|
|
||||||
onMouseMove={(event) => {
|
onMouseMove={(event) => {
|
||||||
setCurrentIndex(event.activeTooltipIndex ?? null);
|
setCurrentIndex(event.activeTooltipIndex ?? null);
|
||||||
}}
|
}}
|
||||||
margin={{ top: 0, right: 0, left: 0, bottom: 0 }}
|
style={{
|
||||||
|
background: 'transparent',
|
||||||
|
}}
|
||||||
|
width={width}
|
||||||
>
|
>
|
||||||
<Tooltip content={() => null} cursor={false} />
|
<Tooltip content={() => null} cursor={false} />
|
||||||
<Bar
|
<Bar
|
||||||
dataKey={'current'}
|
dataKey={'current'}
|
||||||
type="step"
|
|
||||||
fill={graphColors}
|
fill={graphColors}
|
||||||
fillOpacity={1}
|
fillOpacity={1}
|
||||||
strokeWidth={0}
|
|
||||||
isAnimationActive={false}
|
isAnimationActive={false}
|
||||||
|
strokeWidth={0}
|
||||||
|
type="step"
|
||||||
/>
|
/>
|
||||||
</BarChart>
|
</BarChart>
|
||||||
)}
|
)}
|
||||||
</AutoSizer>
|
</AutoSizer>
|
||||||
</div>
|
</div>
|
||||||
<OverviewMetricCardNumber
|
<OverviewMetricCardNumber
|
||||||
label={label}
|
|
||||||
value={renderValue(current, 'ml-1 font-light text-xl')}
|
|
||||||
enhancer={
|
enhancer={
|
||||||
<PreviousDiffIndicatorPure
|
<PreviousDiffIndicatorPure
|
||||||
className="text-sm"
|
className="text-sm"
|
||||||
size="sm"
|
|
||||||
inverted={inverted}
|
inverted={inverted}
|
||||||
|
size="sm"
|
||||||
{...getPreviousMetric(current, previous)}
|
{...getPreviousMetric(current, previous)}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
|
label={label}
|
||||||
|
value={renderValue(current, 'ml-1 font-light text-xl')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
@@ -207,9 +203,9 @@ export function OverviewMetricCardNumber({
|
|||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className={cn('min-w-0 col gap-2', className)}>
|
<div className={cn('col min-w-0 gap-2', className)}>
|
||||||
<div className="flex min-w-0 items-center gap-2 text-left">
|
<div className="flex min-w-0 items-center gap-2 text-left">
|
||||||
<span className="truncate text-sm font-medium text-muted-foreground leading-[1.1]">
|
<span className="truncate font-medium text-muted-foreground text-sm leading-[1.1]">
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -219,11 +215,11 @@ export function OverviewMetricCardNumber({
|
|||||||
<Skeleton className="h-6 w-12" />
|
<Skeleton className="h-6 w-12" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="truncate font-mono text-3xl leading-[1.1] font-bold w-full text-left">
|
<div className="w-full truncate text-left font-bold font-mono text-3xl leading-[1.1]">
|
||||||
{value}
|
{value}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="absolute right-0 top-0 bottom-0 center justify-center col pr-4">
|
<div className="center col absolute top-0 right-0 bottom-0 justify-center pr-4">
|
||||||
{enhancer}
|
{enhancer}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { OverviewMetricCard } from '@/components/overview/overview-metric-card';
|
|
||||||
|
|
||||||
import type { IProfileMetrics } from '@openpanel/db';
|
import type { IProfileMetrics } from '@openpanel/db';
|
||||||
|
import { OverviewMetricCard } from '@/components/overview/overview-metric-card';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
data: IProfileMetrics;
|
data: IProfileMetrics;
|
||||||
@@ -102,7 +101,7 @@ const PROFILE_METRICS = [
|
|||||||
|
|
||||||
export const ProfileMetrics = ({ data }: Props) => {
|
export const ProfileMetrics = ({ data }: Props) => {
|
||||||
return (
|
return (
|
||||||
<div className="relative col-span-6 -m-4 mb-0 mt-0 md:m-0">
|
<div className="relative col-span-6 -m-4 mt-0 mb-0 md:m-0">
|
||||||
<div className="card grid grid-cols-2 overflow-hidden rounded-md md:grid-cols-4 lg:grid-cols-6">
|
<div className="card grid grid-cols-2 overflow-hidden rounded-md md:grid-cols-4 lg:grid-cols-6">
|
||||||
{PROFILE_METRICS.filter((metric) => {
|
{PROFILE_METRICS.filter((metric) => {
|
||||||
if (metric.hideOnZero && data[metric.key] === 0) {
|
if (metric.hideOnZero && data[metric.key] === 0) {
|
||||||
@@ -111,20 +110,20 @@ export const ProfileMetrics = ({ data }: Props) => {
|
|||||||
return true;
|
return true;
|
||||||
}).map((metric) => (
|
}).map((metric) => (
|
||||||
<OverviewMetricCard
|
<OverviewMetricCard
|
||||||
key={metric.key}
|
data={[]}
|
||||||
id={metric.key}
|
id={metric.key}
|
||||||
|
inverted={metric.inverted}
|
||||||
|
isLoading={false}
|
||||||
|
key={metric.key}
|
||||||
label={metric.title}
|
label={metric.title}
|
||||||
metric={{
|
metric={{
|
||||||
current:
|
current:
|
||||||
metric.unit === 'timeAgo'
|
metric.unit === 'timeAgo' && data[metric.key]
|
||||||
? new Date(data[metric.key]).getTime()
|
? new Date(data[metric.key]!).getTime()
|
||||||
: (data[metric.key] as number) || 0,
|
: (data[metric.key] as number) || 0,
|
||||||
previous: null,
|
previous: null,
|
||||||
}}
|
}}
|
||||||
unit={metric.unit}
|
unit={metric.unit}
|
||||||
data={[]}
|
|
||||||
inverted={metric.inverted}
|
|
||||||
isLoading={false}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { DateTime } from 'luxon';
|
export { DateTime } from 'luxon';
|
||||||
|
|
||||||
export { DateTime };
|
export type { DateTime };
|
||||||
|
|
||||||
export function getTime(date: string | number | Date) {
|
export function getTime(date: string | number | Date) {
|
||||||
return new Date(date).getTime();
|
return new Date(date).getTime();
|
||||||
|
|||||||
@@ -290,3 +290,8 @@ export function toDate(str: string, interval?: IInterval) {
|
|||||||
export function convertClickhouseDateToJs(date: string) {
|
export function convertClickhouseDateToJs(date: string) {
|
||||||
return new Date(`${date.replace(' ', 'T')}Z`);
|
return new Date(`${date.replace(' ', 'T')}Z`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ROLLUP_DATE_PREFIX = '1970-01-01';
|
||||||
|
export function isClickhouseDefaultMinDate(date: string): boolean {
|
||||||
|
return date.startsWith(ROLLUP_DATE_PREFIX) || date.startsWith('1969-12-31');
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
import { average, sum } from '@openpanel/common';
|
import { average, sum } from '@openpanel/common';
|
||||||
import { chartColors } from '@openpanel/constants';
|
import { chartColors } from '@openpanel/constants';
|
||||||
import { getCache } from '@openpanel/redis';
|
|
||||||
import { type IChartEventFilter, zTimeInterval } from '@openpanel/validation';
|
import { type IChartEventFilter, zTimeInterval } from '@openpanel/validation';
|
||||||
import { omit } from 'ramda';
|
|
||||||
import sqlstring from 'sqlstring';
|
import sqlstring from 'sqlstring';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import {
|
import {
|
||||||
TABLE_NAMES,
|
|
||||||
ch,
|
ch,
|
||||||
convertClickhouseDateToJs,
|
convertClickhouseDateToJs,
|
||||||
|
isClickhouseDefaultMinDate,
|
||||||
|
TABLE_NAMES,
|
||||||
} from '../clickhouse/client';
|
} from '../clickhouse/client';
|
||||||
import { clix } from '../clickhouse/query-builder';
|
import { clix } from '../clickhouse/query-builder';
|
||||||
import {
|
import {
|
||||||
@@ -172,24 +171,12 @@ export type IGetMapDataInput = z.infer<typeof zGetMapDataInput> & {
|
|||||||
export class OverviewService {
|
export class OverviewService {
|
||||||
constructor(private client: typeof ch) {}
|
constructor(private client: typeof ch) {}
|
||||||
|
|
||||||
// Helper methods
|
|
||||||
private isRollupRow(date: string): boolean {
|
|
||||||
// The rollup row has date 1970-01-01 00:00:00 (epoch) from ClickHouse.
|
|
||||||
// After transform with `new Date().toISOString()`, this becomes an ISO string.
|
|
||||||
// Due to timezone handling in JavaScript's Date constructor (which interprets
|
|
||||||
// the input as local time), the UTC date might become:
|
|
||||||
// - 1969-12-31T... for positive UTC offsets (e.g., UTC+8)
|
|
||||||
// - 1970-01-01T... for UTC or negative offsets
|
|
||||||
// We check for both year prefixes to handle all server timezones.
|
|
||||||
return date.startsWith(ROLLUP_DATE_PREFIX) || date.startsWith('1969-12-31');
|
|
||||||
}
|
|
||||||
|
|
||||||
private getFillConfig(interval: string, startDate: string, endDate: string) {
|
private getFillConfig(interval: string, startDate: string, endDate: string) {
|
||||||
const useDateOnly = ['month', 'week'].includes(interval);
|
const useDateOnly = ['month', 'week'].includes(interval);
|
||||||
return {
|
return {
|
||||||
from: clix.toStartOf(
|
from: clix.toStartOf(
|
||||||
clix.datetime(startDate, useDateOnly ? 'toDate' : 'toDateTime'),
|
clix.datetime(startDate, useDateOnly ? 'toDate' : 'toDateTime'),
|
||||||
interval as any,
|
interval as any
|
||||||
),
|
),
|
||||||
to: clix.datetime(endDate, useDateOnly ? 'toDate' : 'toDateTime'),
|
to: clix.datetime(endDate, useDateOnly ? 'toDate' : 'toDateTime'),
|
||||||
step: clix.toInterval('1', interval as any),
|
step: clix.toInterval('1', interval as any),
|
||||||
@@ -234,12 +221,12 @@ export class OverviewService {
|
|||||||
|
|
||||||
private mergeRevenueIntoSeries<T extends { date: string }>(
|
private mergeRevenueIntoSeries<T extends { date: string }>(
|
||||||
series: T[],
|
series: T[],
|
||||||
revenueData: { date: string; total_revenue: number }[],
|
revenueData: { date: string; total_revenue: number }[]
|
||||||
): (T & { total_revenue: number })[] {
|
): (T & { total_revenue: number })[] {
|
||||||
const revenueByDate = new Map(
|
const revenueByDate = new Map(
|
||||||
revenueData
|
revenueData
|
||||||
.filter((r) => !this.isRollupRow(r.date))
|
.filter((r) => !isClickhouseDefaultMinDate(r.date))
|
||||||
.map((r) => [r.date, r.total_revenue]),
|
.map((r) => [r.date, r.total_revenue])
|
||||||
);
|
);
|
||||||
return series.map((row) => ({
|
return series.map((row) => ({
|
||||||
...row,
|
...row,
|
||||||
@@ -248,10 +235,11 @@ export class OverviewService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private getOverallRevenue(
|
private getOverallRevenue(
|
||||||
revenueData: { date: string; total_revenue: number }[],
|
revenueData: { date: string; total_revenue: number }[]
|
||||||
): number {
|
): number {
|
||||||
return (
|
return (
|
||||||
revenueData.find((r) => this.isRollupRow(r.date))?.total_revenue ?? 0
|
revenueData.find((r) => isClickhouseDefaultMinDate(r.date))
|
||||||
|
?.total_revenue ?? 0
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -263,7 +251,7 @@ export class OverviewService {
|
|||||||
startDate: string;
|
startDate: string;
|
||||||
endDate: string;
|
endDate: string;
|
||||||
timezone: string;
|
timezone: string;
|
||||||
},
|
}
|
||||||
): ReturnType<typeof clix> {
|
): ReturnType<typeof clix> {
|
||||||
if (!this.isPageFilter(params.filters)) {
|
if (!this.isPageFilter(params.filters)) {
|
||||||
query.rawWhere(this.getRawWhereClause('sessions', params.filters));
|
query.rawWhere(this.getRawWhereClause('sessions', params.filters));
|
||||||
@@ -276,7 +264,7 @@ export class OverviewService {
|
|||||||
.where(
|
.where(
|
||||||
'id',
|
'id',
|
||||||
'IN',
|
'IN',
|
||||||
clix.exp('(SELECT session_id FROM distinct_sessions)'),
|
clix.exp('(SELECT session_id FROM distinct_sessions)')
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -475,14 +463,14 @@ export class OverviewService {
|
|||||||
clix(this.client, timezone)
|
clix(this.client, timezone)
|
||||||
.select(['bounce_rate'])
|
.select(['bounce_rate'])
|
||||||
.from('session_agg')
|
.from('session_agg')
|
||||||
.where('date', '=', rollupDate),
|
.where('date', '=', rollupDate)
|
||||||
)
|
)
|
||||||
.with(
|
.with(
|
||||||
'daily_session_stats',
|
'daily_session_stats',
|
||||||
clix(this.client, timezone)
|
clix(this.client, timezone)
|
||||||
.select(['date', 'bounce_rate'])
|
.select(['date', 'bounce_rate'])
|
||||||
.from('session_agg')
|
.from('session_agg')
|
||||||
.where('date', '!=', rollupDate),
|
.where('date', '!=', rollupDate)
|
||||||
)
|
)
|
||||||
.with('overall_unique_visitors', overallUniqueVisitorsQuery)
|
.with('overall_unique_visitors', overallUniqueVisitorsQuery)
|
||||||
.select<{
|
.select<{
|
||||||
@@ -512,7 +500,7 @@ export class OverviewService {
|
|||||||
.from(`${TABLE_NAMES.events} AS e`)
|
.from(`${TABLE_NAMES.events} AS e`)
|
||||||
.leftJoin(
|
.leftJoin(
|
||||||
'daily_session_stats AS dss',
|
'daily_session_stats AS dss',
|
||||||
`${clix.toStartOf('e.created_at', interval as any)} = dss.date`,
|
`${clix.toStartOf('e.created_at', interval as any)} = dss.date`
|
||||||
)
|
)
|
||||||
.where('e.project_id', '=', projectId)
|
.where('e.project_id', '=', projectId)
|
||||||
.where('e.name', '=', 'screen_view')
|
.where('e.name', '=', 'screen_view')
|
||||||
@@ -551,7 +539,7 @@ export class OverviewService {
|
|||||||
(item) =>
|
(item) =>
|
||||||
item.overall_bounce_rate !== null ||
|
item.overall_bounce_rate !== null ||
|
||||||
item.overall_total_sessions !== null ||
|
item.overall_total_sessions !== null ||
|
||||||
item.overall_unique_visitors !== null,
|
item.overall_unique_visitors !== null
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -560,11 +548,11 @@ export class OverviewService {
|
|||||||
unique_visitors: anyRowWithData?.overall_unique_visitors ?? 0,
|
unique_visitors: anyRowWithData?.overall_unique_visitors ?? 0,
|
||||||
total_sessions: anyRowWithData?.overall_total_sessions ?? 0,
|
total_sessions: anyRowWithData?.overall_total_sessions ?? 0,
|
||||||
avg_session_duration: average(
|
avg_session_duration: average(
|
||||||
mainRes.map((item) => item.avg_session_duration),
|
mainRes.map((item) => item.avg_session_duration)
|
||||||
),
|
),
|
||||||
total_screen_views: sum(mainRes.map((item) => item.total_screen_views)),
|
total_screen_views: sum(mainRes.map((item) => item.total_screen_views)),
|
||||||
views_per_session: average(
|
views_per_session: average(
|
||||||
mainRes.map((item) => item.views_per_session),
|
mainRes.map((item) => item.views_per_session)
|
||||||
),
|
),
|
||||||
total_revenue: overallRevenue,
|
total_revenue: overallRevenue,
|
||||||
},
|
},
|
||||||
@@ -591,7 +579,7 @@ export class OverviewService {
|
|||||||
return item;
|
return item;
|
||||||
}
|
}
|
||||||
return item;
|
return item;
|
||||||
}),
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
return Object.values(where).join(' AND ');
|
return Object.values(where).join(' AND ');
|
||||||
@@ -879,7 +867,7 @@ export class OverviewService {
|
|||||||
startDate,
|
startDate,
|
||||||
endDate,
|
endDate,
|
||||||
timezone,
|
timezone,
|
||||||
},
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const timeSeriesData = await mainTimeSeriesQuery.execute();
|
const timeSeriesData = await mainTimeSeriesQuery.execute();
|
||||||
@@ -1066,7 +1054,7 @@ export class OverviewService {
|
|||||||
.from('paths_deduped_cte')
|
.from('paths_deduped_cte')
|
||||||
.having('length(paths)', '>=', 2)
|
.having('length(paths)', '>=', 2)
|
||||||
// ONLY sessions starting with top entry pages
|
// ONLY sessions starting with top entry pages
|
||||||
.having('paths[1]', 'IN', topEntryPages),
|
.having('paths[1]', 'IN', topEntryPages)
|
||||||
)
|
)
|
||||||
.select<{
|
.select<{
|
||||||
source: string;
|
source: string;
|
||||||
@@ -1081,8 +1069,8 @@ export class OverviewService {
|
|||||||
])
|
])
|
||||||
.from(
|
.from(
|
||||||
clix.exp(
|
clix.exp(
|
||||||
'(SELECT arrayJoin(arrayMap(i -> (paths[i], paths[i + 1], i), range(1, length(paths)))) as pair FROM session_paths WHERE length(paths) >= 2)',
|
'(SELECT arrayJoin(arrayMap(i -> (paths[i], paths[i + 1], i), range(1, length(paths)))) as pair FROM session_paths WHERE length(paths) >= 2)'
|
||||||
),
|
)
|
||||||
)
|
)
|
||||||
.groupBy(['source', 'target', 'step'])
|
.groupBy(['source', 'target', 'step'])
|
||||||
.orderBy('step', 'ASC')
|
.orderBy('step', 'ASC')
|
||||||
@@ -1143,7 +1131,9 @@ export class OverviewService {
|
|||||||
|
|
||||||
for (const t of fromSource) {
|
for (const t of fromSource) {
|
||||||
// Skip self-loops
|
// Skip self-loops
|
||||||
if (t.source === t.target) continue;
|
if (t.source === t.target) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const targetNodeId = getNodeId(t.target, step + 1);
|
const targetNodeId = getNodeId(t.target, step + 1);
|
||||||
|
|
||||||
@@ -1180,7 +1170,9 @@ export class OverviewService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Stop if no more nodes to process
|
// Stop if no more nodes to process
|
||||||
if (activeNodes.size === 0) break;
|
if (activeNodes.size === 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 5: Filter links by threshold (0.25% of total sessions)
|
// Step 5: Filter links by threshold (0.25% of total sessions)
|
||||||
@@ -1235,22 +1227,24 @@ export class OverviewService {
|
|||||||
})
|
})
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
// Sort by step first, then by value descending
|
// Sort by step first, then by value descending
|
||||||
if (a.step !== b.step) return a.step - b.step;
|
if (a.step !== b.step) {
|
||||||
|
return a.step - b.step;
|
||||||
|
}
|
||||||
return b.value - a.value;
|
return b.value - a.value;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Sanity check: Ensure all link endpoints exist in nodes
|
// Sanity check: Ensure all link endpoints exist in nodes
|
||||||
const nodeIds = new Set(finalNodes.map((n) => n.id));
|
const nodeIds = new Set(finalNodes.map((n) => n.id));
|
||||||
const invalidLinks = filteredLinks.filter(
|
const invalidLinks = filteredLinks.filter(
|
||||||
(link) => !nodeIds.has(link.source) || !nodeIds.has(link.target),
|
(link) => !(nodeIds.has(link.source) && nodeIds.has(link.target))
|
||||||
);
|
);
|
||||||
if (invalidLinks.length > 0) {
|
if (invalidLinks.length > 0) {
|
||||||
console.warn(
|
console.warn(
|
||||||
`UserJourney: Found ${invalidLinks.length} links with missing nodes`,
|
`UserJourney: Found ${invalidLinks.length} links with missing nodes`
|
||||||
);
|
);
|
||||||
// Remove invalid links
|
// Remove invalid links
|
||||||
const validLinks = filteredLinks.filter(
|
const validLinks = filteredLinks.filter(
|
||||||
(link) => nodeIds.has(link.source) && nodeIds.has(link.target),
|
(link) => nodeIds.has(link.source) && nodeIds.has(link.target)
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
nodes: finalNodes,
|
nodes: finalNodes,
|
||||||
@@ -1260,7 +1254,9 @@ export class OverviewService {
|
|||||||
|
|
||||||
// Sanity check: Ensure steps are monotonic (should always be true, but verify)
|
// Sanity check: Ensure steps are monotonic (should always be true, but verify)
|
||||||
const stepsValid = finalNodes.every((node, idx, arr) => {
|
const stepsValid = finalNodes.every((node, idx, arr) => {
|
||||||
if (idx === 0) return true;
|
if (idx === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
return node.step! >= arr[idx - 1]!.step!;
|
return node.step! >= arr[idx - 1]!.step!;
|
||||||
});
|
});
|
||||||
if (!stepsValid) {
|
if (!stepsValid) {
|
||||||
|
|||||||
@@ -1,23 +1,21 @@
|
|||||||
import { omit, uniq } from 'ramda';
|
|
||||||
import sqlstring from 'sqlstring';
|
|
||||||
|
|
||||||
import { strip, toObject } from '@openpanel/common';
|
import { strip, toObject } from '@openpanel/common';
|
||||||
import { cacheable } from '@openpanel/redis';
|
import { cacheable } from '@openpanel/redis';
|
||||||
import type { IChartEventFilter } from '@openpanel/validation';
|
import type { IChartEventFilter } from '@openpanel/validation';
|
||||||
|
import { uniq } from 'ramda';
|
||||||
|
import sqlstring from 'sqlstring';
|
||||||
import { profileBuffer } from '../buffers';
|
import { profileBuffer } from '../buffers';
|
||||||
import {
|
import {
|
||||||
TABLE_NAMES,
|
|
||||||
ch,
|
|
||||||
chQuery,
|
chQuery,
|
||||||
convertClickhouseDateToJs,
|
convertClickhouseDateToJs,
|
||||||
formatClickhouseDate,
|
formatClickhouseDate,
|
||||||
|
isClickhouseDefaultMinDate,
|
||||||
|
TABLE_NAMES,
|
||||||
} from '../clickhouse/client';
|
} from '../clickhouse/client';
|
||||||
import { createSqlBuilder } from '../sql-builder';
|
import { createSqlBuilder } from '../sql-builder';
|
||||||
|
|
||||||
export type IProfileMetrics = {
|
export interface IProfileMetrics {
|
||||||
lastSeen: Date;
|
lastSeen: Date | null;
|
||||||
firstSeen: Date;
|
firstSeen: Date | null;
|
||||||
screenViews: number;
|
screenViews: number;
|
||||||
sessions: number;
|
sessions: number;
|
||||||
durationAvg: number;
|
durationAvg: number;
|
||||||
@@ -29,7 +27,7 @@ export type IProfileMetrics = {
|
|||||||
conversionEvents: number;
|
conversionEvents: number;
|
||||||
avgTimeBetweenSessions: number;
|
avgTimeBetweenSessions: number;
|
||||||
revenue: number;
|
revenue: number;
|
||||||
};
|
}
|
||||||
export function getProfileMetrics(profileId: string, projectId: string) {
|
export function getProfileMetrics(profileId: string, projectId: string) {
|
||||||
return chQuery<
|
return chQuery<
|
||||||
Omit<IProfileMetrics, 'lastSeen' | 'firstSeen'> & {
|
Omit<IProfileMetrics, 'lastSeen' | 'firstSeen'> & {
|
||||||
@@ -100,8 +98,12 @@ export function getProfileMetrics(profileId: string, projectId: string) {
|
|||||||
.then((data) => {
|
.then((data) => {
|
||||||
return {
|
return {
|
||||||
...data,
|
...data,
|
||||||
lastSeen: convertClickhouseDateToJs(data.lastSeen),
|
lastSeen: isClickhouseDefaultMinDate(data.lastSeen)
|
||||||
firstSeen: convertClickhouseDateToJs(data.firstSeen),
|
? null
|
||||||
|
: convertClickhouseDateToJs(data.lastSeen),
|
||||||
|
firstSeen: isClickhouseDefaultMinDate(data.firstSeen)
|
||||||
|
? null
|
||||||
|
: convertClickhouseDateToJs(data.firstSeen),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -127,7 +129,7 @@ export async function getProfileById(id: string, projectId: string) {
|
|||||||
last_value(is_external) as is_external,
|
last_value(is_external) as is_external,
|
||||||
last_value(properties) as properties,
|
last_value(properties) as properties,
|
||||||
last_value(created_at) as created_at
|
last_value(created_at) as created_at
|
||||||
FROM ${TABLE_NAMES.profiles} FINAL WHERE id = ${sqlstring.escape(String(id))} AND project_id = ${sqlstring.escape(projectId)} GROUP BY id, project_id ORDER BY created_at DESC LIMIT 1`,
|
FROM ${TABLE_NAMES.profiles} FINAL WHERE id = ${sqlstring.escape(String(id))} AND project_id = ${sqlstring.escape(projectId)} GROUP BY id, project_id ORDER BY created_at DESC LIMIT 1`
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!profile) {
|
if (!profile) {
|
||||||
@@ -169,7 +171,7 @@ export async function getProfiles(ids: string[], projectId: string) {
|
|||||||
project_id = ${sqlstring.escape(projectId)} AND
|
project_id = ${sqlstring.escape(projectId)} AND
|
||||||
id IN (${filteredIds.map((id) => sqlstring.escape(id)).join(',')})
|
id IN (${filteredIds.map((id) => sqlstring.escape(id)).join(',')})
|
||||||
GROUP BY id, project_id
|
GROUP BY id, project_id
|
||||||
`,
|
`
|
||||||
);
|
);
|
||||||
|
|
||||||
return data.map(transformProfile);
|
return data.map(transformProfile);
|
||||||
@@ -221,7 +223,7 @@ export async function getProfileListCount({
|
|||||||
return data[0]?.count ?? 0;
|
return data[0]?.count ?? 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type IServiceProfile = {
|
export interface IServiceProfile {
|
||||||
id: string;
|
id: string;
|
||||||
email: string;
|
email: string;
|
||||||
avatar: string;
|
avatar: string;
|
||||||
@@ -245,7 +247,7 @@ export type IServiceProfile = {
|
|||||||
model?: string;
|
model?: string;
|
||||||
referrer?: string;
|
referrer?: string;
|
||||||
};
|
};
|
||||||
};
|
}
|
||||||
|
|
||||||
export interface IClickhouseProfile {
|
export interface IClickhouseProfile {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -289,7 +291,7 @@ export function transformProfile({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function upsertProfile(
|
export function upsertProfile(
|
||||||
{
|
{
|
||||||
id,
|
id,
|
||||||
firstName,
|
firstName,
|
||||||
@@ -300,7 +302,7 @@ export async function upsertProfile(
|
|||||||
projectId,
|
projectId,
|
||||||
isExternal,
|
isExternal,
|
||||||
}: IServiceUpsertProfile,
|
}: IServiceUpsertProfile,
|
||||||
isFromEvent = false,
|
isFromEvent = false
|
||||||
) {
|
) {
|
||||||
const profile: IClickhouseProfile = {
|
const profile: IClickhouseProfile = {
|
||||||
id,
|
id,
|
||||||
|
|||||||
Reference in New Issue
Block a user