Skip to content

Commit c0e3e96

Browse files
committed
feat: time on page
1 parent 09c68cd commit c0e3e96

File tree

4 files changed

+244
-24
lines changed

4 files changed

+244
-24
lines changed

apps/api/src/query/builders/pages.ts

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,4 +247,130 @@ export const PagesBuilders: Record<string, SimpleQueryConfig> = {
247247
],
248248
customizable: true,
249249
},
250+
251+
page_time_analysis: {
252+
allowedFilters: [
253+
'path',
254+
'country',
255+
'device_type',
256+
'browser_name',
257+
'os_name',
258+
'referrer',
259+
'utm_source',
260+
'utm_medium',
261+
'utm_campaign',
262+
],
263+
customizable: true,
264+
customSql: (
265+
websiteId: string,
266+
startDate: string,
267+
endDate: string,
268+
_filters?: unknown[],
269+
_granularity?: unknown,
270+
limit?: number,
271+
offset?: number,
272+
_timezone?: string,
273+
filterConditions?: string[],
274+
filterParams?: Record<string, Filter['value']>
275+
) => {
276+
const combinedWhereClause = buildWhereClause(filterConditions);
277+
278+
return {
279+
sql: `
280+
SELECT
281+
CASE WHEN trimRight(path(path), '/') = '' THEN '/' ELSE trimRight(path(path), '/') END as name,
282+
COUNT(*) as sessions_with_time,
283+
COUNT(DISTINCT anonymous_id) as visitors,
284+
ROUND(quantile(0.5)(time_on_page), 2) as median_time_on_page,
285+
ROUND((COUNT(*) / SUM(COUNT(*)) OVER()) * 100, 2) as percentage_of_sessions
286+
FROM analytics.events
287+
WHERE client_id = {websiteId:String}
288+
AND time >= parseDateTimeBestEffort({startDate:String})
289+
AND time <= parseDateTimeBestEffort({endDate:String})
290+
AND event_name = 'page_exit'
291+
AND time_on_page IS NOT NULL
292+
AND time_on_page > 1
293+
AND time_on_page < 3600
294+
${combinedWhereClause}
295+
GROUP BY name
296+
HAVING COUNT(*) >= 1
297+
ORDER BY median_time_on_page DESC
298+
LIMIT {limit:Int32} OFFSET {offset:Int32}
299+
`,
300+
params: {
301+
websiteId,
302+
startDate,
303+
endDate,
304+
limit: limit || 100,
305+
offset: offset || 0,
306+
...filterParams,
307+
},
308+
};
309+
},
310+
meta: {
311+
title: 'Page Time Analysis',
312+
description:
313+
'Analysis of time spent on each page, showing median time with quality filters to ensure reliable data.',
314+
category: 'Engagement',
315+
tags: ['time', 'engagement', 'pages', 'performance'],
316+
output_fields: [
317+
{
318+
name: 'name',
319+
type: 'string',
320+
label: 'Page Path',
321+
description: 'The URL path of the page',
322+
example: '/home',
323+
},
324+
{
325+
name: 'sessions_with_time',
326+
type: 'number',
327+
label: 'Sessions with Time Data',
328+
description: 'Number of sessions with valid time measurements',
329+
example: 245,
330+
},
331+
{
332+
name: 'visitors',
333+
type: 'number',
334+
label: 'Unique Visitors',
335+
description: 'Number of unique visitors with time data',
336+
example: 189,
337+
},
338+
{
339+
name: 'median_time_on_page',
340+
type: 'number',
341+
label: 'Median Time (seconds)',
342+
description: 'Median time spent on the page in seconds',
343+
unit: 'seconds',
344+
example: 32.5,
345+
},
346+
{
347+
name: 'percentage_of_sessions',
348+
type: 'number',
349+
label: 'Session %',
350+
description: 'Percentage of total sessions with time data',
351+
unit: '%',
352+
example: 15.8,
353+
},
354+
],
355+
output_example: [
356+
{
357+
name: '/home',
358+
sessions_with_time: 245,
359+
visitors: 189,
360+
median_time_on_page: 32.5,
361+
percentage_of_sessions: 15.8,
362+
},
363+
{
364+
name: '/about',
365+
sessions_with_time: 156,
366+
visitors: 134,
367+
median_time_on_page: 54.2,
368+
percentage_of_sessions: 10.1,
369+
},
370+
],
371+
default_visualization: 'table',
372+
supports_granularity: ['hour', 'day'],
373+
version: '1.0',
374+
},
375+
},
250376
};

