update profile page

This commit is contained in:
Carl-Gerhard Lindesvärd
2024-05-07 14:39:10 +02:00
parent fc0a6a3c73
commit 25db65005a
19 changed files with 539 additions and 155 deletions

View File

@@ -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);

View File

@@ -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;

View File

@@ -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 (
<>
<PageLayout
organizationSlug={organizationSlug}
title={
<div className="flex items-center gap-2">
<ProfileAvatar {...profile} size="sm" className="hidden sm:block" />
{getProfileName(profile)}
<PageLayout organizationSlug={organizationSlug} title={<div />} />
<StickyBelowHeader className="!relative !top-auto !z-0 flex items-center gap-8 p-8">
<div className="flex flex-1 gap-4">
<ProfileAvatar {...profile} size={'lg'} />
<div className="">
<h1 className="text-2xl font-semibold">
{getProfileName(profile)}
</h1>
<div className="flex items-center gap-4">
<ListPropertiesIcon {...profile.properties} />
</div>
</div>
}
/>
{/* <StickyBelowHeader className="flex justify-between p-4">
<OverviewFiltersDrawer
projectId={projectId}
mode="events"
nuqsOptions={{ shallow: false }}
/>
<OverviewFiltersButtons
nuqsOptions={{ shallow: false }}
className="justify-end p-0"
/>
</StickyBelowHeader> */}
</div>
<ProfileMetrics profileId={profileId} projectId={projectId} />
</StickyBelowHeader>
<div className="p-4">
<div className="mb-8 grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<EventList data={events} count={count} />
<div className="grid grid-cols-1 gap-4 md:grid-cols-6">
<div className="col-span-2">
<ProfileActivityServer
profileId={profileId}
projectId={projectId}
/>
</div>
<div className="flex flex-col gap-4">
<Widget className="w-full">
<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 className="col-span-2">
<MostEventsServer profileId={profileId} projectId={projectId} />
</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>
</>
@@ -199,7 +206,7 @@ function ValueRow({ name, value }: { name: string; value?: unknown }) {
return null;
}
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">
{name.replace('_', ' ')}
</div>

View File

@@ -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);

View File

@@ -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;

View File

@@ -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);

View File

@@ -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;

View File

@@ -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);

View File

@@ -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;

View File

@@ -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 (
<>
<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">
<ProfileListServer
projectId={projectId}
@@ -51,7 +38,7 @@ export default function Page({
}
/>
<div className="flex flex-col gap-4">
<ProfileLastSeenServer projectId={projectId} />
<LastActiveUsersServer projectId={projectId} />
<ProfileTopServer
projectId={projectId}
organizationSlug={organizationSlug}

View File

@@ -1,3 +1,5 @@
import withLoadingWidget from '@/hocs/with-loading-widget';
import { getProfileList, getProfileListCount } from '@openpanel/db';
import type { IChartEventFilter } from '@openpanel/validation';
@@ -9,15 +11,13 @@ interface Props {
filters?: IChartEventFilter[];
}
export default async function ProfileListServer({
projectId,
cursor,
filters,
}: Props) {
const limit = 50;
async function ProfileListServer({ projectId, cursor, filters }: Props) {
const [profiles, count] = await Promise.all([
getProfileList({
projectId,
take: 10,
take: limit,
cursor,
filters,
}),
@@ -26,5 +26,7 @@ export default async function ProfileListServer({
filters,
}),
]);
return <ProfileList data={profiles} count={count} />;
return <ProfileList data={profiles} count={count} limit={limit} />;
}
export default withLoadingWidget(ProfileListServer);

View File

@@ -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}
/>
</WidgetHead>
{data.length ? (
@@ -84,7 +85,7 @@ export function ProfileList({ data, count }: ProfileListProps) {
cursor={cursor}
setCursor={setCursor}
count={count}
take={10}
take={limit}
/>
</div>
</>
@@ -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
</Button>

View File

@@ -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({
</Widget>
);
}
export default withLoadingWidget(ProfileTopServer);

View File

@@ -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 (
<div className="p-4">
<ResponsiveContainer>

View File

@@ -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 && <AvatarImage src={avatar} />}
<AvatarFallback
className={cn(
size === 'sm'
? 'text-xs'
: size === 'xs'
? 'text-[8px]'
: 'text-base',
size === 'lg'
? 'text-lg'
: size === 'sm'
? 'text-xs'
: size === 'xs'
? 'text-[8px]'
: 'text-base',
'bg-slate-200 text-slate-800'
)}
>

View File

@@ -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 (
<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 {
children: React.ReactNode;
className?: string;

View File

@@ -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<string, unknown> & {
className?: string;
};
const withLoadingWidget = <P extends Props>(
Component: React.ComponentType<P>
) => {
const withLoadingWidget = <P,>(Component: React.ComponentType<P>) => {
const WithLoadingWidget: React.ComponentType<P> = (props) => {
return (
<Suspense
fallback={
<Widget className={props.className}>
<Widget
className={cn(
'w-full',
props &&
typeof props === 'object' &&
'className' in props &&
typeof props.className === 'string' &&
props?.className
)}
>
<WidgetHead>
<span className="title">Loading...</span>
</WidgetHead>

View File

@@ -5,7 +5,7 @@ const withSuspense = <P,>(
Fallback: React.ComponentType<P>
) => {
const WithSuspense: React.ComponentType<P> = (props) => {
const fallback = <Fallback {...(props as any)} key="faaaaalling" />;
const fallback = <Fallback {...(props as any)} />;
return (
<Suspense fallback={fallback}>
<Component {...(props as any)} />