diff --git a/src/app/(main)/App.tsx b/src/app/(main)/App.tsx
index e3602d5a..ee453941 100644
--- a/src/app/(main)/App.tsx
+++ b/src/app/(main)/App.tsx
@@ -30,7 +30,7 @@ export function App({ children }) {
-
+
{children}
diff --git a/src/app/(main)/websites/[websiteId]/WebsiteExpandedView.tsx b/src/app/(main)/websites/[websiteId]/WebsiteExpandedView.tsx
index acd72ab9..e6788ae9 100644
--- a/src/app/(main)/websites/[websiteId]/WebsiteExpandedView.tsx
+++ b/src/app/(main)/websites/[websiteId]/WebsiteExpandedView.tsx
@@ -156,6 +156,7 @@ export function WebsiteExpandedView({
itemCount={25}
allowFilter={true}
allowSearch={true}
+ expanded={true}
/>
diff --git a/src/app/api/websites/[websiteId]/metrics/expanded/route.ts b/src/app/api/websites/[websiteId]/metrics/expanded/route.ts
new file mode 100644
index 00000000..395971b6
--- /dev/null
+++ b/src/app/api/websites/[websiteId]/metrics/expanded/route.ts
@@ -0,0 +1,87 @@
+import { canViewWebsite } from '@/lib/auth';
+import { EVENT_COLUMNS, SESSION_COLUMNS } from '@/lib/constants';
+import { getQueryFilters, parseRequest } from '@/lib/request';
+import { badRequest, json, unauthorized } from '@/lib/response';
+import { dateRangeParams, filterParams, searchParams } from '@/lib/schema';
+import {
+ getChannelMetrics,
+ getEventMetrics,
+ getPageviewMetrics,
+ getSessionExpandedMetrics,
+} from '@/queries';
+import { z } from 'zod';
+
+export async function GET(
+ request: Request,
+ { params }: { params: Promise<{ websiteId: string }> },
+) {
+ const schema = z.object({
+ type: z.string(),
+ limit: z.coerce.number().optional(),
+ offset: z.coerce.number().optional(),
+ ...dateRangeParams,
+ ...searchParams,
+ ...filterParams,
+ });
+
+ const { auth, query, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const { websiteId } = await params;
+
+ if (!(await canViewWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ const { type, limit, offset, search } = query;
+ const filters = await getQueryFilters(query, websiteId);
+
+ if (search) {
+ filters[type] = `c.${search}`;
+ }
+
+ if (SESSION_COLUMNS.includes(type)) {
+ const data = await getSessionExpandedMetrics(websiteId, { type, limit, offset }, filters);
+
+ // if (type === 'language') {
+ // const combined = {};
+
+ // for (const { x, y } of data) {
+ // const key = String(x).toLowerCase().split('-')[0];
+
+ // if (combined[key] === undefined) {
+ // combined[key] = { x: key, y };
+ // } else {
+ // combined[key].y += y;
+ // }
+ // }
+
+ // return json(Object.values(combined));
+ // }
+
+ return json(data);
+ }
+
+ if (EVENT_COLUMNS.includes(type)) {
+ let data;
+
+ if (type === 'event') {
+ data = await getEventMetrics(websiteId, { type, limit, offset }, filters);
+ } else {
+ data = await getPageviewMetrics(websiteId, { type, limit, offset }, filters);
+ }
+
+ return json(data);
+ }
+
+ if (type === 'channel') {
+ const data = await getChannelMetrics(websiteId, filters);
+
+ return json(data);
+ }
+
+ return badRequest();
+}
diff --git a/src/components/hooks/index.ts b/src/components/hooks/index.ts
index 5c0d8d6b..e62479fb 100644
--- a/src/components/hooks/index.ts
+++ b/src/components/hooks/index.ts
@@ -31,6 +31,7 @@ export * from './queries/useWebsitesQuery';
export * from './queries/useWebsiteEventsQuery';
export * from './queries/useWebsiteEventsSeriesQuery';
export * from './queries/useWebsiteMetricsQuery';
+export * from './queries/useWebsiteExpandedMetricsQuery';
export * from './queries/useWebsiteValuesQuery';
export * from './useApi';
export * from './useConfig';
diff --git a/src/components/hooks/queries/useWebsiteExpandedMetricsQuery.ts b/src/components/hooks/queries/useWebsiteExpandedMetricsQuery.ts
new file mode 100644
index 00000000..0443fd5f
--- /dev/null
+++ b/src/components/hooks/queries/useWebsiteExpandedMetricsQuery.ts
@@ -0,0 +1,45 @@
+import { keepPreviousData } from '@tanstack/react-query';
+import { useApi } from '../useApi';
+import { useFilterParameters } from '../useFilterParameters';
+import { useDateParameters } from '../useDateParameters';
+import { ReactQueryOptions } from '@/lib/types';
+
+export type WebsiteExpandedMetricsData = {
+ label: string;
+ pageviews: number;
+ visitors: number;
+ visits: number;
+ bounces: number;
+ totaltime: number;
+}[];
+
+export function useWebsiteExpandedMetricsQuery(
+ websiteId: string,
+ params: { type: string; limit?: number; search?: string },
+ options?: ReactQueryOptions,
+) {
+ const { get, useQuery } = useApi();
+ const date = useDateParameters(websiteId);
+ const filters = useFilterParameters();
+
+ return useQuery({
+ queryKey: [
+ 'websites:metrics:expanded',
+ {
+ websiteId,
+ ...date,
+ ...filters,
+ ...params,
+ },
+ ],
+ queryFn: async () =>
+ get(`/websites/${websiteId}/metrics/expanded`, {
+ ...date,
+ ...filters,
+ ...params,
+ }),
+ enabled: !!websiteId,
+ placeholderData: keepPreviousData,
+ ...options,
+ });
+}
diff --git a/src/components/metrics/ListExpandedTable.tsx b/src/components/metrics/ListExpandedTable.tsx
new file mode 100644
index 00000000..fb3efdb5
--- /dev/null
+++ b/src/components/metrics/ListExpandedTable.tsx
@@ -0,0 +1,47 @@
+import { useMessages } from '@/components/hooks';
+import { formatShortTime } from '@/lib/format';
+import { DataColumn, DataTable } from '@umami/react-zen';
+import { ReactNode } from 'react';
+
+export interface ListExpandedTableProps {
+ data?: any[];
+ title?: string;
+ renderLabel?: (row: any, index: number) => ReactNode;
+}
+
+export function ListExpandedTable({ data = [], title, renderLabel }: ListExpandedTableProps) {
+ const { formatMessage, labels } = useMessages();
+
+ return (
+
+
+ {row =>
+ renderLabel
+ ? renderLabel({ x: row?.label, country: row?.['country'] }, Number(row.id))
+ : (row.label ?? formatMessage(labels.unknown))
+ }
+
+
+ {row => row?.['visitors']?.toLocaleString()}
+
+
+ {row => row?.['visits']?.toLocaleString()}
+
+
+ {row => row?.['pageviews']?.toLocaleString()}
+
+
+ {row => {
+ const n = (Math.min(row?.['visits'], row?.['bounces']) / row?.['visits']) * 100;
+ return Math.round(+n) + '%';
+ }}
+
+
+ {row => {
+ const n = (row?.['totaltime'] / row?.['visits']) * 100;
+ return `${+n < 0 ? '-' : ''}${formatShortTime(Math.abs(~~n), ['m', 's'], ' ')}`;
+ }}
+
+
+ );
+}
diff --git a/src/components/metrics/MetricsTable.tsx b/src/components/metrics/MetricsTable.tsx
index c229b00c..760a0da6 100644
--- a/src/components/metrics/MetricsTable.tsx
+++ b/src/components/metrics/MetricsTable.tsx
@@ -1,13 +1,20 @@
-import { ReactNode, useMemo, useState } from 'react';
-import { Icon, Text, SearchField, Row, Column } from '@umami/react-zen';
import { LinkButton } from '@/components/common/LinkButton';
+import { LoadingPanel } from '@/components/common/LoadingPanel';
+import {
+ useFormat,
+ useMessages,
+ useNavigation,
+ useWebsiteExpandedMetricsQuery,
+ useWebsiteMetricsQuery,
+} from '@/components/hooks';
+import { Arrow } from '@/components/icons';
+import { DownloadButton } from '@/components/input/DownloadButton';
import { DEFAULT_ANIMATION_DURATION } from '@/lib/constants';
import { percentFilter } from '@/lib/filters';
-import { useNavigation, useWebsiteMetricsQuery, useMessages, useFormat } from '@/components/hooks';
-import { Arrow } from '@/components/icons';
+import { Column, Icon, Row, SearchField, Text } from '@umami/react-zen';
+import { ReactNode, useMemo, useState } from 'react';
+import { ListExpandedTable, ListExpandedTableProps } from './ListExpandedTable';
import { ListTable, ListTableProps } from './ListTable';
-import { LoadingPanel } from '@/components/common/LoadingPanel';
-import { DownloadButton } from '@/components/input/DownloadButton';
export interface MetricsTableProps extends ListTableProps {
websiteId: string;
@@ -21,6 +28,7 @@ export interface MetricsTableProps extends ListTableProps {
showMore?: boolean;
params?: { [key: string]: any };
allowDownload?: boolean;
+ expanded?: boolean;
children?: ReactNode;
}
@@ -35,6 +43,7 @@ export function MetricsTable({
showMore = true,
params,
allowDownload = true,
+ expanded = false,
children,
...props
}: MetricsTableProps) {
@@ -43,7 +52,21 @@ export function MetricsTable({
const { updateParams } = useNavigation();
const { formatMessage, labels } = useMessages();
- const { data, isLoading, isFetching, error } = useWebsiteMetricsQuery(
+ const expandedQuery = useWebsiteExpandedMetricsQuery(
+ websiteId,
+ {
+ type,
+ limit: 30,
+ search: searchFormattedValues ? undefined : search,
+ ...params,
+ },
+ {
+ retryDelay: delay || DEFAULT_ANIMATION_DURATION,
+ enabled: expanded,
+ },
+ );
+
+ const query = useWebsiteMetricsQuery(
websiteId,
{
type,
@@ -53,9 +76,12 @@ export function MetricsTable({
},
{
retryDelay: delay || DEFAULT_ANIMATION_DURATION,
+ enabled: !expanded,
},
);
+ const { data, isLoading, isFetching, error } = expanded ? expandedQuery : query;
+
const filteredData = useMemo(() => {
if (data) {
let items = data as any[];
@@ -95,7 +121,12 @@ export function MetricsTable({
{allowDownload && }
- {data && }
+ {data &&
+ (expanded ? (
+
+ ) : (
+
+ ))}
{showMore && data && !error && limit && (
diff --git a/src/queries/index.ts b/src/queries/index.ts
index fba7e548..52ca6513 100644
--- a/src/queries/index.ts
+++ b/src/queries/index.ts
@@ -28,6 +28,7 @@ export * from '@/queries/sql/sessions/getSessionData';
export * from '@/queries/sql/sessions/getSessionDataProperties';
export * from '@/queries/sql/sessions/getSessionDataValues';
export * from '@/queries/sql/sessions/getSessionMetrics';
+export * from '@/queries/sql/sessions/getSessionExpandedMetrics';
export * from '@/queries/sql/sessions/getWebsiteSessions';
export * from '@/queries/sql/sessions/getWebsiteSessionStats';
export * from '@/queries/sql/sessions/getWebsiteSessionsWeekly';
diff --git a/src/queries/sql/sessions/getSessionDataValues.ts b/src/queries/sql/sessions/getSessionDataValues.ts
index 682bfe4f..e4d04f1a 100644
--- a/src/queries/sql/sessions/getSessionDataValues.ts
+++ b/src/queries/sql/sessions/getSessionDataValues.ts
@@ -28,9 +28,9 @@ async function relationalQuery(
else string_value
end as "value",
count(distinct session_data.session_id) as "total"
- from website_event e
+ from website_event
${cohortQuery}
- join session_data d
+ join session_data
on session_data.session_id = website_event.session_id
where website_event.website_id = {{websiteId::uuid}}
and website_event.created_at between {{startDate}} and {{endDate}}
@@ -58,9 +58,9 @@ async function clickhouseQuery(
data_type = 4, toString(date_trunc('hour', date_value)),
string_value) as "value",
uniq(session_data.session_id) as "total"
- from website_event e
+ from website_event
${cohortQuery}
- join session_data d final
+ join session_data final
on session_data.session_id = website_event.session_id
where website_event.website_id = {websiteId:UUID}
and website_event.created_at between {startDate:DateTime64} and {endDate:DateTime64}
diff --git a/src/queries/sql/sessions/getSessionExpandedMetrics.ts b/src/queries/sql/sessions/getSessionExpandedMetrics.ts
new file mode 100644
index 00000000..ba0841dc
--- /dev/null
+++ b/src/queries/sql/sessions/getSessionExpandedMetrics.ts
@@ -0,0 +1,126 @@
+import clickhouse from '@/lib/clickhouse';
+import { EVENT_TYPE, FILTER_COLUMNS, SESSION_COLUMNS } from '@/lib/constants';
+import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db';
+import prisma from '@/lib/prisma';
+import { QueryFilters } from '@/lib/types';
+
+export interface SessionExpandedMetricsParameters {
+ type: string;
+ limit?: number | string;
+ offset?: number | string;
+}
+
+export interface SessionExpandedMetricsData {
+ label: string;
+ pageviews: number;
+ visitors: number;
+ visits: number;
+ bounces: number;
+ totaltime: number;
+}
+
+export async function getSessionExpandedMetrics(
+ ...args: [websiteId: string, parameters: SessionExpandedMetricsParameters, filters: QueryFilters]
+): Promise {
+ return runQuery({
+ [PRISMA]: () => relationalQuery(...args),
+ [CLICKHOUSE]: () => clickhouseQuery(...args),
+ });
+}
+
+async function relationalQuery(
+ websiteId: string,
+ parameters: SessionExpandedMetricsParameters,
+ filters: QueryFilters,
+): Promise {
+ const { type, limit = 500, offset = 0 } = parameters;
+ const column = FILTER_COLUMNS[type] || type;
+ const { parseFilters, rawQuery } = prisma;
+ const { filterQuery, joinSessionQuery, cohortQuery, queryParams } = parseFilters(
+ {
+ ...filters,
+ websiteId,
+ eventType: EVENT_TYPE.pageView,
+ },
+ {
+ joinSession: SESSION_COLUMNS.includes(type),
+ },
+ );
+ const includeCountry = column === 'city' || column === 'region';
+
+ return rawQuery(
+ `
+ select
+ ${column} x,
+ count(distinct website_event.session_id) y
+ ${includeCountry ? ', country' : ''}
+ from website_event
+ ${cohortQuery}
+ ${joinSessionQuery}
+ where website_event.website_id = {{websiteId::uuid}}
+ and website_event.created_at between {{startDate}} and {{endDate}}
+ and website_event.event_type = {{eventType}}
+ ${filterQuery}
+ group by 1
+ ${includeCountry ? ', 3' : ''}
+ order by 2 desc
+ limit ${limit}
+ offset ${offset}
+ `,
+ { ...queryParams, ...parameters },
+ );
+}
+
+async function clickhouseQuery(
+ websiteId: string,
+ parameters: SessionExpandedMetricsParameters,
+ filters: QueryFilters,
+): Promise {
+ const { type, limit = 500, offset = 0 } = parameters;
+ const column = FILTER_COLUMNS[type] || type;
+ const { parseFilters, rawQuery } = clickhouse;
+ const { filterQuery, cohortQuery, queryParams } = parseFilters({
+ ...filters,
+ websiteId,
+ eventType: EVENT_TYPE.pageView,
+ });
+ const includeCountry = column === 'city' || column === 'region';
+
+ return rawQuery(
+ `
+ select
+ label,
+ ${includeCountry ? 'country,' : ''}
+ sum(t.c) as "pageviews",
+ uniq(t.session_id) as "visitors",
+ uniq(t.visit_id) as "visits",
+ sum(if(t.c = 1, 1, 0)) as "bounces",
+ sum(max_time-min_time) as "totaltime"
+ from (
+ select
+ ${column} label,
+ ${includeCountry ? 'country,' : ''}
+ session_id,
+ visit_id,
+ count(*) c,
+ min(created_at) min_time,
+ max(created_at) max_time
+ from website_event
+ ${cohortQuery}
+ where website_id = {websiteId:UUID}
+ and created_at between {startDate:DateTime64} and {endDate:DateTime64}
+ and event_type = {eventType:UInt32}
+ and label != ''
+ ${filterQuery}
+ group by label, session_id, visit_id
+ ${includeCountry ? ', country' : ''}
+ ) as t
+ group by label
+ ${includeCountry ? ', country' : ''}
+ order by visitors desc, visits desc
+ limit ${limit}
+ offset ${offset}
+ `,
+ { ...queryParams, ...parameters },
+ );
+}