improve profile page

This commit is contained in:
Carl-Gerhard Lindesvärd
2024-05-07 23:27:31 +02:00
parent bc54801ad3
commit ab367cf4db
14 changed files with 85 additions and 127 deletions

View File

@@ -30,7 +30,7 @@ interface EventListProps {
count: number; count: number;
} }
export function EventList({ data, count }: EventListProps) { export function EventList({ data, count }: EventListProps) {
const { cursor, setCursor } = useCursor(); const { cursor, setCursor, loading } = useCursor();
const [filters] = useEventQueryFilters(); const [filters] = useEventQueryFilters();
return ( return (
<> <>
@@ -77,6 +77,7 @@ export function EventList({ data, count }: EventListProps) {
setCursor={setCursor} setCursor={setCursor}
count={count} count={count}
take={50} take={50}
loading={loading}
/> />
)} )}
</div> </div>
@@ -92,6 +93,7 @@ export function EventList({ data, count }: EventListProps) {
setCursor={setCursor} setCursor={setCursor}
count={count} count={count}
take={50} take={50}
loading={loading}
/> />
</> </>
)} )}

View File

@@ -1,3 +0,0 @@
import FullPageLoadingState from '@/components/full-page-loading-state';
export default FullPageLoadingState;

View File

@@ -1,4 +1,4 @@
import { useMemo } from 'react'; import { Suspense, 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 { 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';
@@ -64,12 +64,7 @@ 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, metrics] = await Promise.all([ const profile = await getProfileById(profileId, projectId);
getProfileById(profileId, projectId),
getEventList(eventListOptions),
getEventsCount(eventListOptions),
getProfileMetrics(profileId, projectId),
]);
const pageViewsChart: IChartInput = { const pageViewsChart: IChartInput = {
projectId, projectId,
@@ -148,11 +143,11 @@ export default async function Page({
return ( return (
<> <>
<PageLayout organizationSlug={organizationSlug} title={<div />} /> <PageLayout organizationSlug={organizationSlug} title={<div />} />
<StickyBelowHeader className="!relative !top-auto !z-0 flex items-center gap-8 p-8"> <StickyBelowHeader className="!relative !top-auto !z-0 flex flex-col gap-8 p-4 md:flex-row md:items-center md:p-8">
<div className="flex flex-1 gap-4"> <div className="flex flex-1 gap-4">
<ProfileAvatar {...profile} size={'lg'} /> <ProfileAvatar {...profile} size={'lg'} />
<div className=""> <div className="min-w-0">
<h1 className="text-2xl font-semibold"> <h1 className="max-w-full overflow-hidden text-ellipsis break-words text-lg font-semibold md:max-w-sm md:whitespace-nowrap md:text-2xl">
{getProfileName(profile)} {getProfileName(profile)}
</h1> </h1>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
@@ -194,31 +189,19 @@ export default async function Page({
</Widget> </Widget>
</div> </div>
<div className="mt-8"> <div className="mt-8">
<EventList data={events} count={count} /> <Suspense fallback={<div />}>
<EventListServer {...eventListOptions} />
</Suspense>
</div> </div>
</div> </div>
</> </>
); );
} }
function ValueRow({ name, value }: { name: string; value?: unknown }) { async function EventListServer(props: GetEventListOptions) {
if (!value) { const [events, count] = await Promise.all([
return null; getEventList(props),
} getEventsCount(props),
return ( ]);
<div className="flex flex-row justify-between"> return <EventList data={events} count={count} />;
<div className="font-medium capitalize text-muted-foreground">
{name.replace('_', ' ')}
</div>
<div className="flex items-center gap-2 text-right">
{typeof value === 'string' ? (
<>
<SerieIcon name={value} /> {value}
</>
) : (
<>{value}</>
)}
</div>
</div>
);
} }

View File

@@ -1,4 +1,4 @@
import withLoadingWidget from '@/hocs/with-loading-widget'; import withSuspense from '@/hocs/with-suspense';
import { getProfileMetrics } from '@openpanel/db'; import { getProfileMetrics } from '@openpanel/db';
@@ -14,4 +14,4 @@ const ProfileMetricsServer = async ({ projectId, profileId }: Props) => {
return <ProfileMetrics data={data} />; return <ProfileMetrics data={data} />;
}; };
export default withLoadingWidget(ProfileMetricsServer); export default withSuspense(ProfileMetricsServer, () => null);

View File

@@ -12,52 +12,52 @@ type Props = {
const ProfileMetrics = ({ data }: Props) => { const ProfileMetrics = ({ data }: Props) => {
const number = useNumber(); const number = useNumber();
return ( return (
<div className="flex gap-6"> <div className="flex flex-wrap gap-6 whitespace-nowrap md:justify-end md:text-right">
<div className="rounded-xl text-right"> <div>
<div className="text-xs font-medium text-muted-foreground"> <div className="text-xs font-medium text-muted-foreground">
First seen First seen
</div> </div>
<div className="text-xl font-medium"> <div className="text-lg font-semibold">
{formatDistanceToNow(data.firstSeen)} {formatDistanceToNow(data.firstSeen)}
</div> </div>
</div> </div>
<div className="rounded-xl text-right"> <div>
<div className="text-xs font-medium text-muted-foreground"> <div className="text-xs font-medium text-muted-foreground">
Last seen Last seen
</div> </div>
<div className="text-xl font-medium"> <div className="text-lg font-semibold">
{formatDistanceToNow(data.lastSeen)} {formatDistanceToNow(data.lastSeen)}
</div> </div>
</div> </div>
<div className="rounded-xl text-right"> <div>
<div className="text-xs font-medium text-muted-foreground"> <div className="text-xs font-medium text-muted-foreground">
Sessions Sessions
</div> </div>
<div className="text-xl font-medium"> <div className="text-lg font-semibold">
{number.format(data.sessions)} {number.format(data.sessions)}
</div> </div>
</div> </div>
<div className="rounded-xl text-right"> <div>
<div className="text-xs font-medium text-muted-foreground"> <div className="text-xs font-medium text-muted-foreground">
Avg. Session Avg. Session
</div> </div>
<div className="text-xl font-medium"> <div className="text-lg font-semibold">
{number.formatWithUnit(data.durationAvg / 1000, 'min')} {number.formatWithUnit(data.durationAvg / 1000, 'min')}
</div> </div>
</div> </div>
<div className="rounded-xl text-right"> <div>
<div className="text-xs font-medium text-muted-foreground"> <div className="text-xs font-medium text-muted-foreground">
P90. Session P90. Session
</div> </div>
<div className="text-xl font-medium"> <div className="text-lg font-semibold">
{number.formatWithUnit(data.durationP90 / 1000, 'min')} {number.formatWithUnit(data.durationP90 / 1000, 'min')}
</div> </div>
</div> </div>
<div className="rounded-xl text-right"> <div>
<div className="text-xs font-medium text-muted-foreground"> <div className="text-xs font-medium text-muted-foreground">
Page views Page views
</div> </div>
<div className="text-xl font-medium"> <div className="text-lg font-semibold">
{number.format(data.screenViews)} {number.format(data.screenViews)}
</div> </div>
</div> </div>

View File

@@ -1,3 +0,0 @@
import FullPageLoadingState from '@/components/full-page-loading-state';
export default FullPageLoadingState;

View File

@@ -11,7 +11,7 @@ interface Props {
filters?: IChartEventFilter[]; filters?: IChartEventFilter[];
} }
const limit = 50; const limit = 40;
async function ProfileListServer({ projectId, cursor, filters }: Props) { async function ProfileListServer({ projectId, cursor, filters }: Props) {
const [profiles, count] = await Promise.all([ const [profiles, count] = await Promise.all([

View File

@@ -23,7 +23,7 @@ interface ProfileListProps {
} }
export function ProfileList({ data, count, limit = 50 }: ProfileListProps) { export function ProfileList({ data, count, limit = 50 }: ProfileListProps) {
const { organizationSlug, projectId } = useAppParams(); const { organizationSlug, projectId } = useAppParams();
const { cursor, setCursor } = useCursor(); const { cursor, setCursor, loading } = useCursor();
return ( return (
<Widget> <Widget>
<WidgetHead className="flex items-center justify-between"> <WidgetHead className="flex items-center justify-between">
@@ -32,6 +32,7 @@ export function ProfileList({ data, count, limit = 50 }: ProfileListProps) {
size="sm" size="sm"
cursor={cursor} cursor={cursor}
setCursor={setCursor} setCursor={setCursor}
loading={loading}
count={count} count={count}
take={limit} take={limit}
/> />

View File

@@ -29,6 +29,7 @@ export function Pagination({
setCursor, setCursor,
className, className,
size = 'base', size = 'base',
loading,
}: { }: {
take: number; take: number;
count: number; count: number;
@@ -36,6 +37,7 @@ export function Pagination({
setCursor: Dispatch<SetStateAction<number>>; setCursor: Dispatch<SetStateAction<number>>;
className?: string; className?: string;
size?: 'sm' | 'base'; size?: 'sm' | 'base';
loading?: boolean;
}) { }) {
const lastCursor = Math.floor(count / take) - 1; const lastCursor = Math.floor(count / take) - 1;
const isNextDisabled = count === 0 || lastCursor === cursor; const isNextDisabled = count === 0 || lastCursor === cursor;
@@ -56,42 +58,43 @@ export function Pagination({
)} )}
{size === 'base' && ( {size === 'base' && (
<Button <Button
loading={loading}
variant="outline" variant="outline"
size="icon" size="icon"
onClick={() => setCursor(0)} onClick={() => setCursor(0)}
disabled={cursor === 0} disabled={cursor === 0}
className="max-sm:hidden" className="max-sm:hidden"
> icon={ChevronsLeftIcon}
<ChevronsLeftIcon size={14} /> />
</Button>
)} )}
<Button <Button
loading={loading}
variant="outline" variant="outline"
size="icon" size="icon"
onClick={() => setCursor((p) => Math.max(0, p - 1))} onClick={() => setCursor((p) => Math.max(0, p - 1))}
disabled={cursor === 0} disabled={cursor === 0}
> icon={ChevronLeftIcon}
<ChevronLeftIcon size={14} /> />
</Button>
<Button <Button
loading={loading}
variant="outline" variant="outline"
size="icon" size="icon"
onClick={() => setCursor((p) => Math.min(lastCursor, p + 1))} onClick={() => setCursor((p) => Math.min(lastCursor, p + 1))}
disabled={isNextDisabled} disabled={isNextDisabled}
> icon={ChevronRightIcon}
<ChevronRightIcon size={14} /> />
</Button>
{size === 'base' && ( {size === 'base' && (
<Button <Button
loading={loading}
variant="outline" variant="outline"
size="icon" size="icon"
onClick={() => setCursor(lastCursor)} onClick={() => setCursor(lastCursor)}
disabled={isNextDisabled} disabled={isNextDisabled}
className="max-sm:hidden" className="max-sm:hidden"
> icon={ChevronsRightIcon}
<ChevronsRightIcon size={14} /> />
</Button>
)} )}
</div> </div>
); );

View File

@@ -76,9 +76,10 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
{Icon && ( {Icon && (
<Icon <Icon
className={cn( className={cn(
'mr-2 h-4 w-4 flex-shrink-0', 'h-4 w-4 flex-shrink-0',
responsive && 'mr-0 sm:mr-2', loading && 'animate-spin',
loading && 'animate-spin' size !== 'icon' && responsive && 'mr-0 sm:mr-2',
size !== 'icon' && 'mr-2'
)} )}
/> />
)} )}

View File

@@ -1,14 +1,17 @@
import { useTransition } from 'react';
import { parseAsInteger, useQueryState } from 'nuqs'; import { parseAsInteger, useQueryState } from 'nuqs';
export function useCursor() { export function useCursor() {
const [loading, startTransition] = useTransition();
const [cursor, setCursor] = useQueryState( const [cursor, setCursor] = useQueryState(
'cursor', 'cursor',
parseAsInteger parseAsInteger
.withOptions({ shallow: false, history: 'push' }) .withOptions({ shallow: false, history: 'push', startTransition })
.withDefault(0) .withDefault(0)
); );
return { return {
cursor, cursor,
setCursor, setCursor,
loading,
}; };
} }

View File

@@ -15,16 +15,15 @@ export const ch = createClient({
export async function chQueryWithMeta<T extends Record<string, any>>( export async function chQueryWithMeta<T extends Record<string, any>>(
query: string query: string
): Promise<ResponseJSON<T>> { ): Promise<ResponseJSON<T>> {
console.log('Query:', query);
const start = Date.now(); const start = Date.now();
const res = await ch.query({ const res = await ch.query({
query, query,
}); });
const json = await res.json<T>(); const json = await res.json<T>();
const keys = Object.keys(json.data[0] || {});
const response = { const response = {
...json, ...json,
data: json.data.map((item) => { data: json.data.map((item) => {
const keys = Object.keys(item);
return keys.reduce((acc, key) => { return keys.reduce((acc, key) => {
const meta = json.meta?.find((m) => m.name === key); const meta = json.meta?.find((m) => m.name === key);
return { return {
@@ -38,8 +37,10 @@ export async function chQueryWithMeta<T extends Record<string, any>>(
}), }),
}; };
console.log(`Clickhouse query took ${response.statistics?.elapsed}ms`); console.log(
console.log(`chQuery took ${Date.now() - start}ms`); `Query: (${Date.now() - start}ms, ${response.statistics?.elapsed}ms)`,
query
);
return response; return response;
} }

View File

@@ -42,7 +42,7 @@ export async function getProfileById(id: string, projectId: string) {
} }
const [profile] = await chQuery<IClickhouseProfile>( const [profile] = await chQuery<IClickhouseProfile>(
`SELECT *, created_at as max_created_at FROM profiles WHERE id = ${escape(id)} AND project_id = ${escape(projectId)} ORDER BY created_at DESC LIMIT 1` `SELECT * FROM profiles WHERE id = ${escape(id)} AND project_id = ${escape(projectId)} ORDER BY created_at DESC LIMIT 1`
); );
if (!profile) { if (!profile) {
@@ -59,20 +59,6 @@ interface GetProfileListOptions {
filters?: IChartEventFilter[]; filters?: IChartEventFilter[];
} }
function getProfileSelectFields() {
return [
'id',
'argMax(first_name, created_at) as first_name',
'argMax(last_name, created_at) as last_name',
'argMax(email, created_at) as email',
'argMax(avatar, created_at) as avatar',
'argMax(properties, created_at) as properties',
'argMax(project_id, created_at) as project_id',
'argMax(is_external, created_at) as is_external',
'max(created_at) as max_created_at',
].join(', ');
}
interface GetProfilesOptions { interface GetProfilesOptions {
ids: string[]; ids: string[];
} }
@@ -82,25 +68,15 @@ export async function getProfiles({ ids }: GetProfilesOptions) {
} }
const data = await chQuery<IClickhouseProfile>( const data = await chQuery<IClickhouseProfile>(
`SELECT `SELECT *
${getProfileSelectFields()} FROM profiles FINAL
FROM profiles
WHERE id IN (${ids.map((id) => escape(id)).join(',')}) WHERE id IN (${ids.map((id) => escape(id)).join(',')})
GROUP BY id
` `
); );
return data.map(transformProfile); return data.map(transformProfile);
} }
function getProfileInnerSelect(projectId: string) {
return `(SELECT
${getProfileSelectFields()}
FROM profiles
GROUP BY id
HAVING project_id = ${escape(projectId)})`;
}
export async function getProfileList({ export async function getProfileList({
take, take,
cursor, cursor,
@@ -108,16 +84,12 @@ export async function getProfileList({
filters, filters,
}: GetProfileListOptions) { }: GetProfileListOptions) {
const { sb, getSql } = createSqlBuilder(); const { sb, getSql } = createSqlBuilder();
sb.from = getProfileInnerSelect(projectId); sb.from = 'profiles FINAL';
if (filters) { sb.select.all = '*';
sb.where = { sb.where.project_id = `project_id = ${escape(projectId)}`;
...sb.where,
...getEventFiltersWhereClause(filters),
};
}
sb.limit = take; sb.limit = take;
sb.offset = Math.max(0, (cursor ?? 0) * take); sb.offset = Math.max(0, (cursor ?? 0) * take);
sb.orderBy.created_at = 'max_created_at DESC'; sb.orderBy.created_at = 'created_at DESC';
const data = await chQuery<IClickhouseProfile>(getSql()); const data = await chQuery<IClickhouseProfile>(getSql());
return data.map(transformProfile); return data.map(transformProfile);
} }
@@ -127,21 +99,17 @@ export async function getProfileListCount({
filters, filters,
}: Omit<GetProfileListOptions, 'cursor' | 'take'>) { }: Omit<GetProfileListOptions, 'cursor' | 'take'>) {
const { sb, getSql } = createSqlBuilder(); const { sb, getSql } = createSqlBuilder();
sb.from = 'profiles FINAL';
sb.select.count = 'count(id) as count'; sb.select.count = 'count(id) as count';
sb.from = getProfileInnerSelect(projectId); sb.where.project_id = `project_id = ${escape(projectId)}`;
if (filters) { sb.groupBy.project_id = 'project_id';
sb.where = { const data = await chQuery<{ count: number }>(getSql());
...sb.where, return data[0]?.count ?? 0;
...getEventFiltersWhereClause(filters),
};
}
const [data] = await chQuery<{ count: number }>(getSql());
return data?.count ?? 0;
} }
export type IServiceProfile = Omit< export type IServiceProfile = Omit<
IClickhouseProfile, IClickhouseProfile,
'max_created_at' | 'properties' | 'first_name' | 'last_name' | 'is_external' 'created_at' | 'properties' | 'first_name' | 'last_name' | 'is_external'
> & { > & {
firstName: string; firstName: string;
lastName: string; lastName: string;
@@ -168,7 +136,7 @@ export interface IClickhouseProfile {
properties: Record<string, string | undefined>; properties: Record<string, string | undefined>;
project_id: string; project_id: string;
is_external: boolean; is_external: boolean;
max_created_at: string; created_at: string;
} }
export interface IServiceUpsertProfile { export interface IServiceUpsertProfile {
@@ -183,7 +151,7 @@ export interface IServiceUpsertProfile {
} }
export function transformProfile({ export function transformProfile({
max_created_at, created_at,
first_name, first_name,
last_name, last_name,
...profile ...profile
@@ -194,7 +162,7 @@ export function transformProfile({
lastName: last_name, lastName: last_name,
isExternal: profile.is_external, isExternal: profile.is_external,
properties: toObject(profile.properties), properties: toObject(profile.properties),
createdAt: new Date(max_created_at), createdAt: new Date(created_at),
}; };
} }

View File

@@ -1,5 +1,6 @@
export interface SqlBuilderObject { export interface SqlBuilderObject {
where: Record<string, string>; where: Record<string, string>;
having: Record<string, string>;
select: Record<string, string>; select: Record<string, string>;
groupBy: Record<string, string>; groupBy: Record<string, string>;
orderBy: Record<string, string>; orderBy: Record<string, string>;
@@ -18,12 +19,15 @@ export function createSqlBuilder() {
select: {}, select: {},
groupBy: {}, groupBy: {},
orderBy: {}, orderBy: {},
having: {},
limit: undefined, limit: undefined,
offset: undefined, offset: undefined,
}; };
const getWhere = () => const getWhere = () =>
Object.keys(sb.where).length ? 'WHERE ' + join(sb.where, ' AND ') : ''; Object.keys(sb.where).length ? 'WHERE ' + join(sb.where, ' AND ') : '';
const getHaving = () =>
Object.keys(sb.having).length ? 'HAVING ' + join(sb.having, ' AND ') : '';
const getFrom = () => `FROM ${sb.from}`; const getFrom = () => `FROM ${sb.from}`;
const getSelect = () => const getSelect = () =>
'SELECT ' + (Object.keys(sb.select).length ? join(sb.select, ', ') : '*'); 'SELECT ' + (Object.keys(sb.select).length ? join(sb.select, ', ') : '*');
@@ -42,22 +46,20 @@ export function createSqlBuilder() {
getSelect, getSelect,
getGroupBy, getGroupBy,
getOrderBy, getOrderBy,
getHaving,
getSql: () => { getSql: () => {
const sql = [ const sql = [
getSelect(), getSelect(),
getFrom(), getFrom(),
getWhere(), getWhere(),
getGroupBy(), getGroupBy(),
getHaving(),
getOrderBy(), getOrderBy(),
getLimit(), getLimit(),
getOffset(), getOffset(),
] ]
.filter(Boolean) .filter(Boolean)
.join(' '); .join(' ');
console.log('---');
console.log(sql);
console.log('---');
return sql; return sql;
}, },
}; };