From 61e69a65d512a3f8f47be8a7edd4786b0ba21741 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carl-Gerhard=20Lindesva=CC=88rd?= Date: Mon, 2 Nov 2026 21:10:44 +0100 Subject: [PATCH 1/7] feat(dashboard): redesigned session page and session replay player The session page now follows the profile page: who the visitor was and the session's key facts up top, a metric strip, tabbed details (session, source, device), the visitor and their groups on the side, a journey strip of the pages visited with time on each, and a timeline that groups events under the page they happened on. The replay player is rebuilt. Recordings sit on a stage in a browser frame, or a phone frame for tall viewports, sized to fit the recording. The scrubber shows an activity waveform from the recorded interactions, page bands you can click to jump to a page, event markers and rage clicks. Controls add skip idle time (on by default), playback speed, jump 10s, previous/next event, fullscreen and keyboard shortcuts. The activity feed lists every event, marks the current one and follows playback. Also fixes long replays stopping early: chunks loaded after the first batch were added to the player but the duration was read before rrweb applied them, so the timeline ended at the first batch. --- .../sessions/details/session-journey.tsx | 87 +++ .../sessions/details/session-metrics.tsx | 82 +++ .../sessions/details/session-pages.ts | 56 ++ .../sessions/details/session-properties.tsx | 123 ++++ .../sessions/details/session-timeline.tsx | 202 ++++++ .../sessions/replay/browser-chrome.tsx | 42 -- .../src/components/sessions/replay/index.tsx | 342 +++++----- .../sessions/replay/replay-context.tsx | 133 +++- .../sessions/replay/replay-controls.tsx | 346 ++++++++++- .../sessions/replay/replay-event-feed.tsx | 168 +++-- .../sessions/replay/replay-event-item.tsx | 104 +++- .../sessions/replay/replay-player.tsx | 333 ++++++---- .../sessions/replay/replay-timeline.tsx | 588 ++++++++++-------- .../sessions/replay/replay-utils.ts | 225 ++++++- ...tionId.$projectId.sessions_.$sessionId.tsx | 537 +++++++--------- apps/start/src/types/rrweb-player.d.ts | 10 + 16 files changed, 2334 insertions(+), 1044 deletions(-) create mode 100644 apps/start/src/components/sessions/details/session-journey.tsx create mode 100644 apps/start/src/components/sessions/details/session-metrics.tsx create mode 100644 apps/start/src/components/sessions/details/session-pages.ts create mode 100644 apps/start/src/components/sessions/details/session-properties.tsx create mode 100644 apps/start/src/components/sessions/details/session-timeline.tsx delete mode 100644 apps/start/src/components/sessions/replay/browser-chrome.tsx diff --git a/apps/start/src/components/sessions/details/session-journey.tsx b/apps/start/src/components/sessions/details/session-journey.tsx new file mode 100644 index 000000000..b0687a4f3 --- /dev/null +++ b/apps/start/src/components/sessions/details/session-journey.tsx @@ -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 ( + + {label} + + ); +} + +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 ( + + + Journey + +
    + {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 ( +
  1. +
    +
    + + {index + 1} + + {isFirst && } + {isLast && } +
    + + {page.path} + +
    +
    +
    + + {fancyMinutes(page.durationMs / MS_PER_SECOND)} + {customEvents > 0 && + ` · ${customEvents} ${customEvents === 1 ? 'event' : 'events'}`} + +
    + {!isLast && ( + + )} +
  2. + ); + })} +
