wip
This commit is contained in:
113
apps/start/src/components/pages/page-sparkline.tsx
Normal file
113
apps/start/src/components/pages/page-sparkline.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Tooltiper } from '../ui/tooltip';
|
||||
import { LazyComponent } from '@/components/lazy-component';
|
||||
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
|
||||
interface SparklineBarsProps {
|
||||
data: { date: string; pageviews: number }[];
|
||||
}
|
||||
|
||||
const gap = 1;
|
||||
const height = 24;
|
||||
const width = 100;
|
||||
|
||||
function getTrendDirection(data: { pageviews: number }[]): '↑' | '↓' | '→' {
|
||||
const n = data.length;
|
||||
if (n < 3) {
|
||||
return '→';
|
||||
}
|
||||
const third = Math.max(1, Math.floor(n / 3));
|
||||
const firstAvg =
|
||||
data.slice(0, third).reduce((s, d) => s + d.pageviews, 0) / third;
|
||||
const lastAvg =
|
||||
data.slice(n - third).reduce((s, d) => s + d.pageviews, 0) / third;
|
||||
const threshold = firstAvg * 0.05;
|
||||
if (lastAvg - firstAvg > threshold) {
|
||||
return '↑';
|
||||
}
|
||||
if (firstAvg - lastAvg > threshold) {
|
||||
return '↓';
|
||||
}
|
||||
return '→';
|
||||
}
|
||||
|
||||
function SparklineBars({ data }: SparklineBarsProps) {
|
||||
if (!data.length) {
|
||||
return <div style={{ height, width }} />;
|
||||
}
|
||||
const max = Math.max(...data.map((d) => d.pageviews), 1);
|
||||
const total = data.length;
|
||||
const barW = Math.max(2, Math.floor((width - gap * (total - 1)) / total));
|
||||
const trend = getTrendDirection(data);
|
||||
const trendColor =
|
||||
trend === '↑'
|
||||
? 'text-emerald-500'
|
||||
: trend === '↓'
|
||||
? 'text-red-500'
|
||||
: 'text-muted-foreground';
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<svg className="shrink-0" height={height} width={width}>
|
||||
{data.map((d, i) => {
|
||||
const barH = Math.max(
|
||||
2,
|
||||
Math.round((d.pageviews / max) * (height * 0.8))
|
||||
);
|
||||
return (
|
||||
<rect
|
||||
className="fill-chart-0"
|
||||
height={barH}
|
||||
key={d.date}
|
||||
rx="1"
|
||||
width={barW}
|
||||
x={i * (barW + gap)}
|
||||
y={height - barH}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
<Tooltiper
|
||||
content={
|
||||
trend === '↑'
|
||||
? 'Upgoing trend'
|
||||
: trend === '↓'
|
||||
? 'Downgoing trend'
|
||||
: 'Stable trend'
|
||||
}
|
||||
>
|
||||
<span className={`shrink-0 font-medium text-xs ${trendColor}`}>
|
||||
{trend}
|
||||
</span>
|
||||
</Tooltiper>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface PageSparklineProps {
|
||||
projectId: string;
|
||||
origin: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export function PageSparkline({ projectId, origin, path }: PageSparklineProps) {
|
||||
const { range, interval } = useOverviewOptions();
|
||||
const trpc = useTRPC();
|
||||
|
||||
const query = useQuery(
|
||||
trpc.event.pageTimeseries.queryOptions({
|
||||
projectId,
|
||||
range,
|
||||
interval,
|
||||
origin,
|
||||
path,
|
||||
})
|
||||
);
|
||||
|
||||
return (
|
||||
<LazyComponent fallback={<div style={{ height, width }} />}>
|
||||
<SparklineBars data={query.data ?? []} />
|
||||
</LazyComponent>
|
||||
);
|
||||
}
|
||||
199
apps/start/src/components/pages/table/columns.tsx
Normal file
199
apps/start/src/components/pages/table/columns.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
import { ExternalLinkIcon } from 'lucide-react';
|
||||
import { useMemo } from 'react';
|
||||
import { PageSparkline } from '@/components/pages/page-sparkline';
|
||||
import { createHeaderColumn } from '@/components/ui/data-table/data-table-helpers';
|
||||
import { useAppContext } from '@/hooks/use-app-context';
|
||||
import { fancyMinutes, useNumber } from '@/hooks/use-numer-formatter';
|
||||
import type { RouterOutputs } from '@/trpc/client';
|
||||
|
||||
export type PageRow = RouterOutputs['event']['pages'][number] & {
|
||||
gsc?: { clicks: number; impressions: number; ctr: number; position: number };
|
||||
};
|
||||
|
||||
export function useColumns({
|
||||
projectId,
|
||||
isGscConnected,
|
||||
previousMap,
|
||||
}: {
|
||||
projectId: string;
|
||||
isGscConnected: boolean;
|
||||
previousMap?: Map<string, number>;
|
||||
}): ColumnDef<PageRow>[] {
|
||||
const number = useNumber();
|
||||
const { apiUrl } = useAppContext();
|
||||
|
||||
return useMemo<ColumnDef<PageRow>[]>(() => {
|
||||
const cols: ColumnDef<PageRow>[] = [
|
||||
{
|
||||
id: 'page',
|
||||
accessorFn: (row) => `${row.origin}${row.path} ${row.title ?? ''}`,
|
||||
header: createHeaderColumn('Page'),
|
||||
size: 400,
|
||||
meta: { bold: true },
|
||||
cell: ({ row }) => {
|
||||
const page = row.original;
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<img
|
||||
alt=""
|
||||
className="size-4 shrink-0 rounded-sm"
|
||||
loading="lazy"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
src={`${apiUrl}/misc/favicon?url=${page.origin}`}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
{page.title && (
|
||||
<div className="truncate font-medium text-sm leading-tight">
|
||||
{page.title}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
<span className="truncate font-mono text-muted-foreground text-xs">
|
||||
{page.path}
|
||||
</span>
|
||||
<a
|
||||
className="shrink-0 opacity-0 transition-opacity group-hover/row:opacity-100"
|
||||
href={page.origin + page.path}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
rel="noreferrer noopener"
|
||||
target="_blank"
|
||||
>
|
||||
<ExternalLinkIcon className="size-3 text-muted-foreground" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'trend',
|
||||
header: 'Trend',
|
||||
enableSorting: false,
|
||||
size: 96,
|
||||
cell: ({ row }) => (
|
||||
<PageSparkline
|
||||
origin={row.original.origin}
|
||||
path={row.original.path}
|
||||
projectId={projectId}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'pageviews',
|
||||
header: createHeaderColumn('Views'),
|
||||
size: 80,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-sm tabular-nums">
|
||||
{number.short(row.original.pageviews)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'sessions',
|
||||
header: createHeaderColumn('Sessions'),
|
||||
size: 90,
|
||||
cell: ({ row }) => {
|
||||
const prev = previousMap?.get(
|
||||
row.original.origin + row.original.path
|
||||
);
|
||||
if (prev == null) {
|
||||
return <span className="text-muted-foreground">—</span>;
|
||||
}
|
||||
|
||||
const pct = ((row.original.sessions - prev) / prev) * 100;
|
||||
const isPos = pct >= 0;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-sm tabular-nums">
|
||||
{number.short(row.original.sessions)}
|
||||
</span>
|
||||
{prev === 0 && <span className="text-muted-foreground">new</span>}
|
||||
{prev > 0 && (
|
||||
<span
|
||||
className={`font-mono text-sm tabular-nums ${isPos ? 'text-emerald-600 dark:text-emerald-400' : 'text-red-600 dark:text-red-400'}`}
|
||||
>
|
||||
{isPos ? '+' : ''}
|
||||
{pct.toFixed(1)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'bounce_rate',
|
||||
header: createHeaderColumn('Bounce'),
|
||||
size: 80,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-sm tabular-nums">
|
||||
{row.original.bounce_rate.toFixed(0)}%
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'avg_duration',
|
||||
header: createHeaderColumn('Duration'),
|
||||
size: 90,
|
||||
cell: ({ row }) => (
|
||||
<span className="whitespace-nowrap font-mono text-sm tabular-nums">
|
||||
{fancyMinutes(row.original.avg_duration)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
if (isGscConnected) {
|
||||
cols.push(
|
||||
{
|
||||
id: 'gsc_impressions',
|
||||
accessorFn: (row) => row.gsc?.impressions ?? 0,
|
||||
header: createHeaderColumn('Impr.'),
|
||||
size: 80,
|
||||
cell: ({ row }) =>
|
||||
row.original.gsc ? (
|
||||
<span className="font-mono text-sm tabular-nums">
|
||||
{number.short(row.original.gsc.impressions)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'gsc_ctr',
|
||||
accessorFn: (row) => row.gsc?.ctr ?? 0,
|
||||
header: createHeaderColumn('CTR'),
|
||||
size: 70,
|
||||
cell: ({ row }) =>
|
||||
row.original.gsc ? (
|
||||
<span className="font-mono text-sm tabular-nums">
|
||||
{(row.original.gsc.ctr * 100).toFixed(1)}%
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'gsc_clicks',
|
||||
accessorFn: (row) => row.gsc?.clicks ?? 0,
|
||||
header: createHeaderColumn('Clicks'),
|
||||
size: 80,
|
||||
cell: ({ row }) =>
|
||||
row.original.gsc ? (
|
||||
<span className="font-mono text-sm tabular-nums">
|
||||
{number.short(row.original.gsc.clicks)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return cols;
|
||||
}, [isGscConnected, number, apiUrl, projectId, previousMap]);
|
||||
}
|
||||
147
apps/start/src/components/pages/table/index.tsx
Normal file
147
apps/start/src/components/pages/table/index.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
import { OverviewInterval } from '@/components/overview/overview-interval';
|
||||
import { OverviewRange } from '@/components/overview/overview-range';
|
||||
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
|
||||
import { DataTable } from '@/components/ui/data-table/data-table';
|
||||
import {
|
||||
AnimatedSearchInput,
|
||||
DataTableToolbarContainer,
|
||||
} from '@/components/ui/data-table/data-table-toolbar';
|
||||
import { DataTableViewOptions } from '@/components/ui/data-table/data-table-view-options';
|
||||
import { useTable } from '@/components/ui/data-table/use-table';
|
||||
import { useSearchQueryState } from '@/hooks/use-search-query-state';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { pushModal } from '@/modals';
|
||||
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||
import { useMemo } from 'react';
|
||||
import { type PageRow, useColumns } from './columns';
|
||||
|
||||
interface PagesTableProps {
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
export function PagesTable({ projectId }: PagesTableProps) {
|
||||
const trpc = useTRPC();
|
||||
const { range, interval, startDate, endDate } = useOverviewOptions();
|
||||
const { debouncedSearch, setSearch, search } = useSearchQueryState();
|
||||
|
||||
const pagesQuery = useQuery(
|
||||
trpc.event.pages.queryOptions(
|
||||
{ projectId, cursor: 1, take: 1000, search: undefined, range, interval },
|
||||
{ placeholderData: keepPreviousData },
|
||||
),
|
||||
);
|
||||
|
||||
const connectionQuery = useQuery(
|
||||
trpc.gsc.getConnection.queryOptions({ projectId }),
|
||||
);
|
||||
|
||||
const isGscConnected = !!(connectionQuery.data?.siteUrl);
|
||||
|
||||
const gscPagesQuery = useQuery(
|
||||
trpc.gsc.getPages.queryOptions(
|
||||
{
|
||||
projectId,
|
||||
range,
|
||||
startDate: startDate ?? undefined,
|
||||
endDate: endDate ?? undefined,
|
||||
limit: 1000,
|
||||
},
|
||||
{ enabled: isGscConnected },
|
||||
),
|
||||
);
|
||||
|
||||
const previousPagesQuery = useQuery(
|
||||
trpc.event.previousPages.queryOptions(
|
||||
{ projectId, range, interval },
|
||||
{ placeholderData: keepPreviousData },
|
||||
),
|
||||
);
|
||||
|
||||
const previousMap = useMemo(() => {
|
||||
const map = new Map<string, number>();
|
||||
for (const p of previousPagesQuery.data ?? []) {
|
||||
map.set(p.origin + p.path, p.sessions);
|
||||
}
|
||||
return map;
|
||||
}, [previousPagesQuery.data]);
|
||||
|
||||
const gscMap = useMemo(() => {
|
||||
const map = new Map<
|
||||
string,
|
||||
{ clicks: number; impressions: number; ctr: number; position: number }
|
||||
>();
|
||||
for (const row of gscPagesQuery.data ?? []) {
|
||||
map.set(row.page, {
|
||||
clicks: row.clicks,
|
||||
impressions: row.impressions,
|
||||
ctr: row.ctr,
|
||||
position: row.position,
|
||||
});
|
||||
}
|
||||
return map;
|
||||
}, [gscPagesQuery.data]);
|
||||
|
||||
const rawData: PageRow[] = useMemo(() => {
|
||||
return (pagesQuery.data ?? []).map((p) => ({
|
||||
...p,
|
||||
gsc: gscMap.get(p.origin + p.path),
|
||||
}));
|
||||
}, [pagesQuery.data, gscMap]);
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
if (!debouncedSearch) return rawData;
|
||||
const q = debouncedSearch.toLowerCase();
|
||||
return rawData.filter(
|
||||
(p) =>
|
||||
p.path.toLowerCase().includes(q) ||
|
||||
p.origin.toLowerCase().includes(q) ||
|
||||
(p.title ?? '').toLowerCase().includes(q),
|
||||
);
|
||||
}, [rawData, debouncedSearch]);
|
||||
|
||||
const columns = useColumns({ projectId, isGscConnected, previousMap });
|
||||
|
||||
const { table } = useTable({
|
||||
columns,
|
||||
data: filteredData,
|
||||
loading: pagesQuery.isLoading,
|
||||
pageSize: 50,
|
||||
name: 'pages',
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<DataTableToolbarContainer>
|
||||
<AnimatedSearchInput
|
||||
placeholder="Search pages"
|
||||
value={search ?? ''}
|
||||
onChange={setSearch}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<OverviewRange />
|
||||
<OverviewInterval />
|
||||
<DataTableViewOptions table={table} />
|
||||
</div>
|
||||
</DataTableToolbarContainer>
|
||||
<DataTable
|
||||
table={table}
|
||||
loading={pagesQuery.isLoading}
|
||||
empty={{
|
||||
title: 'No pages',
|
||||
description: debouncedSearch
|
||||
? `No pages found matching "${debouncedSearch}"`
|
||||
: 'Integrate our web SDK to your site to get pages here.',
|
||||
}}
|
||||
onRowClick={(row) => {
|
||||
if (!isGscConnected) return;
|
||||
const page = row.original;
|
||||
pushModal('PageDetails', {
|
||||
type: 'page',
|
||||
projectId,
|
||||
value: page.origin + page.path,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user