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/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 ( { - 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'], ' ')}`; + }} + , + ]} )} diff --git a/src/lib/clickhouse.ts b/src/lib/clickhouse.ts index fb5e1227..2c70272d 100644 --- a/src/lib/clickhouse.ts +++ b/src/lib/clickhouse.ts @@ -2,7 +2,7 @@ import { ClickHouseClient, createClient } from '@clickhouse/client'; import { formatInTimeZone } from 'date-fns-tz'; import debug from 'debug'; import { CLICKHOUSE } from '@/lib/db'; -import { DEFAULT_PAGE_SIZE, OPERATORS } from './constants'; +import { DEFAULT_PAGE_SIZE, FILTER_COLUMNS, OPERATORS } from './constants'; import { filtersObjectToArray } from './params'; import { QueryFilters, QueryOptions } from './types'; @@ -89,6 +89,12 @@ function mapFilter(column: string, operator: string, name: string, type: string function getFilterQuery(filters: Record, 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 3dcf1f6c..fc07ab8c 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, @@ -100,11 +100,39 @@ 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) { - filters.cohortFilters = (await getWebsiteSegment(websiteId, params.cohort))?.parameters; + const cohortParams = (await getWebsiteSegment(websiteId, params.cohort)) + ?.parameters as Record; + + // convert dateRange to startDate and endDate + 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(cohortParams || {}).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