+
+ ); +} diff --git a/apps/start/src/components/sessions/details/session-metrics.tsx b/apps/start/src/components/sessions/details/session-metrics.tsx new file mode 100644 index 000000000..2300b8e27 --- /dev/null +++ b/apps/start/src/components/sessions/details/session-metrics.tsx @@ -0,0 +1,82 @@ +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 = { + 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; + const pageCount = Math.max(session.screenViewCount, 1); + + // 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: '' }, + { + key: 'timePerPage', + label: 'Time per page', + value: durationSeconds / pageCount, + unit: 'min', + }, + ...(session.revenue > 0 + ? [ + { + key: 'revenue', + label: 'Revenue', + value: session.revenue, + unit: 'currency' as const, + }, + ] + : []), + ]; + + return ( +
+ {metrics.map((metric) => ( + + ))} +
+ ); +} diff --git a/apps/start/src/components/sessions/details/session-pages.ts b/apps/start/src/components/sessions/details/session-pages.ts new file mode 100644 index 000000000..c76d8403a --- /dev/null +++ b/apps/start/src/components/sessions/details/session-pages.ts @@ -0,0 +1,56 @@ +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(); +} + +export function sortEventsAscending(events: IServiceEvent[]): IServiceEvent[] { + return [...events].sort((a, b) => toMs(a.createdAt) - toMs(b.createdAt)); +} + +/** 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 ? event.path || '/' : '', + 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)); +} diff --git a/apps/start/src/components/sessions/details/session-properties.tsx b/apps/start/src/components/sessions/details/session-properties.tsx new file mode 100644 index 000000000..e3d1563bc --- /dev/null +++ b/apps/start/src/components/sessions/details/session-properties.tsx @@ -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 = { + 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 ( + + +
Details
+ + {TABS.map((t) => ( + + ))} + +
+ {items.length > 0 ? ( + + ) : ( +
+ Nothing recorded for this session. +
+ )} +
+ ); +} diff --git a/apps/start/src/components/sessions/details/session-timeline.tsx b/apps/start/src/components/sessions/details/session-timeline.tsx new file mode 100644 index 000000000..a309f42ad --- /dev/null +++ b/apps/start/src/components/sessions/details/session-timeline.tsx @@ -0,0 +1,202 @@ +import type { IServiceEvent } from '@openpanel/core'; +import { ListTreeIcon, ZapIcon } from 'lucide-react'; +import { formatDuration } from '../replay/replay-utils'; +import { getElapsedMs, type SessionPageVisit } from './session-pages'; +import { EmptyState } from '@/components/empty-state'; +import { EventIcon } from '@/components/events/event-icon'; +import { useEventDrawer } from '@/components/events/use-event-drawer'; +import { WidgetHead, WidgetTitle } from '@/components/overview/overview-widget'; +import { Widget } from '@/components/widget'; +import { fancyMinutes } from '@/hooks/use-numer-formatter'; +import { formatTime } from '@/utils/date'; + +const MS_PER_SECOND = 1000; +const MAX_PROPERTY_CHIPS = 3; +const MAX_CHIP_VALUE_LENGTH = 32; + +function getPropertyChips(event: IServiceEvent): [string, string][] { + return Object.entries(event.properties ?? {}) + .filter( + ([key, value]) => + !key.startsWith('__') && + (typeof value === 'string' || + typeof value === 'number' || + typeof value === 'boolean') + ) + .slice(0, MAX_PROPERTY_CHIPS) + .map(([key, value]) => { + const text = String(value); + return [ + key, + text.length > MAX_CHIP_VALUE_LENGTH + ? `${text.slice(0, MAX_CHIP_VALUE_LENGTH)}…` + : text, + ]; + }); +} + +function Elapsed({ ms }: { ms: number }) { + return ( + + +{formatDuration(ms)} + + ); +} + +function EventRow({ + event, + sessionStart, + onOpen, +}: { + event: IServiceEvent; + sessionStart: Date; + onOpen: () => void; +}) { + const chips = getPropertyChips(event); + return ( +
  • + + +
  • + ); +} + +function PageHeader({ + visit, + index, + sessionStart, + onOpen, +}: { + visit: SessionPageVisit; + index: number; + sessionStart: Date; + onOpen?: () => void; +}) { + const view = visit.view; + const content = ( + <> + {view ? ( + + {index} + + ) : ( + + )} + + + {view ? visit.path : 'Session started'} + + + {formatTime(new Date(visit.startedAt))} + {view && + ` · ${fancyMinutes(visit.durationMs / MS_PER_SECOND)} on page`} + + + {view && } + + ); + + if (!onOpen) { + return ( +
    {content}
    + ); + } + return ( + + ); +} + +export function SessionTimeline({ + visits, + sessionStart, +}: { + visits: SessionPageVisit[]; + sessionStart: Date; +}) { + const { openEvent } = useEventDrawer(); + const open = (event: IServiceEvent) => + openEvent({ id: event.id, createdAt: event.createdAt }); + let pageNumber = 0; + + return ( + + + Timeline + + {visits.length === 0 ? ( + + ) : ( +
      + {visits.map((visit, index) => { + const view = visit.view; + if (view) { + pageNumber++; + } + return ( +
    1. + open(view) : undefined} + sessionStart={sessionStart} + visit={visit} + /> + {visit.events.length > 0 && ( +
        + {visit.events.map((event) => ( + open(event)} + sessionStart={sessionStart} + /> + ))} +
      + )} +
    2. + ); + })} +
    + )} +
    + ); +} diff --git a/apps/start/src/components/sessions/replay/browser-chrome.tsx b/apps/start/src/components/sessions/replay/browser-chrome.tsx deleted file mode 100644 index 6162322f0..000000000 --- a/apps/start/src/components/sessions/replay/browser-chrome.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import type { ReactNode } from 'react'; -import { cn } from '@/utils/cn'; - -export function BrowserChrome({ - url, - children, - right, - controls = ( -
    -
    -
    -
    -
    - ), - className, -}: { - url?: ReactNode; - children: ReactNode; - right?: ReactNode; - controls?: ReactNode; - className?: string; -}) { - return ( -
    -
    - {controls} - {url !== false && ( -
    - {url} -
    - )} - {right} -
    - {children} -
    - ); -} diff --git a/apps/start/src/components/sessions/replay/index.tsx b/apps/start/src/components/sessions/replay/index.tsx index fb26c8067..f2eefda44 100644 --- a/apps/start/src/components/sessions/replay/index.tsx +++ b/apps/start/src/components/sessions/replay/index.tsx @@ -1,54 +1,30 @@ import type { IServiceEvent } from '@openpanel/core'; import { useQuery, useQueryClient } from '@tanstack/react-query'; -import { Maximize2, Minimize2 } from 'lucide-react'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { BrowserChrome } from './browser-chrome'; -import { ReplayTime } from './replay-controls'; -import { ReplayTimeline } from './replay-timeline'; -import { getEventOffsetMs } from './replay-utils'; +import { FastForwardIcon, VideoOffIcon } from 'lucide-react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { ReplayProvider, useCurrentTime, useReplayContext, -} from '@/components/sessions/replay/replay-context'; -import { ReplayEventFeed } from '@/components/sessions/replay/replay-event-feed'; -import { ReplayPlayer } from '@/components/sessions/replay/replay-player'; +} from './replay-context'; +import { ReplayControls, ReplayPlayPauseButton } from './replay-controls'; +import { ReplayEventFeed } from './replay-event-feed'; +import { getStageFitHeight, ReplayPlayer } from './replay-player'; +import { ReplayTimeline } from './replay-timeline'; +import { + type EventWithOffset, + findLastIndexAtTime, + getPageSegments, + type PageSegment, + type ReplayRecordEvent, + sortEventsByOffset, +} from './replay-utils'; import { useTRPC } from '@/integrations/trpc/react'; +import { cn } from '@/utils/cn'; -function BrowserUrlBar({ events }: { events: IServiceEvent[] }) { - const { startTime } = useReplayContext(); - const currentTime = useCurrentTime(250); +const FEED_TICK_MS = 100; - const currentUrl = useMemo(() => { - if (startTime == null || !events.length) { - return ''; - } - - const withOffset = events - .map((ev) => ({ - event: ev, - offsetMs: getEventOffsetMs(ev, startTime), - })) - .filter(({ offsetMs }) => offsetMs >= -10_000 && offsetMs <= currentTime) - .sort((a, b) => a.offsetMs - b.offsetMs); - - const latest = withOffset.at(-1); - if (!latest) { - return ''; - } - - const { origin = '', path = '/' } = latest.event; - return `${origin}${path}`; - }, [events, currentTime, startTime]); - - return {currentUrl}; -} - -/** - * Feeds remaining chunks into the player after it's ready. - * Receives already-fetched chunks from the initial batch, then pages - * through the rest using replayChunksFrom. - */ +/** Pages through the chunks after the first batch and feeds them to the player. */ function ReplayChunkLoader({ sessionId, projectId, @@ -60,9 +36,12 @@ function ReplayChunkLoader({ }) { const trpc = useTRPC(); const queryClient = useQueryClient(); - const { addEvent, refreshDuration } = useReplayContext(); + const { addEvent, refreshDuration, isReady } = useReplayContext(); useEffect(() => { + if (!isReady) { + return; + } let cancelled = false; function loadFrom(index: number) { @@ -84,13 +63,12 @@ function ReplayChunkLoader({ } } refreshDuration(); - const advanced = res.nextIndex > index; - if (res.hasMore && advanced) { + if (res.hasMore && res.nextIndex > index) { loadFrom(res.nextIndex); } }) .catch(() => { - // chunk loading failed — replay may be incomplete + // A failed page leaves the replay shorter, not broken. }); } @@ -98,52 +76,174 @@ function ReplayChunkLoader({ return () => { cancelled = true; }; - }, []); + }, [ + isReady, + queryClient, + trpc, + sessionId, + projectId, + fromIndex, + addEvent, + refreshDuration, + ]); return null; } -function FullscreenButton({ - containerRef, -}: { - containerRef: React.RefObject; -}) { - const [isFullscreen, setIsFullscreen] = useState(false); +function CurrentUrl({ segment }: { segment: PageSegment | undefined }) { + if (!segment) { + return about:blank; + } + const host = segment.origin.replace(/^https?:\/\//, ''); + return ( + + {host} + {segment.path} + + ); +} - useEffect(() => { - const onChange = () => setIsFullscreen(!!document.fullscreenElement); - document.addEventListener('fullscreenchange', onChange); - return () => document.removeEventListener('fullscreenchange', onChange); - }, []); +/** A big play button until the viewer starts the replay for the first time. */ +function StartOverlay() { + const { isPlaying, isReady } = useReplayContext(); + const [hasStarted, setHasStarted] = useState(false); - const toggle = useCallback(() => { - if (!containerRef.current) { - return; - } - if (document.fullscreenElement) { - document.exitFullscreen(); - } else { - containerRef.current.requestFullscreen(); + useEffect(() => { + if (isPlaying) { + setHasStarted(true); } - }, [containerRef]); + }, [isPlaying]); + + if (!isReady || hasStarted) { + return null; + } return ( - +
    + +
    + ); +} + +function SkippingBadge() { + const { isSkipping, isPlaying } = useReplayContext(); + if (!(isSkipping && isPlaying)) { + return null; + } + return ( +
    + + Skipping idle time +
    ); } -function ReplayContent({ +function StagePlaceholder({ isLoading }: { isLoading: boolean }) { + if (isLoading) { + return ( +
    +
    +
    +
    +
    +
    + Loading recording… +
    +
    +
    +
    + ); + } + return ( +
    + + + No recording was captured for this session. + +
    + ); +} + +function ReplayStage({ + events, + playerEvents, + isLoading, + fullscreenTarget, +}: { + events: IServiceEvent[]; + playerEvents: ReplayRecordEvent[]; + isLoading: boolean; + fullscreenTarget: React.RefObject; +}) { + const { startTime, duration } = useReplayContext(); + const currentTime = useCurrentTime(FEED_TICK_MS); + + const sorted = useMemo( + () => (startTime == null ? [] : sortEventsByOffset(events, startTime)), + [events, startTime] + ); + const segments = useMemo( + () => getPageSegments(sorted, duration), + [sorted, duration] + ); + const currentIndex = findLastIndexAtTime(sorted, currentTime); + const currentSegment = + segments.filter((segment) => segment.startMs <= currentTime).at(-1) ?? + segments[0]; + const hasRecording = playerEvents.length > 0; + const stageFitHeight = useMemo( + () => getStageFitHeight(playerEvents), + [playerEvents] + ); + + return ( +
    +
    +
    +
    + {hasRecording ? ( + } + url={} + /> + ) : ( + + )} + +
    +
    + + +
    +
    + +
    + ); +} + +export function ReplayShell({ sessionId, projectId, }: { @@ -151,7 +251,7 @@ function ReplayContent({ projectId: string; }) { const trpc = useTRPC(); - const containerRef = useRef(null); + const rootRef = useRef(null); const { data: eventsData } = useQuery( trpc.event.events.queryOptions({ @@ -162,8 +262,8 @@ function ReplayContent({ }) ); - // Fetch first batch of chunks (includes chunk 0 for player init + more) - const { data: firstBatch, isLoading: replayLoading } = useQuery( + // The first batch carries the full snapshot the player needs to start. + const { data: firstBatch, isLoading } = useQuery( trpc.session.replayChunksFrom.queryOptions({ sessionId, projectId, @@ -172,66 +272,28 @@ function ReplayContent({ ); const events = eventsData?.data ?? []; - const playerEvents = - firstBatch?.data.flatMap((row) => row?.events ?? []) ?? []; - const hasMore = firstBatch?.hasMore ?? false; - const hasReplay = playerEvents.length !== 0; - - function renderReplay() { - if (replayLoading) { - return ( -
    -
    -
    Loading session replay
    -
    - ); - } - if (hasReplay) { - return ; - } - return ( -
    - No replay data available for this session. -
    - ); - } + const playerEvents = useMemo( + () => firstBatch?.data.flatMap((row) => row?.events ?? []) ?? [], + [firstBatch] + ); return (
    -
    - - {hasReplay && } - -
    - } - url={ - hasReplay ? ( - - ) : ( - about:blank - ) - } - > - {renderReplay()} - {hasReplay && } - -
    -
    -
    - -
    -
    +
    - {hasReplay && hasMore && ( + {firstBatch?.hasMore && ( @@ -239,13 +301,3 @@ function ReplayContent({ ); } - -export function ReplayShell({ - sessionId, - projectId, -}: { - sessionId: string; - projectId: string; -}) { - return ; -} diff --git a/apps/start/src/components/sessions/replay/replay-context.tsx b/apps/start/src/components/sessions/replay/replay-context.tsx index cba34b19a..d70bc52cf 100644 --- a/apps/start/src/components/sessions/replay/replay-context.tsx +++ b/apps/start/src/components/sessions/replay/replay-context.tsx @@ -7,6 +7,11 @@ import { useRef, useState, } from 'react'; +import { + getClick, + isUserInteraction, + type ReplayRecordEvent, +} from './replay-utils'; export interface ReplayPlayerInstance { play: () => void; @@ -14,9 +19,10 @@ export interface ReplayPlayerInstance { toggle: () => void; goto: (timeOffset: number, play?: boolean) => void; setSpeed: (speed: number) => void; + toggleSkipInactive: () => void; + triggerResize: () => void; getMetaData: () => { startTime: number; endTime: number; totalTime: number }; - getReplayer: () => { getCurrentTime: () => number }; - addEvent: (event: { type: number; data: unknown; timestamp: number }) => void; + addEvent: (event: ReplayRecordEvent) => void; addEventListener: ( event: string, handler: (e: { payload: unknown }) => void @@ -27,41 +33,49 @@ export interface ReplayPlayerInstance { type CurrentTimeListener = (t: number) => void; +export interface RecordingActivity { + interactions: number[]; + clicks: { timestamp: number; x: number; y: number }[]; +} + +export const SPEED_OPTIONS = [0.5, 1, 2, 4, 8] as const; +export type ReplaySpeed = (typeof SPEED_OPTIONS)[number]; +const DEFAULT_SPEED: ReplaySpeed = 1; +export const DEFAULT_SKIP_INACTIVE = true; + interface ReplayContextValue { // High-frequency value — read via ref, not state. Use subscribeToCurrentTime // or useCurrentTime() to get updates without causing 60fps re-renders. currentTimeRef: React.MutableRefObject; subscribeToCurrentTime: (fn: CurrentTimeListener) => () => void; - // Low-frequency state (safe to consume directly) isPlaying: boolean; + isSkipping: boolean; duration: number; startTime: number | null; isReady: boolean; - // Playback controls + speed: ReplaySpeed; + skipInactive: boolean; + activity: RecordingActivity; play: () => void; pause: () => void; toggle: () => void; seek: (timeMs: number) => void; - setSpeed: (speed: number) => void; - // Lazy chunk loading - addEvent: (event: { type: number; data: unknown; timestamp: number }) => void; + setSpeed: (speed: ReplaySpeed) => void; + toggleSkipInactive: () => void; + addEvent: (event: ReplayRecordEvent) => void; refreshDuration: () => void; - // Called by ReplayPlayer to register/unregister the rrweb instance onPlayerReady: ( player: ReplayPlayerInstance, - playerStartTime: number + initialEvents: ReplayRecordEvent[] ) => void; onPlayerDestroy: () => void; - // State setters exposed so ReplayPlayer can wire rrweb event listeners setCurrentTime: (t: number) => void; setIsPlaying: (p: boolean) => void; - setDuration: (d: number) => void; + setIsSkipping: (s: boolean) => void; } const ReplayContext = createContext(null); -const SPEED_OPTIONS = [0.5, 1, 2, 4, 8] as const; - export function useReplayContext() { const ctx = useContext(ReplayContext); if (!ctx) { @@ -97,16 +111,28 @@ export function useCurrentTime(intervalMs = 0): number { return time; } +const EMPTY_ACTIVITY: RecordingActivity = { interactions: [], clicks: [] }; + export function ReplayProvider({ children }: { children: ReactNode }) { const playerRef = useRef(null); const isPlayingRef = useRef(false); const currentTimeRef = useRef(0); const listenersRef = useRef>(new Set()); + // Chunks stream in after the player starts; collect what they tell us about + // the user's activity here and publish it in one go per batch. + const activityRef = useRef({ + interactions: [], + clicks: [], + }); const [isPlaying, setIsPlaying] = useState(false); + const [isSkipping, setIsSkipping] = useState(false); const [duration, setDuration] = useState(0); const [startTime, setStartTime] = useState(null); const [isReady, setIsReady] = useState(false); + const [speed, setSpeedState] = useState(DEFAULT_SPEED); + const [skipInactive, setSkipInactive] = useState(DEFAULT_SKIP_INACTIVE); + const [activity, setActivity] = useState(EMPTY_ACTIVITY); const setIsPlayingWithRef = useCallback((playing: boolean) => { isPlayingRef.current = playing; @@ -129,15 +155,52 @@ export function ReplayProvider({ children }: { children: ReactNode }) { } }, []); + const collectActivity = useCallback((event: ReplayRecordEvent) => { + if (!isUserInteraction(event)) { + return; + } + activityRef.current.interactions.push(event.timestamp); + const click = getClick(event); + if (click) { + activityRef.current.clicks.push(click); + } + }, []); + + const publishActivity = useCallback(() => { + setActivity({ + interactions: [...activityRef.current.interactions], + clicks: [...activityRef.current.clicks], + }); + }, []); + + // rrweb queues each added event in a microtask; reading the metadata in the + // next one sees the whole batch instead of the length before it. + const refreshDuration = useCallback(() => { + queueMicrotask(() => { + const total = playerRef.current?.getMetaData().totalTime ?? 0; + if (total > 0) { + setDuration(total); + } + publishActivity(); + }); + }, [publishActivity]); + const onPlayerReady = useCallback( - (player: ReplayPlayerInstance, playerStartTime: number) => { + (player: ReplayPlayerInstance, initialEvents: ReplayRecordEvent[]) => { playerRef.current = player; - setStartTime(playerStartTime); + activityRef.current = { interactions: [], clicks: [] }; + for (const event of initialEvents) { + collectActivity(event); + } + setStartTime(player.getMetaData().startTime); currentTimeRef.current = 0; setIsPlayingWithRef(false); + setSpeedState(DEFAULT_SPEED); + setSkipInactive(DEFAULT_SKIP_INACTIVE); + refreshDuration(); setIsReady(true); }, - [setIsPlayingWithRef] + [collectActivity, refreshDuration, setIsPlayingWithRef] ); const onPlayerDestroy = useCallback(() => { @@ -147,6 +210,8 @@ export function ReplayProvider({ children }: { children: ReactNode }) { setDuration(0); setStartTime(null); setIsPlayingWithRef(false); + setIsSkipping(false); + setActivity(EMPTY_ACTIVITY); }, [setIsPlayingWithRef]); const play = useCallback(() => { @@ -162,54 +227,54 @@ export function ReplayProvider({ children }: { children: ReactNode }) { }, []); const seek = useCallback((timeMs: number) => { - playerRef.current?.goto(timeMs, isPlayingRef.current); + playerRef.current?.goto(Math.max(0, timeMs), isPlayingRef.current); }, []); - const setSpeed = useCallback((s: number) => { - if (!SPEED_OPTIONS.includes(s as (typeof SPEED_OPTIONS)[number])) { - return; - } - playerRef.current?.setSpeed(s); + const setSpeed = useCallback((next: ReplaySpeed) => { + playerRef.current?.setSpeed(next); + setSpeedState(next); + }, []); + + const toggleSkipInactive = useCallback(() => { + playerRef.current?.toggleSkipInactive(); + setSkipInactive((current) => !current); }, []); const addEvent = useCallback( - (event: { type: number; data: unknown; timestamp: number }) => { + (event: ReplayRecordEvent) => { playerRef.current?.addEvent(event); + collectActivity(event); }, - [] + [collectActivity] ); - const refreshDuration = useCallback(() => { - const total = playerRef.current?.getMetaData().totalTime ?? 0; - if (total > 0) { - setDuration(total); - } - }, []); - const value: ReplayContextValue = { currentTimeRef, subscribeToCurrentTime, isPlaying, + isSkipping, duration, startTime, isReady, + speed, + skipInactive, + activity, play, pause, toggle, seek, setSpeed, + toggleSkipInactive, addEvent, refreshDuration, onPlayerReady, onPlayerDestroy, setCurrentTime, setIsPlaying: setIsPlayingWithRef, - setDuration, + setIsSkipping, }; return ( {children} ); } - -export { SPEED_OPTIONS }; diff --git a/apps/start/src/components/sessions/replay/replay-controls.tsx b/apps/start/src/components/sessions/replay/replay-controls.tsx index d3edb07d3..e4d4c767a 100644 --- a/apps/start/src/components/sessions/replay/replay-controls.tsx +++ b/apps/start/src/components/sessions/replay/replay-controls.tsx @@ -1,38 +1,346 @@ -import { Pause, Play } from 'lucide-react'; -import { formatDuration } from './replay-utils'; import { + FastForwardIcon, + Maximize2Icon, + Minimize2Icon, + PauseIcon, + PlayIcon, + RotateCcwIcon, + RotateCwIcon, + SkipBackIcon, + SkipForwardIcon, +} from 'lucide-react'; +import { type ReactNode, useCallback, useEffect, useState } from 'react'; +import { + SPEED_OPTIONS, useCurrentTime, useReplayContext, -} from '@/components/sessions/replay/replay-context'; -import { Button } from '@/components/ui/button'; +} from './replay-context'; +import { type EventWithOffset, formatDuration } from './replay-utils'; +import { Tooltiper } from '@/components/ui/tooltip'; +import { cn } from '@/utils/cn'; + +const JUMP_MS = 10_000; +// Jumping to "previous event" right after one fired should go to the one before. +const PREVIOUS_EVENT_GRACE_MS = 1500; + +function ControlButton({ + label, + shortcut, + onClick, + children, + active, + className, +}: { + label: string; + shortcut?: string; + onClick: () => void; + children: ReactNode; + active?: boolean; + className?: string; +}) { + return ( + + {label} + {shortcut && ( + + {shortcut} + + )} + + } + > + + + ); +} + +export function ReplayPlayPauseButton({ size = 'md' }: { size?: 'md' | 'lg' }) { + const { isPlaying, isReady, toggle } = useReplayContext(); + const isLarge = size === 'lg'; + + return ( + + ); +} -export function ReplayTime() { +function ReplayTime() { const { duration } = useReplayContext(); const currentTime = useCurrentTime(250); return ( - - {formatDuration(currentTime)} / {formatDuration(duration)} + + {formatDuration(currentTime)} + + {' '} + / {formatDuration(duration)} + ); } -export function ReplayPlayPauseButton() { - const { isPlaying, isReady, toggle } = useReplayContext(); +function useEventJumps(events: EventWithOffset[]) { + const { currentTimeRef, seek } = useReplayContext(); + const next = useCallback(() => { + const target = events.find( + ({ offsetMs }) => offsetMs > currentTimeRef.current + 1 + ); + if (target) { + seek(target.offsetMs); + } + }, [events, currentTimeRef, seek]); + const previous = useCallback(() => { + const target = events + .filter( + ({ offsetMs }) => + offsetMs < currentTimeRef.current - PREVIOUS_EVENT_GRACE_MS + ) + .at(-1); + seek(target ? target.offsetMs : 0); + }, [events, currentTimeRef, seek]); + return { next, previous }; +} + +function useFullscreen(target: React.RefObject) { + const [isFullscreen, setIsFullscreen] = useState(false); - if (!isReady) { - return null; + useEffect(() => { + const onChange = () => + setIsFullscreen(document.fullscreenElement === target.current); + document.addEventListener('fullscreenchange', onChange); + return () => document.removeEventListener('fullscreenchange', onChange); + }, [target]); + + const toggle = useCallback(() => { + if (document.fullscreenElement) { + document.exitFullscreen(); + } else { + target.current?.requestFullscreen(); + } + }, [target]); + + return { isFullscreen, toggle }; +} + +function isTypingTarget(target: EventTarget | null): boolean { + if (!(target instanceof HTMLElement)) { + return false; } + return ( + target.isContentEditable || + ['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName) + ); +} + +function nextSpeed(current: number, direction: 1 | -1) { + const index = SPEED_OPTIONS.indexOf( + current as (typeof SPEED_OPTIONS)[number] + ); + const nextIndex = Math.max( + 0, + Math.min(SPEED_OPTIONS.length - 1, index + direction) + ); + return SPEED_OPTIONS[nextIndex] ?? SPEED_OPTIONS[1]; +} + +/** Player shortcuts; ignored while the user is typing anywhere on the page. */ +function useReplayHotkeys({ + events, + onFullscreen, +}: { + events: EventWithOffset[]; + onFullscreen: () => void; +}) { + const { + isReady, + toggle, + seek, + currentTimeRef, + duration, + speed, + setSpeed, + toggleSkipInactive, + } = useReplayContext(); + const jumps = useEventJumps(events); + + useEffect(() => { + if (!isReady) { + return; + } + const onKeyDown = (e: KeyboardEvent) => { + const hasModifier = e.metaKey || e.ctrlKey || e.altKey; + if (hasModifier || isTypingTarget(e.target)) { + return; + } + const actions: Record void> = { + ' ': toggle, + k: toggle, + j: () => seek(currentTimeRef.current - JUMP_MS), + l: () => seek(Math.min(duration, currentTimeRef.current + JUMP_MS)), + ',': jumps.previous, + '.': jumps.next, + '<': () => setSpeed(nextSpeed(speed, -1)), + '>': () => setSpeed(nextSpeed(speed, 1)), + i: toggleSkipInactive, + f: onFullscreen, + }; + const action = actions[e.key]; + if (!action) { + return; + } + // Space on a focused button would also click it. + const isButton = e.target instanceof HTMLButtonElement; + if (e.key === ' ' && isButton) { + return; + } + e.preventDefault(); + action(); + }; + window.addEventListener('keydown', onKeyDown); + return () => window.removeEventListener('keydown', onKeyDown); + }, [ + isReady, + toggle, + seek, + currentTimeRef, + duration, + speed, + setSpeed, + toggleSkipInactive, + jumps, + onFullscreen, + ]); +} + +export function ReplayControls({ + events, + fullscreenTarget, +}: { + events: EventWithOffset[]; + fullscreenTarget: React.RefObject; +}) { + const { + isReady, + seek, + currentTimeRef, + duration, + speed, + setSpeed, + skipInactive, + toggleSkipInactive, + } = useReplayContext(); + const jumps = useEventJumps(events); + const fullscreen = useFullscreen(fullscreenTarget); + useReplayHotkeys({ events, onFullscreen: fullscreen.toggle }); return ( - + +
    + + + + seek(currentTimeRef.current - JUMP_MS)} + shortcut="J" + > + + + + seek(Math.min(duration, currentTimeRef.current + JUMP_MS)) + } + shortcut="L" + > + + + + + +
    +
    + +
    + +
    + + + Skip idle + + { + const index = SPEED_OPTIONS.indexOf(speed); + setSpeed(SPEED_OPTIONS[(index + 1) % SPEED_OPTIONS.length]!); + }} + shortcut="< >" + > + {speed}× + + + {fullscreen.isFullscreen ? ( + + ) : ( + + )} + +
    +
    ); } diff --git a/apps/start/src/components/sessions/replay/replay-event-feed.tsx b/apps/start/src/components/sessions/replay/replay-event-feed.tsx index d2fbd9958..ea98a4764 100644 --- a/apps/start/src/components/sessions/replay/replay-event-feed.tsx +++ b/apps/start/src/components/sessions/replay/replay-event-feed.tsx @@ -1,119 +1,97 @@ -import type { IServiceEvent } from '@openpanel/core'; -import { useEffect, useMemo, useRef } from 'react'; -import { BrowserChrome } from './browser-chrome'; -import { getEventOffsetMs } from './replay-utils'; -import { - useCurrentTime, - useReplayContext, -} from '@/components/sessions/replay/replay-context'; -import { ReplayEventItem } from '@/components/sessions/replay/replay-event-item'; +import { useEffect, useRef } from 'react'; +import { useReplayContext } from './replay-context'; +import { ReplayEventItem, type ReplayItemState } from './replay-event-item'; +import type { EventWithOffset } from './replay-utils'; import { ScrollArea } from '@/components/ui/scroll-area'; -type EventWithOffset = { event: IServiceEvent; offsetMs: number }; +// After the user scrolls the feed by hand, leave it alone for a moment. +const MANUAL_SCROLL_PAUSE_MS = 4000; +const SKELETON_ROWS = Array.from({ length: 6 }, (_, index) => ({ + id: `skeleton-${index}`, + width: 40 + ((index * 17) % 45), +})); + +function getState(index: number, currentIndex: number): ReplayItemState { + if (index === currentIndex) { + return 'current'; + } + return index < currentIndex ? 'past' : 'future'; +} export function ReplayEventFeed({ events, - replayLoading, + currentIndex, + isLoading, }: { - events: IServiceEvent[]; - replayLoading: boolean; + events: EventWithOffset[]; + currentIndex: number; + isLoading: boolean; }) { - const { startTime, isReady, seek } = useReplayContext(); - const currentTime = useCurrentTime(100); + const { seek } = useReplayContext(); const viewportRef = useRef(null); - const prevCountRef = useRef(0); - - // Pre-sort events by offset once when events/startTime changes. - // This is the expensive part — done once, not on every tick. - const sortedEvents = useMemo(() => { - if (startTime == null || !isReady) { - return []; - } - return events - .map((ev) => ({ event: ev, offsetMs: getEventOffsetMs(ev, startTime) })) - .filter(({ offsetMs }) => offsetMs >= -10_000) - .sort((a, b) => a.offsetMs - b.offsetMs); - }, [events, startTime, isReady]); - - // Binary search to find how many events are visible at currentTime. - // O(log n) instead of O(n) filter on every tick. - const visibleCount = useMemo(() => { - let lo = 0; - let hi = sortedEvents.length; - while (lo < hi) { - const mid = (lo + hi) >>> 1; - if ((sortedEvents[mid]?.offsetMs ?? 0) <= currentTime) { - lo = mid + 1; - } else { - hi = mid; - } - } - return lo; - }, [sortedEvents, currentTime]); - - const visibleEvents = sortedEvents.slice(0, visibleCount); - const currentEventId = visibleEvents[visibleCount - 1]?.event.id ?? null; + const lastManualScrollRef = useRef(0); useEffect(() => { const viewport = viewportRef.current; - if (!viewport || visibleEvents.length === 0) { + const isUserScrolling = + performance.now() - lastManualScrollRef.current < MANUAL_SCROLL_PAUSE_MS; + if (!viewport || isUserScrolling) { return; } - - const isNewItem = visibleEvents.length > prevCountRef.current; - prevCountRef.current = visibleEvents.length; - - requestAnimationFrame(() => { - viewport.scrollTo({ - top: viewport.scrollHeight, - behavior: isNewItem ? 'smooth' : 'instant', - }); - }); - }, [visibleEvents.length]); + const current = viewport.querySelector( + '[data-replay-state="current"]' + ); + if (!current) { + return; + } + const top = + current.offsetTop - viewport.clientHeight / 2 + current.clientHeight / 2; + viewport.scrollTo({ top, behavior: 'smooth' }); + }, [currentIndex]); return ( - Timeline} - url={false} - > - -
    - {visibleEvents.map(({ event, offsetMs }) => ( -
    +
    +
    + Activity + + {Math.max(0, currentIndex + 1)} / {events.length} + +
    + { + lastManualScrollRef.current = performance.now(); + }} + ref={viewportRef} + > +
    + {isLoading && + SKELETON_ROWS.map((row) => ( +
    +
    +
    +
    + ))} + {!isLoading && + events.map(({ event, offsetMs }, index) => ( seek(Math.max(0, offsetMs))} + key={event.id} + offsetMs={offsetMs} + onClick={() => seek(offsetMs)} + state={getState(index, currentIndex)} /> -
    - ))} - {!replayLoading && visibleEvents.length === 0 && ( -
    - Events will appear as the replay plays. + ))} + {!isLoading && events.length === 0 && ( +
    + No events were tracked during this recording.
    )} - {replayLoading && - Array.from({ length: 5 }).map((_, i) => ( -
    -
    -
    -
    -
    -
    -
    - ))}
    - +
    ); } diff --git a/apps/start/src/components/sessions/replay/replay-event-item.tsx b/apps/start/src/components/sessions/replay/replay-event-item.tsx index cd23da7ef..a25312b33 100644 --- a/apps/start/src/components/sessions/replay/replay-event-item.tsx +++ b/apps/start/src/components/sessions/replay/replay-event-item.tsx @@ -1,51 +1,101 @@ import type { IServiceEvent } from '@openpanel/core'; +import { formatDuration, getEventLabel } from './replay-utils'; import { EventIcon } from '@/components/events/event-icon'; -import { cn } from '@/lib/utils'; +import { cn } from '@/utils/cn'; -function formatTime(date: Date | string): string { - const d = date instanceof Date ? date : new Date(date); - const h = d.getHours().toString().padStart(2, '0'); - const m = d.getMinutes().toString().padStart(2, '0'); - const s = d.getSeconds().toString().padStart(2, '0'); - return `${h}:${m}:${s}`; +const MAX_PROPERTY_CHIPS = 2; +const MAX_CHIP_VALUE_LENGTH = 24; + +export type ReplayItemState = 'past' | 'current' | 'future'; + +function getPropertyChips(event: IServiceEvent): string[] { + return Object.entries(event.properties ?? {}) + .filter( + ([key, value]) => + !key.startsWith('__') && + (typeof value === 'string' || typeof value === 'number') + ) + .slice(0, MAX_PROPERTY_CHIPS) + .map(([key, value]) => { + const text = String(value); + const shortened = + text.length > MAX_CHIP_VALUE_LENGTH + ? `${text.slice(0, MAX_CHIP_VALUE_LENGTH)}…` + : text; + return `${key}: ${shortened}`; + }); } export function ReplayEventItem({ event, - isCurrent, + offsetMs, + state, onClick, }: { event: IServiceEvent; - isCurrent: boolean; + offsetMs: number; + state: ReplayItemState; onClick: () => void; }) { - const displayName = - event.name === 'screen_view' && event.path - ? event.path - : event.name.replace(/_/g, ' '); + const isPageView = event.name === 'screen_view'; + const chips = isPageView ? [] : getPropertyChips(event); return ( ); } diff --git a/apps/start/src/components/sessions/replay/replay-player.tsx b/apps/start/src/components/sessions/replay/replay-player.tsx index 056da91aa..d25405528 100644 --- a/apps/start/src/components/sessions/replay/replay-player.tsx +++ b/apps/start/src/components/sessions/replay/replay-player.tsx @@ -1,44 +1,149 @@ -import { useEffect, useMemo, useRef, useState } from 'react'; -import type { ReplayPlayerInstance } from '@/components/sessions/replay/replay-context'; -import { useReplayContext } from '@/components/sessions/replay/replay-context'; +import { LockIcon } from 'lucide-react'; +import { + type ReactNode, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { + DEFAULT_SKIP_INACTIVE, + type ReplayPlayerInstance, + SPEED_OPTIONS, + useReplayContext, +} from './replay-context'; +import { getRecordedDimensions, type ReplayRecordEvent } from './replay-utils'; +import { cn } from '@/utils/cn'; import 'rrweb-player/dist/style.css'; -/** rrweb meta event (type 4) carries the recorded viewport size */ -function getRecordedDimensions( - events: Array<{ type: number; data: unknown }> -): { width: number; height: number } | null { - const meta = events.find((e) => e.type === 4); - if ( - meta && - typeof meta.data === 'object' && - meta.data !== null && - 'width' in meta.data && - 'height' in meta.data - ) { - const { width, height } = meta.data as { width: number; height: number }; - if (width > 0 && height > 0) { - return { width, height }; - } +const FALLBACK_ASPECT_RATIO = 16 / 9; +const STAGE_PADDING = 24; +const BROWSER_BAR_HEIGHT = 36; +const PHONE_BEZEL = 10; +const PHONE_URL_HEIGHT = 36; +const MOUSE_TAIL = { + duration: 600, + lineCap: 'round', + lineWidth: 3, + strokeStyle: 'rgba(34, 102, 236, 0.45)', +}; + +type FrameKind = 'browser' | 'phone'; + +interface Size { + width: number; + height: number; +} + +function fitViewport(area: Size, aspectRatio: number, kind: FrameKind): Size { + const chromeWidth = kind === 'phone' ? PHONE_BEZEL * 2 : 0; + const chromeHeight = + kind === 'phone' ? PHONE_BEZEL * 2 + PHONE_URL_HEIGHT : BROWSER_BAR_HEIGHT; + const maxWidth = Math.max(0, area.width - STAGE_PADDING * 2 - chromeWidth); + const maxHeight = Math.max(0, area.height - STAGE_PADDING * 2 - chromeHeight); + const height = Math.min(maxHeight, maxWidth / aspectRatio); + return { + width: Math.round(height * aspectRatio), + height: Math.round(height), + }; +} + +/** + * Height a stage needs to show a wide recording edge to edge, as CSS against + * the stage's container width; tall recordings are bounded by the stage's max. + */ +export function getStageFitHeight(events: ReplayRecordEvent[]): string { + const recorded = getRecordedDimensions(events); + const aspectRatio = recorded + ? recorded.width / recorded.height + : FALLBACK_ASPECT_RATIO; + if (aspectRatio < 1) { + return '100vh'; } - return null; + const verticalChrome = STAGE_PADDING * 2 + BROWSER_BAR_HEIGHT; + return `calc((100cqw - ${STAGE_PADDING * 2}px) / ${aspectRatio.toFixed(4)} + ${verticalChrome}px)`; +} + +function BrowserFrame({ + url, + viewport, + children, + width, +}: { + url: ReactNode; + viewport: string | null; + children: ReactNode; + width: number; +}) { + return ( +
    +
    +
    + + + +
    +
    + + {url} +
    + {viewport && ( + + {viewport} + + )} +
    + {children} +
    + ); } -function calcDimensions( - containerWidth: number, - aspectRatio: number -): { width: number; height: number } { - const maxHeight = window.innerHeight * 0.7; - const height = Math.min(Math.round(containerWidth / aspectRatio), maxHeight); - const width = Math.min(containerWidth, Math.round(height * aspectRatio)); - return { width, height }; +function PhoneFrame({ + url, + children, +}: { + url: ReactNode; + children: ReactNode; +}) { + return ( +
    +
    +
    + + {url} +
    +
    +
    +
    {children}
    +
    +
    + ); } export function ReplayPlayer({ events, + url, + overlay, }: { - events: Array<{ type: number; data: unknown; timestamp: number }>; + events: ReplayRecordEvent[]; + url: ReactNode; + overlay?: ReactNode; }) { + const areaRef = useRef(null); const containerRef = useRef(null); const playerRef = useRef(null); const { @@ -46,78 +151,84 @@ export function ReplayPlayer({ onPlayerDestroy, setCurrentTime, setIsPlaying, - setDuration, + setIsSkipping, } = useReplayContext(); const [importError, setImportError] = useState(false); + const [size, setSize] = useState(null); + const sizeRef = useRef(null); - const recordedDimensions = useMemo( - () => getRecordedDimensions(events), - [events] - ); + const recorded = useMemo(() => getRecordedDimensions(events), [events]); + const aspectRatio = recorded + ? recorded.width / recorded.height + : FALLBACK_ASPECT_RATIO; + const frameKind: FrameKind = aspectRatio < 1 ? 'phone' : 'browser'; - useEffect(() => { - if (!(events.length && containerRef.current)) { + useLayoutEffect(() => { + const area = areaRef.current; + if (!area) { return; } + const measure = () => { + const next = fitViewport( + { width: area.clientWidth, height: area.clientHeight }, + aspectRatio, + frameKind + ); + sizeRef.current = next; + setSize(next); + }; + measure(); + const observer = new ResizeObserver(measure); + observer.observe(area); + return () => observer.disconnect(); + }, [aspectRatio, frameKind]); - // Clear any previous player DOM - containerRef.current.innerHTML = ''; + const hasSize = size !== null && size.width > 0; + useEffect(() => { + const container = containerRef.current; + const initialSize = sizeRef.current; + if (!(events.length && container && hasSize && initialSize)) { + return; + } + + container.innerHTML = ''; let mounted = true; let player: ReplayPlayerInstance | null = null; let handleVisibilityChange: (() => void) | null = null; - const aspectRatio = recordedDimensions - ? recordedDimensions.width / recordedDimensions.height - : 16 / 9; - - const { width, height } = calcDimensions( - containerRef.current.offsetWidth, - aspectRatio - ); - import('rrweb-player') .then((module) => { - if (!(containerRef.current && mounted)) { + if (!mounted) { return; } - - const PlayerConstructor = module.default; - player = new PlayerConstructor({ - target: containerRef.current, + player = new module.default({ + target: container, props: { events, - width, - height, + width: initialSize.width, + height: initialSize.height, autoPlay: false, showController: false, - speedOption: [0.5, 1, 2, 4, 8], - skipInactive: false, + speedOption: [...SPEED_OPTIONS], + skipInactive: DEFAULT_SKIP_INACTIVE, + mouseTail: MOUSE_TAIL, }, - }) as ReplayPlayerInstance; - + }) as unknown as ReplayPlayerInstance; playerRef.current = player; - // Track play state from replayer (getMetaData() does not expose isPlaying) let playingState = false; - - // Wire rrweb's built-in event emitter — no RAF loop needed. - // Note: rrweb-player does NOT emit ui-update-duration; duration is - // read from getMetaData() on init and after each addEvent batch. player.addEventListener('ui-update-current-time', (e) => { - const t = e.payload as number; - setCurrentTime(t); + setCurrentTime(e.payload as number); }); - player.addEventListener('ui-update-player-state', (e) => { - const playing = e.payload === 'playing'; - playingState = playing; - setIsPlaying(playing); + playingState = e.payload === 'playing'; + setIsPlaying(playingState); }); + player.addEventListener('skip-start', () => setIsSkipping(true)); + player.addEventListener('skip-end', () => setIsSkipping(false)); - // Pause on tab hide; resume on show (prevents timer drift). - // getMetaData() does not expose isPlaying, so we use playingState - // kept in sync by ui-update-player-state above. + // Pause while the tab is hidden so the replay clock does not drift. let wasPlaying = false; handleVisibilityChange = () => { if (!player) { @@ -135,12 +246,7 @@ export function ReplayPlayer({ }; document.addEventListener('visibilitychange', handleVisibilityChange); - // Notify context — marks isReady = true and sets initial duration - const meta = player.getMetaData(); - if (meta.totalTime > 0) { - setDuration(meta.totalTime); - } - onPlayerReady(player, meta.startTime); + onPlayerReady(player, events); }) .catch(() => { if (mounted) { @@ -148,61 +254,76 @@ export function ReplayPlayer({ } }); - const onWindowResize = () => { - if (!(containerRef.current && mounted && playerRef.current?.$set)) { - return; - } - const { width: w, height: h } = calcDimensions( - containerRef.current.offsetWidth, - aspectRatio - ); - playerRef.current.$set({ width: w, height: h }); - }; - window.addEventListener('resize', onWindowResize); - return () => { mounted = false; - window.removeEventListener('resize', onWindowResize); if (handleVisibilityChange) { document.removeEventListener( 'visibilitychange', handleVisibilityChange ); } - if (player) { - player.pause(); - } - if (containerRef.current) { - containerRef.current.innerHTML = ''; - } + player?.pause(); + container.innerHTML = ''; playerRef.current = null; onPlayerDestroy(); }; }, [ events, - recordedDimensions, + hasSize, onPlayerReady, onPlayerDestroy, setCurrentTime, setIsPlaying, - setDuration, + setIsSkipping, ]); + useEffect(() => { + if (!(size && playerRef.current?.$set)) { + return; + } + playerRef.current.$set({ width: size.width, height: size.height }); + playerRef.current.triggerResize(); + }, [size]); + if (importError) { return ( -
    +
    Failed to load replay player.
    ); } + const viewport = ( +
    +
    + {overlay} +
    + ); + + const viewportLabel = recorded + ? `${recorded.width} × ${recorded.height}` + : null; + return ( -
    -
    +
    + {hasSize && + (frameKind === 'phone' ? ( + {viewport} + ) : ( + + {viewport} + + ))}
    ); } diff --git a/apps/start/src/components/sessions/replay/replay-timeline.tsx b/apps/start/src/components/sessions/replay/replay-timeline.tsx index 3e84a96f5..361d01fb5 100644 --- a/apps/start/src/components/sessions/replay/replay-timeline.tsx +++ b/apps/start/src/components/sessions/replay/replay-timeline.tsx @@ -1,21 +1,212 @@ -import type { IServiceEvent } from '@openpanel/core'; -import { AnimatePresence, motion } from 'framer-motion'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { ReplayPlayPauseButton } from './replay-controls'; -import { formatDuration, getEventOffsetMs } from './replay-utils'; -import { EventIcon } from '@/components/events/event-icon'; +import { useReplayContext } from './replay-context'; import { - useCurrentTime, - useReplayContext, -} from '@/components/sessions/replay/replay-context'; + detectRageClicks, + type EventWithOffset, + formatDuration, + getActivityHistogram, + getEventLabel, + type PageSegment, +} from './replay-utils'; +import { EventIcon } from '@/components/events/event-icon'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from '@/components/ui/tooltip'; +import { cn } from '@/utils/cn'; + +const HISTOGRAM_BUCKETS = 140; +const MARKER_GROUP_PX = 22; +const MIN_LABEL_WIDTH_PX = 48; +const FALLBACK_TRACK_WIDTH = 600; +const MIN_BAR_HEIGHT = 0.08; + +function percent(timeMs: number, duration: number): number { + return Math.max(0, Math.min(100, (timeMs / duration) * 100)); +} -export function ReplayTimeline({ events }: { events: IServiceEvent[] }) { +function groupMarkers( + events: EventWithOffset[], + duration: number, + trackWidth: number +) { + const thresholdMs = (MARKER_GROUP_PX / trackWidth) * duration; + const groups: EventWithOffset[][] = []; + for (const item of events) { + const last = groups.at(-1); + const lastItem = last?.at(-1); + if (last && lastItem && item.offsetMs - lastItem.offsetMs <= thresholdMs) { + last.push(item); + } else { + groups.push([item]); + } + } + return groups; +} + +function useElementWidth(ref: React.RefObject) { + const [width, setWidth] = useState(FALLBACK_TRACK_WIDTH); + useEffect(() => { + const element = ref.current; + if (!element) { + return; + } + const observer = new ResizeObserver(() => { + setWidth(element.clientWidth || FALLBACK_TRACK_WIDTH); + }); + observer.observe(element); + return () => observer.disconnect(); + }, [ref]); + return width; +} + +function EventMarkers({ + events, + duration, + trackWidth, +}: { + events: EventWithOffset[]; + duration: number; + trackWidth: number; +}) { + const { seek } = useReplayContext(); + // Screen views already have their own band; markers are for what happened on a page. + const markers = useMemo( + () => + groupMarkers( + events.filter( + ({ event, offsetMs }) => + event.name !== 'screen_view' && + offsetMs >= 0 && + offsetMs <= duration + ), + duration, + trackWidth + ), + [events, duration, trackWidth] + ); + + return ( +
    + {markers.map((group) => { + const first = group[0]!; + return ( + + + + + + {group.map(({ event, offsetMs }) => ( +
    + + {getEventLabel(event)} + + {formatDuration(offsetMs)} + +
    + ))} +
    +
    + ); + })} +
    + ); +} + +function PageBands({ + segments, + duration, + trackWidth, + currentSegmentId, +}: { + segments: PageSegment[]; + duration: number; + trackWidth: number; + currentSegmentId: string | null; +}) { + const { seek } = useReplayContext(); + return ( +
    + {segments.map((segment) => { + const left = percent(segment.startMs, duration); + const width = percent(segment.endMs, duration) - left; + const isCurrent = segment.id === currentSegmentId; + const showLabel = (width / 100) * trackWidth >= MIN_LABEL_WIDTH_PX; + return ( + + + + + + {segment.path} + + {formatDuration(segment.endMs - segment.startMs)} + + + + ); + })} +
    + ); +} + +function Waveform({ bars, className }: { bars: number[]; className: string }) { + return ( +
    + {bars.map((value, index) => ( +
    + ))} +
    + ); +} + +export function ReplayTimeline({ + events, + segments, + currentSegmentId, +}: { + events: EventWithOffset[]; + segments: PageSegment[]; + currentSegmentId: string | null; +}) { const { currentTimeRef, duration, @@ -23,276 +214,183 @@ export function ReplayTimeline({ events }: { events: IServiceEvent[] }) { isReady, seek, subscribeToCurrentTime, + activity, } = useReplayContext(); - // currentTime as React state is only needed for keyboard seeks (low frequency). - // The progress bar and thumb are updated directly via DOM refs to avoid re-renders. - const currentTime = useCurrentTime(250); const trackRef = useRef(null); - const progressBarRef = useRef(null); - const thumbRef = useRef(null); - const [isDragging, setIsDragging] = useState(false); - const [hoverInfo, setHoverInfo] = useState<{ - pct: number; - timeMs: number; - } | null>(null); - const dragCleanupRef = useRef<(() => void) | null>(null); - const rafDragRef = useRef(null); + const playedRef = useRef(null); + const playheadRef = useRef(null); + const [hover, setHover] = useState<{ pct: number; timeMs: number } | null>( + null + ); + const trackWidth = useElementWidth(trackRef); - // Clean up any in-progress drag listeners when the component unmounts - useEffect(() => { - return () => { - dragCleanupRef.current?.(); - }; - }, []); + const bars = useMemo( + () => + startTime == null + ? [] + : getActivityHistogram( + activity.interactions, + startTime, + duration, + HISTOGRAM_BUCKETS + ), + [activity.interactions, startTime, duration] + ); - // Update progress bar and thumb directly via DOM on every tick — no React re-render. + const rageClicks = useMemo( + () => + startTime == null ? [] : detectRageClicks(activity.clicks, startTime), + [activity.clicks, startTime] + ); + + // The playhead moves on every tick; write to the DOM instead of re-rendering. useEffect(() => { if (duration <= 0) { return; } - return subscribeToCurrentTime((t) => { - const pct = Math.max(0, Math.min(100, (t / duration) * 100)); - if (progressBarRef.current) { - progressBarRef.current.style.width = `${pct}%`; + const paint = (t: number) => { + const pct = `${percent(t, duration)}%`; + if (playedRef.current) { + playedRef.current.style.clipPath = `inset(0 calc(100% - ${pct}) 0 0)`; } - if (thumbRef.current) { - thumbRef.current.style.left = `calc(${pct}% - 8px)`; + if (playheadRef.current) { + playheadRef.current.style.left = pct; } - }); - }, [subscribeToCurrentTime, duration]); + }; + paint(currentTimeRef.current); + return subscribeToCurrentTime(paint); + }, [subscribeToCurrentTime, duration, currentTimeRef]); const getTimeFromClientX = useCallback( (clientX: number) => { - if (!trackRef.current || duration <= 0) { - return null; - } - const rect = trackRef.current.getBoundingClientRect(); - if (rect.width <= 0 || !Number.isFinite(rect.width)) { + const rect = trackRef.current?.getBoundingClientRect(); + if (!rect || rect.width <= 0 || duration <= 0) { return null; } - const x = clientX - rect.left; - const pct = Math.max(0, Math.min(1, x / rect.width)); + const pct = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)); return { pct, timeMs: pct * duration }; }, [duration] ); - const handleTrackMouseMove = useCallback( - (e: React.MouseEvent) => { - if ((e.target as HTMLElement).closest('[data-timeline-event]')) { - setHoverInfo(null); - return; - } - const info = getTimeFromClientX(e.clientX); - if (info) { - setHoverInfo(info); - } - }, - [getTimeFromClientX] - ); - - const handleTrackMouseLeave = useCallback(() => { - if (!isDragging) { - setHoverInfo(null); - } - }, [isDragging]); - - const handleTrackMouseDown = useCallback( - (e: React.MouseEvent) => { - // Only handle direct clicks on the track, not on child elements like the thumb - if ( - e.target !== trackRef.current && - !(e.target as HTMLElement).closest('.replay-track-bg') - ) { - return; - } - const info = getTimeFromClientX(e.clientX); - if (info) { - seek(info.timeMs); + const startScrub = useCallback( + (e: React.PointerEvent) => { + const first = getTimeFromClientX(e.clientX); + if (first) { + seek(first.timeMs); } + const onMove = (move: PointerEvent) => { + const info = getTimeFromClientX(move.clientX); + if (info) { + setHover(info); + seek(info.timeMs); + } + }; + const onUp = () => { + window.removeEventListener('pointermove', onMove); + window.removeEventListener('pointerup', onUp); + }; + window.addEventListener('pointermove', onMove); + window.addEventListener('pointerup', onUp); }, [getTimeFromClientX, seek] ); - const eventsWithOffset = useMemo( - () => - events - .map((ev) => ({ - event: ev, - offsetMs: startTime != null ? getEventOffsetMs(ev, startTime) : 0, - })) - .filter(({ offsetMs }) => offsetMs >= 0 && offsetMs <= duration), - [events, startTime, duration] - ); - - // Group events that are within 24px of each other on the track. - // We need the track width for pixel math — use a stable ref-based calculation. - const groupedEvents = useMemo(() => { - if (!eventsWithOffset.length || duration <= 0) { - return []; - } - - // Sort by offsetMs so we sweep left-to-right - const sorted = [...eventsWithOffset].sort( - (a, b) => a.offsetMs - b.offsetMs - ); - - // 24px in ms — recalculated from container width; fall back to 2% of duration - const trackWidth = trackRef.current?.offsetWidth ?? 600; - const thresholdMs = (24 / trackWidth) * duration; - - const groups: { items: typeof sorted; pct: number }[] = []; - for (const item of sorted) { - const last = groups[groups.length - 1]; - const lastPct = last - ? (last.items[last.items.length - 1]!.offsetMs / duration) * 100 - : Number.NEGATIVE_INFINITY; - const thisPct = (item.offsetMs / duration) * 100; - - if ( - last && - item.offsetMs - last.items[last.items.length - 1]!.offsetMs <= - thresholdMs - ) { - last.items.push(item); - // Anchor the group at its first item's position - } else { - groups.push({ items: [item], pct: thisPct }); - } - // keep pct pointing at the first item (already set on push) - void lastPct; - } - - return groups; - }, [eventsWithOffset, duration]); - if (!isReady || duration <= 0) { - return null; + return
    ; } - const progressPct = Math.max( - 0, - Math.min(100, (currentTimeRef.current / duration) * 100) - ); + const hoverSegment = hover + ? segments.find( + (segment) => + hover.timeMs >= segment.startMs && hover.timeMs < segment.endMs + ) + : undefined; return ( - -
    - -
    + +
    + +
    { + const step = 5000; + if (e.key === 'ArrowLeft') { + e.preventDefault(); + seek(currentTimeRef.current - step); + } else if (e.key === 'ArrowRight') { + e.preventDefault(); + seek(Math.min(duration, currentTimeRef.current + step)); + } + }} + onPointerDown={startScrub} + onPointerLeave={() => setHover(null)} + onPointerMove={(e) => setHover(getTimeFromClientX(e.clientX))} + ref={trackRef} + role="slider" + tabIndex={0} + > + +
    + +
    + + {rageClicks.map((rage) => ( + + +
    + +
    +
    + + Rage click + + {rage.clicks} clicks at {formatDuration(rage.offsetMs)} + + +
    + ))} + + {hover && ( +
    +
    + + {formatDuration(hover.timeMs)} + + {hoverSegment && ( + {hoverSegment.path} + )} +
    +
    + )} +
    { - const step = 5000; - if (e.key === 'ArrowLeft') { - e.preventDefault(); - seek(Math.max(0, currentTime - step)); - } else if (e.key === 'ArrowRight') { - e.preventDefault(); - seek(Math.min(duration, currentTime + step)); - } - }} - onMouseDown={handleTrackMouseDown} - onMouseLeave={handleTrackMouseLeave} - onMouseMove={handleTrackMouseMove} - ref={trackRef} - role="slider" - tabIndex={0} + className="pointer-events-none absolute -top-1 -bottom-1 z-30 w-0.5 -translate-x-1/2 rounded-full bg-foreground" + ref={playheadRef} > -
    -
    -
    -
    - - {hoverInfo && ( - -
    - - {formatDuration(hoverInfo.timeMs)} - - - )} - - {groupedEvents.map((group) => { - const first = group.items[0]!; - const isGroup = group.items.length > 1; - return ( - - - - - - {group.items.map(({ event: ev, offsetMs }) => ( -
    - - - {ev.name === 'screen_view' ? ev.path : ev.name} - - - {formatDuration(offsetMs)} - -
    - ))} -
    -
    - ); - })} +
    +
    ); diff --git a/apps/start/src/components/sessions/replay/replay-utils.ts b/apps/start/src/components/sessions/replay/replay-utils.ts index 9e0edd608..dfeae74e9 100644 --- a/apps/start/src/components/sessions/replay/replay-utils.ts +++ b/apps/start/src/components/sessions/replay/replay-utils.ts @@ -1,14 +1,222 @@ import type { IServiceEvent } from '@openpanel/core'; +export interface ReplayRecordEvent { + type: number; + data: unknown; + timestamp: number; +} + +export interface EventWithOffset { + event: IServiceEvent; + offsetMs: number; +} + +export interface PageSegment { + id: string; + path: string; + origin: string; + startMs: number; + endMs: number; +} + +export interface RageClick { + offsetMs: number; + clicks: number; +} + +// rrweb's numeric enums (EventType, IncrementalSource, MouseInteractions). +const RRWEB_META = 4; +const RRWEB_INCREMENTAL = 3; +const SOURCE_MOUSE_MOVE = 1; +const SOURCE_MOUSE_INTERACTION = 2; +const SOURCE_SCROLL = 3; +const SOURCE_INPUT = 5; +const SOURCE_TOUCH_MOVE = 6; +const INTERACTION_CLICK = 2; +const USER_SOURCES = new Set([ + SOURCE_MOUSE_MOVE, + SOURCE_MOUSE_INTERACTION, + SOURCE_SCROLL, + SOURCE_INPUT, + SOURCE_TOUCH_MOVE, +]); + +// Three clicks inside a second on roughly the same spot is the usual +// definition of a rage click (frustration with something that does not respond). +const RAGE_CLICK_MIN_CLICKS = 3; +const RAGE_CLICK_WINDOW_MS = 1000; +const RAGE_CLICK_RADIUS_PX = 30; + +// Server events can land slightly before the recorder's first frame. +const EARLY_EVENT_TOLERANCE_MS = 10_000; + +function toMs(date: Date | string): number { + return date instanceof Date ? date.getTime() : new Date(date).getTime(); +} + export function getEventOffsetMs( event: IServiceEvent, startTime: number ): number { - const t = - typeof event.createdAt === 'object' && event.createdAt instanceof Date - ? event.createdAt.getTime() - : new Date(event.createdAt).getTime(); - return t - startTime; + return toMs(event.createdAt) - startTime; +} + +export function sortEventsByOffset( + events: IServiceEvent[], + startTime: number +): EventWithOffset[] { + return events + .map((event) => ({ event, offsetMs: getEventOffsetMs(event, startTime) })) + .filter(({ offsetMs }) => offsetMs >= -EARLY_EVENT_TOLERANCE_MS) + .sort((a, b) => a.offsetMs - b.offsetMs); +} + +/** Index of the last item at or before `timeMs`, or -1 when none has happened yet. */ +export function findLastIndexAtTime( + sorted: EventWithOffset[], + timeMs: number +): number { + let lo = 0; + let hi = sorted.length; + while (lo < hi) { + const mid = Math.floor((lo + hi) / 2); + if ((sorted[mid]?.offsetMs ?? 0) <= timeMs) { + lo = mid + 1; + } else { + hi = mid; + } + } + return lo - 1; +} + +/** Each screen view owns the stretch of the recording until the next one. */ +export function getPageSegments( + sorted: EventWithOffset[], + durationMs: number +): PageSegment[] { + const views = sorted.filter(({ event }) => event.name === 'screen_view'); + return views.map(({ event, offsetMs }, index) => { + const next = views[index + 1]; + return { + id: event.id, + path: event.path || '/', + origin: event.origin ?? '', + startMs: Math.max(0, offsetMs), + endMs: Math.min(durationMs, next ? next.offsetMs : durationMs), + }; + }); +} + +/** Viewport the recording started with, carried by rrweb's meta event. */ +export function getRecordedDimensions( + events: ReplayRecordEvent[] +): { width: number; height: number } | null { + const meta = events.find((event) => event.type === RRWEB_META); + if ( + meta && + typeof meta.data === 'object' && + meta.data !== null && + 'width' in meta.data && + 'height' in meta.data + ) { + const { width, height } = meta.data as { width: number; height: number }; + if (width > 0 && height > 0) { + return { width, height }; + } + } + return null; +} + +interface IncrementalData { + source?: number; + type?: number; + x?: number; + y?: number; +} + +function getIncrementalData(event: ReplayRecordEvent): IncrementalData | null { + if (event.type !== RRWEB_INCREMENTAL) { + return null; + } + if (typeof event.data !== 'object' || event.data === null) { + return null; + } + return event.data as IncrementalData; +} + +export function isUserInteraction(event: ReplayRecordEvent): boolean { + const data = getIncrementalData(event); + return data?.source !== undefined && USER_SOURCES.has(data.source); +} + +export function getClick( + event: ReplayRecordEvent +): { timestamp: number; x: number; y: number } | null { + const data = getIncrementalData(event); + const isClick = + data?.source === SOURCE_MOUSE_INTERACTION && + data.type === INTERACTION_CLICK; + if (!isClick) { + return null; + } + return { timestamp: event.timestamp, x: data.x ?? 0, y: data.y ?? 0 }; +} + +export function detectRageClicks( + clicks: { timestamp: number; x: number; y: number }[], + startTime: number +): RageClick[] { + const sorted = [...clicks].sort((a, b) => a.timestamp - b.timestamp); + const rageClicks: RageClick[] = []; + let index = 0; + while (index < sorted.length) { + const first = sorted[index]!; + let end = index + 1; + while (end < sorted.length) { + const click = sorted[end]!; + const withinWindow = + click.timestamp - first.timestamp <= RAGE_CLICK_WINDOW_MS; + const samePlace = + Math.hypot(click.x - first.x, click.y - first.y) <= + RAGE_CLICK_RADIUS_PX; + if (!(withinWindow && samePlace)) { + break; + } + end++; + } + const count = end - index; + if (count >= RAGE_CLICK_MIN_CLICKS) { + rageClicks.push({ offsetMs: first.timestamp - startTime, clicks: count }); + index = end; + } else { + index++; + } + } + return rageClicks; +} + +/** Interaction counts per bucket, normalised to 0..1 for drawing. */ +export function getActivityHistogram( + timestamps: number[], + startTime: number, + durationMs: number, + buckets: number +): number[] { + const counts = new Array(buckets).fill(0); + if (durationMs <= 0) { + return counts; + } + for (const timestamp of timestamps) { + const ratio = (timestamp - startTime) / durationMs; + if (ratio < 0 || ratio > 1) { + continue; + } + const bucket = Math.min(buckets - 1, Math.floor(ratio * buckets)); + counts[bucket] = (counts[bucket] ?? 0) + 1; + } + const max = Math.max(...counts, 1); + // Square root keeps a single burst of mouse moves from flattening the rest. + return counts.map((count) => Math.sqrt(count / max)); } /** Format a duration in milliseconds as M:SS */ @@ -18,3 +226,10 @@ export function formatDuration(ms: number): string { const s = totalSeconds % 60; return `${m}:${s.toString().padStart(2, '0')}`; } + +export function getEventLabel(event: IServiceEvent): string { + if (event.name === 'screen_view' && event.path) { + return event.path; + } + return event.name.replace(/_/g, ' '); +} diff --git a/apps/start/src/routes/_app.$organizationId.$projectId.sessions_.$sessionId.tsx b/apps/start/src/routes/_app.$organizationId.$projectId.sessions_.$sessionId.tsx index 0f066c4a7..54e06c732 100644 --- a/apps/start/src/routes/_app.$organizationId.$projectId.sessions_.$sessionId.tsx +++ b/apps/start/src/routes/_app.$organizationId.$projectId.sessions_.$sessionId.tsx @@ -1,32 +1,44 @@ -import type { IServiceEvent, IServiceSession } from '@openpanel/core'; +import type { IServiceProfile, IServiceSession } from '@openpanel/core'; import { useQuery, useSuspenseQuery } from '@tanstack/react-query'; import { createFileRoute, Link } from '@tanstack/react-router'; -import { ZapIcon } from 'lucide-react'; -import { formatCost, formatTokens } from '@/components/ai/ai-format'; +import { + ArrowRightIcon, + CalendarIcon, + ClockIcon, + CopyIcon, + VideoIcon, +} from 'lucide-react'; +import { useMemo } from 'react'; import { AiUsageWidget } from '@/components/ai/ai-usage-widget'; -import { EmptyState } from '@/components/empty-state'; -import { EventIcon } from '@/components/events/event-icon'; -import { useEventDrawer } from '@/components/events/use-event-drawer'; import FullPageLoadingState from '@/components/full-page-loading-state'; import { PageContainer } from '@/components/page-container'; import { PageHeader } from '@/components/page-header'; +import { MostEvents } from '@/components/profiles/most-events'; import { ProfileAvatar } from '@/components/profiles/profile-avatar'; import { SerieIcon } from '@/components/report-chart/common/serie-icon'; +import { SessionJourney } from '@/components/sessions/details/session-journey'; +import { SessionMetrics } from '@/components/sessions/details/session-metrics'; +import { groupEventsByPage } from '@/components/sessions/details/session-pages'; +import { SessionProperties } from '@/components/sessions/details/session-properties'; +import { SessionTimeline } from '@/components/sessions/details/session-timeline'; import { ReplayShell } from '@/components/sessions/replay'; -import { KeyValueGrid } from '@/components/ui/key-value-grid'; +import { Button } from '@/components/ui/button'; import { Widget, WidgetBody, WidgetHead, WidgetTitle, } from '@/components/widget'; -import { useNumber } from '@/hooks/use-numer-formatter'; +import { fancyMinutes } from '@/hooks/use-numer-formatter'; import { useEntityPageContext } from '@/hooks/use-page-context-helpers'; import { useTRPC } from '@/integrations/trpc/react'; +import { clipboard } from '@/utils/clipboard'; import { formatDateTime } from '@/utils/date'; import { getProfileName } from '@/utils/getters'; import { createProjectTitle } from '@/utils/title'; +const MS_PER_SECOND = 1000; + export const Route = createFileRoute( '/_app/$organizationId/$projectId/sessions_/$sessionId' )({ @@ -55,100 +67,143 @@ export const Route = createFileRoute( pendingComponent: FullPageLoadingState, }); -/** `model · tokens · cost` from the flat `ai_*` properties an AI event carries. */ -function aiEventSummary(properties: Record): string { - const parts = [ - properties.ai_name, - properties.ai_model, - properties.ai_total_tokens - ? `${formatTokens(Number(properties.ai_total_tokens))} tokens` - : null, - properties.ai_cost ? formatCost(Number(properties.ai_cost)) : null, - properties.ai_is_error === 'true' ? 'error' : null, - ].filter((part): part is string => typeof part === 'string' && part !== ''); - return parts.join(' · '); +function HeaderFact({ + icon, + children, +}: { + icon: React.ReactNode; + children: React.ReactNode; +}) { + return ( +
    + {icon} + {children} +
    + ); } -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 SessionFacts({ session }: { session: IServiceSession }) { + const location = [session.city, session.country].filter(Boolean).join(', '); + return ( +
    + }> + {formatDateTime(session.createdAt)} + + }> + {fancyMinutes(session.duration / MS_PER_SECOND)} + + {session.country && ( + }> + {location} + + )} + {session.referrerName && ( + }> + {session.referrerName} + + )} + {session.device && ( + }> + {session.device} + + )} + {session.os && ( + }> + {session.os} + + )} + {session.browser && ( + }> + {session.browser} + + )} +
    + ); } -function VisitedRoutes({ paths }: { paths: string[] }) { - const counted = paths.reduce>((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; - } +function VisitorCard({ + profile, + session, + organizationId, + projectId, +}: { + profile: IServiceProfile | null | undefined; + session: IServiceSession; + organizationId: string; + projectId: string; +}) { + const isIdentified = session.profileId !== session.deviceId; + const name = profile && isIdentified ? getProfileName(profile, false) : null; return ( - + - Visited pages + Visitor -
    - {sorted.map(([path, count]) => ( -
    -
    -
    - {path} - {count} -
    + + + +
    + + {name ?? 'Anonymous visitor'} + + + {profile?.email || session.profileId} +
    - ))} -
    + + + ); } -function EventDistribution({ events }: { events: IServiceEvent[] }) { - const counted = events.reduce>((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; +function SessionGroups({ + groupIds, + organizationId, + projectId, +}: { + groupIds: string[]; + organizationId: string; + projectId: string; +}) { + const trpc = useTRPC(); + const { data: groups } = useQuery({ + ...trpc.group.listByIds.queryOptions({ projectId, ids: groupIds }), + enabled: groupIds.length > 0, + }); - if (sorted.length === 0) { + if (!groups?.length) { return null; } return ( - Event distribution + Groups -
    - {sorted.map(([name, count]) => ( -
    -
    -
    - {name.replace(/_/g, ' ')} - {count} +
    + {groups.map((group) => ( + +
    + {group.name} + + {group.id} +
    -
    + + {group.type} + + ))}
    @@ -158,8 +213,6 @@ function EventDistribution({ events }: { events: IServiceEvent[] }) { function Component() { const { projectId, sessionId, organizationId } = Route.useParams(); const trpc = useTRPC(); - const { openEvent } = useEventDrawer(); - const number = useNumber(); const { data: session } = useSuspenseQuery( trpc.session.byId.queryOptions({ sessionId, projectId }) @@ -175,12 +228,8 @@ function Component() { columnVisibility: {}, }) ); - const events = eventsData?.data ?? []; - const isIdentified = - session.profileId && session.profileId !== session.deviceId; - const { data: profile } = useSuspenseQuery( trpc.profile.byId.queryOptions({ profileId: session.profileId, @@ -188,261 +237,97 @@ function Component() { }) ); - const { data: sessionGroups } = useQuery({ - ...trpc.group.listByIds.queryOptions({ - projectId, - ids: session.groups ?? [], - }), - enabled: (session.groups?.length ?? 0) > 0, - }); + const visits = useMemo( + () => groupEventsByPage(events, session.endedAt), + [events, session.endedAt] + ); + const eventCounts = useMemo(() => { + const counts = new Map(); + for (const event of events) { + counts.set(event.name, (counts.get(event.name) ?? 0) + 1); + } + return [...counts.entries()] + .map(([name, count]) => ({ name, count })) + .sort((a, b) => b.count - a.count); + }, [events]); - const fakeEvent = sessionToFakeEvent(session); + const isIdentified = session.profileId !== session.deviceId; + const title = + profile && isIdentified + ? getProfileName(profile, false) + : 'Anonymous visitor'; return ( - - -
    - {session.country && ( -
    - - - {session.country} - {session.city && ` / ${session.city}`} - -
    - )} - {session.device && ( -
    - - {session.device} -
    - )} - {session.os && ( -
    - - {session.os} -
    - )} - {session.model && ( -
    - - {session.model} -
    - )} - {session.browser && ( -
    - - {session.browser} -
    - )} -
    + + + {session.hasReplay && ( + + )} + + + } + title={ +
    + + {title} + + Session + +
    + } + > +
    + + {session.hasReplay && ( )} -
    -
    - - - Session info - - 0 - ? [ - { - name: 'revenue', - value: number.currency(session.revenue / 100), - }, - ] - : []), - { 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 }] - : []), - ]} - /> - - - {isIdentified && profile && ( - - - Profile - - - - -
    - - {getProfileName(profile, false) ?? session.profileId} - - {profile.email && ( - - {profile.email} - - )} -
    - -
    -
    - )} - - {sessionGroups && sessionGroups.length > 0 && ( - - - Groups - - - {sessionGroups.map((group) => ( - -
    - {group.name} - - {group.id} - -
    - - {group.type} - - - ))} -
    -
    - )} - +
    + - e.name === 'screen_view' && e.path) - .map((e) => e.path)} - /> - - -
    - -
    - - - Events - -
    - {events.map((event) => ( - - ))} - {events.length === 0 && ( - - )} -
    -
    +
    + +
    diff --git a/apps/start/src/types/rrweb-player.d.ts b/apps/start/src/types/rrweb-player.d.ts index 1d404666b..eb0eac778 100644 --- a/apps/start/src/types/rrweb-player.d.ts +++ b/apps/start/src/types/rrweb-player.d.ts @@ -7,6 +7,14 @@ declare module 'rrweb-player' { showController?: boolean; speedOption?: number[]; skipInactive?: boolean; + mouseTail?: + | boolean + | { + duration?: number; + lineCap?: string; + lineWidth?: number; + strokeStyle?: string; + }; } interface RrwebPlayerOptions { @@ -30,6 +38,8 @@ declare module 'rrweb-player' { toggle: () => void; goto: (timeOffset: number, play?: boolean) => void; setSpeed: (speed: number) => void; + toggleSkipInactive: () => void; + triggerResize: () => void; getMetaData: () => RrwebPlayerMetaData; getReplayer: () => RrwebReplayer; addEvent: (event: { From 1acf162faebd08861be5c70896ed45646489ce51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carl-Gerhard=20Lindesva=CC=88rd?= Date: Mon, 2 Nov 2026 21:50:28 +0100 Subject: [PATCH 2/7] fix(dashboard): session page loads every event and replay edge cases Review of the session redesign caught these: - The session page read only the first 50 events, so long sessions had a truncated journey, timeline and unique-page count. It now loads every page (up to 2,000 events) and shares them with the replay feed. - Events in the same millisecond as a page view stay with the page they happened on. - Unique pages counts an empty path as "/"; time per page is hidden when a session has no page views; the header shows the city when the country is missing. - A failed recording load says so instead of "no recording was captured". - The start overlay resets when moving to another session. - Replay shortcuts no longer fire while a link or button elsewhere has focus. - Scrubbing ends on a cancelled touch and ignores other pointers. - Timeline markers group from their first event, so every event can be reached; rage clicks outside the recording are not drawn, and a click elsewhere no longer breaks up a rage-click burst. --- .../sessions/details/session-metrics.tsx | 21 ++++---- .../sessions/details/session-pages.ts | 25 +++++++++- .../src/components/sessions/replay/index.tsx | 43 ++++++++++------- .../sessions/replay/replay-controls.tsx | 25 +++++----- .../sessions/replay/replay-timeline.tsx | 28 ++++++++--- .../sessions/replay/replay-utils.ts | 45 +++++++++-------- .../components/sessions/use-session-events.ts | 48 +++++++++++++++++++ ...tionId.$projectId.sessions_.$sessionId.tsx | 30 ++++++------ 8 files changed, 184 insertions(+), 81 deletions(-) create mode 100644 apps/start/src/components/sessions/use-session-events.ts diff --git a/apps/start/src/components/sessions/details/session-metrics.tsx b/apps/start/src/components/sessions/details/session-metrics.tsx index 2300b8e27..a9b5099ea 100644 --- a/apps/start/src/components/sessions/details/session-metrics.tsx +++ b/apps/start/src/components/sessions/details/session-metrics.tsx @@ -6,6 +6,7 @@ import { import { cn } from '@/utils/cn'; const COLUMNS_BY_COUNT: Record = { + 4: 'xl:grid-cols-4', 5: 'xl:grid-cols-5', 6: 'xl:grid-cols-6', }; @@ -26,9 +27,9 @@ export function SessionMetrics({ events: IServiceEvent[]; }) { const pageViews = events.filter((event) => event.name === 'screen_view'); - const uniquePages = new Set(pageViews.map((event) => event.path)).size; + const uniquePages = new Set(pageViews.map((event) => event.path || '/')).size; const durationSeconds = session.duration / MS_PER_SECOND; - const pageCount = Math.max(session.screenViewCount, 1); + const hasPageViews = session.screenViewCount > 0; // The card's 'min' unit takes seconds and renders them as a duration. const metrics: SessionMetric[] = [ @@ -41,12 +42,16 @@ export function SessionMetrics({ }, { key: 'uniquePages', label: 'Unique pages', value: uniquePages, unit: '' }, { key: 'events', label: 'Events', value: session.eventCount, unit: '' }, - { - key: 'timePerPage', - label: 'Time per page', - value: durationSeconds / pageCount, - unit: 'min', - }, + ...(hasPageViews + ? [ + { + key: 'timePerPage', + label: 'Time per page', + value: durationSeconds / session.screenViewCount, + unit: 'min' as const, + }, + ] + : []), ...(session.revenue > 0 ? [ { diff --git a/apps/start/src/components/sessions/details/session-pages.ts b/apps/start/src/components/sessions/details/session-pages.ts index c76d8403a..5a865a9a7 100644 --- a/apps/start/src/components/sessions/details/session-pages.ts +++ b/apps/start/src/components/sessions/details/session-pages.ts @@ -13,8 +13,29 @@ 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((a, b) => toMs(a.createdAt) - toMs(b.createdAt)); + return [...events].sort(compareEvents); } /** Splits a session's events into page visits, each lasting until the next page. */ @@ -29,7 +50,7 @@ export function groupEventsByPage( if (isPageView || !current) { visits.push({ view: isPageView ? event : null, - path: isPageView ? event.path || '/' : '', + path: isPageView ? pagePath(event) : '', startedAt: toMs(event.createdAt), durationMs: 0, events: isPageView ? [] : [event], diff --git a/apps/start/src/components/sessions/replay/index.tsx b/apps/start/src/components/sessions/replay/index.tsx index f2eefda44..2373d83d2 100644 --- a/apps/start/src/components/sessions/replay/index.tsx +++ b/apps/start/src/components/sessions/replay/index.tsx @@ -141,8 +141,10 @@ function SkippingBadge() { ); } -function StagePlaceholder({ isLoading }: { isLoading: boolean }) { - if (isLoading) { +type StageStatus = 'loading' | 'error' | 'empty'; + +function StagePlaceholder({ status }: { status: StageStatus }) { + if (status === 'loading') { return (
    @@ -161,21 +163,32 @@ function StagePlaceholder({ isLoading }: { isLoading: boolean }) {
    - No recording was captured for this session. + {status === 'error' + ? 'The recording could not be loaded. Reload the page to try again.' + : 'No recording was captured for this session.'}
    ); } +function getStageStatus(isLoading: boolean, isError: boolean): StageStatus { + if (isLoading) { + return 'loading'; + } + return isError ? 'error' : 'empty'; +} + function ReplayStage({ events, playerEvents, isLoading, + isError, fullscreenTarget, }: { events: IServiceEvent[]; playerEvents: ReplayRecordEvent[]; isLoading: boolean; + isError: boolean; fullscreenTarget: React.RefObject; }) { const { startTime, duration } = useReplayContext(); @@ -217,7 +230,7 @@ function ReplayStage({ url={} /> ) : ( - + )}
    @@ -246,24 +259,21 @@ function ReplayStage({ export function ReplayShell({ sessionId, projectId, + events, }: { sessionId: string; projectId: string; + events: IServiceEvent[]; }) { const trpc = useTRPC(); const rootRef = useRef(null); - const { data: eventsData } = useQuery( - trpc.event.events.queryOptions({ - projectId, - sessionId, - filters: [], - columnVisibility: {}, - }) - ); - // The first batch carries the full snapshot the player needs to start. - const { data: firstBatch, isLoading } = useQuery( + const { + data: firstBatch, + isLoading, + isError, + } = useQuery( trpc.session.replayChunksFrom.queryOptions({ sessionId, projectId, @@ -271,14 +281,14 @@ export function ReplayShell({ }) ); - const events = eventsData?.data ?? []; const playerEvents = useMemo( () => firstBatch?.data.flatMap((row) => row?.events ?? []) ?? [], [firstBatch] ); return ( - + // Keyed so playback state (e.g. the start overlay) resets per session. +
    diff --git a/apps/start/src/components/sessions/replay/replay-controls.tsx b/apps/start/src/components/sessions/replay/replay-controls.tsx index e4d4c767a..4a0d57644 100644 --- a/apps/start/src/components/sessions/replay/replay-controls.tsx +++ b/apps/start/src/components/sessions/replay/replay-controls.tsx @@ -156,16 +156,6 @@ function useFullscreen(target: React.RefObject) { return { isFullscreen, toggle }; } -function isTypingTarget(target: EventTarget | null): boolean { - if (!(target instanceof HTMLElement)) { - return false; - } - return ( - target.isContentEditable || - ['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName) - ); -} - function nextSpeed(current: number, direction: 1 | -1) { const index = SPEED_OPTIONS.indexOf( current as (typeof SPEED_OPTIONS)[number] @@ -180,9 +170,11 @@ function nextSpeed(current: number, direction: 1 | -1) { /** Player shortcuts; ignored while the user is typing anywhere on the page. */ function useReplayHotkeys({ events, + scope, onFullscreen, }: { events: EventWithOffset[]; + scope: React.RefObject; onFullscreen: () => void; }) { const { @@ -203,7 +195,11 @@ function useReplayHotkeys({ } const onKeyDown = (e: KeyboardEvent) => { const hasModifier = e.metaKey || e.ctrlKey || e.altKey; - if (hasModifier || isTypingTarget(e.target)) { + // Only while nothing else on the page has focus, or focus is in the player. + const isInScope = + e.target === document.body || + (e.target instanceof Node && scope.current?.contains(e.target)); + if (hasModifier || !isInScope) { return; } const actions: Record void> = { @@ -242,6 +238,7 @@ function useReplayHotkeys({ setSpeed, toggleSkipInactive, jumps, + scope, onFullscreen, ]); } @@ -265,7 +262,11 @@ export function ReplayControls({ } = useReplayContext(); const jumps = useEventJumps(events); const fullscreen = useFullscreen(fullscreenTarget); - useReplayHotkeys({ events, onFullscreen: fullscreen.toggle }); + useReplayHotkeys({ + events, + scope: fullscreenTarget, + onFullscreen: fullscreen.toggle, + }); return (
    - startTime == null ? [] : detectRageClicks(activity.clicks, startTime), - [activity.clicks, startTime] + startTime == null + ? [] + : detectRageClicks(activity.clicks, startTime).filter( + (rage) => rage.offsetMs >= 0 && rage.offsetMs <= duration + ), + [activity.clicks, startTime, duration] ); // The playhead moves on every tick; write to the DOM instead of re-rendering. @@ -279,19 +284,28 @@ export function ReplayTimeline({ if (first) { seek(first.timeMs); } + const { pointerId } = e; const onMove = (move: PointerEvent) => { + if (move.pointerId !== pointerId) { + return; + } const info = getTimeFromClientX(move.clientX); if (info) { setHover(info); seek(info.timeMs); } }; - const onUp = () => { + const onEnd = (end: PointerEvent) => { + if (end.pointerId !== pointerId) { + return; + } window.removeEventListener('pointermove', onMove); - window.removeEventListener('pointerup', onUp); + window.removeEventListener('pointerup', onEnd); + window.removeEventListener('pointercancel', onEnd); }; window.addEventListener('pointermove', onMove); - window.addEventListener('pointerup', onUp); + window.addEventListener('pointerup', onEnd); + window.addEventListener('pointercancel', onEnd); }, [getTimeFromClientX, seek] ); diff --git a/apps/start/src/components/sessions/replay/replay-utils.ts b/apps/start/src/components/sessions/replay/replay-utils.ts index dfeae74e9..188a75e09 100644 --- a/apps/start/src/components/sessions/replay/replay-utils.ts +++ b/apps/start/src/components/sessions/replay/replay-utils.ts @@ -1,4 +1,5 @@ import type { IServiceEvent } from '@openpanel/core'; +import { sortEventsAscending } from '../details/session-pages'; export interface ReplayRecordEvent { type: number; @@ -65,10 +66,9 @@ export function sortEventsByOffset( events: IServiceEvent[], startTime: number ): EventWithOffset[] { - return events + return sortEventsAscending(events) .map((event) => ({ event, offsetMs: getEventOffsetMs(event, startTime) })) - .filter(({ offsetMs }) => offsetMs >= -EARLY_EVENT_TOLERANCE_MS) - .sort((a, b) => a.offsetMs - b.offsetMs); + .filter(({ offsetMs }) => offsetMs >= -EARLY_EVENT_TOLERANCE_MS); } /** Index of the last item at or before `timeMs`, or -1 when none has happened yet. */ @@ -162,34 +162,39 @@ export function getClick( return { timestamp: event.timestamp, x: data.x ?? 0, y: data.y ?? 0 }; } +/** Clicks elsewhere in between do not break a burst on one spot. */ export function detectRageClicks( clicks: { timestamp: number; x: number; y: number }[], startTime: number ): RageClick[] { const sorted = [...clicks].sort((a, b) => a.timestamp - b.timestamp); + const counted = new Set(); const rageClicks: RageClick[] = []; - let index = 0; - while (index < sorted.length) { - const first = sorted[index]!; - let end = index + 1; - while (end < sorted.length) { - const click = sorted[end]!; - const withinWindow = - click.timestamp - first.timestamp <= RAGE_CLICK_WINDOW_MS; + for (const [index, first] of sorted.entries()) { + if (counted.has(index)) { + continue; + } + const burst = [index]; + for (let next = index + 1; next < sorted.length; next++) { + const click = sorted[next]!; + if (click.timestamp - first.timestamp > RAGE_CLICK_WINDOW_MS) { + break; + } const samePlace = Math.hypot(click.x - first.x, click.y - first.y) <= RAGE_CLICK_RADIUS_PX; - if (!(withinWindow && samePlace)) { - break; + if (samePlace && !counted.has(next)) { + burst.push(next); } - end++; } - const count = end - index; - if (count >= RAGE_CLICK_MIN_CLICKS) { - rageClicks.push({ offsetMs: first.timestamp - startTime, clicks: count }); - index = end; - } else { - index++; + if (burst.length >= RAGE_CLICK_MIN_CLICKS) { + for (const member of burst) { + counted.add(member); + } + rageClicks.push({ + offsetMs: first.timestamp - startTime, + clicks: burst.length, + }); } } return rageClicks; diff --git a/apps/start/src/components/sessions/use-session-events.ts b/apps/start/src/components/sessions/use-session-events.ts new file mode 100644 index 000000000..318b6e2d3 --- /dev/null +++ b/apps/start/src/components/sessions/use-session-events.ts @@ -0,0 +1,48 @@ +import type { IServiceEvent } from '@openpanel/core'; +import { useSuspenseInfiniteQuery } from '@tanstack/react-query'; +import { useEffect, useMemo } from 'react'; +import type { useTRPC } from '@/integrations/trpc/react'; + +// The list endpoint pages 50 events at a time; a session past this many pages +// is a bot or a tab left open, and the page would not be readable anyway. +const MAX_SESSION_EVENT_PAGES = 40; + +type Trpc = ReturnType; + +export function sessionEventsQueryOptions( + trpc: Trpc, + { projectId, sessionId }: { projectId: string; sessionId: string } +) { + return trpc.event.events.infiniteQueryOptions( + { projectId, sessionId, filters: [], columnVisibility: {} }, + { + getNextPageParam: (lastPage, pages) => { + const isLastPage = + lastPage.data.length === 0 || pages.length >= MAX_SESSION_EVENT_PAGES; + return isLastPage ? undefined : lastPage.meta.next; + }, + } + ); +} + +/** Every event in the session, loading the remaining pages in the background. */ +export function useSessionEvents( + trpc: Trpc, + params: { projectId: string; sessionId: string } +): IServiceEvent[] { + const query = useSuspenseInfiniteQuery( + sessionEventsQueryOptions(trpc, params) + ); + const { hasNextPage, isFetchingNextPage, fetchNextPage } = query; + + useEffect(() => { + if (hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + } + }, [hasNextPage, isFetchingNextPage, fetchNextPage]); + + return useMemo( + () => query.data.pages.flatMap((page) => page.data), + [query.data] + ); +} diff --git a/apps/start/src/routes/_app.$organizationId.$projectId.sessions_.$sessionId.tsx b/apps/start/src/routes/_app.$organizationId.$projectId.sessions_.$sessionId.tsx index 54e06c732..77c391cd6 100644 --- a/apps/start/src/routes/_app.$organizationId.$projectId.sessions_.$sessionId.tsx +++ b/apps/start/src/routes/_app.$organizationId.$projectId.sessions_.$sessionId.tsx @@ -22,6 +22,10 @@ import { groupEventsByPage } from '@/components/sessions/details/session-pages'; import { SessionProperties } from '@/components/sessions/details/session-properties'; import { SessionTimeline } from '@/components/sessions/details/session-timeline'; import { ReplayShell } from '@/components/sessions/replay'; +import { + sessionEventsQueryOptions, + useSessionEvents, +} from '@/components/sessions/use-session-events'; import { Button } from '@/components/ui/button'; import { Widget, @@ -51,12 +55,10 @@ export const Route = createFileRoute( projectId: params.projectId, }) ), - context.queryClient.prefetchQuery( - context.trpc.event.events.queryOptions({ + context.queryClient.prefetchInfiniteQuery( + sessionEventsQueryOptions(context.trpc, { projectId: params.projectId, sessionId: params.sessionId, - filters: [], - columnVisibility: {}, }) ), ]); @@ -92,8 +94,8 @@ function SessionFacts({ session }: { session: IServiceSession }) { }> {fancyMinutes(session.duration / MS_PER_SECOND)} - {session.country && ( - }> + {location && ( + }> {location} )} @@ -220,15 +222,7 @@ function Component() { useEntityPageContext('session', { sessionId }); - const { data: eventsData } = useSuspenseQuery( - trpc.event.events.queryOptions({ - projectId, - sessionId, - filters: [], - columnVisibility: {}, - }) - ); - const events = eventsData?.data ?? []; + const events = useSessionEvents(trpc, { projectId, sessionId }); const { data: profile } = useSuspenseQuery( trpc.profile.byId.queryOptions({ @@ -300,7 +294,11 @@ function Component() { {session.hasReplay && ( - + )}
    From e75d5a8b5665c307a38452c2932a0c84f3551a90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carl-Gerhard=20Lindesva=CC=88rd?= Date: Mon, 2 Nov 2026 22:05:13 +0100 Subject: [PATCH 3/7] fix(dashboard): replay feed and page bands stay within the recording Second review round on the session redesign: - The replay activity feed and "next event" only include events the recording covers, so they no longer jump past the end. - The page the recording opened on is kept even when it was viewed more than 10 seconds before the recorder started, instead of showing about:blank with no page band. - A second finger on the scrubber no longer starts a competing scrub. - When loading the next page of session events fails, the page stops instead of retrying in a loop. --- .../src/components/sessions/replay/index.tsx | 15 +++++++++++---- .../sessions/replay/replay-timeline.tsx | 3 +++ .../components/sessions/replay/replay-utils.ts | 14 +++++++++++--- .../src/components/sessions/use-session-events.ts | 12 +++++++++--- 4 files changed, 34 insertions(+), 10 deletions(-) diff --git a/apps/start/src/components/sessions/replay/index.tsx b/apps/start/src/components/sessions/replay/index.tsx index 2373d83d2..7b7b7193f 100644 --- a/apps/start/src/components/sessions/replay/index.tsx +++ b/apps/start/src/components/sessions/replay/index.tsx @@ -194,10 +194,17 @@ function ReplayStage({ const { startTime, duration } = useReplayContext(); const currentTime = useCurrentTime(FEED_TICK_MS); - const sorted = useMemo( - () => (startTime == null ? [] : sortEventsByOffset(events, startTime)), - [events, startTime] - ); + // Until the duration is known every event is kept; after that, only what + // the recording covers can be seeked to. + const sorted = useMemo(() => { + if (startTime == null) { + return []; + } + const all = sortEventsByOffset(events, startTime); + return duration > 0 + ? all.filter(({ offsetMs }) => offsetMs <= duration) + : all; + }, [events, startTime, duration]); const segments = useMemo( () => getPageSegments(sorted, duration), [sorted, duration] diff --git a/apps/start/src/components/sessions/replay/replay-timeline.tsx b/apps/start/src/components/sessions/replay/replay-timeline.tsx index fff246efc..e20dfbc7d 100644 --- a/apps/start/src/components/sessions/replay/replay-timeline.tsx +++ b/apps/start/src/components/sessions/replay/replay-timeline.tsx @@ -280,6 +280,9 @@ export function ReplayTimeline({ const startScrub = useCallback( (e: React.PointerEvent) => { + if (!e.isPrimary) { + return; + } const first = getTimeFromClientX(e.clientX); if (first) { seek(first.timeMs); diff --git a/apps/start/src/components/sessions/replay/replay-utils.ts b/apps/start/src/components/sessions/replay/replay-utils.ts index 188a75e09..22115b7b6 100644 --- a/apps/start/src/components/sessions/replay/replay-utils.ts +++ b/apps/start/src/components/sessions/replay/replay-utils.ts @@ -66,9 +66,17 @@ export function sortEventsByOffset( events: IServiceEvent[], startTime: number ): EventWithOffset[] { - return sortEventsAscending(events) - .map((event) => ({ event, offsetMs: getEventOffsetMs(event, startTime) })) - .filter(({ offsetMs }) => offsetMs >= -EARLY_EVENT_TOLERANCE_MS); + const withOffsets = sortEventsAscending(events).map((event) => ({ + event, + offsetMs: getEventOffsetMs(event, startTime), + })); + const isEarly = ({ offsetMs }: EventWithOffset) => + offsetMs < -EARLY_EVENT_TOLERANCE_MS; + // The page the recording opened on can be viewed long before the recorder starts. + const openingView = withOffsets + .filter((item) => isEarly(item) && item.event.name === 'screen_view') + .at(-1); + return withOffsets.filter((item) => !isEarly(item) || item === openingView); } /** Index of the last item at or before `timeMs`, or -1 when none has happened yet. */ diff --git a/apps/start/src/components/sessions/use-session-events.ts b/apps/start/src/components/sessions/use-session-events.ts index 318b6e2d3..16c5002f2 100644 --- a/apps/start/src/components/sessions/use-session-events.ts +++ b/apps/start/src/components/sessions/use-session-events.ts @@ -33,13 +33,19 @@ export function useSessionEvents( const query = useSuspenseInfiniteQuery( sessionEventsQueryOptions(trpc, params) ); - const { hasNextPage, isFetchingNextPage, fetchNextPage } = query; + const { + hasNextPage, + isFetchingNextPage, + isFetchNextPageError, + fetchNextPage, + } = query; + // A failed page stops the chain; the page shows what loaded rather than retrying forever. useEffect(() => { - if (hasNextPage && !isFetchingNextPage) { + if (hasNextPage && !(isFetchingNextPage || isFetchNextPageError)) { fetchNextPage(); } - }, [hasNextPage, isFetchingNextPage, fetchNextPage]); + }, [hasNextPage, isFetchingNextPage, isFetchNextPageError, fetchNextPage]); return useMemo( () => query.data.pages.flatMap((page) => page.data), From c0d05bc6224b86a2899758cd3d39b997b72a30bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carl-Gerhard=20Lindesva=CC=88rd?= Date: Mon, 2 Nov 2026 22:15:40 +0100 Subject: [PATCH 4/7] fix(dashboard): show when a session's events only partly loaded Third review round on the session redesign: - When a later page of session events fails to load, the session page says the journey and timeline are incomplete and offers a retry, instead of silently showing partial data. - Only one scrub runs at a time on the replay timeline, even when a pen and a finger are both down. --- .../sessions/replay/replay-timeline.tsx | 6 +++++- .../components/sessions/use-session-events.ts | 16 ++++++++++++++-- ...tionId.$projectId.sessions_.$sessionId.tsx | 19 ++++++++++++++++++- 3 files changed, 37 insertions(+), 4 deletions(-) diff --git a/apps/start/src/components/sessions/replay/replay-timeline.tsx b/apps/start/src/components/sessions/replay/replay-timeline.tsx index e20dfbc7d..6132f8637 100644 --- a/apps/start/src/components/sessions/replay/replay-timeline.tsx +++ b/apps/start/src/components/sessions/replay/replay-timeline.tsx @@ -218,6 +218,7 @@ export function ReplayTimeline({ activity, } = useReplayContext(); const trackRef = useRef(null); + const scrubPointerRef = useRef(null); const playedRef = useRef(null); const playheadRef = useRef(null); const [hover, setHover] = useState<{ pct: number; timeMs: number } | null>( @@ -280,7 +281,8 @@ export function ReplayTimeline({ const startScrub = useCallback( (e: React.PointerEvent) => { - if (!e.isPrimary) { + // A pen and a finger can both be primary; only one scrub at a time. + if (!e.isPrimary || scrubPointerRef.current !== null) { return; } const first = getTimeFromClientX(e.clientX); @@ -288,6 +290,7 @@ export function ReplayTimeline({ seek(first.timeMs); } const { pointerId } = e; + scrubPointerRef.current = pointerId; const onMove = (move: PointerEvent) => { if (move.pointerId !== pointerId) { return; @@ -302,6 +305,7 @@ export function ReplayTimeline({ if (end.pointerId !== pointerId) { return; } + scrubPointerRef.current = null; window.removeEventListener('pointermove', onMove); window.removeEventListener('pointerup', onEnd); window.removeEventListener('pointercancel', onEnd); diff --git a/apps/start/src/components/sessions/use-session-events.ts b/apps/start/src/components/sessions/use-session-events.ts index 16c5002f2..47ee7a19c 100644 --- a/apps/start/src/components/sessions/use-session-events.ts +++ b/apps/start/src/components/sessions/use-session-events.ts @@ -25,11 +25,18 @@ export function sessionEventsQueryOptions( ); } +interface SessionEvents { + events: IServiceEvent[]; + /** A later page failed, so `events` is only part of the session. */ + isIncomplete: boolean; + retry: () => void; +} + /** Every event in the session, loading the remaining pages in the background. */ export function useSessionEvents( trpc: Trpc, params: { projectId: string; sessionId: string } -): IServiceEvent[] { +): SessionEvents { const query = useSuspenseInfiniteQuery( sessionEventsQueryOptions(trpc, params) ); @@ -47,8 +54,13 @@ export function useSessionEvents( } }, [hasNextPage, isFetchingNextPage, isFetchNextPageError, fetchNextPage]); - return useMemo( + const events = useMemo( () => query.data.pages.flatMap((page) => page.data), [query.data] ); + return { + events, + isIncomplete: isFetchNextPageError, + retry: () => fetchNextPage(), + }; } diff --git a/apps/start/src/routes/_app.$organizationId.$projectId.sessions_.$sessionId.tsx b/apps/start/src/routes/_app.$organizationId.$projectId.sessions_.$sessionId.tsx index 77c391cd6..cf6126e1c 100644 --- a/apps/start/src/routes/_app.$organizationId.$projectId.sessions_.$sessionId.tsx +++ b/apps/start/src/routes/_app.$organizationId.$projectId.sessions_.$sessionId.tsx @@ -26,6 +26,7 @@ import { sessionEventsQueryOptions, useSessionEvents, } from '@/components/sessions/use-session-events'; +import { Alert, AlertDescription } from '@/components/ui/alert'; import { Button } from '@/components/ui/button'; import { Widget, @@ -222,7 +223,11 @@ function Component() { useEntityPageContext('session', { sessionId }); - const events = useSessionEvents(trpc, { projectId, sessionId }); + const { + events, + isIncomplete, + retry: retryEvents, + } = useSessionEvents(trpc, { projectId, sessionId }); const { data: profile } = useSuspenseQuery( trpc.profile.byId.queryOptions({ @@ -291,6 +296,18 @@ function Component() { + {isIncomplete && ( + + + Some of this session's events could not be loaded, so the journey + and timeline are incomplete. + + + + )} + {session.hasReplay && ( From 3a05de82e35fc662baf696d41cf823ebffb54a12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carl-Gerhard=20Lindesva=CC=88rd?= Date: Mon, 2 Nov 2026 22:25:39 +0100 Subject: [PATCH 5/7] fix(dashboard): session page says when its events are still loading or capped Fourth review round on the session redesign. The journey, timeline, unique pages, popular events and replay activity are built from the session's loaded events, so the page now tells you when that list is not the whole session: - an indicator while the remaining pages load; - a notice with a retry when a page failed; - a notice when the session has more than 2,000 events and only the latest 2,000 are used. --- .../components/sessions/use-session-events.ts | 36 +++++++++++- ...tionId.$projectId.sessions_.$sessionId.tsx | 55 +++++++++++++++---- 2 files changed, 76 insertions(+), 15 deletions(-) diff --git a/apps/start/src/components/sessions/use-session-events.ts b/apps/start/src/components/sessions/use-session-events.ts index 47ee7a19c..8bb40db88 100644 --- a/apps/start/src/components/sessions/use-session-events.ts +++ b/apps/start/src/components/sessions/use-session-events.ts @@ -6,6 +6,8 @@ import type { useTRPC } from '@/integrations/trpc/react'; // The list endpoint pages 50 events at a time; a session past this many pages // is a bot or a tab left open, and the page would not be readable anyway. const MAX_SESSION_EVENT_PAGES = 40; +const EVENT_PAGE_SIZE = 50; +export const MAX_SESSION_EVENTS = MAX_SESSION_EVENT_PAGES * EVENT_PAGE_SIZE; type Trpc = ReturnType; @@ -25,13 +27,36 @@ export function sessionEventsQueryOptions( ); } +/** + * `loading`: later pages are still coming. `failed`: a page failed and can be + * retried. `capped`: the session has more events than the page will load. + */ +export type SessionEventsStatus = 'complete' | 'loading' | 'failed' | 'capped'; + interface SessionEvents { events: IServiceEvent[]; - /** A later page failed, so `events` is only part of the session. */ - isIncomplete: boolean; + status: SessionEventsStatus; retry: () => void; } +function getStatus({ + isFetchNextPageError, + hasNextPage, + isCapped, +}: { + isFetchNextPageError: boolean; + hasNextPage: boolean; + isCapped: boolean; +}): SessionEventsStatus { + if (isFetchNextPageError) { + return 'failed'; + } + if (hasNextPage) { + return 'loading'; + } + return isCapped ? 'capped' : 'complete'; +} + /** Every event in the session, loading the remaining pages in the background. */ export function useSessionEvents( trpc: Trpc, @@ -58,9 +83,14 @@ export function useSessionEvents( () => query.data.pages.flatMap((page) => page.data), [query.data] ); + const pageCount = query.data.pages.length; + const lastPageHasEvents = (query.data.pages.at(-1)?.data.length ?? 0) > 0; + const isCapped = + !hasNextPage && pageCount >= MAX_SESSION_EVENT_PAGES && lastPageHasEvents; + return { events, - isIncomplete: isFetchNextPageError, + status: getStatus({ isFetchNextPageError, hasNextPage, isCapped }), retry: () => fetchNextPage(), }; } diff --git a/apps/start/src/routes/_app.$organizationId.$projectId.sessions_.$sessionId.tsx b/apps/start/src/routes/_app.$organizationId.$projectId.sessions_.$sessionId.tsx index cf6126e1c..cf5490176 100644 --- a/apps/start/src/routes/_app.$organizationId.$projectId.sessions_.$sessionId.tsx +++ b/apps/start/src/routes/_app.$organizationId.$projectId.sessions_.$sessionId.tsx @@ -6,6 +6,7 @@ import { CalendarIcon, ClockIcon, CopyIcon, + Loader2Icon, VideoIcon, } from 'lucide-react'; import { useMemo } from 'react'; @@ -23,6 +24,8 @@ import { SessionProperties } from '@/components/sessions/details/session-propert import { SessionTimeline } from '@/components/sessions/details/session-timeline'; import { ReplayShell } from '@/components/sessions/replay'; import { + MAX_SESSION_EVENTS, + type SessionEventsStatus, sessionEventsQueryOptions, useSessionEvents, } from '@/components/sessions/use-session-events'; @@ -124,6 +127,44 @@ function SessionFacts({ session }: { session: IServiceSession }) { ); } +// Everything below is built from the loaded events, except the metric totals. +const EVENT_DERIVED_SECTIONS = + 'the journey, timeline, unique pages, popular events and replay activity'; + +function SessionEventsNotice({ + status, + onRetry, +}: { + status: SessionEventsStatus; + onRetry: () => void; +}) { + if (status === 'complete') { + return null; + } + if (status === 'loading') { + return ( +
    + + Loading the rest of this session's events… +
    + ); + } + return ( + + + {status === 'failed' + ? `Some of this session's events could not be loaded, so ${EVENT_DERIVED_SECTIONS} are incomplete.` + : `This session has more than ${MAX_SESSION_EVENTS.toLocaleString()} events. Only the latest ${MAX_SESSION_EVENTS.toLocaleString()} are used for ${EVENT_DERIVED_SECTIONS}.`} + + {status === 'failed' && ( + + )} + + ); +} + function VisitorCard({ profile, session, @@ -225,7 +266,7 @@ function Component() { const { events, - isIncomplete, + status: eventsStatus, retry: retryEvents, } = useSessionEvents(trpc, { projectId, sessionId }); @@ -296,17 +337,7 @@ function Component() { - {isIncomplete && ( - - - Some of this session's events could not be loaded, so the journey - and timeline are incomplete. - - - - )} + From 4ff7a44c308657cac3f28fa1d3e38c07596fc7eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carl-Gerhard=20Lindesva=CC=88rd?= Date: Mon, 2 Nov 2026 22:29:13 +0100 Subject: [PATCH 6/7] fix(api): event lists no longer skip events when paging The event list cursor was sent to ClickHouse without its milliseconds, so the next page started at the whole second. Events still unread in the same second as the last row of a page were skipped. This affected every paged event list (events, profile, group and cohort event tables) and the session page, which now pages through all of a session's events. The bound now keeps the millisecond. --- packages/core/src/modules/event/src/sql.test.ts | 6 ++++-- packages/core/src/modules/event/src/sql.ts | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/core/src/modules/event/src/sql.test.ts b/packages/core/src/modules/event/src/sql.test.ts index 0c3b7738f..081aec704 100644 --- a/packages/core/src/modules/event/src/sql.test.ts +++ b/packages/core/src/modules/event/src/sql.test.ts @@ -81,12 +81,14 @@ describe('eventListQuery', () => { }).toStatement(); expect(query).toContain( - 'WHERE created_at >= toDateTime64({p1:String}, 3) - INTERVAL {p2:Float64} DAY AND created_at < {p3:String} AND project_id = {p4:String}' + 'WHERE created_at >= toDateTime64({p1:String}, 3) - INTERVAL {p2:Float64} DAY AND created_at < toDateTime64({p3:String}, 3) AND project_id = {p4:String}' ); + // The upper bound keeps the millisecond: a whole-second bound would skip + // the unread events that share the last row's second. expect(query_params).toMatchObject({ p1: '2026-09-01 12:34:56', p2: 7, - p3: '2026-09-01 12:34:56', + p3: '2026-09-01 12:34:56.789', p4: PROJECT_ID, }); }); diff --git a/packages/core/src/modules/event/src/sql.ts b/packages/core/src/modules/event/src/sql.ts index a6c660523..f112a2a09 100644 --- a/packages/core/src/modules/event/src/sql.ts +++ b/packages/core/src/modules/event/src/sql.ts @@ -154,7 +154,7 @@ function eventListConditions(query: EventListQuery): SqlFragment[] { } if (query.cursor) { conditions.push( - sql`created_at < ${sql.string(formatClickhouseDate(query.cursor))}` + sql`created_at < toDateTime64(${sql.string(formatClickhouseDateTime64(query.cursor))}, 3)` ); } conditions.push(sql`project_id = ${projectId}`); From 88ec6f22a6725b23bc28a03709585e8162df6716 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carl-Gerhard=20Lindesva=CC=88rd?= Date: Mon, 2 Nov 2026 22:36:31 +0100 Subject: [PATCH 7/7] fix(dashboard): only report the session event cap when it cut events off MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A session whose 40th page of events was partly filled (1,951–2,000 events) was reported as having more than 2,000 events. The cap notice now appears only when the last loaded page is full. --- apps/start/src/components/sessions/use-session-events.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/start/src/components/sessions/use-session-events.ts b/apps/start/src/components/sessions/use-session-events.ts index 8bb40db88..c0ea62f85 100644 --- a/apps/start/src/components/sessions/use-session-events.ts +++ b/apps/start/src/components/sessions/use-session-events.ts @@ -84,9 +84,11 @@ export function useSessionEvents( [query.data] ); const pageCount = query.data.pages.length; - const lastPageHasEvents = (query.data.pages.at(-1)?.data.length ?? 0) > 0; + // A short last page means the session ended inside the limit. + const isLastPageFull = + (query.data.pages.at(-1)?.data.length ?? 0) >= EVENT_PAGE_SIZE; const isCapped = - !hasNextPage && pageCount >= MAX_SESSION_EVENT_PAGES && lastPageHasEvents; + !hasNextPage && pageCount >= MAX_SESSION_EVENT_PAGES && isLastPageFull; return { events,