diff --git a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/profiles/[profileId]/most-events/index.tsx b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/profiles/[profileId]/most-events/index.tsx new file mode 100644 index 00000000..f52cd411 --- /dev/null +++ b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/profiles/[profileId]/most-events/index.tsx @@ -0,0 +1,20 @@ +import withLoadingWidget from '@/hocs/with-loading-widget'; +import { escape } from 'sqlstring'; + +import { chQuery } from '@openpanel/db'; + +import MostEvents from './most-events'; + +type Props = { + projectId: string; + profileId: string; +}; + +const MostEventsServer = async ({ projectId, profileId }: Props) => { + const data = await chQuery<{ count: number; name: string }>( + `SELECT count(*) as count, name FROM events WHERE name NOT IN ('screen_view', 'session_start', 'session_end') AND project_id = ${escape(projectId)} and profile_id = ${escape(profileId)} GROUP BY name ORDER BY count DESC` + ); + return ; +}; + +export default withLoadingWidget(MostEventsServer); diff --git a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/profiles/[profileId]/most-events/most-events.tsx b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/profiles/[profileId]/most-events/most-events.tsx new file mode 100644 index 00000000..674f7a3b --- /dev/null +++ b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/profiles/[profileId]/most-events/most-events.tsx @@ -0,0 +1,37 @@ +'use client'; + +import { Widget, WidgetHead, WidgetTitle } from '@/components/widget'; +import { BellIcon } from 'lucide-react'; + +type Props = { + data: { count: number; name: string }[]; +}; + +const MostEvents = ({ data }: Props) => { + const max = Math.max(...data.map((item) => item.count)); + return ( + + + Popular events + +
+ {data.slice(0, 5).map((item) => ( +
+
+
+
{item.name}
+
{item.count}
+
+
+ ))} +
+
+ ); +}; + +export default MostEvents; diff --git a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/profiles/[profileId]/page.tsx b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/profiles/[profileId]/page.tsx index ff4db3dc..8abcd29c 100644 --- a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/profiles/[profileId]/page.tsx +++ b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/profiles/[profileId]/page.tsx @@ -1,4 +1,6 @@ +import { useMemo } from 'react'; import PageLayout from '@/app/(app)/[organizationSlug]/[projectId]/page-layout'; +import { ListPropertiesIcon } from '@/components/events/list-properties-icon'; import { OverviewFiltersButtons } from '@/components/overview/filters/overview-filters-buttons'; import { OverviewFiltersDrawer } from '@/components/overview/filters/overview-filters-drawer'; import { ProfileAvatar } from '@/components/profiles/profile-avatar'; @@ -19,11 +21,16 @@ import { getEventList, getEventsCount, getProfileById, + getProfileMetrics, } from '@openpanel/db'; import type { IChartEvent, IChartInput } from '@openpanel/validation'; import { EventList } from '../../events/event-list'; import { StickyBelowHeader } from '../../layout-sticky-below-header'; +import MostEventsServer from './most-events'; +import PopularRoutesServer from './popular-routes'; +import ProfileActivityServer from './profile-activity'; +import ProfileMetrics from './profile-metrics'; interface PageProps { params: { @@ -57,60 +64,75 @@ export default async function Page({ }; const startDate = parseAsString.parseServerSide(searchParams.startDate); const endDate = parseAsString.parseServerSide(searchParams.endDate); - const [profile, events, count, conversions] = await Promise.all([ + const [profile, events, count, metrics] = await Promise.all([ getProfileById(profileId, projectId), getEventList(eventListOptions), getEventsCount(eventListOptions), - getConversionEventNames(projectId), + getProfileMetrics(profileId, projectId), ]); - const chartSelectedEvents: IChartEvent[] = [ - { - segment: 'event', - filters: [ - { - id: 'profile_id', - name: 'profile_id', - operator: 'is', - value: [profileId], - }, - ], - id: 'A', - name: '*', - displayName: 'Events', - }, - ]; - - if (conversions.length) { - chartSelectedEvents.push({ - segment: 'event', - filters: [ - { - id: 'profile_id', - name: 'profile_id', - operator: 'is', - value: [profileId], - }, - { - id: 'name', - name: 'name', - operator: 'is', - value: conversions.map((c) => c.name), - }, - ], - id: 'B', - name: '*', - displayName: 'Conversions', - }); - } - - const profileChart: IChartInput = { + const pageViewsChart: IChartInput = { projectId, startDate, endDate, - chartType: 'histogram', - events: chartSelectedEvents, - breakdowns: [], + chartType: 'linear', + events: [ + { + segment: 'event', + filters: [ + { + id: 'profile_id', + name: 'profile_id', + operator: 'is', + value: [profileId], + }, + ], + id: 'A', + name: '*', + displayName: 'Events', + }, + ], + breakdowns: [ + { + id: 'path', + name: 'path', + }, + ], + lineType: 'monotone', + interval: 'day', + name: 'Events', + range: '30d', + previous: false, + metric: 'sum', + }; + + const eventsChart: IChartInput = { + projectId, + startDate, + endDate, + chartType: 'linear', + events: [ + { + segment: 'event', + filters: [ + { + id: 'profile_id', + name: 'profile_id', + operator: 'is', + value: [profileId], + }, + ], + id: 'A', + name: '*', + displayName: 'Events', + }, + ], + breakdowns: [ + { + id: 'name', + name: 'name', + }, + ], lineType: 'monotone', interval: 'day', name: 'Events', @@ -125,69 +147,54 @@ export default async function Page({ return ( <> - - - {getProfileName(profile)} + } /> + +
+ +
+

+ {getProfileName(profile)} +

+
+ +
- } - /> - {/* - - - */} +
+ +
-
-
- +
+
+
-
- - - Events per day - - - - - - - - Profile - - -
- - - - - -
-
- - - Properties - -
- {Object.entries(profile.properties) - .filter(([, value]) => !!value) - .map(([key, value]) => ( - - ))} -
-
+
+
+
+ +
+ + + Page views + + + + + + + + Events per day + + + + + +
+
+
@@ -199,7 +206,7 @@ function ValueRow({ name, value }: { name: string; value?: unknown }) { return null; } return ( -
+
{name.replace('_', ' ')}
diff --git a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/profiles/[profileId]/popular-routes/index.tsx b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/profiles/[profileId]/popular-routes/index.tsx new file mode 100644 index 00000000..64980052 --- /dev/null +++ b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/profiles/[profileId]/popular-routes/index.tsx @@ -0,0 +1,20 @@ +import withLoadingWidget from '@/hocs/with-loading-widget'; +import { escape } from 'sqlstring'; + +import { chQuery } from '@openpanel/db'; + +import PopularRoutes from './popular-routes'; + +type Props = { + projectId: string; + profileId: string; +}; + +const PopularRoutesServer = async ({ projectId, profileId }: Props) => { + const data = await chQuery<{ count: number; path: string }>( + `SELECT count(*) as count, path FROM events WHERE name = 'screen_view' AND project_id = ${escape(projectId)} and profile_id = ${escape(profileId)} GROUP BY path ORDER BY count DESC` + ); + return ; +}; + +export default withLoadingWidget(PopularRoutesServer); diff --git a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/profiles/[profileId]/popular-routes/popular-routes.tsx b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/profiles/[profileId]/popular-routes/popular-routes.tsx new file mode 100644 index 00000000..bd11120e --- /dev/null +++ b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/profiles/[profileId]/popular-routes/popular-routes.tsx @@ -0,0 +1,37 @@ +'use client'; + +import { Widget, WidgetHead, WidgetTitle } from '@/components/widget'; +import { BellIcon, MonitorPlayIcon } from 'lucide-react'; + +type Props = { + data: { count: number; path: string }[]; +}; + +const PopularRoutes = ({ data }: Props) => { + const max = Math.max(...data.map((item) => item.count)); + return ( + + + Most visted pages + +
+ {data.slice(0, 5).map((item) => ( +
+
+
+
{item.path}
+
{item.count}
+
+
+ ))} +
+
+ ); +}; + +export default PopularRoutes; diff --git a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/profiles/[profileId]/profile-activity/index.tsx b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/profiles/[profileId]/profile-activity/index.tsx new file mode 100644 index 00000000..0bd724a0 --- /dev/null +++ b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/profiles/[profileId]/profile-activity/index.tsx @@ -0,0 +1,20 @@ +import withLoadingWidget from '@/hocs/with-loading-widget'; +import { escape } from 'sqlstring'; + +import { chQuery } from '@openpanel/db'; + +import ProfileActivity from './profile-activity'; + +type Props = { + projectId: string; + profileId: string; +}; + +const ProfileActivityServer = async ({ projectId, profileId }: Props) => { + const data = await chQuery<{ count: number; date: string }>( + `SELECT count(*) as count, toStartOfDay(created_at) as date FROM events WHERE project_id = ${escape(projectId)} and profile_id = ${escape(profileId)} GROUP BY date ORDER BY date DESC` + ); + return ; +}; + +export default withLoadingWidget(ProfileActivityServer); diff --git a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/profiles/[profileId]/profile-activity/profile-activity.tsx b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/profiles/[profileId]/profile-activity/profile-activity.tsx new file mode 100644 index 00000000..4ca62a83 --- /dev/null +++ b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/profiles/[profileId]/profile-activity/profile-activity.tsx @@ -0,0 +1,105 @@ +'use client'; + +import { useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { + Widget, + WidgetBody, + WidgetHead, + WidgetTitle, +} from '@/components/widget'; +import { cn } from '@/utils/cn'; +import { + addMonths, + eachDayOfInterval, + endOfMonth, + format, + startOfMonth, + subMonths, +} from 'date-fns'; +import { ActivityIcon, ChevronLeftIcon, ChevronRightIcon } from 'lucide-react'; + +type Props = { + data: { count: number; date: string }[]; +}; + +const ProfileActivity = ({ data }: Props) => { + const [startDate, setStartDate] = useState(startOfMonth(new Date())); + const endDate = endOfMonth(startDate); + return ( + + + Activity +
+ + + +
+
+ +
+
+
+ {format(subMonths(startDate, 1), 'MMMM yyyy')} +
+
+ {eachDayOfInterval({ + start: startOfMonth(subMonths(startDate, 1)), + end: endOfMonth(subMonths(startDate, 1)), + }).map((date) => { + const hit = data.find((item) => + item.date.includes(date.toISOString().split('T')[0]) + ); + return ( +
+ ); + })} +
+
+
+
{format(startDate, 'MMMM yyyy')}
+
+ {eachDayOfInterval({ + start: startDate, + end: endDate, + }).map((date) => { + const hit = data.find((item) => + item.date.includes(date.toISOString().split('T')[0]) + ); + return ( +
+ ); + })} +
+
+
+
+
+ ); +}; + +export default ProfileActivity; diff --git a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/profiles/[profileId]/profile-metrics/index.tsx b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/profiles/[profileId]/profile-metrics/index.tsx new file mode 100644 index 00000000..b53a92f6 --- /dev/null +++ b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/profiles/[profileId]/profile-metrics/index.tsx @@ -0,0 +1,17 @@ +import withLoadingWidget from '@/hocs/with-loading-widget'; + +import { getProfileMetrics } from '@openpanel/db'; + +import ProfileMetrics from './profile-metrics'; + +type Props = { + projectId: string; + profileId: string; +}; + +const ProfileMetricsServer = async ({ projectId, profileId }: Props) => { + const data = await getProfileMetrics(profileId, projectId); + return ; +}; + +export default withLoadingWidget(ProfileMetricsServer); diff --git a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/profiles/[profileId]/profile-metrics/profile-metrics.tsx b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/profiles/[profileId]/profile-metrics/profile-metrics.tsx new file mode 100644 index 00000000..2b314f75 --- /dev/null +++ b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/profiles/[profileId]/profile-metrics/profile-metrics.tsx @@ -0,0 +1,68 @@ +'use client'; + +import { useNumber } from '@/hooks/useNumerFormatter'; +import { formatDistanceToNow } from 'date-fns'; + +import type { IProfileMetrics } from '@openpanel/db'; + +type Props = { + data: IProfileMetrics; +}; + +const ProfileMetrics = ({ data }: Props) => { + const number = useNumber(); + return ( +
+
+
+ First seen +
+
+ {formatDistanceToNow(data.firstSeen)} +
+
+
+
+ Last seen +
+
+ {formatDistanceToNow(data.lastSeen)} +
+
+
+
+ Sessions +
+
+ {number.format(data.sessions)} +
+
+
+
+ Avg. Session +
+
+ {number.formatWithUnit(data.durationAvg / 1000, 'min')} +
+
+
+
+ P90. Session +
+
+ {number.formatWithUnit(data.durationP90 / 1000, 'min')} +
+
+
+
+ Page views +
+
+ {number.format(data.screenViews)} +
+
+
+ ); +}; + +export default ProfileMetrics; diff --git a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/profiles/page.tsx b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/profiles/page.tsx index 6f8e8564..73f726f9 100644 --- a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/profiles/page.tsx +++ b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/profiles/page.tsx @@ -1,11 +1,9 @@ +import { Suspense } from 'react'; import PageLayout from '@/app/(app)/[organizationSlug]/[projectId]/page-layout'; -import { OverviewFiltersButtons } from '@/components/overview/filters/overview-filters-buttons'; -import { OverviewFiltersDrawer } from '@/components/overview/filters/overview-filters-drawer'; import { eventQueryFiltersParser } from '@/hooks/useEventQueryFilters'; import { parseAsInteger } from 'nuqs'; -import { StickyBelowHeader } from '../layout-sticky-below-header'; -import ProfileLastSeenServer from './profile-last-seen'; +import LastActiveUsersServer from '../retention/last-active-users'; import ProfileListServer from './profile-list'; import ProfileTopServer from './profile-top'; @@ -31,17 +29,6 @@ export default function Page({ return ( <> - {/* - - - */}
- + ; + return ; } + +export default withLoadingWidget(ProfileListServer); diff --git a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/profiles/profile-list/profile-list.tsx b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/profiles/profile-list/profile-list.tsx index db81477c..1f5ba303 100644 --- a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/profiles/profile-list/profile-list.tsx +++ b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/profiles/profile-list/profile-list.tsx @@ -19,8 +19,9 @@ import type { IServiceProfile } from '@openpanel/db'; interface ProfileListProps { data: IServiceProfile[]; count: number; + limit?: number; } -export function ProfileList({ data, count }: ProfileListProps) { +export function ProfileList({ data, count, limit = 50 }: ProfileListProps) { const { organizationSlug, projectId } = useAppParams(); const { cursor, setCursor } = useCursor(); return ( @@ -32,7 +33,7 @@ export function ProfileList({ data, count }: ProfileListProps) { cursor={cursor} setCursor={setCursor} count={count} - take={10} + take={limit} /> {data.length ? ( @@ -84,7 +85,7 @@ export function ProfileList({ data, count }: ProfileListProps) { cursor={cursor} setCursor={setCursor} count={count} - take={10} + take={limit} />
@@ -97,7 +98,7 @@ export function ProfileList({ data, count }: ProfileListProps) { className="mt-4" variant="outline" size="sm" - onClick={() => setCursor(Math.max(0, count / 10 - 1))} + onClick={() => setCursor(Math.max(0, count / limit - 1))} > Go back diff --git a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/profiles/profile-top/index.tsx b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/profiles/profile-top/index.tsx index 195ac84c..f78ae234 100644 --- a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/profiles/profile-top/index.tsx +++ b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/profiles/profile-top/index.tsx @@ -2,6 +2,7 @@ import { ListPropertiesIcon } from '@/components/events/list-properties-icon'; import { ProfileAvatar } from '@/components/profiles/profile-avatar'; import { Widget, WidgetHead } from '@/components/widget'; import { WidgetTable } from '@/components/widget-table'; +import withLoadingWidget from '@/hocs/with-loading-widget'; import { getProfileName } from '@/utils/getters'; import Link from 'next/link'; import { escape } from 'sqlstring'; @@ -13,14 +14,11 @@ interface Props { organizationSlug: string; } -export default async function ProfileTopServer({ - organizationSlug, - projectId, -}: Props) { +async function ProfileTopServer({ organizationSlug, projectId }: Props) { // Days since last event from users // group by days const res = await chQuery<{ profile_id: string; count: number }>( - `SELECT profile_id, count(*) as count from events where profile_id != '' and project_id = ${escape(projectId)} group by profile_id order by count() DESC LIMIT 10` + `SELECT profile_id, count(*) as count from events where profile_id != '' and project_id = ${escape(projectId)} group by profile_id order by count() DESC LIMIT 50` ); const profiles = await getProfiles({ ids: res.map((r) => r.profile_id) }); const list = res.map((item) => { @@ -71,3 +69,5 @@ export default async function ProfileTopServer({ ); } + +export default withLoadingWidget(ProfileTopServer); diff --git a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/retention/users-retention-series/chart.tsx b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/retention/users-retention-series/chart.tsx index f4bd5a80..bda35bf8 100644 --- a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/retention/users-retention-series/chart.tsx +++ b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/retention/users-retention-series/chart.tsx @@ -56,8 +56,6 @@ function Tooltip({ payload }: any) { const Chart = ({ data }: Props) => { const max = Math.max(...data.map((d) => d.retention)); const number = useNumber(); - console.log('data', data); - return (
diff --git a/apps/dashboard/src/components/profiles/profile-avatar.tsx b/apps/dashboard/src/components/profiles/profile-avatar.tsx index a95699fd..d81fb0aa 100644 --- a/apps/dashboard/src/components/profiles/profile-avatar.tsx +++ b/apps/dashboard/src/components/profiles/profile-avatar.tsx @@ -15,9 +15,10 @@ interface ProfileAvatarProps className?: string; } -const variants = cva('', { +const variants = cva('shrink-0', { variants: { size: { + lg: 'h-14 w-14 rounded [&>span]:rounded', default: 'h-8 w-8 rounded [&>span]:rounded', sm: 'h-6 w-6 rounded [&>span]:rounded', xs: 'h-4 w-4 rounded [&>span]:rounded', @@ -39,11 +40,13 @@ export function ProfileAvatar({ {avatar && } diff --git a/apps/dashboard/src/components/widget.tsx b/apps/dashboard/src/components/widget.tsx index 4d7512bf..fa42b384 100644 --- a/apps/dashboard/src/components/widget.tsx +++ b/apps/dashboard/src/components/widget.tsx @@ -1,4 +1,5 @@ import { cn } from '@/utils/cn'; +import type { LucideIcon } from 'lucide-react'; export interface WidgetHeadProps { children: React.ReactNode; @@ -17,6 +18,34 @@ export function WidgetHead({ children, className }: WidgetHeadProps) { ); } +export interface WidgetTitleProps { + children: React.ReactNode; + className?: string; + icon?: LucideIcon; +} +export function WidgetTitle({ + children, + className, + icon: Icon, +}: WidgetTitleProps) { + return ( +
+ {Icon && ( +
+ +
+ )} +
{children}
+
+ ); +} + export interface WidgetBodyProps { children: React.ReactNode; className?: string; diff --git a/apps/dashboard/src/hocs/with-loading-widget.tsx b/apps/dashboard/src/hocs/with-loading-widget.tsx index debe9de6..ddb91c47 100644 --- a/apps/dashboard/src/hocs/with-loading-widget.tsx +++ b/apps/dashboard/src/hocs/with-loading-widget.tsx @@ -1,19 +1,23 @@ import { Suspense } from 'react'; import { ChartLoading } from '@/components/report/chart/ChartLoading'; import { Widget, WidgetHead } from '@/components/widget'; +import { cn } from '@/utils/cn'; -type Props = Record & { - className?: string; -}; - -const withLoadingWidget =

( - Component: React.ComponentType

-) => { +const withLoadingWidget = (Component: React.ComponentType

) => { const WithLoadingWidget: React.ComponentType

= (props) => { return ( + Loading... diff --git a/apps/dashboard/src/hocs/with-suspense.tsx b/apps/dashboard/src/hocs/with-suspense.tsx index 7ba914a4..5525ddc9 100644 --- a/apps/dashboard/src/hocs/with-suspense.tsx +++ b/apps/dashboard/src/hocs/with-suspense.tsx @@ -5,7 +5,7 @@ const withSuspense = ( Fallback: React.ComponentType

) => { const WithSuspense: React.ComponentType

= (props) => { - const fallback = ; + const fallback = ; return ( diff --git a/packages/db/src/services/profile.service.ts b/packages/db/src/services/profile.service.ts index 799efdee..26b27da3 100644 --- a/packages/db/src/services/profile.service.ts +++ b/packages/db/src/services/profile.service.ts @@ -7,6 +7,35 @@ import { ch, chQuery } from '../clickhouse-client'; import { createSqlBuilder } from '../sql-builder'; import { getEventFiltersWhereClause } from './chart.service'; +export type IProfileMetrics = { + lastSeen: string; + firstSeen: string; + screenViews: number; + sessions: number; + durationAvg: number; + durationP90: number; +}; +export function getProfileMetrics(profileId: string, projectId: string) { + return chQuery(` + WITH lastSeen AS ( + SELECT max(created_at) as lastSeen FROM events WHERE profile_id = ${escape(profileId)} AND project_id = ${escape(projectId)} + ), + firstSeen AS ( + SELECT min(created_at) as firstSeen FROM events WHERE profile_id = ${escape(profileId)} AND project_id = ${escape(projectId)} + ), + screenViews AS ( + SELECT count(*) as screenViews FROM events WHERE name = 'screen_view' AND profile_id = ${escape(profileId)} AND project_id = ${escape(projectId)} + ), + sessions AS ( + SELECT count(*) as sessions FROM events WHERE name = 'session_start' AND profile_id = ${escape(profileId)} AND project_id = ${escape(projectId)} + ), + duration AS ( + SELECT avg(duration) as durationAvg, quantilesExactInclusive(0.9)(duration)[1] as durationP90 FROM events WHERE name = 'session_end' AND duration != 0 AND profile_id = ${escape(profileId)} AND project_id = ${escape(projectId)} + ) + SELECT lastSeen, firstSeen, screenViews, sessions, durationAvg, durationP90 FROM lastSeen, firstSeen, screenViews,sessions, duration + `).then((data) => data[0]!); +} + export async function getProfileById(id: string, projectId: string) { if (id === '' || projectId === '') { return null;