diff --git a/src/app/(main)/websites/[websiteId]/(reports)/breakdown/Breakdown.tsx b/src/app/(main)/websites/[websiteId]/(reports)/breakdown/Breakdown.tsx index a4c00d4ac..404e7de26 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']; + const n = (row?.['totaltime'] / row?.['visits']) * 100; 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 abf03d10f..4ddc3c558 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 bd5f3abfa..51ac8505d 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 7821815a5..817477e3b 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 new file mode 100644 index 000000000..a413f0e47 --- /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?.['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 35da0ee6c..f90f1b709 100644 --- a/src/components/metrics/MetricsExpandedTable.tsx +++ b/src/components/metrics/MetricsExpandedTable.tsx @@ -6,7 +6,6 @@ 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; @@ -35,7 +34,6 @@ 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, @@ -87,31 +85,22 @@ export function MetricsExpandedTable({ {row => row?.['pageviews']?.toLocaleString()} - {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'], ' ')}`; - }} - , - ]} + + {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/lib/clickhouse.ts b/src/lib/clickhouse.ts index 2c70272d5..fb5e12272 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, FILTER_COLUMNS, OPERATORS } from './constants'; +import { DEFAULT_PAGE_SIZE, OPERATORS } from './constants'; import { filtersObjectToArray } from './params'; import { QueryFilters, QueryOptions } from './types'; @@ -89,12 +89,6 @@ 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')}`); @@ -113,18 +107,18 @@ function getFilterQuery(filters: Record, options: QueryOptions = {} return query.join('\n'); } -function getCohortQuery(filters: Record) { - if (!filters || Object.keys(filters).length === 0) { +function getCohortQuery(filters: Record, options: QueryOptions = {}) { + if (!filters) { return ''; } - const filterQuery = getFilterQuery(filters, { isCohort: true }); + const filterQuery = getFilterQuery(filters, options); return `join ( select distinct session_id from website_event where website_id = {websiteId:UUID} - and created_at between {cohort_startDate:DateTime64} and {cohort_endDate:DateTime64} + and created_at between {startDate:DateTime64} and {endDate:DateTime64} ${filterQuery} ) as cohort on cohort.session_id = website_event.session_id @@ -165,15 +159,11 @@ 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(cohortFilters), + cohortQuery: getCohortQuery(filters?.cohortFilters, options), }; } diff --git a/src/lib/prisma.ts b/src/lib/prisma.ts index 8f239e5f2..1bacff1b0 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, FILTER_COLUMNS } from './constants'; +import { SESSION_COLUMNS, OPERATORS, DEFAULT_PAGE_SIZE } from './constants'; import { QueryOptions, QueryFilters } from './types'; import { filtersObjectToArray } from './params'; @@ -79,15 +79,24 @@ 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)}`); @@ -106,23 +115,41 @@ function getFilterQuery(filters: Record, options: QueryOptions = {} return query.join('\n'); } -function getCohortQuery(filters: QueryFilters = {}) { - if (!filters || Object.keys(filters).length === 0) { - return ''; - } +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)}`, + ); - const filterQuery = getFilterQuery(filters, { isCohort: true }); + if (name === 'referrer') { + arr.push(`and referrer_domain != hostname`); + } + } - return `join + 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 (select distinct website_event.session_id from website_event join session on session.session_id = website_event.session_id - where website_event.website_id = {{websiteId}} - and website_event.created_at between {{cohort_startDate}} and {{cohort_endDate}} - ${filterQuery} - ) cohort + ${query.join('\n')}) cohort on cohort.session_id = website_event.session_id `; + } + + return ''; } function getDateQuery(filters: Record) { @@ -157,10 +184,6 @@ 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 @@ -169,7 +192,7 @@ function parseFilters(filters: Record, options?: QueryOptions) { dateQuery: getDateQuery(filters), filterQuery: getFilterQuery(filters, options), queryParams: getQueryParams(filters), - cohortQuery: getCohortQuery(cohortFilters), + cohortQuery: getCohortQuery(filters?.cohort), }; } diff --git a/src/lib/request.ts b/src/lib/request.ts index e4ecb8849..3dcf1f6c8 100644 --- a/src/lib/request.ts +++ b/src/lib/request.ts @@ -1,11 +1,11 @@ -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 { 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 { fetchWebsite } from '@/lib/load'; import { QueryFilters } from '@/lib/types'; import { getWebsiteSegment } from '@/queries'; -import { z } from 'zod/v4'; export async function parseRequest( request: Request, @@ -104,27 +104,7 @@ export async function getQueryFilters( } if (params.cohort) { - 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], - ), - ), - ); + filters.cohortFilters = (await getWebsiteSegment(websiteId, params.cohort))?.parameters; } } diff --git a/src/lib/types.ts b/src/lib/types.ts index 6fdbfde73..53bdaea57 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -49,7 +49,6 @@ export interface QueryOptions { columns?: Record; limit?: number; prefix?: string; - isCohort?: boolean; } export interface QueryFilters