Skip to content
Merged
87 changes: 87 additions & 0 deletions apps/start/src/components/sessions/details/session-journey.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { ChevronRightIcon, RouteIcon } from 'lucide-react';
import type { SessionPageVisit } from './session-pages';
import { WidgetHead, WidgetTitle } from '@/components/overview/overview-widget';
import { Widget } from '@/components/widget';
import { fancyMinutes } from '@/hooks/use-numer-formatter';
import { cn } from '@/utils/cn';

const MS_PER_SECOND = 1000;
const MIN_BAR_PERCENT = 4;

function StepBadge({ label, tone }: { label: string; tone: 'entry' | 'exit' }) {
return (
<span
className={cn(
'rounded px-1 py-px font-semibold text-[10px] uppercase tracking-wider',
tone === 'entry'
? 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400'
: 'bg-rose-500/10 text-rose-600 dark:text-rose-400'
)}
>
{label}
</span>
);
}

export function SessionJourney({ visits }: { visits: SessionPageVisit[] }) {
const pages = visits.filter((visit) => visit.view !== null);
if (pages.length === 0) {
return null;
}
const longest = Math.max(...pages.map((page) => page.durationMs), 1);

return (
<Widget className="w-full">
<WidgetHead>
<WidgetTitle icon={RouteIcon}>Journey</WidgetTitle>
</WidgetHead>
<ol className="row items-stretch overflow-x-auto p-4">
{pages.map((page, index) => {
const isFirst = index === 0;
const isLast = index === pages.length - 1;
const customEvents = page.events.length;
const barWidth = Math.max(
MIN_BAR_PERCENT,
(page.durationMs / longest) * 100
);
return (
<li
className="row shrink-0 items-center"
key={page.view?.id ?? index}
>
<div className="col w-36 gap-1.5 rounded-lg border bg-def-100 px-3 py-2.5 transition-colors hover:border-def-400">
<div className="row items-center gap-1.5">
<span className="font-mono text-2xs text-muted-foreground tabular-nums">
{index + 1}
</span>
{isFirst && <StepBadge label="Entry" tone="entry" />}
{isLast && <StepBadge label="Exit" tone="exit" />}
</div>
<span
className="truncate font-medium text-sm"
title={page.path}
>
{page.path}
</span>
<div className="h-1 overflow-hidden rounded-full bg-def-200">
<div
className="h-full rounded-full bg-highlight"
style={{ width: `${barWidth}%` }}
/>
</div>
<span className="text-2xs text-muted-foreground tabular-nums">
{fancyMinutes(page.durationMs / MS_PER_SECOND)}
{customEvents > 0 &&
` · ${customEvents} ${customEvents === 1 ? 'event' : 'events'}`}
</span>
</div>
{!isLast && (
<ChevronRightIcon className="mx-1 size-4 shrink-0 text-def-400" />
)}
</li>
);
})}
</ol>
</Widget>
);
}
87 changes: 87 additions & 0 deletions apps/start/src/components/sessions/details/session-metrics.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import type { IServiceEvent, IServiceSession } from '@openpanel/core';
import {
type MetricUnit,
OverviewMetricCard,
} from '@/components/overview/overview-metric-card';
import { cn } from '@/utils/cn';

const COLUMNS_BY_COUNT: Record<number, string> = {
4: 'xl:grid-cols-4',
5: 'xl:grid-cols-5',
6: 'xl:grid-cols-6',
};
const MS_PER_SECOND = 1000;

interface SessionMetric {
key: string;
label: string;
value: number;
unit: MetricUnit;
}

