improve(dashboard): make pages page better (ux and features)
This commit is contained in:
@@ -1,128 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { ReportChart } from '@/components/report-chart';
|
|
||||||
import { useNumber } from '@/hooks/useNumerFormatter';
|
|
||||||
import { cn } from '@/utils/cn';
|
|
||||||
import isEqual from 'lodash.isequal';
|
|
||||||
import { ExternalLinkIcon } from 'lucide-react';
|
|
||||||
import { memo } from 'react';
|
|
||||||
|
|
||||||
import type { IServicePage } from '@openpanel/db';
|
|
||||||
|
|
||||||
export const PagesTable = memo(
|
|
||||||
({ data }: { data: IServicePage[] }) => {
|
|
||||||
const number = useNumber();
|
|
||||||
const cell =
|
|
||||||
'flex min-h-12 whitespace-nowrap px-4 align-middle shadow-[0_0_0_0.5px] shadow-border';
|
|
||||||
return (
|
|
||||||
<div className="overflow-x-auto rounded-md border bg-background">
|
|
||||||
<div className={cn('min-w-[800px]')}>
|
|
||||||
<div className="grid grid-cols-[0.2fr_auto_1fr] overflow-hidden rounded-t-none border-b">
|
|
||||||
<div className="center-center h-10 rounded-tl-md bg-def-100 p-4 font-semibold text-muted-foreground">
|
|
||||||
Views
|
|
||||||
</div>
|
|
||||||
<div className="flex h-10 w-80 items-center bg-def-100 p-4 font-semibold text-muted-foreground">
|
|
||||||
Path
|
|
||||||
</div>
|
|
||||||
<div className="flex h-10 items-center rounded-tr-md bg-def-100 p-4 font-semibold text-muted-foreground">
|
|
||||||
Chart
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{data.map((item, index) => {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={item.path + item.origin + item.title}
|
|
||||||
className="grid grid-cols-[0.2fr_auto_1fr] border-b transition-colors last:border-b-0 hover:bg-muted/50 data-[state=selected]:bg-muted"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
cell,
|
|
||||||
'center-center font-mono text-lg font-semibold',
|
|
||||||
index === data.length - 1 && 'rounded-bl-md',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{number.short(item.count)}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
cell,
|
|
||||||
'flex w-80 flex-col justify-center gap-2 text-left',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className="truncate font-medium">{item.title}</span>
|
|
||||||
{item.origin ? (
|
|
||||||
<a
|
|
||||||
href={item.origin + item.path}
|
|
||||||
className="truncate font-mono text-sm text-muted-foreground underline"
|
|
||||||
>
|
|
||||||
<ExternalLinkIcon className="mr-2 inline-block size-3" />
|
|
||||||
{item.path}
|
|
||||||
</a>
|
|
||||||
) : (
|
|
||||||
<span className="truncate font-mono text-sm text-muted-foreground">
|
|
||||||
{item.path}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
cell,
|
|
||||||
'p-1',
|
|
||||||
index === data.length - 1 && 'rounded-br-md',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<ReportChart
|
|
||||||
options={{
|
|
||||||
hideID: true,
|
|
||||||
hideXAxis: true,
|
|
||||||
hideYAxis: true,
|
|
||||||
aspectRatio: 0.15,
|
|
||||||
}}
|
|
||||||
report={{
|
|
||||||
lineType: 'linear',
|
|
||||||
breakdowns: [],
|
|
||||||
name: 'screen_view',
|
|
||||||
metric: 'sum',
|
|
||||||
range: '30d',
|
|
||||||
interval: 'day',
|
|
||||||
previous: true,
|
|
||||||
|
|
||||||
chartType: 'linear',
|
|
||||||
projectId: item.project_id,
|
|
||||||
events: [
|
|
||||||
{
|
|
||||||
id: 'A',
|
|
||||||
name: 'screen_view',
|
|
||||||
segment: 'event',
|
|
||||||
filters: [
|
|
||||||
{
|
|
||||||
id: 'path',
|
|
||||||
name: 'path',
|
|
||||||
value: [item.path],
|
|
||||||
operator: 'is',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'origin',
|
|
||||||
name: 'origin',
|
|
||||||
value: [item.origin],
|
|
||||||
operator: 'is',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
(prevProps, nextProps) => {
|
|
||||||
return isEqual(prevProps.data, nextProps.data);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
PagesTable.displayName = 'PagesTable';
|
|
||||||
@@ -1,18 +1,26 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { TableButtons } from '@/components/data-table';
|
import { TableButtons } from '@/components/data-table';
|
||||||
import { Pagination } from '@/components/pagination';
|
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { TableSkeleton } from '@/components/ui/table';
|
|
||||||
import { useDebounceValue } from '@/hooks/useDebounceValue';
|
import { useDebounceValue } from '@/hooks/useDebounceValue';
|
||||||
import { api } from '@/trpc/client';
|
import { type RouterOutputs, api } from '@/trpc/client';
|
||||||
import { Loader2Icon } from 'lucide-react';
|
|
||||||
import { parseAsInteger, useQueryState } from 'nuqs';
|
import { parseAsInteger, useQueryState } from 'nuqs';
|
||||||
|
|
||||||
import { PagesTable } from './pages-table';
|
import { OverviewFiltersDrawer } from '@/components/overview/filters/overview-filters-drawer';
|
||||||
|
import { OverviewInterval } from '@/components/overview/overview-interval';
|
||||||
|
import { OverviewRange } from '@/components/overview/overview-range';
|
||||||
|
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
|
||||||
|
import { Pagination } from '@/components/pagination';
|
||||||
|
import { ReportChart } from '@/components/report-chart';
|
||||||
|
import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
|
||||||
|
import { useNumber } from '@/hooks/useNumerFormatter';
|
||||||
|
import type { IChartRange, IInterval } from '@openpanel/validation';
|
||||||
|
import { memo } from 'react';
|
||||||
|
|
||||||
export function Pages({ projectId }: { projectId: string }) {
|
export function Pages({ projectId }: { projectId: string }) {
|
||||||
const take = 20;
|
const take = 20;
|
||||||
|
const { range, interval } = useOverviewOptions();
|
||||||
|
const [filters, setFilters] = useEventQueryFilters();
|
||||||
const [cursor, setCursor] = useQueryState(
|
const [cursor, setCursor] = useQueryState(
|
||||||
'cursor',
|
'cursor',
|
||||||
parseAsInteger.withDefault(0),
|
parseAsInteger.withDefault(0),
|
||||||
@@ -28,6 +36,9 @@ export function Pages({ projectId }: { projectId: string }) {
|
|||||||
cursor,
|
cursor,
|
||||||
take,
|
take,
|
||||||
search: debouncedSearch,
|
search: debouncedSearch,
|
||||||
|
range,
|
||||||
|
interval,
|
||||||
|
filters,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
keepPreviousData: true,
|
keepPreviousData: true,
|
||||||
@@ -38,7 +49,11 @@ export function Pages({ projectId }: { projectId: string }) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<TableButtons>
|
<TableButtons>
|
||||||
|
<OverviewRange />
|
||||||
|
<OverviewInterval />
|
||||||
|
<OverviewFiltersDrawer projectId={projectId} mode="events" />
|
||||||
<Input
|
<Input
|
||||||
|
className="self-auto"
|
||||||
placeholder="Search path"
|
placeholder="Search path"
|
||||||
value={search ?? ''}
|
value={search ?? ''}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
@@ -47,19 +62,132 @@ export function Pages({ projectId }: { projectId: string }) {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</TableButtons>
|
</TableButtons>
|
||||||
{query.isLoading ? (
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<TableSkeleton cols={3} />
|
{data.map((page) => {
|
||||||
) : (
|
return (
|
||||||
<PagesTable data={data} />
|
<PageCard
|
||||||
)}
|
key={page.path}
|
||||||
<Pagination
|
page={page}
|
||||||
className="mt-2"
|
range={range}
|
||||||
setCursor={setCursor}
|
interval={interval}
|
||||||
cursor={cursor}
|
projectId={projectId}
|
||||||
count={Number.POSITIVE_INFINITY}
|
/>
|
||||||
take={take}
|
);
|
||||||
loading={query.isFetching}
|
})}
|
||||||
/>
|
</div>
|
||||||
|
<div className="p-4">
|
||||||
|
<Pagination
|
||||||
|
take={20}
|
||||||
|
count={9999}
|
||||||
|
cursor={cursor}
|
||||||
|
setCursor={setCursor}
|
||||||
|
className="self-auto"
|
||||||
|
size="base"
|
||||||
|
loading={query.isFetching}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PageCard = memo(
|
||||||
|
({
|
||||||
|
page,
|
||||||
|
range,
|
||||||
|
interval,
|
||||||
|
projectId,
|
||||||
|
}: {
|
||||||
|
page: RouterOutputs['event']['pages'][number];
|
||||||
|
range: IChartRange;
|
||||||
|
interval: IInterval;
|
||||||
|
projectId: string;
|
||||||
|
}) => {
|
||||||
|
const number = useNumber();
|
||||||
|
return (
|
||||||
|
<div className="card">
|
||||||
|
<div className="row gap-4 justify-between p-4 py-2 items-center">
|
||||||
|
<div className="col min-w-0">
|
||||||
|
<div className="font-medium leading-[28px] truncate">
|
||||||
|
{page.title}
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
href={`${page.origin}${page.path}`}
|
||||||
|
className="text-muted-foreground font-mono truncate hover:underline"
|
||||||
|
>
|
||||||
|
{page.path}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="row border-y">
|
||||||
|
<div className="center-center col flex-1 p-4 py-2">
|
||||||
|
<div className="font-medium text-xl font-mono">
|
||||||
|
{number.formatWithUnit(page.avg_duration, 'min')}
|
||||||
|
</div>
|
||||||
|
<div className="text-muted-foreground whitespace-nowrap text-sm">
|
||||||
|
duration
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="center-center col flex-1 p-4 py-2">
|
||||||
|
<div className="font-medium text-xl font-mono">
|
||||||
|
{number.formatWithUnit(page.bounce_rate / 100, '%')}
|
||||||
|
</div>
|
||||||
|
<div className="text-muted-foreground whitespace-nowrap text-sm">
|
||||||
|
bounce rate
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="center-center col flex-1 p-4 py-2">
|
||||||
|
<div className="font-medium text-xl font-mono">
|
||||||
|
{number.format(page.sessions)}
|
||||||
|
</div>
|
||||||
|
<div className="text-muted-foreground whitespace-nowrap text-sm">
|
||||||
|
sessions
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ReportChart
|
||||||
|
options={{
|
||||||
|
hideID: true,
|
||||||
|
hideXAxis: true,
|
||||||
|
hideYAxis: true,
|
||||||
|
aspectRatio: 0.15,
|
||||||
|
}}
|
||||||
|
report={{
|
||||||
|
lineType: 'linear',
|
||||||
|
breakdowns: [],
|
||||||
|
name: 'screen_view',
|
||||||
|
metric: 'sum',
|
||||||
|
range,
|
||||||
|
interval,
|
||||||
|
previous: true,
|
||||||
|
|
||||||
|
chartType: 'linear',
|
||||||
|
projectId,
|
||||||
|
events: [
|
||||||
|
{
|
||||||
|
id: 'A',
|
||||||
|
name: 'screen_view',
|
||||||
|
segment: 'event',
|
||||||
|
filters: [
|
||||||
|
{
|
||||||
|
id: 'path',
|
||||||
|
name: 'path',
|
||||||
|
value: [page.path],
|
||||||
|
operator: 'is',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'origin',
|
||||||
|
name: 'origin',
|
||||||
|
value: [page.origin],
|
||||||
|
operator: 'is',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -617,7 +617,7 @@ export async function getTopPages({
|
|||||||
search?: string;
|
search?: string;
|
||||||
}) {
|
}) {
|
||||||
const res = await chQuery<IServicePage>(`
|
const res = await chQuery<IServicePage>(`
|
||||||
SELECT path, count(*) as count, project_id, first_value(created_at) as first_seen, max(properties['__title']) as title, origin
|
SELECT path, count(*) as count, project_id, first_value(created_at) as first_seen, last_value(properties['__title']) as title, origin
|
||||||
FROM ${TABLE_NAMES.events}
|
FROM ${TABLE_NAMES.events}
|
||||||
WHERE name = 'screen_view'
|
WHERE name = 'screen_view'
|
||||||
AND project_id = ${escape(projectId)}
|
AND project_id = ${escape(projectId)}
|
||||||
|
|||||||
@@ -389,6 +389,7 @@ export class OverviewService {
|
|||||||
.select([
|
.select([
|
||||||
'origin',
|
'origin',
|
||||||
'path',
|
'path',
|
||||||
|
`last_value(properties['__title']) as title`,
|
||||||
'uniq(session_id) as count',
|
'uniq(session_id) as count',
|
||||||
'round(avg(duration)/1000, 2) as avg_duration',
|
'round(avg(duration)/1000, 2) as avg_duration',
|
||||||
])
|
])
|
||||||
@@ -427,12 +428,14 @@ export class OverviewService {
|
|||||||
.with('page_stats', pageStatsQuery)
|
.with('page_stats', pageStatsQuery)
|
||||||
.with('bounce_stats', bounceStatsQuery)
|
.with('bounce_stats', bounceStatsQuery)
|
||||||
.select<{
|
.select<{
|
||||||
|
title: string;
|
||||||
origin: string;
|
origin: string;
|
||||||
path: string;
|
path: string;
|
||||||
avg_duration: number;
|
avg_duration: number;
|
||||||
bounce_rate: number;
|
bounce_rate: number;
|
||||||
sessions: number;
|
sessions: number;
|
||||||
}>([
|
}>([
|
||||||
|
'p.title',
|
||||||
'p.origin',
|
'p.origin',
|
||||||
'p.path',
|
'p.path',
|
||||||
'p.avg_duration',
|
'p.avg_duration',
|
||||||
|
|||||||
@@ -13,14 +13,20 @@ import {
|
|||||||
getEventList,
|
getEventList,
|
||||||
getEvents,
|
getEvents,
|
||||||
getTopPages,
|
getTopPages,
|
||||||
|
overviewService,
|
||||||
} from '@openpanel/db';
|
} from '@openpanel/db';
|
||||||
import { zChartEventFilter } from '@openpanel/validation';
|
import {
|
||||||
|
zChartEventFilter,
|
||||||
|
zRange,
|
||||||
|
zTimeInterval,
|
||||||
|
} from '@openpanel/validation';
|
||||||
|
|
||||||
import { addMinutes, subMinutes } from 'date-fns';
|
import { addMinutes, subDays, subMinutes } from 'date-fns';
|
||||||
import { clone } from 'ramda';
|
import { clone } from 'ramda';
|
||||||
import { getProjectAccessCached } from '../access';
|
import { getProjectAccessCached } from '../access';
|
||||||
import { TRPCAccessError } from '../errors';
|
import { TRPCAccessError } from '../errors';
|
||||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from '../trpc';
|
import { createTRPCRouter, protectedProcedure, publicProcedure } from '../trpc';
|
||||||
|
import { getChartStartEndDate } from './chart.helpers';
|
||||||
|
|
||||||
export const eventRouter = createTRPCRouter({
|
export const eventRouter = createTRPCRouter({
|
||||||
updateEventMeta: protectedProcedure
|
updateEventMeta: protectedProcedure
|
||||||
@@ -228,10 +234,30 @@ export const eventRouter = createTRPCRouter({
|
|||||||
cursor: z.number().optional(),
|
cursor: z.number().optional(),
|
||||||
take: z.number().default(20),
|
take: z.number().default(20),
|
||||||
search: z.string().optional(),
|
search: z.string().optional(),
|
||||||
|
range: zRange,
|
||||||
|
interval: zTimeInterval,
|
||||||
|
filters: z.array(zChartEventFilter).default([]),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.query(async ({ input }) => {
|
.query(async ({ input }) => {
|
||||||
return getTopPages(input);
|
const { startDate, endDate } = getChartStartEndDate(input);
|
||||||
|
if (input.search) {
|
||||||
|
input.filters.push({
|
||||||
|
id: 'path',
|
||||||
|
name: 'path',
|
||||||
|
value: [input.search],
|
||||||
|
operator: 'contains',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return overviewService.getTopPages({
|
||||||
|
projectId: input.projectId,
|
||||||
|
filters: input.filters,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
interval: input.interval,
|
||||||
|
cursor: input.cursor || 1,
|
||||||
|
limit: input.take,
|
||||||
|
});
|
||||||
}),
|
}),
|
||||||
|
|
||||||
origin: protectedProcedure
|
origin: protectedProcedure
|
||||||
|
|||||||
Reference in New Issue
Block a user