From b2e03390daec0a3f6e036cc4df2e3b82f080b9fd Mon Sep 17 00:00:00 2001 From: Francis Cao Date: Mon, 25 Aug 2025 07:39:51 -0700 Subject: [PATCH 1/3] translate dateRange and cohort prefix for filtering --- .../[websiteId]/cohorts/CohortAddButton.tsx | 2 +- .../[websiteId]/cohorts/CohortEditButton.tsx | 2 +- .../[websiteId]/segments/SegmentAddButton.tsx | 2 +- .../segments/SegmentEditButton.tsx | 2 +- src/lib/clickhouse.ts | 22 ++++-- src/lib/prisma.ts | 67 ++++++------------- src/lib/request.ts | 30 +++++++-- src/lib/types.ts | 1 + 8 files changed, 68 insertions(+), 60 deletions(-) diff --git a/src/app/(main)/websites/[websiteId]/cohorts/CohortAddButton.tsx b/src/app/(main)/websites/[websiteId]/cohorts/CohortAddButton.tsx index 4ddc3c55..abf03d10 100644 --- a/src/app/(main)/websites/[websiteId]/cohorts/CohortAddButton.tsx +++ b/src/app/(main)/websites/[websiteId]/cohorts/CohortAddButton.tsx @@ -15,7 +15,7 @@ export function CohortAddButton({ websiteId }: { websiteId: string }) { {formatMessage(labels.cohort)} - + {({ close }) => { return ; }} diff --git a/src/app/(main)/websites/[websiteId]/cohorts/CohortEditButton.tsx b/src/app/(main)/websites/[websiteId]/cohorts/CohortEditButton.tsx index 51ac8505..bd5f3abf 100644 --- a/src/app/(main)/websites/[websiteId]/cohorts/CohortEditButton.tsx +++ b/src/app/(main)/websites/[websiteId]/cohorts/CohortEditButton.tsx @@ -17,7 +17,7 @@ export function CohortEditButton({ return ( }> - + {({ close }) => { return ( {formatMessage(labels.segment)} - + {({ close }) => { return ; }} diff --git a/src/app/(main)/websites/[websiteId]/segments/SegmentEditButton.tsx b/src/app/(main)/websites/[websiteId]/segments/SegmentEditButton.tsx index 817477e3..7821815a 100644 --- a/src/app/(main)/websites/[websiteId]/segments/SegmentEditButton.tsx +++ b/src/app/(main)/websites/[websiteId]/segments/SegmentEditButton.tsx @@ -17,7 +17,7 @@ export function SegmentEditButton({ return ( }> - + {({ close }) => { return ( , options: QueryOptions = {}) { const query = filtersObjectToArray(filters, options).reduce((arr, { name, column, operator }) => { + const isCohort = options?.isCohort; + + if (isCohort) { + column = FILTER_COLUMNS[name.slice('cohort_'.length)]; + } + if (column) { if (name === 'eventType') { arr.push(`and ${mapFilter(column, operator, name, 'UInt32')}`); @@ -107,18 +113,18 @@ function getFilterQuery(filters: Record, options: QueryOptions = {} return query.join('\n'); } -function getCohortQuery(filters: Record, options: QueryOptions = {}) { - if (!filters) { +function getCohortQuery(filters: Record) { + if (!filters || Object.keys(filters).length === 0) { return ''; } - const filterQuery = getFilterQuery(filters, options); + const filterQuery = getFilterQuery(filters, { isCohort: true }); return `join ( select distinct session_id from website_event where website_id = {websiteId:UUID} - and created_at between {startDate:DateTime64} and {endDate:DateTime64} + and created_at between {cohort_startDate:DateTime64} and {cohort_endDate:DateTime64} ${filterQuery} ) as cohort on cohort.session_id = website_event.session_id @@ -159,11 +165,15 @@ function getQueryParams(filters: Record) { } function parseFilters(filters: Record, options?: QueryOptions) { + const cohortFilters = Object.fromEntries( + Object.entries(filters).filter(([key]) => key.startsWith('cohort_')), + ); + return { filterQuery: getFilterQuery(filters, options), dateQuery: getDateQuery(filters), queryParams: getQueryParams(filters), - cohortQuery: getCohortQuery(filters?.cohortFilters, options), + cohortQuery: getCohortQuery(cohortFilters), }; } diff --git a/src/lib/prisma.ts b/src/lib/prisma.ts index 1bacff1b..8f239e5f 100644 --- a/src/lib/prisma.ts +++ b/src/lib/prisma.ts @@ -2,7 +2,7 @@ import debug from 'debug'; import { PrismaClient } from '@/generated/prisma/client'; import { PrismaPg } from '@prisma/adapter-pg'; import { readReplicas } from '@prisma/extension-read-replicas'; -import { SESSION_COLUMNS, OPERATORS, DEFAULT_PAGE_SIZE } from './constants'; +import { SESSION_COLUMNS, OPERATORS, DEFAULT_PAGE_SIZE, FILTER_COLUMNS } from './constants'; import { QueryOptions, QueryFilters } from './types'; import { filtersObjectToArray } from './params'; @@ -79,24 +79,15 @@ function mapFilter(column: string, operator: string, name: string, type: string } } -function mapCohortFilter(column: string, operator: string, value: string) { - switch (operator) { - case OPERATORS.equals: - return `${column} = '${value}'`; - case OPERATORS.notEquals: - return `${column} != '${value}'`; - case OPERATORS.contains: - return `${column} ilike '${value}'`; - case OPERATORS.doesNotContain: - return `${column} not ilike '${value}'`; - default: - return ''; - } -} - function getFilterQuery(filters: Record, options: QueryOptions = {}): string { const query = filtersObjectToArray(filters, options).reduce( (arr, { name, column, operator, prefix = '' }) => { + const isCohort = options?.isCohort; + + if (isCohort) { + column = FILTER_COLUMNS[name.slice('cohort_'.length)]; + } + if (column) { arr.push(`and ${mapFilter(`${prefix}${column}`, operator, name)}`); @@ -115,41 +106,23 @@ function getFilterQuery(filters: Record, options: QueryOptions = {} return query.join('\n'); } -function getCohortQuery(websiteId: string, filters: QueryFilters = {}, options: QueryOptions = {}) { - const query = filtersObjectToArray(filters, options).reduce( - (arr, { name, column, operator, value }) => { - if (column) { - arr.push( - `${arr.length === 0 ? 'where' : 'and'} ${mapCohortFilter(column, operator, value)}`, - ); +function getCohortQuery(filters: QueryFilters = {}) { + if (!filters || Object.keys(filters).length === 0) { + return ''; + } - if (name === 'referrer') { - arr.push(`and referrer_domain != hostname`); - } - } + const filterQuery = getFilterQuery(filters, { isCohort: true }); - return arr; - }, - [], - ); - - if (query.length > 0) { - // add website and date range filters - query.push(`and website_event.website_id = '${websiteId}'`); - query.push( - `and website_event.created_at between '${filters.startDate}'::timestamptz and '${filters.endDate}'::timestamptz`, - ); - - return `join + return `join (select distinct website_event.session_id from website_event join session on session.session_id = website_event.session_id - ${query.join('\n')}) cohort + where website_event.website_id = {{websiteId}} + and website_event.created_at between {{cohort_startDate}} and {{cohort_endDate}} + ${filterQuery} + ) cohort on cohort.session_id = website_event.session_id `; - } - - return ''; } function getDateQuery(filters: Record) { @@ -184,6 +157,10 @@ function parseFilters(filters: Record, options?: QueryOptions) { ['referrer', ...SESSION_COLUMNS].includes(key), ); + const cohortFilters = Object.fromEntries( + Object.entries(filters).filter(([key]) => key.startsWith('cohort_')), + ); + return { joinSessionQuery: options?.joinSession || joinSession @@ -192,7 +169,7 @@ function parseFilters(filters: Record, options?: QueryOptions) { dateQuery: getDateQuery(filters), filterQuery: getFilterQuery(filters, options), queryParams: getQueryParams(filters), - cohortQuery: getCohortQuery(filters?.cohort), + cohortQuery: getCohortQuery(cohortFilters), }; } diff --git a/src/lib/request.ts b/src/lib/request.ts index c5e5b9bc..faa76520 100644 --- a/src/lib/request.ts +++ b/src/lib/request.ts @@ -1,11 +1,11 @@ -import { z } from 'zod/v4'; -import { FILTER_COLUMNS, DEFAULT_PAGE_SIZE } from '@/lib/constants'; -import { badRequest, unauthorized } from '@/lib/response'; -import { getAllowedUnits, getMinimumUnit, maxDate } from '@/lib/date'; import { checkAuth } from '@/lib/auth'; +import { DEFAULT_PAGE_SIZE, FILTER_COLUMNS } from '@/lib/constants'; +import { getAllowedUnits, getMinimumUnit, maxDate, parseDateRange } from '@/lib/date'; import { fetchWebsite } from '@/lib/load'; +import { badRequest, unauthorized } from '@/lib/response'; import { QueryFilters } from '@/lib/types'; import { getWebsiteSegment } from '@/queries'; +import { z } from 'zod/v4'; export async function parseRequest( request: Request, @@ -104,7 +104,27 @@ export async function getQueryFilters( } if (params.cohort) { - filters.cohortFilters = (await getWebsiteSegment(websiteId, params.cohort))?.parameters; + const cohortFilters = (await getWebsiteSegment(websiteId, params.cohort)) + ?.parameters as Record; + + // convert dateRange to startDate and endDate + if (cohortFilters.dateRange) { + const { startDate, endDate } = parseDateRange(cohortFilters.dateRange); + cohortFilters.startDate = startDate; + cohortFilters.endDate = endDate; + delete cohortFilters.dateRange; + } + + Object.assign( + filters, + Object.fromEntries( + Object.entries(cohortFilters || {}).map(([key, value]) => + key === 'startDate' || key === 'endDate' + ? [`cohort_${key}`, new Date(value)] + : [`cohort_${key}`, value], + ), + ), + ); } } diff --git a/src/lib/types.ts b/src/lib/types.ts index 53bdaea5..6fdbfde7 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -49,6 +49,7 @@ export interface QueryOptions { columns?: Record; limit?: number; prefix?: string; + isCohort?: boolean; } export interface QueryFilters From f06ef6fbc9743aba3ea593cb8123912b60962752 Mon Sep 17 00:00:00 2001 From: Francis Cao Date: Mon, 25 Aug 2025 09:26:49 -0700 Subject: [PATCH 2/3] fix visitDuration calculation and conditionally show bounce/visitDuration on session columns --- .../(reports)/breakdown/Breakdown.tsx | 2 +- src/components/metrics/CountriesTable.tsx | 42 ----------------- src/components/metrics/ListExpandedTable.tsx | 47 ------------------- .../metrics/MetricsExpandedTable.tsx | 43 ++++++++++------- 4 files changed, 28 insertions(+), 106 deletions(-) delete mode 100644 src/components/metrics/CountriesTable.tsx delete mode 100644 src/components/metrics/ListExpandedTable.tsx diff --git a/src/app/(main)/websites/[websiteId]/(reports)/breakdown/Breakdown.tsx b/src/app/(main)/websites/[websiteId]/(reports)/breakdown/Breakdown.tsx index 404e7de2..a4c00d4a 100644 --- a/src/app/(main)/websites/[websiteId]/(reports)/breakdown/Breakdown.tsx +++ b/src/app/(main)/websites/[websiteId]/(reports)/breakdown/Breakdown.tsx @@ -59,7 +59,7 @@ export function Breakdown({ websiteId, selectedFields = [], startDate, endDate } {row => { - const n = (row?.['totaltime'] / row?.['visits']) * 100; + const n = row?.['totaltime'] / row?.['visits']; return `${+n < 0 ? '-' : ''}${formatShortTime(Math.abs(~~n), ['m', 's'], ' ')}`; }} diff --git a/src/components/metrics/CountriesTable.tsx b/src/components/metrics/CountriesTable.tsx deleted file mode 100644 index 52c82803..00000000 --- a/src/components/metrics/CountriesTable.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { FilterLink } from '@/components/common/FilterLink'; -import { useCountryNames } from '@/components/hooks'; -import { useLocale, useMessages, useFormat } from '@/components/hooks'; -import { MetricsTable, MetricsTableProps } from './MetricsTable'; -import { TypeIcon } from '@/components/common/TypeIcon'; -import { MetricsExpandedTable } from '@/components/metrics/MetricsExpandedTable'; - -export interface CountriesTableProps extends MetricsTableProps { - isExpanded?: boolean; -} - -export function CountriesTable({ isExpanded, ...props }: CountriesTableProps) { - const { locale } = useLocale(); - const { countryNames } = useCountryNames(locale); - const { formatMessage, labels } = useMessages(); - const { formatCountry } = useFormat(); - - const renderLabel = ({ label: code }) => { - return ( - - - - ); - }; - - const Component = isExpanded ? MetricsExpandedTable : MetricsTable; - - return ( - - ); -} diff --git a/src/components/metrics/ListExpandedTable.tsx b/src/components/metrics/ListExpandedTable.tsx deleted file mode 100644 index a413f0e4..00000000 --- a/src/components/metrics/ListExpandedTable.tsx +++ /dev/null @@ -1,47 +0,0 @@ -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?.['name'], country: row?.['country'] }, Number(row.id)) - : (row?.['name'] ?? 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/MetricsExpandedTable.tsx b/src/components/metrics/MetricsExpandedTable.tsx index f90f1b70..35da0ee6 100644 --- a/src/components/metrics/MetricsExpandedTable.tsx +++ b/src/components/metrics/MetricsExpandedTable.tsx @@ -6,6 +6,7 @@ import { Close } from '@/components/icons'; import { DownloadButton } from '@/components/input/DownloadButton'; import { formatShortTime } from '@/lib/format'; import { MetricLabel } from '@/components/metrics/MetricLabel'; +import { SESSION_COLUMNS } from '@/lib/constants'; export interface MetricsExpandedTableProps { websiteId: string; @@ -34,6 +35,7 @@ export function MetricsExpandedTable({ const [search, setSearch] = useState(''); const { formatMessage, labels } = useMessages(); const isType = ['browser', 'country', 'device', 'os'].includes(type); + const showBounceDuration = SESSION_COLUMNS.includes(type); const { data, isLoading, isFetching, error } = useWebsiteExpandedMetricsQuery(websiteId, { type, @@ -85,22 +87,31 @@ export function MetricsExpandedTable({ {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'], ' ')}`; - }} - + {showBounceDuration && [ + + {row => { + const n = (Math.min(row?.['visits'], row?.['bounces']) / row?.['visits']) * 100; + return Math.round(+n) + '%'; + }} + , + + + {row => { + const n = row?.['totaltime'] / row?.['visits']; + return `${+n < 0 ? '-' : ''}${formatShortTime(Math.abs(~~n), ['m', 's'], ' ')}`; + }} + , + ]} )} From 984f426cfec5ebe74e89198bfdb15ab4e0b5bf69 Mon Sep 17 00:00:00 2001 From: Francis Cao Date: Tue, 26 Aug 2025 09:02:55 -0700 Subject: [PATCH 3/3] update getQueryFilters to support new segment/cohort data structure --- src/lib/request.ts | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/src/lib/request.ts b/src/lib/request.ts index e4ecb884..fc07ab8c 100644 --- a/src/lib/request.ts +++ b/src/lib/request.ts @@ -100,25 +100,33 @@ export async function getQueryFilters( await setWebsiteDate(websiteId, dateRange); if (params.segment) { - Object.assign(filters, (await getWebsiteSegment(websiteId, params.segment))?.parameters); + const segmentParams = (await getWebsiteSegment(websiteId, params.segment)) + ?.parameters as Record; + + Object.assign(filters, segmentParams.filters); } if (params.cohort) { - const cohortFilters = (await getWebsiteSegment(websiteId, params.cohort)) + const cohortParams = (await getWebsiteSegment(websiteId, params.cohort)) ?.parameters as Record; // convert dateRange to startDate and endDate - if (cohortFilters.dateRange) { - const { startDate, endDate } = parseDateRange(cohortFilters.dateRange); - cohortFilters.startDate = startDate; - cohortFilters.endDate = endDate; - delete cohortFilters.dateRange; + if (cohortParams.dateRange) { + const { startDate, endDate } = parseDateRange(cohortParams.dateRange); + cohortParams.startDate = startDate; + cohortParams.endDate = endDate; + delete cohortParams.dateRange; + } + + if (cohortParams.filters) { + Object.assign(cohortParams, cohortParams.filters); + delete cohortParams.filters; } Object.assign( filters, Object.fromEntries( - Object.entries(cohortFilters || {}).map(([key, value]) => + Object.entries(cohortParams || {}).map(([key, value]) => key === 'startDate' || key === 'endDate' ? [`cohort_${key}`, new Date(value)] : [`cohort_${key}`, value],