This commit is contained in:
Carl-Gerhard Lindesvärd
2026-03-09 12:30:28 +01:00
parent 2981638893
commit c9cf7901ad
32 changed files with 3908 additions and 677 deletions

View 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>
);
}

View 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]);
}

View 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,
});
}}
/>
</>
);
}