export function SessionMetrics({
session,
events,
}: {
session: IServiceSession;
events: IServiceEvent[];
}) {
const pageViews = events.filter((event) => event.name === 'screen_view');
const uniquePages = new Set(pageViews.map((event) => event.path || '/')).size;
const durationSeconds = session.duration / MS_PER_SECOND;
Comment thread
lindesvard marked this conversation as resolved.
Resolved
const hasPageViews = session.screenViewCount > 0;

Comment thread
lindesvard marked this conversation as resolved.
Resolved
// The card's 'min' unit takes seconds and renders them as a duration.
const metrics: SessionMetric[] = [
{ key: 'duration', label: 'Duration', value: durationSeconds, unit: 'min' },
{
key: 'screenViews',
label: 'Page views',
value: session.screenViewCount,
unit: '',
},
{ key: 'uniquePages', label: 'Unique pages', value: uniquePages, unit: '' },
{ key: 'events', label: 'Events', value: session.eventCount, unit: '' },
...(hasPageViews
? [
{
key: 'timePerPage',
label: 'Time per page',
value: durationSeconds / session.screenViewCount,
unit: 'min' as const,
},
]
: []),
...(session.revenue > 0
? [
{
key: 'revenue',
label: 'Revenue',
value: session.revenue,
unit: 'currency' as const,
},
]
: []),
];

return (
<div
className={cn(
'card grid grid-cols-2 overflow-hidden rounded-md md:grid-cols-3',
COLUMNS_BY_COUNT[metrics.length]
)}
>
{metrics.map((metric) => (
<OverviewMetricCard
data={[]}
id={metric.key}
isLoading={false}
key={metric.key}
label={metric.label}
metric={{ current: metric.value, previous: null }}
unit={metric.unit}
/>
))}
</div>
);
}
77 changes: 77 additions & 0 deletions apps/start/src/components/sessions/details/session-pages.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import type { IServiceEvent } from '@openpanel/core';

export interface SessionPageVisit {
/** The screen view that opened the page; null for events before the first one. */
view: IServiceEvent | null;
path: string;
startedAt: number;
durationMs: number;
events: IServiceEvent[];
}

function toMs(date: Date | string): number {
return date instanceof Date ? date.getTime() : new Date(date).getTime();
}

function pagePath(event: IServiceEvent): string {
return event.path || '/';
}

/**
* Within one millisecond the query's order is arbitrary, so an event goes after
* a screen view only when it happened on that view's page.
*/
function compareEvents(a: IServiceEvent, b: IServiceEvent): number {
const difference = toMs(a.createdAt) - toMs(b.createdAt);
const aIsView = a.name === 'screen_view';
const bIsView = b.name === 'screen_view';
if (difference !== 0 || aIsView === bIsView) {
return difference;
}
const view = aIsView ? a : b;
const other = aIsView ? b : a;
const viewGoesFirst = pagePath(other) === pagePath(view);
return viewGoesFirst === aIsView ? -1 : 1;
}

export function sortEventsAscending(events: IServiceEvent[]): IServiceEvent[] {
return [...events].sort(compareEvents);
}

/** Splits a session's events into page visits, each lasting until the next page. */
export function groupEventsByPage(
events: IServiceEvent[],
sessionEndedAt: Date
): SessionPageVisit[] {
const visits: SessionPageVisit[] = [];
for (const event of sortEventsAscending(events)) {
const isPageView = event.name === 'screen_view';
const current = visits.at(-1);
if (isPageView || !current) {
visits.push({
view: isPageView ? event : null,
path: isPageView ? pagePath(event) : '',
startedAt: toMs(event.createdAt),
durationMs: 0,
events: isPageView ? [] : [event],
});
continue;
}
current.events.push(event);
}

const endedAt = toMs(sessionEndedAt);
for (const [index, visit] of visits.entries()) {
const next = visits[index + 1];
const lastEvent = visit.events.at(-1);
const end =
next?.startedAt ??
Math.max(endedAt, lastEvent ? toMs(lastEvent.createdAt) : 0);
visit.durationMs = Math.max(0, end - visit.startedAt);
}
return visits;
}