apps/dashboard/app/(main)/websites/[id]/_components/tabs/overview-tab.tsx

Lines changed: 94 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -63,13 +63,6 @@ interface ChartDataPoint {
6363
[key: string]: unknown;
6464
}
6565

66-
interface PageData {
67-
name: string;
68-
visitors: number;
69-
pageviews?: number;
70-
percentage: number;
71-
}
72-
7366
interface TechnologyData {
7467
name: string;
7568
visitors: number;
@@ -144,7 +137,12 @@ const QUERY_CONFIG = {
144137
limit: 100,
145138
parameters: {
146139
summary: ['summary_metrics', 'today_metrics', 'events_by_date'] as string[],
147-
pages: ['top_pages', 'entry_pages', 'exit_pages'] as string[],
140+
pages: [
141+
'top_pages',
142+
'entry_pages',
143+
'exit_pages',
144+
'page_time_analysis',
145+
] as string[],
148146
traffic: [
149147
'top_referrers',
150148
'utm_sources',
@@ -261,6 +259,8 @@ export function WebsiteOverviewTab({
261259
top_pages: getDataForQuery('overview-pages', 'top_pages') || [],
262260
entry_pages: getDataForQuery('overview-pages', 'entry_pages') || [],
263261
exit_pages: getDataForQuery('overview-pages', 'exit_pages') || [],
262+
page_time_analysis:
263+
getDataForQuery('overview-pages', 'page_time_analysis') || [],
264264
top_referrers: getDataForQuery('overview-traffic', 'top_referrers') || [],
265265
utm_sources: getDataForQuery('overview-traffic', 'utm_sources') || [],
266266
utm_mediums: getDataForQuery('overview-traffic', 'utm_mediums') || [],
@@ -380,7 +380,7 @@ export function WebsiteOverviewTab({
380380
},
381381
});
382382

383-
const pagesTabs = useTableTabs({
383+
const standardPagesTabs = useTableTabs({
384384
top_pages: {
385385
data: analytics.top_pages || [],
386386
label: 'Top Pages',
@@ -601,11 +601,6 @@ export function WebsiteOverviewTab({
601601
);
602602
};
603603

604-
const createPercentageCell = () => (info: CellInfo) => {
605-
const percentage = info.getValue() as number;
606-
return <PercentageBadge percentage={percentage} />;
607-
};
608-
609604
const formatNumber = useCallback(
610605
(value: number | null | undefined): string => {
611606
if (value == null || Number.isNaN(value)) {
@@ -628,6 +623,91 @@ export function WebsiteOverviewTab({
628623
</div>
629624
);
630625

626+
const formatTimeSeconds = useCallback((seconds: number): string => {
627+
if (seconds < 60) {
628+
return `${seconds.toFixed(1)}s`;
629+
}
630+
const minutes = Math.floor(seconds / 60);
631+
const remainingSeconds = Math.round(seconds % 60);
632+
return `${minutes}m ${remainingSeconds}s`;
633+
}, []);
634+
635+
const createTimeCell = (info: CellInfo) => {
636+
const seconds = info.getValue() as number;
637+
return (
638+
<span className="font-medium text-foreground">
639+
{formatTimeSeconds(seconds)}
640+
</span>
641+
);
642+
};
643+
644+
const createPercentageCell = () => (info: CellInfo) => {
645+
const percentage = info.getValue() as number;
646+
return <PercentageBadge percentage={percentage} />;
647+
};
648+
649+
const pageTimeColumns = [
650+
{
651+
id: 'name',
652+
accessorKey: 'name',
653+
header: 'Page',
654+
cell: (info: CellInfo) => {
655+
const name = info.getValue() as string;
656+
return (
657+
<span className="font-medium text-foreground" title={name}>
658+
{name}
659+
</span>
660+
);
661+
},
662+
},
663+
{
664+
id: 'median_time_on_page',
665+
accessorKey: 'median_time_on_page',
666+
header: 'Avg Time',
667+
cell: createTimeCell,
668+
},
669+
{
670+
id: 'sessions_with_time',
671+
accessorKey: 'sessions_with_time',
672+
header: 'Sessions',
673+
cell: (info: CellInfo) => (
674+
<span className="font-medium text-foreground">
675+
{formatNumber(info.getValue() as number)}
676+
</span>
677+
),
678+
},
679+
{
680+
id: 'visitors',
681+
accessorKey: 'visitors',
682+
header: 'Visitors',
683+
cell: (info: CellInfo) => (
684+
<span className="font-medium text-foreground">
685+
{formatNumber(info.getValue() as number)}
686+
</span>
687+
),
688+
},
689+
{
690+
id: 'percentage_of_sessions',
691+
accessorKey: 'percentage_of_sessions',
692+
header: 'Share',
693+
cell: createPercentageCell(),
694+
},
695+
];
696+
697+
const pagesTabs = [
698+
...standardPagesTabs,
699+
{
700+
id: 'page_time_analysis',
701+
label: 'Time Analysis',
702+
data: analytics.page_time_analysis || [],
703+
columns: pageTimeColumns,
704+
getFilter: (row: any) => ({
705+
field: 'path',
706+
value: row.name,
707+
}),
708+
},
709+
];
710+
631711
const deviceColumns = [
632712
{
633713
id: 'device_type',

apps/dashboard/app/(main)/websites/[id]/page.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,7 @@ function WebsiteDetailsPage() {
173173

174174
if (isError || !data) {
175175
return (
176-
<div className="select-none pt-8">
176+
<div className="select-none py-8">
177177
<EmptyState
178178
action={
179179
<Link href="/websites">
@@ -209,7 +209,7 @@ function WebsiteDetailsPage() {
209209
return (
210210
<div>
211211
<Tabs
212-
className="mt-6 space-y-4"
212+
className="space-y-4 py-6"
213213
defaultValue="overview"
214214
onValueChange={(value) => setActiveTab(value as TabId)}
215215
value={activeTab}

apps/dashboard/components/analytics/data-table.tsx

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -70,12 +70,21 @@ interface DataTableProps<TData extends { name: string | number }, TValue> {
7070
}
7171

7272
function getRowPercentage(row: any): number {
73-
if (row.marketShare !== undefined)
73+
if (row.marketShare !== undefined) {
7474
return Number.parseFloat(row.marketShare) || 0;
75-
if (row.percentage !== undefined)
75+
}
76+
if (row.percentage !== undefined) {
7677
return Number.parseFloat(row.percentage) || 0;
77-
if (row.percent !== undefined) return Number.parseFloat(row.percent) || 0;
78-
if (row.share !== undefined) return Number.parseFloat(row.share) || 0;
78+
}
79+
if (row.percentage_of_sessions !== undefined) {
80+
return Number.parseFloat(row.percentage_of_sessions) || 0;
81+
}
82+
if (row.percent !== undefined) {
83+
return Number.parseFloat(row.percent) || 0;
84+
}
85+
if (row.share !== undefined) {
86+
return Number.parseFloat(row.share) || 0;
87+
}
7988
return 0;
8089
}
8190

@@ -153,7 +162,7 @@ const EnhancedSkeleton = ({ minHeight }: { minHeight: string | number }) => (
153162
</div>
154163
);
155164

156-
function FullScreenTable<TData extends { name: string | number }, TValue>({
165+
function FullScreenTable<TData extends { name: string | number }>({
157166
data,
158167
columns,
159168
search,
@@ -172,7 +181,7 @@ function FullScreenTable<TData extends { name: string | number }, TValue>({
172181
onAddFilter,
173182
}: {
174183
data: TData[];
175-
columns: any[];
184+
columns: ColumnDef<TData, unknown>[];
176185
search: string;
177186
onClose: () => void;
178187
initialPageSize?: number;
@@ -386,7 +395,8 @@ function FullScreenTable<TData extends { name: string | number }, TValue>({
386395
}
387396
className={cn(
388397
'h-11 bg-muted/20 px-2 font-semibold text-muted-foreground text-xs uppercase tracking-wide backdrop-blur-sm sm:px-4',
389-
(header.column.columnDef.meta as any)?.className,
398+
(header.column.columnDef.meta as { className?: string })
399+
?.className,
390400
header.column.getCanSort()
391401
? 'group cursor-pointer select-none transition-all duration-200 hover:bg-muted/30 hover:text-foreground'
392402
: 'select-none'
@@ -464,7 +474,11 @@ function FullScreenTable<TData extends { name: string | number }, TValue>({
464474
className={cn(
465475
'px-2 py-3 font-medium text-sm transition-colors duration-150 sm:px-4',
466476
cellIndex === 0 && 'font-semibold text-foreground',
467-
(cell.column.columnDef.meta as any)?.className
477+
(
478+
cell.column.columnDef.meta as {
479+
className?: string;
480+
}
481+
)?.className
468482
)}
469483
key={cell.id}
470484
style={{

0 commit comments

Comments
 (0)