@@ -16,7 +16,7 @@ import {
1616import { cn } from "@/lib/utils" ;
1717import { runAsynchronously } from "@stackframe/stack-shared/dist/utils/promises" ;
1818import { stringCompare } from "@stackframe/stack-shared/dist/utils/strings" ;
19- import { ArrowsClockwiseIcon , FastForwardIcon , GearIcon , MonitorPlayIcon , PauseIcon , PlayIcon } from "@phosphor-icons/react" ;
19+ import { ArrowsClockwiseIcon , CursorClickIcon , FastForwardIcon , GearIcon , MonitorPlayIcon , PauseIcon , PlayIcon } from "@phosphor-icons/react" ;
2020import { Panel , PanelGroup , PanelResizeHandle } from "react-resizable-panels" ;
2121import React , { useCallback , useEffect , useMemo , useRef , useState } from "react" ;
2222import { AppEnabledGuard } from "../../app-enabled-guard" ;
@@ -113,6 +113,32 @@ function formatTimelineMs(ms: number) {
113113 return `${ m } :${ s . toString ( ) . padStart ( 2 , "0" ) } ` ;
114114}
115115
116+ type TimelineEvent = {
117+ eventType : string ,
118+ eventAtMs : number ,
119+ data : Record < string , unknown > ,
120+ } ;
121+
122+ type TimelineMarker = {
123+ timeMs : number ,
124+ eventType : string ,
125+ label : string ,
126+ } ;
127+
128+ function formatEventTooltip ( event : TimelineEvent ) : string {
129+ const d = event . data ;
130+ if ( event . eventType === "$click" ) {
131+ const tag = ( d . tag_name as string ) || "element" ;
132+ return `Clicked ${ tag } ` ;
133+ }
134+ if ( event . eventType === "$page-view" ) {
135+ const path = ( d . path as string | undefined ) ?? ( d . url as string | undefined ) ?? "/" ;
136+ const truncated = path . length > 30 ? path . slice ( 0 , 27 ) + "..." : path ;
137+ return truncated ;
138+ }
139+ return event . eventType ;
140+ }
141+
116142function DisplayDate ( { date } : { date : Date } ) {
117143 const fromNow = useFromNow ( date ) ;
118144 return < span > { fromNow } </ span > ;
@@ -175,6 +201,7 @@ function Timeline({
175201 onSeek,
176202 playerSpeed,
177203 onSpeedChange,
204+ markers,
178205} : {
179206 getCurrentTimeMs : ( ) => number ,
180207 playerIsPlaying : boolean ,
@@ -183,8 +210,10 @@ function Timeline({
183210 onSeek : ( timeOffset : number ) => void ,
184211 playerSpeed : number ,
185212 onSpeedChange : ( speed : number ) => void ,
213+ markers ?: TimelineMarker [ ] ,
186214} ) {
187215 const [ currentTime , setCurrentTime ] = useState ( 0 ) ;
216+ const [ hoveredMarkerIndex , setHoveredMarkerIndex ] = useState < number | null > ( null ) ;
188217 const trackRef = useRef < HTMLDivElement | null > ( null ) ;
189218 const rafRef = useRef < number > ( 0 ) ;
190219
@@ -208,8 +237,11 @@ function Timeline({
208237 onSeek ( timeOffset ) ;
209238 } , [ totalTimeMs , onSeek ] ) ;
210239
240+ const hasMarkers = ( markers ?. length ?? 0 ) > 0 ;
241+ const hoveredMarker = hoveredMarkerIndex !== null ? markers ?. [ hoveredMarkerIndex ] ?? null : null ;
242+
211243 return (
212- < div className = "border-t border-border/30 bg-background px-3 py-2 flex items-center gap-3" >
244+ < div className = { cn ( "border-t border-border/30 bg-background px-3 flex items-center gap-3" , hasMarkers ? "py-1.5" : "py-2" ) } >
213245 < Button
214246 variant = "ghost"
215247 size = "icon"
@@ -223,16 +255,62 @@ function Timeline({
223255 { formatTimelineMs ( currentTime ) }
224256 </ span >
225257
226- < div
227- ref = { trackRef }
228- onClick = { handleTrackClick }
229- className = "flex-1 h-5 flex items-center cursor-pointer group"
230- >
231- < div className = "w-full h-1.5 rounded-full bg-muted relative overflow-hidden" >
232- < div
233- className = "absolute inset-y-0 left-0 bg-foreground/60 group-hover:bg-foreground/80 rounded-full transition-colors"
234- style = { { width : `${ progress * 100 } %` } }
235- />
258+ < div className = "flex-1 flex flex-col justify-center" >
259+ { /* Event markers lane */ }
260+ { hasMarkers && (
261+ < div className = "relative h-3.5 mb-0.5" >
262+ { markers ?. map ( ( marker , i ) => {
263+ const left = totalTimeMs > 0 ? ( marker . timeMs / totalTimeMs ) * 100 : 0 ;
264+ if ( left < 0 || left > 100 ) return null ;
265+ const isClick = marker . eventType === "$click" ;
266+ return (
267+ < div
268+ key = { i }
269+ className = { cn (
270+ "absolute bottom-0 w-[3px] h-3 rounded-sm cursor-pointer" ,
271+ "transition-colors" ,
272+ isClick
273+ ? "bg-blue-500/70 hover:bg-blue-400"
274+ : "bg-emerald-500/70 hover:bg-emerald-400" ,
275+ ) }
276+ style = { { left : `${ left } %` , marginLeft : "-1.5px" } }
277+ onMouseEnter = { ( ) => setHoveredMarkerIndex ( i ) }
278+ onMouseLeave = { ( ) => setHoveredMarkerIndex ( ( prev ) => prev === i ? null : prev ) }
279+ onClick = { ( ) => onSeek ( marker . timeMs ) }
280+ />
281+ ) ;
282+ } ) }
283+
284+ { /* Custom tooltip */ }
285+ { hoveredMarker && ( ( ) => {
286+ const left = totalTimeMs > 0 ? ( hoveredMarker . timeMs / totalTimeMs ) * 100 : 0 ;
287+ return (
288+ < div
289+ className = "absolute bottom-full mb-1.5 -translate-x-1/2 pointer-events-none z-50"
290+ style = { { left : `${ left } %` } }
291+ >
292+ < div className = "rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground whitespace-nowrap max-w-52" >
293+ < div className = "truncate" > { hoveredMarker . label } </ div >
294+ < div className = "text-[10px] opacity-70" > { formatTimelineMs ( hoveredMarker . timeMs ) } </ div >
295+ </ div >
296+ </ div >
297+ ) ;
298+ } ) ( ) }
299+ </ div >
300+ ) }
301+
302+ { /* Progress bar track (clickable) */ }
303+ < div
304+ ref = { trackRef }
305+ onClick = { handleTrackClick }
306+ className = "h-5 flex items-center cursor-pointer group"
307+ >
308+ < div className = "w-full h-1.5 rounded-full bg-muted relative overflow-hidden" >
309+ < div
310+ className = "absolute inset-y-0 left-0 bg-foreground/60 group-hover:bg-foreground/80 rounded-full transition-colors"
311+ style = { { width : `${ progress * 100 } %` } }
312+ />
313+ </ div >
236314 </ div >
237315 </ div >
238316
@@ -346,6 +424,8 @@ export default function PageClient() {
346424 const [ loadingInitial , setLoadingInitial ] = useState ( true ) ;
347425 const [ loadingMore , setLoadingMore ] = useState ( false ) ;
348426 const [ listError , setListError ] = useState < string | null > ( null ) ;
427+ const [ clickCountsByReplayId , setClickCountsByReplayId ] = useState < Map < string , number > > ( new Map ( ) ) ;
428+ const [ timelineEvents , setTimelineEvents ] = useState < TimelineEvent [ ] > ( [ ] ) ;
349429
350430 const listBoxRef = useRef < HTMLDivElement | null > ( null ) ;
351431
@@ -392,6 +472,28 @@ export default function PageClient() {
392472 runAsynchronously ( ( ) => loadPage ( null ) , { noErrorLogging : true } ) ;
393473 } , [ loadPage ] ) ;
394474
475+ useEffect ( ( ) => {
476+ if ( recordings . length === 0 ) return ;
477+ const ids = recordings . map ( r => r . id ) ;
478+ runAsynchronously ( async ( ) => {
479+ const res = await adminApp . queryAnalytics ( {
480+ query : `SELECT session_replay_id, count() as cnt
481+ FROM default.events
482+ WHERE event_type = '$click'
483+ AND session_replay_id IN ({ids:Array(String)})
484+ GROUP BY session_replay_id` ,
485+ params : { ids } ,
486+ include_all_branches : false ,
487+ timeout_ms : 15000 ,
488+ } ) ;
489+ const map = new Map < string , number > ( ) ;
490+ for ( const row of res . result ) {
491+ map . set ( row . session_replay_id as string , Number ( row . cnt ) ) ;
492+ }
493+ setClickCountsByReplayId ( map ) ;
494+ } , { noErrorLogging : true } ) ;
495+ } , [ recordings , adminApp ] ) ;
496+
395497 const onListScroll = useCallback ( ( ) => {
396498 const el = listBoxRef . current ;
397499 if ( ! el ) return ;
@@ -967,6 +1069,41 @@ export default function PageClient() {
9671069 runAsynchronously ( ( ) => loadChunksAndDownload ( selectedRecordingId ) , { noErrorLogging : true } ) ;
9681070 } , [ loadChunksAndDownload , selectedRecordingId , selectedRecording ] ) ;
9691071
1072+ useEffect ( ( ) => {
1073+ if ( ! selectedRecordingId ) {
1074+ setTimelineEvents ( [ ] ) ;
1075+ return ;
1076+ }
1077+ let cancelled = false ;
1078+ setTimelineEvents ( [ ] ) ;
1079+ runAsynchronously ( async ( ) => {
1080+ const res = await adminApp . queryAnalytics ( {
1081+ query : `SELECT event_type,
1082+ toUnixTimestamp64Milli(event_at) as event_at_ms,
1083+ data
1084+ FROM default.events
1085+ WHERE session_replay_id = {id:String}
1086+ AND event_type IN ('$click', '$page-view')
1087+ ORDER BY event_at ASC
1088+ LIMIT 2000` ,
1089+ params : { id : selectedRecordingId } ,
1090+ include_all_branches : false ,
1091+ timeout_ms : 15000 ,
1092+ } ) ;
1093+ if ( cancelled ) return ;
1094+ setTimelineEvents ( res . result . map ( ( r : any ) => ( {
1095+ eventType : r . event_type as string ,
1096+ eventAtMs : Number ( r . event_at_ms ) ,
1097+ data : typeof r . data === "string"
1098+ ? JSON . parse ( r . data )
1099+ : ( r . data ?? { } ) ,
1100+ } ) ) ) ;
1101+ } , { noErrorLogging : true } ) ;
1102+ return ( ) => {
1103+ cancelled = true ;
1104+ } ;
1105+ } , [ selectedRecordingId , adminApp ] ) ;
1106+
9701107 useEffect ( ( ) => {
9711108 return ( ) => {
9721109 genCounterRef . current += 1 ;
@@ -1144,6 +1281,15 @@ export default function PageClient() {
11441281
11451282 const showMainTabLabel = renderableStreamCount > 1 ;
11461283
1284+ const timelineMarkers = useMemo ( ( ) => {
1285+ if ( timelineEvents . length === 0 || ms . globalTotalMs <= 0 ) return [ ] ;
1286+ return timelineEvents . map ( ( e ) : TimelineMarker => ( {
1287+ timeMs : e . eventAtMs - ms . globalStartTs ,
1288+ eventType : e . eventType ,
1289+ label : formatEventTooltip ( e ) ,
1290+ } ) ) . filter ( m => m . timeMs >= 0 && m . timeMs <= ms . globalTotalMs ) ;
1291+ } , [ timelineEvents , ms . globalStartTs , ms . globalTotalMs ] ) ;
1292+
11471293 // ---- Rendering ----
11481294
11491295 return (
@@ -1208,8 +1354,14 @@ export default function PageClient() {
12081354 { duration }
12091355 </ span >
12101356 </ div >
1211- < div className = "text-xs text-muted-foreground" >
1357+ < div className = "flex items-center justify-between gap-2 text-xs text-muted-foreground" >
12121358 < DisplayDate date = { r . lastEventAt } />
1359+ { ( clickCountsByReplayId . get ( r . id ) ?? 0 ) > 0 && (
1360+ < span className = "flex items-center gap-0.5 text-[10px] text-muted-foreground/70" >
1361+ < CursorClickIcon className = "h-3 w-3" />
1362+ { clickCountsByReplayId . get ( r . id ) }
1363+ </ span >
1364+ ) }
12131365 </ div >
12141366 </ button >
12151367 ) ;
@@ -1430,6 +1582,7 @@ export default function PageClient() {
14301582 onSeek = { handleSeek }
14311583 playerSpeed = { ms . settings . playerSpeed }
14321584 onSpeedChange = { updateSpeed }
1585+ markers = { timelineMarkers }
14331586 />
14341587 ) }
14351588 </ div >
0 commit comments