diff --git a/src/app/(main)/websites/[websiteId]/events/EventsPage.tsx b/src/app/(main)/websites/[websiteId]/events/EventsPage.tsx index 55ec0403..f209705e 100644 --- a/src/app/(main)/websites/[websiteId]/events/EventsPage.tsx +++ b/src/app/(main)/websites/[websiteId]/events/EventsPage.tsx @@ -1,12 +1,16 @@ 'use client'; import { Column, Tab, TabList, TabPanel, Tabs } from '@umami/react-zen'; -import { type Key, useState } from 'react'; +import locale from 'date-fns/locale/af'; +import { LoadingPanel, MetricCard, MetricsBar } from 'dist'; +import { type Key, useMemo, useState } from 'react'; import { SessionModal } from '@/app/(main)/websites/[websiteId]/sessions/SessionModal'; import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls'; import { Panel } from '@/components/common/Panel'; import { useMessages } from '@/components/hooks'; +import { useEventStatsQuery } from '@/components/hooks/queries/useEventStatsQuery'; import { EventsChart } from '@/components/metrics/EventsChart'; import { MetricsTable } from '@/components/metrics/MetricsTable'; +import { formatLongNumber } from '@/lib/format'; import { getItem, setItem } from '@/lib/storage'; import { EventProperties } from './EventProperties'; import { EventsDataTable } from './EventsDataTable'; @@ -15,16 +19,61 @@ const KEY_NAME = 'umami.events.tab'; export function EventsPage({ websiteId }) { const [tab, setTab] = useState(getItem(KEY_NAME) || 'chart'); - const { formatMessage, labels } = useMessages(); + const { formatMessage, labels, getErrorMessage } = useMessages(); + const { data, isLoading, isFetching, error } = useEventStatsQuery({ + websiteId, + }); const handleSelect = (value: Key) => { setItem(KEY_NAME, value); setTab(value); }; + const metrics = useMemo(() => { + if (!data) return []; + + const { events, visitors, visits, uniqueEvents } = data || {}; + + return [ + { + value: visitors, + label: formatMessage(labels.visitors), + formatValue: formatLongNumber, + }, + { + value: visits, + label: formatMessage(labels.visits), + formatValue: formatLongNumber, + }, + { + value: events, + label: formatMessage(labels.events), + formatValue: formatLongNumber, + }, + { + value: uniqueEvents, + label: formatMessage(labels.uniqueEvents), + formatValue: formatLongNumber, + }, + ] as any; + }, [data, locale]); + return ( + + + {metrics?.map(({ label, value, formatValue }) => { + return ; + })} + + handleSelect(key)}> diff --git a/src/app/api/websites/[websiteId]/events/stats/route.ts b/src/app/api/websites/[websiteId]/events/stats/route.ts new file mode 100644 index 00000000..61e151d4 --- /dev/null +++ b/src/app/api/websites/[websiteId]/events/stats/route.ts @@ -0,0 +1,34 @@ +import { z } from 'zod'; +import { getQueryFilters, parseRequest } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { dateRangeParams, filterParams } from '@/lib/schema'; +import { canViewWebsite } from '@/permissions'; +import { getWebsiteEventStats } from '@/queries/sql/events/getWebsiteEventStats'; + +export async function GET( + request: Request, + { params }: { params: Promise<{ websiteId: string }> }, +) { + const schema = z.object({ + ...dateRangeParams, + ...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 filters = await getQueryFilters(query, websiteId); + + const data = await getWebsiteEventStats(websiteId, filters); + + return json({ data }); +} diff --git a/src/components/hooks/queries/useEventStatsQuery.ts b/src/components/hooks/queries/useEventStatsQuery.ts new file mode 100644 index 00000000..44316ca5 --- /dev/null +++ b/src/components/hooks/queries/useEventStatsQuery.ts @@ -0,0 +1,37 @@ +import type { UseQueryOptions } from '@tanstack/react-query'; +import { useDateParameters } from '@/components/hooks/useDateParameters'; +import { useApi } from '../useApi'; +import { useFilterParameters } from '../useFilterParameters'; + +export interface EventStatsData { + events: number; + visitors: number; + visits: number; + uniqueEvents: number; +} + +type EventStatsApiResponse = { + data: EventStatsData; +}; + +export function useEventStatsQuery( + { websiteId }: { websiteId: string }, + options?: UseQueryOptions, +) { + const { get, useQuery } = useApi(); + const { startAt, endAt } = useDateParameters(); + const filters = useFilterParameters(); + + return useQuery({ + queryKey: ['websites:events:stats', { websiteId, startAt, endAt, ...filters }], + queryFn: () => + get(`/websites/${websiteId}/events/stats`, { + startAt, + endAt, + ...filters, + }), + select: response => response.data, + enabled: !!websiteId, + ...options, + }); +} diff --git a/src/components/messages.ts b/src/components/messages.ts index 3d7388cd..de29c306 100644 --- a/src/components/messages.ts +++ b/src/components/messages.ts @@ -146,6 +146,7 @@ export const labels = defineMessages({ poweredBy: { id: 'label.powered-by', defaultMessage: 'Powered by {name}' }, pageViews: { id: 'label.page-views', defaultMessage: 'Page views' }, uniqueVisitors: { id: 'label.unique-visitors', defaultMessage: 'Unique visitors' }, + uniqueEvents: { id: 'label.unique-events', defaultMessage: 'Unique Events' }, bounceRate: { id: 'label.bounce-rate', defaultMessage: 'Bounce rate' }, viewsPerVisit: { id: 'label.views-per-visit', defaultMessage: 'Views per visit' }, visitDuration: { id: 'label.visit-duration', defaultMessage: 'Visit duration' }, diff --git a/src/queries/sql/events/getWebsiteEventStats.ts b/src/queries/sql/events/getWebsiteEventStats.ts new file mode 100644 index 00000000..27179d10 --- /dev/null +++ b/src/queries/sql/events/getWebsiteEventStats.ts @@ -0,0 +1,97 @@ +import clickhouse from '@/lib/clickhouse'; +import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db'; +import prisma from '@/lib/prisma'; +import type { QueryFilters } from '@/lib/types'; + +const FUNCTION_NAME = 'getWebsiteEventStats'; + +export interface WebsiteEventStatsData { + events: number; + visitors: number; + visits: number; + uniqueEvents: number; +} + +export async function getWebsiteEventStats( + ...args: [websiteId: string, filters: QueryFilters] +): Promise { + return runQuery({ + [PRISMA]: () => relationalQuery(...args), + [CLICKHOUSE]: () => clickhouseQuery(...args), + }); +} + +async function relationalQuery( + websiteId: string, + filters: QueryFilters, +): Promise { + const { parseFilters, rawQuery } = prisma; + const { filterQuery, joinSessionQuery, cohortQuery, queryParams } = parseFilters({ + ...filters, + websiteId, + }); + + return rawQuery( + ` + select + cast(coalesce(sum(t.c), 0) as bigint) as "events", + count(distinct t.session_id) as "visitors", + count(distinct t.visit_id) as "visits", + count(distinct t.event_name) as "uniqueEvents" + from ( + select + website_event.session_id, + website_event.visit_id, + website_event.event_name, + count(*) as "c" + 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} + group by 1, 2, 3 + ) as t + `, + queryParams, + FUNCTION_NAME, + ).then(result => result?.[0]); +} + +async function clickhouseQuery( + websiteId: string, + filters: QueryFilters, +): Promise { + const { rawQuery, parseFilters } = clickhouse; + const { filterQuery, cohortQuery, queryParams } = parseFilters({ + ...filters, + websiteId, + }); + + return rawQuery( + ` + select + sum(t.c) as "events", + uniq(t.session_id) as "visitors", + uniq(t.visit_id) as "visits", + count(distinct t.event_name) as "uniqueEvents" + from ( + select + session_id, + visit_id, + event_name, + count(*) c + from website_event + ${cohortQuery} + where website_id = {websiteId:UUID} + and created_at between {startDate:DateTime64} and {endDate:DateTime64} + and event_type = 2 + ${filterQuery} + group by session_id, visit_id, event_name + ) as t; + `, + queryParams, + FUNCTION_NAME, + ).then(result => result?.[0]); +}