Files
stats/apps/start/src/routes/_app.$organizationId.$projectId.sessions_.$sessionId.tsx
Carl-Gerhard Lindesvärd 0cfccd549b wip
2026-03-18 21:16:16 +01:00

413 lines
14 KiB
TypeScript

import type { IServiceEvent, IServiceSession } from '@openpanel/db';
import { useSuspenseQuery, useQuery } from '@tanstack/react-query';
import { createFileRoute, Link } from '@tanstack/react-router';
import { EventIcon } from '@/components/events/event-icon';
import FullPageLoadingState from '@/components/full-page-loading-state';
import { PageContainer } from '@/components/page-container';
import { PageHeader } from '@/components/page-header';
import { ProfileAvatar } from '@/components/profiles/profile-avatar';
import { SerieIcon } from '@/components/report-chart/common/serie-icon';
import { ReplayShell } from '@/components/sessions/replay';
import { KeyValueGrid } from '@/components/ui/key-value-grid';
import {
Widget,
WidgetBody,
WidgetHead,
WidgetTitle,
} from '@/components/widget';
import { useNumber } from '@/hooks/use-numer-formatter';
import { useTRPC } from '@/integrations/trpc/react';
import { formatDateTime } from '@/utils/date';
import { getProfileName } from '@/utils/getters';
import { createProjectTitle } from '@/utils/title';
export const Route = createFileRoute(
'/_app/$organizationId/$projectId/sessions_/$sessionId'
)({
component: Component,
loader: async ({ context, params }) => {
await Promise.all([
context.queryClient.prefetchQuery(
context.trpc.session.byId.queryOptions({
sessionId: params.sessionId,
projectId: params.projectId,
})
),
context.queryClient.prefetchQuery(
context.trpc.event.events.queryOptions({
projectId: params.projectId,
sessionId: params.sessionId,
filters: [],
columnVisibility: {},
})
),
]);
},
head: () => ({
meta: [{ title: createProjectTitle('Session') }],
}),
pendingComponent: FullPageLoadingState,
});
function sessionToFakeEvent(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 VisitedRoutes({ paths }: { paths: string[] }) {
const counted = paths.reduce<Record<string, number>>((acc, p) => {
acc[p] = (acc[p] ?? 0) + 1;
return acc;
}, {});
const sorted = Object.entries(counted).sort((a, b) => b[1] - a[1]);
const max = sorted[0]?.[1] ?? 1;
if (sorted.length === 0) {
return null;
}
return (
<Widget className="w-full">
<WidgetHead>
<WidgetTitle>Visited pages</WidgetTitle>
</WidgetHead>
<div className="flex flex-col gap-1 p-1">
{sorted.map(([path, count]) => (
<div className="group relative px-3 py-2" key={path}>
<div
className="absolute top-0 bottom-0 left-0 rounded bg-def-200 group-hover:bg-def-300"
style={{ width: `${(count / max) * 100}%` }}
/>
<div className="relative flex min-w-0 justify-between gap-2">
<span className="truncate text-sm">{path}</span>
<span className="shrink-0 font-medium text-sm">{count}</span>
</div>
</div>
))}
</div>
</Widget>
);
}
function EventDistribution({ events }: { events: IServiceEvent[] }) {
const counted = events.reduce<Record<string, number>>((acc, e) => {
acc[e.name] = (acc[e.name] ?? 0) + 1;
return acc;
}, {});
const sorted = Object.entries(counted).sort((a, b) => b[1] - a[1]);
const max = sorted[0]?.[1] ?? 1;
if (sorted.length === 0) {
return null;
}
return (
<Widget className="w-full">
<WidgetHead>
<WidgetTitle>Event distribution</WidgetTitle>
</WidgetHead>
<div className="flex flex-col gap-1 p-1">
{sorted.map(([name, count]) => (
<div className="group relative px-3 py-2" key={name}>
<div
className="absolute top-0 bottom-0 left-0 rounded bg-def-200 group-hover:bg-def-300"
style={{ width: `${(count / max) * 100}%` }}
/>
<div className="relative flex justify-between gap-2">
<span className="text-sm">{name.replace(/_/g, ' ')}</span>
<span className="shrink-0 font-medium text-sm">{count}</span>
</div>
</div>
))}
</div>
</Widget>
);
}
function Component() {
const { projectId, sessionId, organizationId } = Route.useParams();
const trpc = useTRPC();
const number = useNumber();
const { data: session } = useSuspenseQuery(
trpc.session.byId.queryOptions({ sessionId, projectId })
);
const { data: eventsData } = useSuspenseQuery(
trpc.event.events.queryOptions({
projectId,
sessionId,
filters: [],
columnVisibility: {},
})
);
const events = eventsData?.data ?? [];
const isIdentified =
session.profileId && session.profileId !== session.deviceId;
const { data: profile } = useSuspenseQuery(
trpc.profile.byId.queryOptions({
profileId: session.profileId,
projectId,
})
);
const { data: sessionGroups } = useQuery({
...trpc.group.listByIds.queryOptions({
projectId,
ids: session.groups ?? [],
}),
enabled: (session.groups?.length ?? 0) > 0,
});
const fakeEvent = sessionToFakeEvent(session);
return (
<PageContainer className="col gap-8">
<PageHeader title={`Session: ${session.id}`}>
<div className="row mb-6 gap-4">
{session.country && (
<div className="row items-center gap-2">
<SerieIcon name={session.country} />
<span>
{session.country}
{session.city && ` / ${session.city}`}
</span>
</div>
)}
{session.device && (
<div className="row items-center gap-2">
<SerieIcon name={session.device} />
<span className="capitalize">{session.device}</span>
</div>
)}
{session.os && (
<div className="row items-center gap-2">
<SerieIcon name={session.os} />
<span>{session.os}</span>
</div>
)}
{session.model && (
<div className="row items-center gap-2">
<SerieIcon name={session.model} />
<span>{session.model}</span>
</div>
)}
{session.browser && (
<div className="row items-center gap-2">
<SerieIcon name={session.browser} />
<span>{session.browser}</span>
</div>
)}
</div>
</PageHeader>
{session.hasReplay && (
<ReplayShell projectId={projectId} sessionId={sessionId} />
)}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[340px_minmax(0,1fr)]">
{/* Left column */}
<div className="col gap-6">
{/* Session info */}
<Widget className="w-full">
<WidgetHead>
<WidgetTitle>Session info</WidgetTitle>
</WidgetHead>
<KeyValueGrid
className="border-0"
columns={1}
copyable
data={[
{
name: 'duration',
value: number.formatWithUnit(session.duration / 1000, 'min'),
},
{ name: 'createdAt', value: session.createdAt },
{ name: 'endedAt', value: session.endedAt },
{ name: 'screenViews', value: session.screenViewCount },
{ name: 'events', value: session.eventCount },
{ name: 'bounce', value: session.isBounce ? 'Yes' : 'No' },
...(session.entryPath
? [{ name: 'entryPath', value: session.entryPath }]
: []),
...(session.exitPath
? [{ name: 'exitPath', value: session.exitPath }]
: []),
...(session.referrerName
? [{ name: 'referrerName', value: session.referrerName }]
: []),
...(session.referrer
? [{ name: 'referrer', value: session.referrer }]
: []),
...(session.utmSource
? [{ name: 'utmSource', value: session.utmSource }]
: []),
...(session.utmMedium
? [{ name: 'utmMedium', value: session.utmMedium }]
: []),
...(session.utmCampaign
? [{ name: 'utmCampaign', value: session.utmCampaign }]
: []),
...(session.revenue > 0
? [{ name: 'revenue', value: `$${session.revenue}` }]
: []),
{ name: 'country', value: session.country, event: fakeEvent },
...(session.city
? [{ name: 'city', value: session.city, event: fakeEvent }]
: []),
...(session.os
? [{ name: 'os', value: session.os, event: fakeEvent }]
: []),
...(session.browser
? [
{
name: 'browser',
value: session.browser,
event: fakeEvent,
},
]
: []),
...(session.device
? [
{
name: 'device',
value: session.device,
event: fakeEvent,
},
]
: []),
...(session.brand
? [{ name: 'brand', value: session.brand, event: fakeEvent }]
: []),
...(session.model
? [{ name: 'model', value: session.model, event: fakeEvent }]
: []),
]}
/>
</Widget>
{/* Profile card */}
{isIdentified && profile && (
<Widget className="w-full">
<WidgetHead>
<WidgetTitle>Profile</WidgetTitle>
</WidgetHead>
<WidgetBody className="p-0">
<Link
className="row items-center gap-3 p-4 transition-colors hover:bg-accent"
params={{
organizationId,
projectId,
profileId: session.profileId,
}}
to="/$organizationId/$projectId/profiles/$profileId"
>
<ProfileAvatar {...profile} size="lg" />
<div className="col min-w-0 gap-0.5">
<span className="truncate font-medium">
{getProfileName(profile, false) ?? session.profileId}
</span>
{profile.email && (
<span className="truncate text-muted-foreground text-sm">
{profile.email}
</span>
)}
</div>
</Link>
</WidgetBody>
</Widget>
)}
{/* Group cards */}
{sessionGroups && sessionGroups.length > 0 && (
<Widget className="w-full">
<WidgetHead>
<WidgetTitle>Groups</WidgetTitle>
</WidgetHead>
<WidgetBody className="p-0">
{sessionGroups.map((group) => (
<Link
key={group.id}
className="row items-center gap-3 p-4 transition-colors hover:bg-accent"
params={{ organizationId, projectId, groupId: group.id }}
to="/$organizationId/$projectId/groups/$groupId"
>
<div className="col min-w-0 flex-1 gap-0.5">
<span className="truncate font-medium">{group.name}</span>
<span className="truncate text-muted-foreground text-sm font-mono">
{group.id}
</span>
</div>
<span className="shrink-0 rounded border px-1.5 py-0.5 text-muted-foreground text-xs">
{group.type}
</span>
</Link>
))}
</WidgetBody>
</Widget>
)}
{/* Visited pages */}
<VisitedRoutes
paths={events
.filter((e) => e.name === 'screen_view' && e.path)
.map((e) => e.path)}
/>
{/* Event distribution */}
<EventDistribution events={events} />
</div>
{/* Right column */}
<div className="col gap-6">
{/* Events list */}
<Widget className="w-full">
<WidgetHead>
<WidgetTitle>Events</WidgetTitle>
</WidgetHead>
<div className="divide-y">
{events.map((event) => (
<div
className="row items-center gap-3 px-4 py-2"
key={event.id}
>
<EventIcon meta={event.meta} name={event.name} size="sm" />
<div className="col min-w-0 flex-1">
<span className="truncate font-medium text-sm">
{event.name === 'screen_view' && event.path
? event.path
: event.name.replace(/_/g, ' ')}
</span>
</div>
<span className="shrink-0 text-muted-foreground text-xs tabular-nums">
{formatDateTime(event.createdAt)}
</span>
</div>
))}
{events.length === 0 && (
<div className="py-8 text-center text-muted-foreground text-sm">
No events found
</div>
)}
</div>
</Widget>
</div>
</div>
</PageContainer>
);
}