update profile page
This commit is contained in:
@@ -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 <MostEvents data={data} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default withLoadingWidget(MostEventsServer);
|
||||||
@@ -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 (
|
||||||
|
<Widget className="w-full">
|
||||||
|
<WidgetHead>
|
||||||
|
<WidgetTitle icon={BellIcon}>Popular events</WidgetTitle>
|
||||||
|
</WidgetHead>
|
||||||
|
<div className="flex flex-col gap-1 p-1">
|
||||||
|
{data.slice(0, 5).map((item) => (
|
||||||
|
<div key={item.name} className="relative px-3 py-2">
|
||||||
|
<div
|
||||||
|
className="absolute bottom-0 left-0 top-0 rounded bg-slate-100"
|
||||||
|
style={{
|
||||||
|
width: `${(item.count / max) * 100}%`,
|
||||||
|
}}
|
||||||
|
></div>
|
||||||
|
<div className="relative flex justify-between text-sm">
|
||||||
|
<div>{item.name}</div>
|
||||||
|
<div>{item.count}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Widget>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MostEvents;
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
import PageLayout from '@/app/(app)/[organizationSlug]/[projectId]/page-layout';
|
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 { OverviewFiltersButtons } from '@/components/overview/filters/overview-filters-buttons';
|
||||||
import { OverviewFiltersDrawer } from '@/components/overview/filters/overview-filters-drawer';
|
import { OverviewFiltersDrawer } from '@/components/overview/filters/overview-filters-drawer';
|
||||||
import { ProfileAvatar } from '@/components/profiles/profile-avatar';
|
import { ProfileAvatar } from '@/components/profiles/profile-avatar';
|
||||||
@@ -19,11 +21,16 @@ import {
|
|||||||
getEventList,
|
getEventList,
|
||||||
getEventsCount,
|
getEventsCount,
|
||||||
getProfileById,
|
getProfileById,
|
||||||
|
getProfileMetrics,
|
||||||
} from '@openpanel/db';
|
} from '@openpanel/db';
|
||||||
import type { IChartEvent, IChartInput } from '@openpanel/validation';
|
import type { IChartEvent, IChartInput } from '@openpanel/validation';
|
||||||
|
|
||||||
import { EventList } from '../../events/event-list';
|
import { EventList } from '../../events/event-list';
|
||||||
import { StickyBelowHeader } from '../../layout-sticky-below-header';
|
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 {
|
interface PageProps {
|
||||||
params: {
|
params: {
|
||||||
@@ -57,60 +64,75 @@ export default async function Page({
|
|||||||
};
|
};
|
||||||
const startDate = parseAsString.parseServerSide(searchParams.startDate);
|
const startDate = parseAsString.parseServerSide(searchParams.startDate);
|
||||||
const endDate = parseAsString.parseServerSide(searchParams.endDate);
|
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),
|
getProfileById(profileId, projectId),
|
||||||
getEventList(eventListOptions),
|
getEventList(eventListOptions),
|
||||||
getEventsCount(eventListOptions),
|
getEventsCount(eventListOptions),
|
||||||
getConversionEventNames(projectId),
|
getProfileMetrics(profileId, projectId),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const chartSelectedEvents: IChartEvent[] = [
|
const pageViewsChart: IChartInput = {
|
||||||
{
|
|
||||||
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 = {
|
|
||||||
projectId,
|
projectId,
|
||||||
startDate,
|
startDate,
|
||||||
endDate,
|
endDate,
|
||||||
chartType: 'histogram',
|
chartType: 'linear',
|
||||||
events: chartSelectedEvents,
|
events: [
|
||||||
breakdowns: [],
|
{
|
||||||
|
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',
|
lineType: 'monotone',
|
||||||
interval: 'day',
|
interval: 'day',
|
||||||
name: 'Events',
|
name: 'Events',
|
||||||
@@ -125,69 +147,54 @@ export default async function Page({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageLayout
|
<PageLayout organizationSlug={organizationSlug} title={<div />} />
|
||||||
organizationSlug={organizationSlug}
|
<StickyBelowHeader className="!relative !top-auto !z-0 flex items-center gap-8 p-8">
|
||||||
title={
|
<div className="flex flex-1 gap-4">
|
||||||
<div className="flex items-center gap-2">
|
<ProfileAvatar {...profile} size={'lg'} />
|
||||||
<ProfileAvatar {...profile} size="sm" className="hidden sm:block" />
|
<div className="">
|
||||||
{getProfileName(profile)}
|
<h1 className="text-2xl font-semibold">
|
||||||
|
{getProfileName(profile)}
|
||||||
|
</h1>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<ListPropertiesIcon {...profile.properties} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
</div>
|
||||||
/>
|
<ProfileMetrics profileId={profileId} projectId={projectId} />
|
||||||
{/* <StickyBelowHeader className="flex justify-between p-4">
|
</StickyBelowHeader>
|
||||||
<OverviewFiltersDrawer
|
|
||||||
projectId={projectId}
|
|
||||||
mode="events"
|
|
||||||
nuqsOptions={{ shallow: false }}
|
|
||||||
/>
|
|
||||||
<OverviewFiltersButtons
|
|
||||||
nuqsOptions={{ shallow: false }}
|
|
||||||
className="justify-end p-0"
|
|
||||||
/>
|
|
||||||
</StickyBelowHeader> */}
|
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<div className="mb-8 grid grid-cols-1 gap-4 md:grid-cols-2">
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-6">
|
||||||
<div>
|
<div className="col-span-2">
|
||||||
<EventList data={events} count={count} />
|
<ProfileActivityServer
|
||||||
|
profileId={profileId}
|
||||||
|
projectId={projectId}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="col-span-2">
|
||||||
<Widget className="w-full">
|
<MostEventsServer profileId={profileId} projectId={projectId} />
|
||||||
<WidgetHead>
|
|
||||||
<span className="title">Events per day</span>
|
|
||||||
</WidgetHead>
|
|
||||||
<WidgetBody className="flex gap-2">
|
|
||||||
<ChartSwitch {...profileChart} />
|
|
||||||
</WidgetBody>
|
|
||||||
</Widget>
|
|
||||||
<Widget className="w-full">
|
|
||||||
<WidgetHead className="flex items-center justify-between">
|
|
||||||
<span className="title">Profile</span>
|
|
||||||
<ProfileAvatar {...profile} />
|
|
||||||
</WidgetHead>
|
|
||||||
<div className="grid grid-cols-1 text-sm">
|
|
||||||
<ValueRow name={'ID'} value={profile.id} />
|
|
||||||
<ValueRow name={'First name'} value={profile.firstName} />
|
|
||||||
<ValueRow name={'Last name'} value={profile.lastName} />
|
|
||||||
<ValueRow name={'Mail'} value={profile.email} />
|
|
||||||
<ValueRow
|
|
||||||
name={'Last seen'}
|
|
||||||
value={profile.createdAt.toLocaleString()}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Widget>
|
|
||||||
<Widget className="w-full">
|
|
||||||
<WidgetHead>
|
|
||||||
<span className="title">Properties</span>
|
|
||||||
</WidgetHead>
|
|
||||||
<div className="grid grid-cols-1 text-sm">
|
|
||||||
{Object.entries(profile.properties)
|
|
||||||
.filter(([, value]) => !!value)
|
|
||||||
.map(([key, value]) => (
|
|
||||||
<ValueRow key={key} name={key} value={value} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Widget>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="col-span-2">
|
||||||
|
<PopularRoutesServer profileId={profileId} projectId={projectId} />
|
||||||
|
</div>
|
||||||
|
<Widget className="col-span-3 w-full">
|
||||||
|
<WidgetHead>
|
||||||
|
<span className="title">Page views</span>
|
||||||
|
</WidgetHead>
|
||||||
|
<WidgetBody className="flex gap-2">
|
||||||
|
<ChartSwitch {...pageViewsChart} />
|
||||||
|
</WidgetBody>
|
||||||
|
</Widget>
|
||||||
|
<Widget className="col-span-3 w-full">
|
||||||
|
<WidgetHead>
|
||||||
|
<span className="title">Events per day</span>
|
||||||
|
</WidgetHead>
|
||||||
|
<WidgetBody className="flex gap-2">
|
||||||
|
<ChartSwitch {...eventsChart} />
|
||||||
|
</WidgetBody>
|
||||||
|
</Widget>
|
||||||
|
</div>
|
||||||
|
<div className="mt-8">
|
||||||
|
<EventList data={events} count={count} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -199,7 +206,7 @@ function ValueRow({ name, value }: { name: string; value?: unknown }) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-row justify-between p-2 px-4">
|
<div className="flex flex-row justify-between">
|
||||||
<div className="font-medium capitalize text-muted-foreground">
|
<div className="font-medium capitalize text-muted-foreground">
|
||||||
{name.replace('_', ' ')}
|
{name.replace('_', ' ')}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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 <PopularRoutes data={data} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default withLoadingWidget(PopularRoutesServer);
|
||||||
@@ -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 (
|
||||||
|
<Widget className="w-full">
|
||||||
|
<WidgetHead>
|
||||||
|
<WidgetTitle icon={MonitorPlayIcon}>Most visted pages</WidgetTitle>
|
||||||
|
</WidgetHead>
|
||||||
|
<div className="flex flex-col gap-1 p-1">
|
||||||
|
{data.slice(0, 5).map((item) => (
|
||||||
|
<div key={item.path} className="relative px-3 py-2">
|
||||||
|
<div
|
||||||
|
className="absolute bottom-0 left-0 top-0 rounded bg-slate-100"
|
||||||
|
style={{
|
||||||
|
width: `${(item.count / max) * 100}%`,
|
||||||
|
}}
|
||||||
|
></div>
|
||||||
|
<div className="relative flex justify-between text-sm">
|
||||||
|
<div>{item.path}</div>
|
||||||
|
<div>{item.count}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Widget>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PopularRoutes;
|
||||||
@@ -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 <ProfileActivity data={data} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default withLoadingWidget(ProfileActivityServer);
|
||||||
@@ -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 (
|
||||||
|
<Widget className="w-full">
|
||||||
|
<WidgetHead className="flex justify-between">
|
||||||
|
<WidgetTitle icon={ActivityIcon}>Activity</WidgetTitle>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setStartDate(subMonths(startDate, 1))}
|
||||||
|
>
|
||||||
|
<ChevronLeftIcon size={14} />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setStartDate(addMonths(startDate, 1))}
|
||||||
|
>
|
||||||
|
<ChevronRightIcon size={14} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</WidgetHead>
|
||||||
|
<WidgetBody className="p-0">
|
||||||
|
<div className="grid grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<div className="p-1 text-xs">
|
||||||
|
{format(subMonths(startDate, 1), 'MMMM yyyy')}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-7 gap-1 p-1">
|
||||||
|
{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 (
|
||||||
|
<div
|
||||||
|
key={date.toISOString()}
|
||||||
|
className={cn(
|
||||||
|
'aspect-square w-full rounded',
|
||||||
|
hit ? 'bg-blue-600' : 'bg-slate-100'
|
||||||
|
)}
|
||||||
|
></div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="p-1 text-xs">{format(startDate, 'MMMM yyyy')}</div>
|
||||||
|
<div className="grid grid-cols-7 gap-1 p-1">
|
||||||
|
{eachDayOfInterval({
|
||||||
|
start: startDate,
|
||||||
|
end: endDate,
|
||||||
|
}).map((date) => {
|
||||||
|
const hit = data.find((item) =>
|
||||||
|
item.date.includes(date.toISOString().split('T')[0])
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={date.toISOString()}
|
||||||
|
className={cn(
|
||||||
|
'aspect-square w-full rounded',
|
||||||
|
hit ? 'bg-blue-600' : 'bg-slate-100'
|
||||||
|
)}
|
||||||
|
></div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</WidgetBody>
|
||||||
|
</Widget>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProfileActivity;
|
||||||
@@ -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 <ProfileMetrics data={data} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default withLoadingWidget(ProfileMetricsServer);
|
||||||
@@ -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 (
|
||||||
|
<div className="flex gap-6">
|
||||||
|
<div className="rounded-xl text-right">
|
||||||
|
<div className="text-xs font-medium text-muted-foreground">
|
||||||
|
First seen
|
||||||
|
</div>
|
||||||
|
<div className="text-xl font-medium">
|
||||||
|
{formatDistanceToNow(data.firstSeen)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl text-right">
|
||||||
|
<div className="text-xs font-medium text-muted-foreground">
|
||||||
|
Last seen
|
||||||
|
</div>
|
||||||
|
<div className="text-xl font-medium">
|
||||||
|
{formatDistanceToNow(data.lastSeen)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl text-right">
|
||||||
|
<div className="text-xs font-medium text-muted-foreground">
|
||||||
|
Sessions
|
||||||
|
</div>
|
||||||
|
<div className="text-xl font-medium">
|
||||||
|
{number.format(data.sessions)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl text-right">
|
||||||
|
<div className="text-xs font-medium text-muted-foreground">
|
||||||
|
Avg. Session
|
||||||
|
</div>
|
||||||
|
<div className="text-xl font-medium">
|
||||||
|
{number.formatWithUnit(data.durationAvg / 1000, 'min')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl text-right">
|
||||||
|
<div className="text-xs font-medium text-muted-foreground">
|
||||||
|
P90. Session
|
||||||
|
</div>
|
||||||
|
<div className="text-xl font-medium">
|
||||||
|
{number.formatWithUnit(data.durationP90 / 1000, 'min')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl text-right">
|
||||||
|
<div className="text-xs font-medium text-muted-foreground">
|
||||||
|
Page views
|
||||||
|
</div>
|
||||||
|
<div className="text-xl font-medium">
|
||||||
|
{number.format(data.screenViews)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProfileMetrics;
|
||||||
@@ -1,11 +1,9 @@
|
|||||||
|
import { Suspense } from 'react';
|
||||||
import PageLayout from '@/app/(app)/[organizationSlug]/[projectId]/page-layout';
|
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 { eventQueryFiltersParser } from '@/hooks/useEventQueryFilters';
|
||||||
import { parseAsInteger } from 'nuqs';
|
import { parseAsInteger } from 'nuqs';
|
||||||
|
|
||||||
import { StickyBelowHeader } from '../layout-sticky-below-header';
|
import LastActiveUsersServer from '../retention/last-active-users';
|
||||||
import ProfileLastSeenServer from './profile-last-seen';
|
|
||||||
import ProfileListServer from './profile-list';
|
import ProfileListServer from './profile-list';
|
||||||
import ProfileTopServer from './profile-top';
|
import ProfileTopServer from './profile-top';
|
||||||
|
|
||||||
@@ -31,17 +29,6 @@ export default function Page({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageLayout title="Profiles" organizationSlug={organizationSlug} />
|
<PageLayout title="Profiles" organizationSlug={organizationSlug} />
|
||||||
{/* <StickyBelowHeader className="flex justify-between p-4">
|
|
||||||
<OverviewFiltersDrawer
|
|
||||||
projectId={projectId}
|
|
||||||
nuqsOptions={nuqsOptions}
|
|
||||||
mode="events"
|
|
||||||
/>
|
|
||||||
<OverviewFiltersButtons
|
|
||||||
className="justify-end p-0"
|
|
||||||
nuqsOptions={nuqsOptions}
|
|
||||||
/>
|
|
||||||
</StickyBelowHeader> */}
|
|
||||||
<div className="grid grid-cols-1 gap-4 p-4 md:grid-cols-2">
|
<div className="grid grid-cols-1 gap-4 p-4 md:grid-cols-2">
|
||||||
<ProfileListServer
|
<ProfileListServer
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
@@ -51,7 +38,7 @@ export default function Page({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<ProfileLastSeenServer projectId={projectId} />
|
<LastActiveUsersServer projectId={projectId} />
|
||||||
<ProfileTopServer
|
<ProfileTopServer
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
organizationSlug={organizationSlug}
|
organizationSlug={organizationSlug}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import withLoadingWidget from '@/hocs/with-loading-widget';
|
||||||
|
|
||||||
import { getProfileList, getProfileListCount } from '@openpanel/db';
|
import { getProfileList, getProfileListCount } from '@openpanel/db';
|
||||||
import type { IChartEventFilter } from '@openpanel/validation';
|
import type { IChartEventFilter } from '@openpanel/validation';
|
||||||
|
|
||||||
@@ -9,15 +11,13 @@ interface Props {
|
|||||||
filters?: IChartEventFilter[];
|
filters?: IChartEventFilter[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function ProfileListServer({
|
const limit = 50;
|
||||||
projectId,
|
|
||||||
cursor,
|
async function ProfileListServer({ projectId, cursor, filters }: Props) {
|
||||||
filters,
|
|
||||||
}: Props) {
|
|
||||||
const [profiles, count] = await Promise.all([
|
const [profiles, count] = await Promise.all([
|
||||||
getProfileList({
|
getProfileList({
|
||||||
projectId,
|
projectId,
|
||||||
take: 10,
|
take: limit,
|
||||||
cursor,
|
cursor,
|
||||||
filters,
|
filters,
|
||||||
}),
|
}),
|
||||||
@@ -26,5 +26,7 @@ export default async function ProfileListServer({
|
|||||||
filters,
|
filters,
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
return <ProfileList data={profiles} count={count} />;
|
return <ProfileList data={profiles} count={count} limit={limit} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default withLoadingWidget(ProfileListServer);
|
||||||
|
|||||||
@@ -19,8 +19,9 @@ import type { IServiceProfile } from '@openpanel/db';
|
|||||||
interface ProfileListProps {
|
interface ProfileListProps {
|
||||||
data: IServiceProfile[];
|
data: IServiceProfile[];
|
||||||
count: number;
|
count: number;
|
||||||
|
limit?: number;
|
||||||
}
|
}
|
||||||
export function ProfileList({ data, count }: ProfileListProps) {
|
export function ProfileList({ data, count, limit = 50 }: ProfileListProps) {
|
||||||
const { organizationSlug, projectId } = useAppParams();
|
const { organizationSlug, projectId } = useAppParams();
|
||||||
const { cursor, setCursor } = useCursor();
|
const { cursor, setCursor } = useCursor();
|
||||||
return (
|
return (
|
||||||
@@ -32,7 +33,7 @@ export function ProfileList({ data, count }: ProfileListProps) {
|
|||||||
cursor={cursor}
|
cursor={cursor}
|
||||||
setCursor={setCursor}
|
setCursor={setCursor}
|
||||||
count={count}
|
count={count}
|
||||||
take={10}
|
take={limit}
|
||||||
/>
|
/>
|
||||||
</WidgetHead>
|
</WidgetHead>
|
||||||
{data.length ? (
|
{data.length ? (
|
||||||
@@ -84,7 +85,7 @@ export function ProfileList({ data, count }: ProfileListProps) {
|
|||||||
cursor={cursor}
|
cursor={cursor}
|
||||||
setCursor={setCursor}
|
setCursor={setCursor}
|
||||||
count={count}
|
count={count}
|
||||||
take={10}
|
take={limit}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -97,7 +98,7 @@ export function ProfileList({ data, count }: ProfileListProps) {
|
|||||||
className="mt-4"
|
className="mt-4"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setCursor(Math.max(0, count / 10 - 1))}
|
onClick={() => setCursor(Math.max(0, count / limit - 1))}
|
||||||
>
|
>
|
||||||
Go back
|
Go back
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { ListPropertiesIcon } from '@/components/events/list-properties-icon';
|
|||||||
import { ProfileAvatar } from '@/components/profiles/profile-avatar';
|
import { ProfileAvatar } from '@/components/profiles/profile-avatar';
|
||||||
import { Widget, WidgetHead } from '@/components/widget';
|
import { Widget, WidgetHead } from '@/components/widget';
|
||||||
import { WidgetTable } from '@/components/widget-table';
|
import { WidgetTable } from '@/components/widget-table';
|
||||||
|
import withLoadingWidget from '@/hocs/with-loading-widget';
|
||||||
import { getProfileName } from '@/utils/getters';
|
import { getProfileName } from '@/utils/getters';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { escape } from 'sqlstring';
|
import { escape } from 'sqlstring';
|
||||||
@@ -13,14 +14,11 @@ interface Props {
|
|||||||
organizationSlug: string;
|
organizationSlug: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function ProfileTopServer({
|
async function ProfileTopServer({ organizationSlug, projectId }: Props) {
|
||||||
organizationSlug,
|
|
||||||
projectId,
|
|
||||||
}: Props) {
|
|
||||||
// Days since last event from users
|
// Days since last event from users
|
||||||
// group by days
|
// group by days
|
||||||
const res = await chQuery<{ profile_id: string; count: number }>(
|
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 profiles = await getProfiles({ ids: res.map((r) => r.profile_id) });
|
||||||
const list = res.map((item) => {
|
const list = res.map((item) => {
|
||||||
@@ -71,3 +69,5 @@ export default async function ProfileTopServer({
|
|||||||
</Widget>
|
</Widget>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default withLoadingWidget(ProfileTopServer);
|
||||||
|
|||||||
@@ -56,8 +56,6 @@ function Tooltip({ payload }: any) {
|
|||||||
const Chart = ({ data }: Props) => {
|
const Chart = ({ data }: Props) => {
|
||||||
const max = Math.max(...data.map((d) => d.retention));
|
const max = Math.max(...data.map((d) => d.retention));
|
||||||
const number = useNumber();
|
const number = useNumber();
|
||||||
console.log('data', data);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<ResponsiveContainer>
|
<ResponsiveContainer>
|
||||||
|
|||||||
@@ -15,9 +15,10 @@ interface ProfileAvatarProps
|
|||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const variants = cva('', {
|
const variants = cva('shrink-0', {
|
||||||
variants: {
|
variants: {
|
||||||
size: {
|
size: {
|
||||||
|
lg: 'h-14 w-14 rounded [&>span]:rounded',
|
||||||
default: 'h-8 w-8 rounded [&>span]:rounded',
|
default: 'h-8 w-8 rounded [&>span]:rounded',
|
||||||
sm: 'h-6 w-6 rounded [&>span]:rounded',
|
sm: 'h-6 w-6 rounded [&>span]:rounded',
|
||||||
xs: 'h-4 w-4 rounded [&>span]:rounded',
|
xs: 'h-4 w-4 rounded [&>span]:rounded',
|
||||||
@@ -39,11 +40,13 @@ export function ProfileAvatar({
|
|||||||
{avatar && <AvatarImage src={avatar} />}
|
{avatar && <AvatarImage src={avatar} />}
|
||||||
<AvatarFallback
|
<AvatarFallback
|
||||||
className={cn(
|
className={cn(
|
||||||
size === 'sm'
|
size === 'lg'
|
||||||
? 'text-xs'
|
? 'text-lg'
|
||||||
: size === 'xs'
|
: size === 'sm'
|
||||||
? 'text-[8px]'
|
? 'text-xs'
|
||||||
: 'text-base',
|
: size === 'xs'
|
||||||
|
? 'text-[8px]'
|
||||||
|
: 'text-base',
|
||||||
'bg-slate-200 text-slate-800'
|
'bg-slate-200 text-slate-800'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { cn } from '@/utils/cn';
|
import { cn } from '@/utils/cn';
|
||||||
|
import type { LucideIcon } from 'lucide-react';
|
||||||
|
|
||||||
export interface WidgetHeadProps {
|
export interface WidgetHeadProps {
|
||||||
children: React.ReactNode;
|
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 (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'relative flex items-center gap-4',
|
||||||
|
className,
|
||||||
|
!!Icon && 'pl-12'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{Icon && (
|
||||||
|
<div className="absolute left-0 rounded-lg bg-slate-100 p-2">
|
||||||
|
<Icon size={18} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="title">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export interface WidgetBodyProps {
|
export interface WidgetBodyProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
|||||||
@@ -1,19 +1,23 @@
|
|||||||
import { Suspense } from 'react';
|
import { Suspense } from 'react';
|
||||||
import { ChartLoading } from '@/components/report/chart/ChartLoading';
|
import { ChartLoading } from '@/components/report/chart/ChartLoading';
|
||||||
import { Widget, WidgetHead } from '@/components/widget';
|
import { Widget, WidgetHead } from '@/components/widget';
|
||||||
|
import { cn } from '@/utils/cn';
|
||||||
|
|
||||||
type Props = Record<string, unknown> & {
|
const withLoadingWidget = <P,>(Component: React.ComponentType<P>) => {
|
||||||
className?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const withLoadingWidget = <P extends Props>(
|
|
||||||
Component: React.ComponentType<P>
|
|
||||||
) => {
|
|
||||||
const WithLoadingWidget: React.ComponentType<P> = (props) => {
|
const WithLoadingWidget: React.ComponentType<P> = (props) => {
|
||||||
return (
|
return (
|
||||||
<Suspense
|
<Suspense
|
||||||
fallback={
|
fallback={
|
||||||
<Widget className={props.className}>
|
<Widget
|
||||||
|
className={cn(
|
||||||
|
'w-full',
|
||||||
|
props &&
|
||||||
|
typeof props === 'object' &&
|
||||||
|
'className' in props &&
|
||||||
|
typeof props.className === 'string' &&
|
||||||
|
props?.className
|
||||||
|
)}
|
||||||
|
>
|
||||||
<WidgetHead>
|
<WidgetHead>
|
||||||
<span className="title">Loading...</span>
|
<span className="title">Loading...</span>
|
||||||
</WidgetHead>
|
</WidgetHead>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ const withSuspense = <P,>(
|
|||||||
Fallback: React.ComponentType<P>
|
Fallback: React.ComponentType<P>
|
||||||
) => {
|
) => {
|
||||||
const WithSuspense: React.ComponentType<P> = (props) => {
|
const WithSuspense: React.ComponentType<P> = (props) => {
|
||||||
const fallback = <Fallback {...(props as any)} key="faaaaalling" />;
|
const fallback = <Fallback {...(props as any)} />;
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={fallback}>
|
<Suspense fallback={fallback}>
|
||||||
<Component {...(props as any)} />
|
<Component {...(props as any)} />
|
||||||
|
|||||||
@@ -7,6 +7,35 @@ import { ch, chQuery } from '../clickhouse-client';
|
|||||||
import { createSqlBuilder } from '../sql-builder';
|
import { createSqlBuilder } from '../sql-builder';
|
||||||
import { getEventFiltersWhereClause } from './chart.service';
|
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<IProfileMetrics>(`
|
||||||
|
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) {
|
export async function getProfileById(id: string, projectId: string) {
|
||||||
if (id === '' || projectId === '') {
|
if (id === '' || projectId === '') {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
Reference in New Issue
Block a user