export function getElapsedMs(event: IServiceEvent, sessionStart: Date): number {
return Math.max(0, toMs(event.createdAt) - toMs(sessionStart));
}
123 changes: 123 additions & 0 deletions apps/start/src/components/sessions/details/session-properties.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import type { IServiceEvent, IServiceSession } from '@openpanel/core';
import { parseAsStringEnum, useQueryState } from 'nuqs';
import {
WidgetButtons,
WidgetHead,
} from '@/components/overview/overview-widget';
import {
KeyValueGrid,
type KeyValueItem,
} from '@/components/ui/key-value-grid';
import { Widget } from '@/components/widget';
import { fancyMinutes } from '@/hooks/use-numer-formatter';
import { cn } from '@/utils/cn';

const TABS = [
{ key: 'session', label: 'Session' },
{ key: 'source', label: 'Source' },
{ key: 'device', label: 'Device' },
] as const;
type TabKey = (typeof TABS)[number]['key'];
const MS_PER_SECOND = 1000;

/** Lets the grid's filter links treat session fields like event properties. */
function sessionAsEvent(session: IServiceSession): IServiceEvent {
return {
...session,
name: 'screen_view',
sessionId: session.id,
properties: {},
path: session.exitPath,
origin: session.exitOrigin,
importedAt: undefined,
meta: undefined,
sdkName: undefined,
sdkVersion: undefined,
profile: undefined,
};
}

function withVersion(name: string, version: string): string {
return version ? `${name} ${version}` : name;
}

function getItems(session: IServiceSession, tab: TabKey): KeyValueItem[] {
const event = sessionAsEvent(session);
const items: Record<TabKey, KeyValueItem[]> = {
session: [
{ name: 'id', value: session.id },
{ name: 'createdAt', value: session.createdAt },
{ name: 'endedAt', value: session.endedAt },
{
name: 'duration',
value: fancyMinutes(session.duration / MS_PER_SECOND),
},
{ name: 'entryPath', value: session.entryPath },
{ name: 'exitPath', value: session.exitPath },
{ name: 'bounce', value: session.isBounce ? 'Yes' : 'No' },
{ name: 'deviceId', value: session.deviceId },
],
source: [
{ name: 'referrerName', value: session.referrerName, event },
{ name: 'referrerType', value: session.referrerType, event },
{ name: 'referrer', value: session.referrer, event },
{ name: 'utmSource', value: session.utmSource },
{ name: 'utmMedium', value: session.utmMedium },
{ name: 'utmCampaign', value: session.utmCampaign },
{ name: 'utmContent', value: session.utmContent },
{ name: 'utmTerm', value: session.utmTerm },
],
device: [
{ name: 'country', value: session.country, event },
{ name: 'region', value: session.region, event },
{ name: 'city', value: session.city, event },
{ name: 'device', value: session.device, event },
{ name: 'os', value: withVersion(session.os, session.osVersion) },
{
name: 'browser',
value: withVersion(session.browser, session.browserVersion),
},
{ name: 'brand', value: session.brand, event },
{ name: 'model', value: session.model, event },
],
};
return items[tab].filter(
(item) =>
item.value !== '' && item.value !== null && item.value !== undefined
);
}

export function SessionProperties({ session }: { session: IServiceSession }) {
const [tab, setTab] = useQueryState(
'tab',
parseAsStringEnum(TABS.map((t) => t.key)).withDefault('session')
);
const items = getItems(session, tab);

return (
<Widget className="w-full">
<WidgetHead>
<div className="title">Details</div>
<WidgetButtons>
{TABS.map((t) => (
<button
className={cn(t.key === tab && 'active')}
key={t.key}
onClick={() => setTab(t.key)}
type="button"
>
{t.label}
</button>
))}
</WidgetButtons>
</WidgetHead>
{items.length > 0 ? (
<KeyValueGrid className="border-0" columns={1} copyable data={items} />
) : (
<div className="p-4 text-muted-foreground text-sm">
Nothing recorded for this session.
</div>
)}
</Widget>
);
}
Loading
Loading