-
Notifications
You must be signed in to change notification settings - Fork 514
analytics: replays event markers #1210
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
a1da657
39ca118
f1fde88
fb49d11
fa09166
2aadf87
97055c9
f44ad77
874f5ba
cb17836
0349c8c
e7a050a
cca8798
9e74bc2
c0e6946
d590207
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -16,7 +16,7 @@ import { | |||||||||||||||||||||||||||||
| import { cn } from "@/lib/utils"; | ||||||||||||||||||||||||||||||
| import { runAsynchronously } from "@stackframe/stack-shared/dist/utils/promises"; | ||||||||||||||||||||||||||||||
| import { stringCompare } from "@stackframe/stack-shared/dist/utils/strings"; | ||||||||||||||||||||||||||||||
| import { ArrowsClockwiseIcon, FastForwardIcon, GearIcon, MonitorPlayIcon, PauseIcon, PlayIcon } from "@phosphor-icons/react"; | ||||||||||||||||||||||||||||||
| import { ArrowsClockwiseIcon, CursorClickIcon, FastForwardIcon, GearIcon, MonitorPlayIcon, PauseIcon, PlayIcon } from "@phosphor-icons/react"; | ||||||||||||||||||||||||||||||
| import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels"; | ||||||||||||||||||||||||||||||
| import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; | ||||||||||||||||||||||||||||||
| import { AppEnabledGuard } from "../../app-enabled-guard"; | ||||||||||||||||||||||||||||||
|
|
@@ -113,6 +113,32 @@ function formatTimelineMs(ms: number) { | |||||||||||||||||||||||||||||
| return `${m}:${s.toString().padStart(2, "0")}`; | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| type TimelineEvent = { | ||||||||||||||||||||||||||||||
| eventType: string, | ||||||||||||||||||||||||||||||
| eventAtMs: number, | ||||||||||||||||||||||||||||||
| data: Record<string, unknown>, | ||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| type TimelineMarker = { | ||||||||||||||||||||||||||||||
| timeMs: number, | ||||||||||||||||||||||||||||||
| eventType: string, | ||||||||||||||||||||||||||||||
| label: string, | ||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| function formatEventTooltip(event: TimelineEvent): string { | ||||||||||||||||||||||||||||||
| const d = event.data; | ||||||||||||||||||||||||||||||
| if (event.eventType === "$click") { | ||||||||||||||||||||||||||||||
| const tag = (d.tag_name as string) || "element"; | ||||||||||||||||||||||||||||||
| return `Clicked ${tag}`; | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| if (event.eventType === "$page-view") { | ||||||||||||||||||||||||||||||
| const path = (d.path as string | undefined) ?? (d.url as string | undefined) ?? "/"; | ||||||||||||||||||||||||||||||
| const truncated = path.length > 30 ? path.slice(0, 27) + "..." : path; | ||||||||||||||||||||||||||||||
| return truncated; | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| return event.eventType; | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| function DisplayDate({ date }: { date: Date }) { | ||||||||||||||||||||||||||||||
| const fromNow = useFromNow(date); | ||||||||||||||||||||||||||||||
| return <span>{fromNow}</span>; | ||||||||||||||||||||||||||||||
|
|
@@ -175,6 +201,7 @@ function Timeline({ | |||||||||||||||||||||||||||||
| onSeek, | ||||||||||||||||||||||||||||||
| playerSpeed, | ||||||||||||||||||||||||||||||
| onSpeedChange, | ||||||||||||||||||||||||||||||
| markers, | ||||||||||||||||||||||||||||||
| }: { | ||||||||||||||||||||||||||||||
| getCurrentTimeMs: () => number, | ||||||||||||||||||||||||||||||
| playerIsPlaying: boolean, | ||||||||||||||||||||||||||||||
|
|
@@ -183,8 +210,10 @@ function Timeline({ | |||||||||||||||||||||||||||||
| onSeek: (timeOffset: number) => void, | ||||||||||||||||||||||||||||||
| playerSpeed: number, | ||||||||||||||||||||||||||||||
| onSpeedChange: (speed: number) => void, | ||||||||||||||||||||||||||||||
| markers?: TimelineMarker[], | ||||||||||||||||||||||||||||||
| }) { | ||||||||||||||||||||||||||||||
| const [currentTime, setCurrentTime] = useState(0); | ||||||||||||||||||||||||||||||
| const [hoveredMarkerIndex, setHoveredMarkerIndex] = useState<number | null>(null); | ||||||||||||||||||||||||||||||
| const trackRef = useRef<HTMLDivElement | null>(null); | ||||||||||||||||||||||||||||||
| const rafRef = useRef<number>(0); | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
|
|
@@ -208,8 +237,11 @@ function Timeline({ | |||||||||||||||||||||||||||||
| onSeek(timeOffset); | ||||||||||||||||||||||||||||||
| }, [totalTimeMs, onSeek]); | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| const hasMarkers = (markers?.length ?? 0) > 0; | ||||||||||||||||||||||||||||||
| const hoveredMarker = hoveredMarkerIndex !== null ? markers?.[hoveredMarkerIndex] ?? null : null; | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| return ( | ||||||||||||||||||||||||||||||
| <div className="border-t border-border/30 bg-background px-3 py-2 flex items-center gap-3"> | ||||||||||||||||||||||||||||||
| <div className={cn("border-t border-border/30 bg-background px-3 flex items-center gap-3", hasMarkers ? "py-1.5" : "py-2")}> | ||||||||||||||||||||||||||||||
| <Button | ||||||||||||||||||||||||||||||
| variant="ghost" | ||||||||||||||||||||||||||||||
| size="icon" | ||||||||||||||||||||||||||||||
|
|
@@ -223,16 +255,62 @@ function Timeline({ | |||||||||||||||||||||||||||||
| {formatTimelineMs(currentTime)} | ||||||||||||||||||||||||||||||
| </span> | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| <div | ||||||||||||||||||||||||||||||
| ref={trackRef} | ||||||||||||||||||||||||||||||
| onClick={handleTrackClick} | ||||||||||||||||||||||||||||||
| className="flex-1 h-5 flex items-center cursor-pointer group" | ||||||||||||||||||||||||||||||
| > | ||||||||||||||||||||||||||||||
| <div className="w-full h-1.5 rounded-full bg-muted relative overflow-hidden"> | ||||||||||||||||||||||||||||||
| <div | ||||||||||||||||||||||||||||||
| className="absolute inset-y-0 left-0 bg-foreground/60 group-hover:bg-foreground/80 rounded-full transition-colors" | ||||||||||||||||||||||||||||||
| style={{ width: `${progress * 100}%` }} | ||||||||||||||||||||||||||||||
| /> | ||||||||||||||||||||||||||||||
| <div className="flex-1 flex flex-col justify-center"> | ||||||||||||||||||||||||||||||
| {/* Event markers lane */} | ||||||||||||||||||||||||||||||
| {hasMarkers && ( | ||||||||||||||||||||||||||||||
| <div className="relative h-3.5 mb-0.5"> | ||||||||||||||||||||||||||||||
| {markers?.map((marker, i) => { | ||||||||||||||||||||||||||||||
| const left = totalTimeMs > 0 ? (marker.timeMs / totalTimeMs) * 100 : 0; | ||||||||||||||||||||||||||||||
| if (left < 0 || left > 100) return null; | ||||||||||||||||||||||||||||||
| const isClick = marker.eventType === "$click"; | ||||||||||||||||||||||||||||||
| return ( | ||||||||||||||||||||||||||||||
| <div | ||||||||||||||||||||||||||||||
| key={i} | ||||||||||||||||||||||||||||||
| className={cn( | ||||||||||||||||||||||||||||||
| "absolute bottom-0 w-[3px] h-3 rounded-sm cursor-pointer", | ||||||||||||||||||||||||||||||
| "transition-colors", | ||||||||||||||||||||||||||||||
| isClick | ||||||||||||||||||||||||||||||
| ? "bg-blue-500/70 hover:bg-blue-400" | ||||||||||||||||||||||||||||||
| : "bg-emerald-500/70 hover:bg-emerald-400", | ||||||||||||||||||||||||||||||
| )} | ||||||||||||||||||||||||||||||
|
Comment on lines
+269
to
+275
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Add The 🔧 Proposed fix- "transition-colors",
+ "transition-colors hover:transition-none",As per coding guidelines: "For hover transitions, avoid hover-enter transitions and use only hover-exit transitions (e.g., 📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||
| style={{ left: `${left}%`, marginLeft: "-1.5px" }} | ||||||||||||||||||||||||||||||
| onMouseEnter={() => setHoveredMarkerIndex(i)} | ||||||||||||||||||||||||||||||
| onMouseLeave={() => setHoveredMarkerIndex((prev) => prev === i ? null : prev)} | ||||||||||||||||||||||||||||||
| onClick={() => onSeek(marker.timeMs)} | ||||||||||||||||||||||||||||||
| /> | ||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||
| })} | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| {/* Custom tooltip */} | ||||||||||||||||||||||||||||||
| {hoveredMarker && (() => { | ||||||||||||||||||||||||||||||
| const left = totalTimeMs > 0 ? (hoveredMarker.timeMs / totalTimeMs) * 100 : 0; | ||||||||||||||||||||||||||||||
| return ( | ||||||||||||||||||||||||||||||
| <div | ||||||||||||||||||||||||||||||
| className="absolute bottom-full mb-1.5 -translate-x-1/2 pointer-events-none z-50" | ||||||||||||||||||||||||||||||
| style={{ left: `${left}%` }} | ||||||||||||||||||||||||||||||
| > | ||||||||||||||||||||||||||||||
| <div className="rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground whitespace-nowrap max-w-52"> | ||||||||||||||||||||||||||||||
| <div className="truncate">{hoveredMarker.label}</div> | ||||||||||||||||||||||||||||||
| <div className="text-[10px] opacity-70">{formatTimelineMs(hoveredMarker.timeMs)}</div> | ||||||||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||
| })()} | ||||||||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||||||||
| )} | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| {/* Progress bar track (clickable) */} | ||||||||||||||||||||||||||||||
| <div | ||||||||||||||||||||||||||||||
| ref={trackRef} | ||||||||||||||||||||||||||||||
| onClick={handleTrackClick} | ||||||||||||||||||||||||||||||
| className="h-5 flex items-center cursor-pointer group" | ||||||||||||||||||||||||||||||
| > | ||||||||||||||||||||||||||||||
| <div className="w-full h-1.5 rounded-full bg-muted relative overflow-hidden"> | ||||||||||||||||||||||||||||||
| <div | ||||||||||||||||||||||||||||||
| className="absolute inset-y-0 left-0 bg-foreground/60 group-hover:bg-foreground/80 rounded-full transition-colors" | ||||||||||||||||||||||||||||||
| style={{ width: `${progress * 100}%` }} | ||||||||||||||||||||||||||||||
| /> | ||||||||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
|
|
@@ -346,6 +424,8 @@ export default function PageClient() { | |||||||||||||||||||||||||||||
| const [loadingInitial, setLoadingInitial] = useState(true); | ||||||||||||||||||||||||||||||
| const [loadingMore, setLoadingMore] = useState(false); | ||||||||||||||||||||||||||||||
| const [listError, setListError] = useState<string | null>(null); | ||||||||||||||||||||||||||||||
| const [clickCountsByReplayId, setClickCountsByReplayId] = useState<Map<string, number>>(new Map()); | ||||||||||||||||||||||||||||||
| const [timelineEvents, setTimelineEvents] = useState<TimelineEvent[]>([]); | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| const listBoxRef = useRef<HTMLDivElement | null>(null); | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
|
|
@@ -392,6 +472,28 @@ export default function PageClient() { | |||||||||||||||||||||||||||||
| runAsynchronously(() => loadPage(null), { noErrorLogging: true }); | ||||||||||||||||||||||||||||||
| }, [loadPage]); | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| useEffect(() => { | ||||||||||||||||||||||||||||||
| if (recordings.length === 0) return; | ||||||||||||||||||||||||||||||
| const ids = recordings.map(r => r.id); | ||||||||||||||||||||||||||||||
| runAsynchronously(async () => { | ||||||||||||||||||||||||||||||
| const res = await adminApp.queryAnalytics({ | ||||||||||||||||||||||||||||||
| query: `SELECT session_replay_id, count() as cnt | ||||||||||||||||||||||||||||||
| FROM default.events | ||||||||||||||||||||||||||||||
| WHERE event_type = '$click' | ||||||||||||||||||||||||||||||
| AND session_replay_id IN ({ids:Array(String)}) | ||||||||||||||||||||||||||||||
| GROUP BY session_replay_id`, | ||||||||||||||||||||||||||||||
| params: { ids }, | ||||||||||||||||||||||||||||||
| include_all_branches: false, | ||||||||||||||||||||||||||||||
| timeout_ms: 15000, | ||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||
| const map = new Map<string, number>(); | ||||||||||||||||||||||||||||||
| for (const row of res.result) { | ||||||||||||||||||||||||||||||
| map.set(row.session_replay_id as string, Number(row.cnt)); | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| setClickCountsByReplayId(map); | ||||||||||||||||||||||||||||||
| }, { noErrorLogging: true }); | ||||||||||||||||||||||||||||||
|
BilalG1 marked this conversation as resolved.
|
||||||||||||||||||||||||||||||
| }, [recordings, adminApp]); | ||||||||||||||||||||||||||||||
|
Comment on lines
+475
to
+495
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Race condition: stale query can overwrite complete data; also has an Race condition: The
🔧 Proposed fix useEffect(() => {
if (recordings.length === 0) return;
const ids = recordings.map(r => r.id);
+ let cancelled = false;
runAsynchronously(async () => {
const res = await adminApp.queryAnalytics({
query: `SELECT session_replay_id, count() as cnt
FROM default.events
WHERE event_type = '$click'
AND session_replay_id IN ({ids:Array(String)})
GROUP BY session_replay_id`,
params: { ids },
include_all_branches: false,
timeout_ms: 15000,
});
+ if (cancelled) return;
const map = new Map<string, number>();
for (const row of res.result) {
- map.set(row.session_replay_id as string, Number(row.cnt));
+ const replayId = typeof row.session_replay_id === "string" ? row.session_replay_id : String(row.session_replay_id);
+ map.set(replayId, Number(row.cnt));
}
setClickCountsByReplayId(map);
}, { noErrorLogging: true });
+ return () => { cancelled = true; };
}, [recordings, adminApp]);As per coding guidelines: "Do not use 🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| const onListScroll = useCallback(() => { | ||||||||||||||||||||||||||||||
| const el = listBoxRef.current; | ||||||||||||||||||||||||||||||
| if (!el) return; | ||||||||||||||||||||||||||||||
|
|
@@ -967,6 +1069,41 @@ export default function PageClient() { | |||||||||||||||||||||||||||||
| runAsynchronously(() => loadChunksAndDownload(selectedRecordingId), { noErrorLogging: true }); | ||||||||||||||||||||||||||||||
| }, [loadChunksAndDownload, selectedRecordingId, selectedRecording]); | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| useEffect(() => { | ||||||||||||||||||||||||||||||
| if (!selectedRecordingId) { | ||||||||||||||||||||||||||||||
| setTimelineEvents([]); | ||||||||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| let cancelled = false; | ||||||||||||||||||||||||||||||
| setTimelineEvents([]); | ||||||||||||||||||||||||||||||
| runAsynchronously(async () => { | ||||||||||||||||||||||||||||||
| const res = await adminApp.queryAnalytics({ | ||||||||||||||||||||||||||||||
| query: `SELECT event_type, | ||||||||||||||||||||||||||||||
| toUnixTimestamp64Milli(event_at) as event_at_ms, | ||||||||||||||||||||||||||||||
| data | ||||||||||||||||||||||||||||||
| FROM default.events | ||||||||||||||||||||||||||||||
| WHERE session_replay_id = {id:String} | ||||||||||||||||||||||||||||||
| AND event_type IN ('$click', '$page-view') | ||||||||||||||||||||||||||||||
| ORDER BY event_at ASC | ||||||||||||||||||||||||||||||
| LIMIT 2000`, | ||||||||||||||||||||||||||||||
| params: { id: selectedRecordingId }, | ||||||||||||||||||||||||||||||
| include_all_branches: false, | ||||||||||||||||||||||||||||||
| timeout_ms: 15000, | ||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||
| if (cancelled) return; | ||||||||||||||||||||||||||||||
| setTimelineEvents(res.result.map((r: any) => ({ | ||||||||||||||||||||||||||||||
| eventType: r.event_type as string, | ||||||||||||||||||||||||||||||
| eventAtMs: Number(r.event_at_ms), | ||||||||||||||||||||||||||||||
| data: typeof r.data === "string" | ||||||||||||||||||||||||||||||
| ? JSON.parse(r.data) | ||||||||||||||||||||||||||||||
| : (r.data ?? {}), | ||||||||||||||||||||||||||||||
| }))); | ||||||||||||||||||||||||||||||
|
BilalG1 marked this conversation as resolved.
|
||||||||||||||||||||||||||||||
| }, { noErrorLogging: true }); | ||||||||||||||||||||||||||||||
| return () => { | ||||||||||||||||||||||||||||||
| cancelled = true; | ||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||
| }, [selectedRecordingId, adminApp]); | ||||||||||||||||||||||||||||||
|
Comment on lines
+1072
to
+1105
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Three issues in the result-mapping callback:
The 🔧 Proposed fix+ type AnalyticsEventRow = { event_type: unknown, event_at_ms: unknown, data: unknown };
setTimelineEvents(res.result.map((r: AnalyticsEventRow) => ({
- eventType: r.event_type as string,
+ eventType: typeof r.event_type === "string" ? r.event_type : String(r.event_type),
eventAtMs: Number(r.event_at_ms),
- data: typeof r.data === "string"
- ? JSON.parse(r.data)
- : (r.data ?? {}),
+ data: (() => {
+ if (typeof r.data !== "string") return (r.data != null && typeof r.data === "object") ? r.data as Record<string, unknown> : {};
+ try { const parsed = JSON.parse(r.data); return (parsed != null && typeof parsed === "object") ? parsed as Record<string, unknown> : {}; } catch { return {}; }
+ })(),
})));As per coding guidelines: "Avoid the 🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| useEffect(() => { | ||||||||||||||||||||||||||||||
| return () => { | ||||||||||||||||||||||||||||||
| genCounterRef.current += 1; | ||||||||||||||||||||||||||||||
|
|
@@ -1144,6 +1281,15 @@ export default function PageClient() { | |||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| const showMainTabLabel = renderableStreamCount > 1; | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| const timelineMarkers = useMemo(() => { | ||||||||||||||||||||||||||||||
| if (timelineEvents.length === 0 || ms.globalTotalMs <= 0) return []; | ||||||||||||||||||||||||||||||
| return timelineEvents.map((e): TimelineMarker => ({ | ||||||||||||||||||||||||||||||
| timeMs: e.eventAtMs - ms.globalStartTs, | ||||||||||||||||||||||||||||||
| eventType: e.eventType, | ||||||||||||||||||||||||||||||
| label: formatEventTooltip(e), | ||||||||||||||||||||||||||||||
| })).filter(m => m.timeMs >= 0 && m.timeMs <= ms.globalTotalMs); | ||||||||||||||||||||||||||||||
| }, [timelineEvents, ms.globalStartTs, ms.globalTotalMs]); | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| // ---- Rendering ---- | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| return ( | ||||||||||||||||||||||||||||||
|
|
@@ -1208,8 +1354,14 @@ export default function PageClient() { | |||||||||||||||||||||||||||||
| {duration} | ||||||||||||||||||||||||||||||
| </span> | ||||||||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||||||||
| <div className="text-xs text-muted-foreground"> | ||||||||||||||||||||||||||||||
| <div className="flex items-center justify-between gap-2 text-xs text-muted-foreground"> | ||||||||||||||||||||||||||||||
| <DisplayDate date={r.lastEventAt} /> | ||||||||||||||||||||||||||||||
| {(clickCountsByReplayId.get(r.id) ?? 0) > 0 && ( | ||||||||||||||||||||||||||||||
| <span className="flex items-center gap-0.5 text-[10px] text-muted-foreground/70"> | ||||||||||||||||||||||||||||||
| <CursorClickIcon className="h-3 w-3" /> | ||||||||||||||||||||||||||||||
| {clickCountsByReplayId.get(r.id)} | ||||||||||||||||||||||||||||||
| </span> | ||||||||||||||||||||||||||||||
| )} | ||||||||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||||||||
| </button> | ||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||
|
|
@@ -1430,6 +1582,7 @@ export default function PageClient() { | |||||||||||||||||||||||||||||
| onSeek={handleSeek} | ||||||||||||||||||||||||||||||
| playerSpeed={ms.settings.playerSpeed} | ||||||||||||||||||||||||||||||
| onSpeedChange={updateSpeed} | ||||||||||||||||||||||||||||||
| markers={timelineMarkers} | ||||||||||||||||||||||||||||||
| /> | ||||||||||||||||||||||||||||||
| )} | ||||||||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Replace
ascasts with runtime type guards; use??instead of||.Three
ascasts and one boolean-coercion fallback violate the project guidelines:(d.tag_name as string)— no runtime guaranteetag_nameis astring; cast should be a type guard.|| "element"—||swallows empty string; use??per the explicit-null-check rule.(d.path as string | undefined)and(d.url as string | undefined)— same issue on line 135.🔧 Proposed fix
function formatEventTooltip(event: TimelineEvent): string { const d = event.data; if (event.eventType === "$click") { - const tag = (d.tag_name as string) || "element"; + const tag = typeof d.tag_name === "string" ? d.tag_name : "element"; return `Clicked ${tag}`; } if (event.eventType === "$page-view") { - const path = (d.path as string | undefined) ?? (d.url as string | undefined) ?? "/"; + const path = (typeof d.path === "string" ? d.path : undefined) + ?? (typeof d.url === "string" ? d.url : undefined) + ?? "/"; const truncated = path.length > 30 ? path.slice(0, 27) + "..." : path; return truncated; } return event.eventType; }As per coding guidelines: "Do not use
as,any, type casts, or other type system bypasses" and "Prefer explicit null/undefinedness checks over boolean checks (e.g.,foo == nullinstead of!foo)".🤖 Prompt for AI Agents