From cd1ee8461a8b2f81abeb942659fcecde65f21f99 Mon Sep 17 00:00:00 2001 From: Dan Kotov Date: Sun, 11 Jan 2026 15:53:57 -0500 Subject: [PATCH 1/4] fix: calculate country visitors share relative to total as opposed to relative to the top-10 countries shown in the breakdown --- src/components/metrics/MetricsTable.tsx | 16 ++-- src/lib/filters.ts | 6 +- src/queries/sql/sessions/getSessionMetrics.ts | 74 ++++++++++++++++--- 3 files changed, 78 insertions(+), 18 deletions(-) diff --git a/src/components/metrics/MetricsTable.tsx b/src/components/metrics/MetricsTable.tsx index e99bd216..6cb64436 100644 --- a/src/components/metrics/MetricsTable.tsx +++ b/src/components/metrics/MetricsTable.tsx @@ -40,21 +40,25 @@ export function MetricsTable({ const filteredData = useMemo(() => { if (data) { - let items = data as any[]; + // Handle both old format (array) and new format ({ data, total }) + const items = Array.isArray(data) ? data : data.data; + const total = Array.isArray(data) ? undefined : data.total; + + let filtered = items as any[]; if (dataFilter) { if (Array.isArray(dataFilter)) { - items = dataFilter.reduce((arr, filter) => { + filtered = dataFilter.reduce((arr, filter) => { return filter(arr); - }, items); + }, filtered); } else { - items = dataFilter(items); + filtered = dataFilter(filtered); } } - items = percentFilter(items); + filtered = percentFilter(filtered, total); - return items.map(({ x, y, z, ...props }) => ({ label: x, count: y, percent: z, ...props })); + return filtered.map(({ x, y, z, ...props }) => ({ label: x, count: y, percent: z, ...props })); } return []; }, [data, dataFilter, limit, type]); diff --git a/src/lib/filters.ts b/src/lib/filters.ts index 3da268d8..588a3189 100644 --- a/src/lib/filters.ts +++ b/src/lib/filters.ts @@ -1,7 +1,7 @@ -export const percentFilter = (data: any[]) => { +export const percentFilter = (data: any[], total?: number) => { if (!Array.isArray(data)) return []; - const total = data.reduce((n, { y }) => n + y, 0); - return data.map(({ x, y, ...props }) => ({ x, y, z: total ? (y / total) * 100 : 0, ...props })); + const sum = total ?? data.reduce((n, { y }) => n + y, 0); + return data.map(({ x, y, ...props }) => ({ x, y, z: sum ? (y / sum) * 100 : 0, ...props })); }; export const paramFilter = (data: any[]) => { diff --git a/src/queries/sql/sessions/getSessionMetrics.ts b/src/queries/sql/sessions/getSessionMetrics.ts index c519bdd0..2fad86da 100644 --- a/src/queries/sql/sessions/getSessionMetrics.ts +++ b/src/queries/sql/sessions/getSessionMetrics.ts @@ -12,9 +12,14 @@ export interface SessionMetricsParameters { offset?: number | string; } +export interface SessionMetricsResult { + data: { x: string; y: number; country?: string }[]; + total: number; +} + export async function getSessionMetrics( ...args: [websiteId: string, parameters: SessionMetricsParameters, filters: QueryFilters] -) { +): Promise { return runQuery({ [PRISMA]: () => relationalQuery(...args), [CLICKHOUSE]: () => clickhouseQuery(...args), @@ -25,7 +30,7 @@ async function relationalQuery( websiteId: string, parameters: SessionMetricsParameters, filters: QueryFilters, -) { +): Promise { const { type, limit = 500, offset = 0 } = parameters; let column = FILTER_COLUMNS[type] || type; const { parseFilters, rawQuery } = prisma; @@ -44,7 +49,8 @@ async function relationalQuery( column = `lower(left(${type}, 2))`; } - return rawQuery( + // Get the data with limit + const data = await rawQuery( ` select ${column} x, @@ -65,14 +71,35 @@ async function relationalQuery( `, { ...queryParams, ...parameters }, FUNCTION_NAME, - ); + ) as { x: string; y: number; country?: string }[]; + + // Get total unique sessions + const totalResult = await rawQuery( + ` + select count(distinct website_event.session_id) as total + 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 != 2 + ${filterQuery} + `, + queryParams, + FUNCTION_NAME, + ) as { total: number }[]; + + return { + data, + total: Number(totalResult[0]?.total) || 0, + }; } async function clickhouseQuery( websiteId: string, parameters: SessionMetricsParameters, filters: QueryFilters, -): Promise<{ x: string; y: number }[]> { +): Promise { const { type, limit = 500, offset = 0 } = parameters; let column = FILTER_COLUMNS[type] || type; const { parseFilters, rawQuery } = clickhouse; @@ -86,10 +113,11 @@ async function clickhouseQuery( column = `lower(left(${type}, 2))`; } - let sql = ''; + let dataSql = ''; + let totalSql = ''; if (EVENT_COLUMNS.some(item => Object.keys(filters).includes(item))) { - sql = ` + dataSql = ` select ${column} x, count(distinct session_id) y @@ -106,8 +134,18 @@ async function clickhouseQuery( limit ${limit} offset ${offset} `; + + totalSql = ` + select count(distinct session_id) as total + from website_event + ${cohortQuery} + where website_id = {websiteId:UUID} + and created_at between {startDate:DateTime64} and {endDate:DateTime64} + and event_type != 2 + ${filterQuery} + `; } else { - sql = ` + dataSql = ` select ${column} x, uniq(session_id) y @@ -124,7 +162,25 @@ async function clickhouseQuery( limit ${limit} offset ${offset} `; + + totalSql = ` + select uniq(session_id) as total + from website_event_stats_hourly as website_event + ${cohortQuery} + where website_id = {websiteId:UUID} + and created_at between {startDate:DateTime64} and {endDate:DateTime64} + and event_type != 2 + ${filterQuery} + `; } - return rawQuery(sql, { ...queryParams, ...parameters }, FUNCTION_NAME); + const [data, totalResult] = await Promise.all([ + rawQuery(dataSql, { ...queryParams, ...parameters }, FUNCTION_NAME) as Promise<{ x: string; y: number; country?: string }[]>, + rawQuery(totalSql, queryParams, FUNCTION_NAME) as Promise<{ total: number }[]>, + ]); + + return { + data, + total: Number(totalResult[0]?.total) || 0, + }; } From 63c562a5feacc06f18abdbe584c6857419aa19b9 Mon Sep 17 00:00:00 2001 From: Dan Kotov Date: Sun, 11 Jan 2026 16:36:59 -0500 Subject: [PATCH 2/4] fix: extract data array from getSessionMetrics in export route getSessionMetrics now returns { data, total } but Papa.unparse() expects a flat array for CSV export --- src/app/api/websites/[websiteId]/export/route.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/app/api/websites/[websiteId]/export/route.ts b/src/app/api/websites/[websiteId]/export/route.ts index eec81c6d..a175b3a2 100644 --- a/src/app/api/websites/[websiteId]/export/route.ts +++ b/src/app/api/websites/[websiteId]/export/route.ts @@ -34,10 +34,10 @@ export async function GET( getEventMetrics(websiteId, { type: 'event' }, filters), getPageviewMetrics(websiteId, { type: 'path' }, filters), getPageviewMetrics(websiteId, { type: 'referrer' }, filters), - getSessionMetrics(websiteId, { type: 'browser' }, filters), - getSessionMetrics(websiteId, { type: 'os' }, filters), - getSessionMetrics(websiteId, { type: 'device' }, filters), - getSessionMetrics(websiteId, { type: 'country' }, filters), + getSessionMetrics(websiteId, { type: 'browser' }, filters).then(r => r.data), + getSessionMetrics(websiteId, { type: 'os' }, filters).then(r => r.data), + getSessionMetrics(websiteId, { type: 'device' }, filters).then(r => r.data), + getSessionMetrics(websiteId, { type: 'country' }, filters).then(r => r.data), ]); const zip = new JSZip(); From 11142d401df0b2788499020f05861f4bcc7ba29d Mon Sep 17 00:00:00 2001 From: Dan Kotov Date: Sun, 11 Jan 2026 16:38:48 -0500 Subject: [PATCH 3/4] fix: handle new getSessionMetrics format in WorldMap WorldMap now correctly extracts data and total from the new { data, total } response format for accurate percentage calculation. --- src/components/metrics/WorldMap.tsx | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/components/metrics/WorldMap.tsx b/src/components/metrics/WorldMap.tsx index 3c8fadb8..1900adc5 100644 --- a/src/components/metrics/WorldMap.tsx +++ b/src/components/metrics/WorldMap.tsx @@ -32,10 +32,16 @@ export function WorldMap({ websiteId, data, ...props }: WorldMapProps) { type: 'country', }); - const metrics = useMemo( - () => (data || mapData ? percentFilter((data || mapData) as any[]) : []), - [data, mapData], - ); + const metrics = useMemo(() => { + const source = data || mapData; + if (!source) return []; + + // Handle both old format (array) and new format ({ data, total }) + const items = Array.isArray(source) ? source : source.data; + const total = Array.isArray(source) ? undefined : source.total; + + return percentFilter(items, total); + }, [data, mapData]); const getFillColor = (code: string) => { if (code === 'AQ') return; From 1a04856a8b83b5e8706178b311d9b1db4a7a4e7f Mon Sep 17 00:00:00 2001 From: Dan Kotov Date: Sun, 11 Jan 2026 16:41:55 -0500 Subject: [PATCH 4/4] fix: update WebsiteMetricsData type for new response format Type now correctly represents both array format (event/pageview metrics) and { data, total } format (session metrics). --- src/components/hooks/queries/useWebsiteMetricsQuery.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/components/hooks/queries/useWebsiteMetricsQuery.ts b/src/components/hooks/queries/useWebsiteMetricsQuery.ts index 67c5e4d4..4dee6aba 100644 --- a/src/components/hooks/queries/useWebsiteMetricsQuery.ts +++ b/src/components/hooks/queries/useWebsiteMetricsQuery.ts @@ -4,10 +4,9 @@ import { useApi } from '../useApi'; import { useDateParameters } from '../useDateParameters'; import { useFilterParameters } from '../useFilterParameters'; -export type WebsiteMetricsData = { - x: string; - y: number; -}[]; +export type WebsiteMetricsData = + | { x: string; y: number }[] + | { data: { x: string; y: number }[]; total: number }; export function useWebsiteMetricsQuery( websiteId: string,