mirror of
https://github.com/umami-software/umami.git
synced 2026-05-30 06:47:25 +00:00
Heatmaps bug fixes, update schema to match overlay, use react-zen component to match ui/ux, polish
This commit is contained in:
@@ -14,6 +14,9 @@ CREATE TABLE umami.heatmap_event
|
||||
viewport_h Nullable(Int32),
|
||||
page_h Nullable(Int32),
|
||||
scroll_pct Nullable(UInt8),
|
||||
replay_chunk_index Nullable(UInt32),
|
||||
replay_event_index Nullable(UInt32),
|
||||
replay_time_ms Nullable(Int64),
|
||||
created_at DateTime('UTC')
|
||||
)
|
||||
ENGINE = MergeTree
|
||||
|
||||
@@ -416,6 +416,9 @@ CREATE TABLE umami.heatmap_event
|
||||
viewport_h Nullable(Int32),
|
||||
page_h Nullable(Int32),
|
||||
scroll_pct Nullable(UInt8),
|
||||
replay_chunk_index Nullable(UInt32),
|
||||
replay_event_index Nullable(UInt32),
|
||||
replay_time_ms Nullable(Int64),
|
||||
created_at DateTime('UTC')
|
||||
)
|
||||
ENGINE = MergeTree
|
||||
|
||||
@@ -13,6 +13,9 @@ CREATE TABLE "heatmap_event" (
|
||||
"viewport_h" INTEGER,
|
||||
"page_h" INTEGER,
|
||||
"scroll_pct" INTEGER,
|
||||
"replay_chunk_index" INTEGER,
|
||||
"replay_event_index" INTEGER,
|
||||
"replay_time_ms" BIGINT,
|
||||
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "heatmap_event_pkey" PRIMARY KEY ("heatmap_event_id")
|
||||
@@ -23,3 +26,4 @@ CREATE INDEX "heatmap_event_website_id_idx" ON "heatmap_event"("website_id");
|
||||
CREATE INDEX "heatmap_event_visit_id_idx" ON "heatmap_event"("visit_id");
|
||||
CREATE INDEX "heatmap_event_website_id_created_at_idx" ON "heatmap_event"("website_id", "created_at");
|
||||
CREATE INDEX "heatmap_event_website_id_url_path_event_type_created_at_idx" ON "heatmap_event"("website_id", "url_path", "event_type", "created_at");
|
||||
CREATE INDEX "heatmap_event_website_id_visit_id_replay_chunk_index_replay_event_index_idx" ON "heatmap_event"("website_id", "visit_id", "replay_chunk_index", "replay_event_index");
|
||||
|
||||
+19
-15
@@ -402,20 +402,23 @@ model SessionReplaySaved {
|
||||
}
|
||||
|
||||
model HeatmapEvent {
|
||||
id String @id() @map("heatmap_event_id") @db.Uuid
|
||||
websiteId String @map("website_id") @db.Uuid
|
||||
sessionId String @map("session_id") @db.Uuid
|
||||
visitId String @map("visit_id") @db.Uuid
|
||||
urlPath String @map("url_path") @db.VarChar(500)
|
||||
eventType Int @map("event_type") @db.Integer
|
||||
nodeId Int? @map("node_id") @db.Integer
|
||||
x Int? @db.Integer
|
||||
y Int? @db.Integer
|
||||
viewportW Int? @map("viewport_w") @db.Integer
|
||||
viewportH Int? @map("viewport_h") @db.Integer
|
||||
pageH Int? @map("page_h") @db.Integer
|
||||
scrollPct Int? @map("scroll_pct") @db.Integer
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
id String @id() @map("heatmap_event_id") @db.Uuid
|
||||
websiteId String @map("website_id") @db.Uuid
|
||||
sessionId String @map("session_id") @db.Uuid
|
||||
visitId String @map("visit_id") @db.Uuid
|
||||
urlPath String @map("url_path") @db.VarChar(500)
|
||||
eventType Int @map("event_type") @db.Integer
|
||||
nodeId Int? @map("node_id") @db.Integer
|
||||
x Int? @db.Integer
|
||||
y Int? @db.Integer
|
||||
viewportW Int? @map("viewport_w") @db.Integer
|
||||
viewportH Int? @map("viewport_h") @db.Integer
|
||||
pageH Int? @map("page_h") @db.Integer
|
||||
scrollPct Int? @map("scroll_pct") @db.Integer
|
||||
replayChunkIndex Int? @map("replay_chunk_index") @db.Integer
|
||||
replayEventIndex Int? @map("replay_event_index") @db.Integer
|
||||
replayTimeMs BigInt? @map("replay_time_ms") @db.BigInt
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
|
||||
website Website @relation(fields: [websiteId], references: [id])
|
||||
|
||||
@@ -423,5 +426,6 @@ model HeatmapEvent {
|
||||
@@index([visitId])
|
||||
@@index([websiteId, createdAt])
|
||||
@@index([websiteId, urlPath, eventType, createdAt])
|
||||
@@index([websiteId, visitId, replayChunkIndex, replayEventIndex])
|
||||
@@map("heatmap_event")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,33 @@
|
||||
.layoutGrid {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pageList {
|
||||
min-width: 0;
|
||||
padding-right: 6px;
|
||||
}
|
||||
|
||||
.railDivider {
|
||||
width: 1px;
|
||||
height: 100%;
|
||||
background: var(--gray-300, #d1d5db);
|
||||
justify-self: center;
|
||||
}
|
||||
|
||||
.contentColumn {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.pageListItems {
|
||||
overflow-y: auto;
|
||||
padding-top: 8px;
|
||||
padding-right: 8px;
|
||||
max-height: 100%;
|
||||
max-height: 850px;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
.pageButton {
|
||||
@@ -10,7 +36,7 @@
|
||||
border: 0;
|
||||
background: transparent;
|
||||
border-radius: 6px;
|
||||
padding: 8px 10px;
|
||||
padding: 8px 10px 8px 6px;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
@@ -26,25 +52,30 @@
|
||||
color: var(--surface-base);
|
||||
}
|
||||
|
||||
.toggleButton {
|
||||
border: 1px solid var(--border-base);
|
||||
background: var(--surface-base);
|
||||
border-radius: 6px;
|
||||
.summaryHeader {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.summaryPath {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.summaryStats {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.summaryStat {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 10px;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.toggleButton:hover {
|
||||
background: var(--interactive-bg-hover);
|
||||
}
|
||||
|
||||
.toggleButtonSelected,
|
||||
.toggleButtonSelected:hover {
|
||||
background: var(--surface-inverted);
|
||||
color: var(--surface-base);
|
||||
border-color: var(--surface-inverted);
|
||||
border-radius: 999px;
|
||||
background: var(--surface-raised);
|
||||
border: 1px solid var(--border-base);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.scrollBand {
|
||||
@@ -73,15 +104,27 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.snapshotControlRow {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.canvas {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
overflow: visible;
|
||||
border: 1px solid var(--border-base);
|
||||
border-radius: 8px;
|
||||
background: var(--surface-sunken);
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.canvasClip {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
border-radius: inherit;
|
||||
background: inherit;
|
||||
}
|
||||
|
||||
.snapshot {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
@@ -117,11 +160,18 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.heatOverlay {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.canvasLoading {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 2;
|
||||
background: var(--surface-sunken);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.dot {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
'use client';
|
||||
import { Column, Grid, Heading, Loading, Row, Text } from '@umami/react-zen';
|
||||
import { Column, Grid, Heading, Loading, Row, Switch, Text } from '@umami/react-zen';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { LoadingPanel } from '@/components/common/LoadingPanel';
|
||||
import { useResultQuery } from '@/components/hooks';
|
||||
import { useMessages, useResultQuery } from '@/components/hooks';
|
||||
import { useReplayQuery } from '@/components/hooks/queries/useReplayQuery';
|
||||
import { formatLongNumber } from '@/lib/format';
|
||||
import type { HeatmapMode, HeatmapPoint, HeatmapResult, HeatmapSnapshot } from '@/queries/sql';
|
||||
@@ -17,6 +17,7 @@ function useElementWidth<T extends HTMLElement>() {
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
setWidth(el.getBoundingClientRect().width || el.clientWidth || 0);
|
||||
const ro = new ResizeObserver(entries => {
|
||||
const w = entries[0]?.contentRect.width ?? 0;
|
||||
setWidth(w);
|
||||
@@ -31,6 +32,15 @@ interface ReplayData {
|
||||
events: any[];
|
||||
}
|
||||
|
||||
interface ReplayInstance {
|
||||
iframe?: HTMLIFrameElement;
|
||||
wrapper?: HTMLDivElement;
|
||||
on: (event: string, handler: (...args: any[]) => void) => void;
|
||||
pause: (timeOffset?: number) => void;
|
||||
disableInteract: () => void;
|
||||
destroy: () => void;
|
||||
}
|
||||
|
||||
interface ViewportBucket {
|
||||
width: number;
|
||||
height: number;
|
||||
@@ -42,38 +52,97 @@ interface HeatmapProps {
|
||||
urlPath: string;
|
||||
onUrlPathChange: (urlPath: string) => void;
|
||||
mode: HeatmapMode;
|
||||
onModeChange: (mode: HeatmapMode) => void;
|
||||
search: string;
|
||||
}
|
||||
|
||||
export function Heatmap({ websiteId, urlPath, onUrlPathChange, mode, onModeChange }: HeatmapProps) {
|
||||
const { data, error, isLoading } = useResultQuery<HeatmapResult>('heatmap', {
|
||||
export function Heatmap({ websiteId, urlPath, onUrlPathChange, mode, search }: HeatmapProps) {
|
||||
const {
|
||||
data: pagesData,
|
||||
error,
|
||||
isLoading,
|
||||
} = useResultQuery<HeatmapResult>('heatmap', {
|
||||
websiteId,
|
||||
urlPath: urlPath || undefined,
|
||||
mode,
|
||||
});
|
||||
|
||||
const pages = data?.pages ?? [];
|
||||
const points = data?.points ?? [];
|
||||
const scroll = data?.scroll;
|
||||
const {
|
||||
data: detailData,
|
||||
isLoading: isDetailLoading,
|
||||
isFetching: isDetailFetching,
|
||||
} = useResultQuery<HeatmapResult>(
|
||||
'heatmap',
|
||||
{
|
||||
websiteId,
|
||||
urlPath: urlPath || undefined,
|
||||
mode,
|
||||
},
|
||||
{
|
||||
enabled: Boolean(urlPath),
|
||||
},
|
||||
);
|
||||
|
||||
const pages = pagesData?.pages ?? [];
|
||||
const filteredPages = useMemo(() => {
|
||||
if (!search) {
|
||||
return pages;
|
||||
}
|
||||
|
||||
const value = search.toLowerCase();
|
||||
|
||||
return pages.filter(page => page.urlPath.toLowerCase().includes(value));
|
||||
}, [pages, search]);
|
||||
const points = detailData?.points ?? [];
|
||||
const scroll = detailData?.scroll;
|
||||
const snapshot = detailData?.snapshot ?? null;
|
||||
const detailLoading = Boolean(urlPath) && (isDetailLoading || isDetailFetching);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (filteredPages.length === 0) {
|
||||
if (urlPath) {
|
||||
onUrlPathChange('');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!urlPath || filteredPages.some(page => page.urlPath === urlPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
onUrlPathChange(filteredPages[0].urlPath);
|
||||
}, [filteredPages, isLoading, onUrlPathChange, urlPath]);
|
||||
|
||||
return (
|
||||
<LoadingPanel data={data} isLoading={isLoading} error={error} minHeight="900px">
|
||||
<Grid columns="320px 1fr" gap minHeight="900px">
|
||||
<PageList pages={pages} selected={urlPath} onSelect={onUrlPathChange} mode={mode} />
|
||||
<Column gap>
|
||||
<ModeToggle mode={mode} onChange={onModeChange} />
|
||||
<LoadingPanel data={pagesData} isLoading={isLoading} error={error} minHeight="900px">
|
||||
<Grid columns="320px 12px 1fr" minHeight="900px" className={styles.layoutGrid}>
|
||||
<PageList
|
||||
pages={filteredPages}
|
||||
selected={urlPath}
|
||||
onSelect={onUrlPathChange}
|
||||
mode={mode}
|
||||
hasSearch={Boolean(search)}
|
||||
/>
|
||||
<div className={styles.railDivider} aria-hidden="true" />
|
||||
<Column className={styles.contentColumn} gap>
|
||||
{urlPath ? (
|
||||
mode === 'scroll' ? (
|
||||
<ScrollHeatmapView
|
||||
websiteId={websiteId}
|
||||
urlPath={urlPath}
|
||||
scroll={scroll}
|
||||
snapshot={data?.snapshot ?? null}
|
||||
snapshot={snapshot}
|
||||
isLoading={detailLoading}
|
||||
/>
|
||||
) : (
|
||||
<HeatmapView
|
||||
urlPath={urlPath}
|
||||
websiteId={websiteId}
|
||||
points={points}
|
||||
snapshot={data?.snapshot ?? null}
|
||||
snapshot={snapshot}
|
||||
isLoading={detailLoading}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
@@ -85,61 +154,43 @@ export function Heatmap({ websiteId, urlPath, onUrlPathChange, mode, onModeChang
|
||||
);
|
||||
}
|
||||
|
||||
function ModeToggle({
|
||||
mode,
|
||||
onChange,
|
||||
}: {
|
||||
mode: HeatmapMode;
|
||||
onChange: (mode: HeatmapMode) => void;
|
||||
}) {
|
||||
return (
|
||||
<Row gap="2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange('click')}
|
||||
className={`${styles.toggleButton} ${mode === 'click' ? styles.toggleButtonSelected : ''}`}
|
||||
>
|
||||
Clicks
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange('scroll')}
|
||||
className={`${styles.toggleButton} ${mode === 'scroll' ? styles.toggleButtonSelected : ''}`}
|
||||
>
|
||||
Scroll
|
||||
</button>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
function PageList({
|
||||
pages,
|
||||
selected,
|
||||
onSelect,
|
||||
mode,
|
||||
hasSearch,
|
||||
}: {
|
||||
pages: HeatmapResult['pages'];
|
||||
selected: string;
|
||||
onSelect: (urlPath: string) => void;
|
||||
mode: HeatmapMode;
|
||||
hasSearch: boolean;
|
||||
}) {
|
||||
const { t, messages } = useMessages();
|
||||
|
||||
return (
|
||||
<Column className={styles.pageList} gap="2">
|
||||
<Column className={styles.pageList} gap="1">
|
||||
<Heading size="lg">Pages</Heading>
|
||||
{pages.length === 0 && <Text color="muted">No data yet</Text>}
|
||||
{pages.map(p => (
|
||||
<button
|
||||
key={p.urlPath}
|
||||
type="button"
|
||||
onClick={() => onSelect(p.urlPath)}
|
||||
className={`${styles.pageButton} ${selected === p.urlPath ? styles.pageButtonSelected : ''}`}
|
||||
>
|
||||
<Row alignItems="center" justifyContent="space-between" gap="2">
|
||||
<Text truncate>{p.urlPath}</Text>
|
||||
<Text color="muted">{formatLongNumber(mode === 'scroll' ? p.sessions : p.count)}</Text>
|
||||
</Row>
|
||||
</button>
|
||||
))}
|
||||
<Column className={styles.pageListItems} gap="2">
|
||||
{pages.length === 0 && (
|
||||
<Text color="muted">{hasSearch ? 'No matching pages' : t(messages.noDataAvailable)}</Text>
|
||||
)}
|
||||
{pages.map(p => (
|
||||
<button
|
||||
key={p.urlPath}
|
||||
type="button"
|
||||
onClick={() => onSelect(p.urlPath)}
|
||||
title={p.urlPath}
|
||||
className={`${styles.pageButton} ${selected === p.urlPath ? styles.pageButtonSelected : ''}`}
|
||||
>
|
||||
<Row alignItems="center" justifyContent="space-between" gap="2">
|
||||
<Text truncate>{p.urlPath}</Text>
|
||||
<Text color="muted">{formatLongNumber(mode === 'scroll' ? p.sessions : p.count)}</Text>
|
||||
</Row>
|
||||
</button>
|
||||
))}
|
||||
</Column>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
@@ -164,13 +215,17 @@ function pickViewport(points: HeatmapPoint[]): ViewportBucket | null {
|
||||
}
|
||||
|
||||
function HeatmapView({
|
||||
urlPath,
|
||||
websiteId,
|
||||
points,
|
||||
snapshot,
|
||||
isLoading,
|
||||
}: {
|
||||
urlPath: string;
|
||||
websiteId: string;
|
||||
points: HeatmapPoint[];
|
||||
snapshot: HeatmapSnapshot | null;
|
||||
isLoading: boolean;
|
||||
}) {
|
||||
const [showPage, setShowPage] = useState(true);
|
||||
const [snapshotReady, setSnapshotReady] = useState(false);
|
||||
@@ -194,51 +249,55 @@ function HeatmapView({
|
||||
setSnapshotReady(!(showPage && snapshot));
|
||||
}, [containerWidth, showPage, snapshot]);
|
||||
|
||||
if (isLoading) {
|
||||
return <CanvasLoading />;
|
||||
}
|
||||
|
||||
if (!viewport || visible.length === 0) {
|
||||
return <EmptyState />;
|
||||
}
|
||||
|
||||
const renderWidth = containerWidth > 0 ? Math.min(containerWidth, MAX_RENDER_WIDTH) : 0;
|
||||
const scale = renderWidth ? renderWidth / viewport.width : 0;
|
||||
const renderWidth = Math.min(containerWidth > 0 ? containerWidth : viewport.width, MAX_RENDER_WIDTH);
|
||||
const scale = renderWidth / viewport.width;
|
||||
const renderHeight = Math.round(viewport.height * scale);
|
||||
const showSnapshot = renderWidth > 0 && showPage && !!snapshot;
|
||||
const showOverlay = !showSnapshot || snapshotReady;
|
||||
|
||||
return (
|
||||
<Column gap>
|
||||
<Row alignItems="center" justifyContent="space-between" gap>
|
||||
<Text color="muted">
|
||||
<Column gap="2" className={styles.summaryHeader}>
|
||||
<Row alignItems="center" justifyContent="space-between" gap>
|
||||
<Text color="muted" title={urlPath} className={styles.summaryPath}>
|
||||
{urlPath}
|
||||
</Text>
|
||||
</Row>
|
||||
<Row alignItems="center" justifyContent="space-between" gap className={styles.summaryStats}>
|
||||
<Text color="muted" className={styles.summaryStat}>
|
||||
{visible.length} positions · {formatLongNumber(visible.reduce((s, p) => s + p.count, 0))}{' '}
|
||||
clicks · viewport {viewport.width}×{viewport.height}
|
||||
</Text>
|
||||
{snapshot && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.toggleButton}
|
||||
onClick={() => setShowPage(v => !v)}
|
||||
>
|
||||
{showPage ? 'Hide page' : 'Show page'}
|
||||
</button>
|
||||
)}
|
||||
</Row>
|
||||
</Text>
|
||||
</Row>
|
||||
</Column>
|
||||
<div ref={containerRef} className={styles.canvasWrapper}>
|
||||
<div
|
||||
className={styles.canvas}
|
||||
style={{ width: renderWidth || '100%', height: renderHeight || 0 }}
|
||||
>
|
||||
{showSnapshot && !snapshotReady && <CanvasLoading />}
|
||||
{showSnapshot && (
|
||||
<ReplaySnapshot
|
||||
websiteId={websiteId}
|
||||
snapshot={snapshot}
|
||||
width={viewport.width}
|
||||
height={viewport.height}
|
||||
scale={scale}
|
||||
onReady={handleSnapshotReady}
|
||||
/>
|
||||
)}
|
||||
<div className={styles.canvasClip}>
|
||||
{showSnapshot && !snapshotReady && <CanvasLoading />}
|
||||
{showSnapshot && (
|
||||
<ReplaySnapshot
|
||||
websiteId={websiteId}
|
||||
snapshot={snapshot}
|
||||
width={viewport.width}
|
||||
height={viewport.height}
|
||||
scale={scale}
|
||||
onReady={handleSnapshotReady}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{showOverlay && (
|
||||
<div className={styles.overlay}>
|
||||
<div className={`${styles.overlay} ${styles.heatOverlay}`}>
|
||||
{visible.map((p, i) => {
|
||||
const intensity = Math.min(1, p.count / maxCount);
|
||||
const size = 24 + intensity * 36;
|
||||
@@ -261,18 +320,29 @@ function HeatmapView({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{snapshot && (
|
||||
<Row justifyContent="center" className={styles.snapshotControlRow}>
|
||||
<Switch isSelected={showPage} onChange={setShowPage}>
|
||||
Show page
|
||||
</Switch>
|
||||
</Row>
|
||||
)}
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
function ScrollHeatmapView({
|
||||
urlPath,
|
||||
websiteId,
|
||||
scroll,
|
||||
snapshot,
|
||||
isLoading,
|
||||
}: {
|
||||
urlPath: string;
|
||||
websiteId: string;
|
||||
scroll: HeatmapResult['scroll'] | undefined;
|
||||
snapshot: HeatmapSnapshot | null;
|
||||
isLoading: boolean;
|
||||
}) {
|
||||
const [showPage, setShowPage] = useState(true);
|
||||
const [snapshotReady, setSnapshotReady] = useState(false);
|
||||
@@ -284,13 +354,17 @@ function ScrollHeatmapView({
|
||||
setSnapshotReady(!(showPage && snapshot));
|
||||
}, [containerWidth, showPage, snapshot]);
|
||||
|
||||
if (isLoading) {
|
||||
return <CanvasLoading />;
|
||||
}
|
||||
|
||||
if (!scroll || scroll.totalSessions === 0 || !scroll.pageH || !scroll.viewportW) {
|
||||
return <EmptyState message="No scroll data for this page yet." />;
|
||||
}
|
||||
|
||||
const { buckets, totalSessions, pageH, viewportW, viewportH } = scroll;
|
||||
const renderWidth = containerWidth > 0 ? Math.min(containerWidth, MAX_RENDER_WIDTH) : 0;
|
||||
const scale = renderWidth ? renderWidth / viewportW : 0;
|
||||
const renderWidth = Math.min(containerWidth > 0 ? containerWidth : viewportW, MAX_RENDER_WIDTH);
|
||||
const scale = renderWidth / viewportW;
|
||||
const renderHeight = Math.round(pageH * scale);
|
||||
const showSnapshot = renderWidth > 0 && showPage && !!snapshot;
|
||||
const showOverlay = !showSnapshot || snapshotReady;
|
||||
@@ -323,39 +397,34 @@ function ScrollHeatmapView({
|
||||
|
||||
return (
|
||||
<Column gap>
|
||||
<Row alignItems="center" justifyContent="space-between" gap>
|
||||
<Text color="muted">
|
||||
<Text color="muted" title={urlPath} className={styles.summaryPath}>
|
||||
{urlPath}
|
||||
</Text>
|
||||
<Row alignItems="center" justifyContent="space-between" gap className={styles.summaryHeader}>
|
||||
<Text color="muted" className={styles.summaryStat}>
|
||||
{formatLongNumber(totalSessions)} sessions · page {viewportW}×{pageH}
|
||||
{viewportH ? ` · viewport ${viewportH}` : ''}
|
||||
</Text>
|
||||
{snapshot && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.toggleButton}
|
||||
onClick={() => setShowPage(v => !v)}
|
||||
>
|
||||
{showPage ? 'Hide page' : 'Show page'}
|
||||
</button>
|
||||
)}
|
||||
</Row>
|
||||
<div ref={containerRef} className={styles.canvasWrapper}>
|
||||
<div
|
||||
className={styles.canvas}
|
||||
style={{ width: renderWidth || '100%', height: renderHeight || 0 }}
|
||||
>
|
||||
{showSnapshot && !snapshotReady && <CanvasLoading />}
|
||||
{showSnapshot && (
|
||||
<ReplaySnapshot
|
||||
websiteId={websiteId}
|
||||
snapshot={snapshot}
|
||||
width={viewportW}
|
||||
height={pageH}
|
||||
scale={scale}
|
||||
onReady={handleSnapshotReady}
|
||||
/>
|
||||
)}
|
||||
{showOverlay && (
|
||||
<div className={styles.overlay}>
|
||||
<div className={styles.canvasClip}>
|
||||
{showSnapshot && !snapshotReady && <CanvasLoading />}
|
||||
{showSnapshot && (
|
||||
<ReplaySnapshot
|
||||
websiteId={websiteId}
|
||||
snapshot={snapshot}
|
||||
width={viewportW}
|
||||
height={pageH}
|
||||
scale={scale}
|
||||
onReady={handleSnapshotReady}
|
||||
/>
|
||||
)}
|
||||
{showOverlay && (
|
||||
<div className={styles.overlay}>
|
||||
{bands.map(b => {
|
||||
const top = Math.round((b.fromPct / 100) * renderHeight);
|
||||
const bottom = Math.round((b.toPct / 100) * renderHeight);
|
||||
@@ -379,10 +448,18 @@ function ScrollHeatmapView({
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{snapshot && (
|
||||
<Row justifyContent="center" className={styles.snapshotControlRow}>
|
||||
<Switch isSelected={showPage} onChange={setShowPage}>
|
||||
Show page
|
||||
</Switch>
|
||||
</Row>
|
||||
)}
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
@@ -403,8 +480,12 @@ function ReplaySnapshot({
|
||||
onReady: () => void;
|
||||
}) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const replayerRef = useRef<any>(null);
|
||||
const { data } = useReplayQuery(websiteId, snapshot.replayId) as { data?: ReplayData };
|
||||
const replayerRef = useRef<ReplayInstance | null>(null);
|
||||
const { data } = useReplayQuery(websiteId, snapshot.replayId, {
|
||||
until: snapshot.timestamp,
|
||||
chunkIndex: snapshot.chunkIndex,
|
||||
eventIndex: snapshot.eventIndex,
|
||||
}) as { data?: ReplayData };
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
@@ -425,34 +506,72 @@ function ReplaySnapshot({
|
||||
triggerFocus: false,
|
||||
pauseAnimation: true,
|
||||
useVirtualDom: false,
|
||||
});
|
||||
loadTimeout: 3000,
|
||||
}) as ReplayInstance;
|
||||
|
||||
replayerRef.current = replayer;
|
||||
let ready = false;
|
||||
let rebuilt = false;
|
||||
let waitingForStyles = false;
|
||||
let settled = false;
|
||||
|
||||
const freeze = () => {
|
||||
const offset = Math.max(0, snapshot.timestamp - events[0].timestamp);
|
||||
resizeReplayFrame(replayer, width, height);
|
||||
replayer.pause(offset);
|
||||
replayer.disableInteract();
|
||||
resizeReplayFrame(replayer, width, height);
|
||||
};
|
||||
|
||||
const handleReady = () => {
|
||||
if (ready || cancelled) return;
|
||||
ready = true;
|
||||
const finalize = async () => {
|
||||
if (settled || waitingForStyles || !rebuilt || cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (cancelled) return;
|
||||
freeze();
|
||||
requestAnimationFrame(() => {
|
||||
if (cancelled) return;
|
||||
freeze();
|
||||
onReady();
|
||||
});
|
||||
});
|
||||
settled = true;
|
||||
|
||||
await waitForReplayLayout(replayer);
|
||||
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
freeze();
|
||||
await waitForAnimationFrames(2);
|
||||
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
freeze();
|
||||
onReady();
|
||||
};
|
||||
|
||||
replayer.on('fullsnapshot-rebuilded', handleReady);
|
||||
replayer.on('load-stylesheet-start', () => {
|
||||
waitingForStyles = true;
|
||||
});
|
||||
|
||||
replayer.on('load-stylesheet-end', () => {
|
||||
waitingForStyles = false;
|
||||
void finalize();
|
||||
});
|
||||
|
||||
replayer.on('fullsnapshot-rebuilded', () => {
|
||||
rebuilt = true;
|
||||
void finalize();
|
||||
});
|
||||
|
||||
replayer.on('resize', (dimension: { width?: number; height?: number }) => {
|
||||
resizeReplayFrame(
|
||||
replayer,
|
||||
dimension.width && Number.isFinite(dimension.width) ? dimension.width : width,
|
||||
dimension.height && Number.isFinite(dimension.height) ? dimension.height : height,
|
||||
);
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
waitingForStyles = false;
|
||||
void finalize();
|
||||
}, 3500);
|
||||
});
|
||||
|
||||
return () => {
|
||||
@@ -490,12 +609,59 @@ function CanvasLoading() {
|
||||
);
|
||||
}
|
||||
|
||||
function resizeReplayFrame(replayer: any, width: number, height: number) {
|
||||
function waitForAnimationFrames(count = 2) {
|
||||
return new Promise<void>(resolve => {
|
||||
const step = (remaining: number) => {
|
||||
if (remaining <= 0) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => step(remaining - 1));
|
||||
};
|
||||
|
||||
step(count);
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForReplayLayout(replayer: ReplayInstance) {
|
||||
const fonts = replayer.iframe?.contentDocument?.fonts;
|
||||
|
||||
if (fonts?.ready) {
|
||||
try {
|
||||
await Promise.race([
|
||||
fonts.ready.then(() => undefined),
|
||||
new Promise(resolve => setTimeout(resolve, 1500)),
|
||||
]);
|
||||
} catch {
|
||||
// Ignore font readiness failures and fall back to frame settling.
|
||||
}
|
||||
}
|
||||
|
||||
await waitForAnimationFrames(3);
|
||||
}
|
||||
|
||||
function syncReplayDocumentViewport(replayer: ReplayInstance, width: number, height: number) {
|
||||
const doc = replayer.iframe?.contentDocument;
|
||||
const html = doc?.documentElement;
|
||||
const body = doc?.body;
|
||||
|
||||
if (html) {
|
||||
html.style.margin = '0';
|
||||
}
|
||||
|
||||
if (body) {
|
||||
body.style.margin = '0';
|
||||
}
|
||||
}
|
||||
|
||||
function resizeReplayFrame(replayer: ReplayInstance, width: number, height: number) {
|
||||
const { iframe, wrapper } = replayer;
|
||||
|
||||
if (wrapper) {
|
||||
wrapper.style.width = `${width}px`;
|
||||
wrapper.style.height = `${height}px`;
|
||||
wrapper.style.overflow = 'hidden';
|
||||
}
|
||||
|
||||
if (iframe) {
|
||||
@@ -503,12 +669,15 @@ function resizeReplayFrame(replayer: any, width: number, height: number) {
|
||||
iframe.setAttribute('height', String(height));
|
||||
iframe.style.width = `${width}px`;
|
||||
iframe.style.height = `${height}px`;
|
||||
iframe.style.display = 'block';
|
||||
}
|
||||
|
||||
syncReplayDocumentViewport(replayer, width, height);
|
||||
}
|
||||
|
||||
function EmptyState({ message }: { message?: string } = {}) {
|
||||
return (
|
||||
<Column alignItems="center" justifyContent="center" height="100%" gap>
|
||||
<Column alignItems="center" justifyContent="center" minHeight="360px" gap>
|
||||
<Heading size="lg">{message ? 'No data' : 'Select a page'}</Heading>
|
||||
<Text color="muted">{message ?? 'Choose a page from the list to view its heatmap.'}</Text>
|
||||
</Column>
|
||||
|
||||
@@ -1,26 +1,69 @@
|
||||
'use client';
|
||||
import { Column } from '@umami/react-zen';
|
||||
import { Column, Row, SearchField } from '@umami/react-zen';
|
||||
import { useState } from 'react';
|
||||
import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
|
||||
import { Panel } from '@/components/common/Panel';
|
||||
import { useMobile } from '@/components/hooks';
|
||||
import { FilterButtons } from '@/components/input/FilterButtons';
|
||||
import type { HeatmapMode } from '@/queries/sql';
|
||||
import { Heatmap } from './Heatmap';
|
||||
import styles from './Heatmap.module.css';
|
||||
|
||||
export function HeatmapsPage({ websiteId }: { websiteId: string }) {
|
||||
const [urlPath, setUrlPath] = useState<string>('');
|
||||
const [urlPathByMode, setUrlPathByMode] = useState<Record<HeatmapMode, string>>({
|
||||
click: '',
|
||||
scroll: '',
|
||||
});
|
||||
const [mode, setMode] = useState<HeatmapMode>('click');
|
||||
const [search, setSearch] = useState('');
|
||||
const { isPhone } = useMobile();
|
||||
|
||||
const buttons = [
|
||||
{ id: 'click', label: 'Clicks' },
|
||||
{ id: 'scroll', label: 'Scroll' },
|
||||
];
|
||||
|
||||
return (
|
||||
<Column gap>
|
||||
<WebsiteControls websiteId={websiteId} />
|
||||
<Panel minHeight="900px" allowFullscreen>
|
||||
<Heatmap
|
||||
websiteId={websiteId}
|
||||
urlPath={urlPath}
|
||||
onUrlPathChange={setUrlPath}
|
||||
mode={mode}
|
||||
onModeChange={setMode}
|
||||
/>
|
||||
<Panel minHeight="900px" allowFullscreen minWidth="0" width="100%" style={{ overflow: 'hidden' }}>
|
||||
<Column gap="5" minWidth="0" width="100%" paddingTop="2">
|
||||
<Column gap="4" minWidth="0" width="100%">
|
||||
{isPhone ? (
|
||||
<Column gap="3">
|
||||
<Row>
|
||||
<SearchField value={search} onSearch={setSearch} placeholder="Search" />
|
||||
</Row>
|
||||
<Row justifyContent="flex-end">
|
||||
<FilterButtons
|
||||
items={buttons}
|
||||
value={mode}
|
||||
onChange={value => setMode(value as HeatmapMode)}
|
||||
/>
|
||||
</Row>
|
||||
</Column>
|
||||
) : (
|
||||
<Row alignItems="center" justifyContent="space-between" gap="4">
|
||||
<SearchField value={search} onSearch={setSearch} placeholder="Search" />
|
||||
<FilterButtons
|
||||
items={buttons}
|
||||
value={mode}
|
||||
onChange={value => setMode(value as HeatmapMode)}
|
||||
/>
|
||||
</Row>
|
||||
)}
|
||||
</Column>
|
||||
|
||||
<Heatmap
|
||||
websiteId={websiteId}
|
||||
urlPath={urlPathByMode[mode]}
|
||||
onUrlPathChange={urlPath =>
|
||||
setUrlPathByMode(state => ({ ...state, [mode]: urlPath }))
|
||||
}
|
||||
mode={mode}
|
||||
search={search}
|
||||
/>
|
||||
</Column>
|
||||
</Panel>
|
||||
</Column>
|
||||
);
|
||||
|
||||
@@ -116,7 +116,7 @@ export async function POST(request: Request) {
|
||||
});
|
||||
|
||||
try {
|
||||
const heatmapRows = extractHeatmapEvents(events).map(e => ({
|
||||
const heatmapRows = extractHeatmapEvents(events, { chunkIndex }).map(e => ({
|
||||
websiteId,
|
||||
sessionId,
|
||||
visitId,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { parseRequest, setWebsiteDate } from '@/lib/request';
|
||||
import { getQueryFilters, parseRequest } from '@/lib/request';
|
||||
import { json, unauthorized } from '@/lib/response';
|
||||
import { reportResultSchema } from '@/lib/schema';
|
||||
import { canViewWebsite } from '@/permissions';
|
||||
@@ -17,7 +17,22 @@ export async function POST(request: Request) {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
const parameters = (await setWebsiteDate(websiteId, body.parameters)) as HeatmapParameters;
|
||||
const filters = await getQueryFilters(
|
||||
{
|
||||
...body.filters,
|
||||
startAt: body.parameters.startDate.getTime(),
|
||||
endAt: body.parameters.endDate.getTime(),
|
||||
timezone: body.parameters.timezone,
|
||||
unit: body.parameters.unit,
|
||||
},
|
||||
websiteId,
|
||||
);
|
||||
|
||||
const parameters = {
|
||||
...filters,
|
||||
urlPath: body.parameters.urlPath,
|
||||
mode: body.parameters.mode,
|
||||
} as HeatmapParameters;
|
||||
|
||||
const data = await getHeatmap(websiteId, parameters);
|
||||
|
||||
|
||||
@@ -3,6 +3,72 @@ import { json, unauthorized } from '@/lib/response';
|
||||
import { canViewWebsite } from '@/permissions';
|
||||
import { getReplayChunks } from '@/queries/sql';
|
||||
|
||||
function getEventTimestamp(event: any): number | null {
|
||||
const timestamp = Number(event?.timestamp);
|
||||
|
||||
return Number.isFinite(timestamp) ? timestamp : null;
|
||||
}
|
||||
|
||||
function parseOptionalInteger(value: string | null): number | undefined {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const parsed = Number(value);
|
||||
|
||||
return Number.isInteger(parsed) ? parsed : undefined;
|
||||
}
|
||||
|
||||
function mergeReplayEvents(
|
||||
chunks: Awaited<ReturnType<typeof getReplayChunks>>,
|
||||
{
|
||||
until,
|
||||
endChunkIndex,
|
||||
endEventIndex,
|
||||
}: { until?: number; endChunkIndex?: number; endEventIndex?: number },
|
||||
) {
|
||||
const events: any[] = [];
|
||||
let isSorted = true;
|
||||
let lastTimestamp = -Infinity;
|
||||
|
||||
for (const chunk of chunks) {
|
||||
if (endChunkIndex !== undefined && chunk.chunkIndex > endChunkIndex) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (let chunkEventIndex = 0; chunkEventIndex < chunk.events.length; chunkEventIndex++) {
|
||||
const event = chunk.events[chunkEventIndex];
|
||||
const timestamp = getEventTimestamp(event);
|
||||
|
||||
if (chunk.chunkIndex === endChunkIndex && endEventIndex !== undefined) {
|
||||
if (chunkEventIndex > endEventIndex) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (until !== undefined && timestamp !== null && timestamp > until) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (timestamp !== null) {
|
||||
if (timestamp < lastTimestamp) {
|
||||
isSorted = false;
|
||||
} else {
|
||||
lastTimestamp = timestamp;
|
||||
}
|
||||
}
|
||||
|
||||
events.push(event);
|
||||
}
|
||||
}
|
||||
|
||||
if (!isSorted) {
|
||||
events.sort((a, b) => (getEventTimestamp(a) ?? 0) - (getEventTimestamp(b) ?? 0));
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ websiteId: string; replayId: string }> },
|
||||
@@ -14,16 +80,18 @@ export async function GET(
|
||||
}
|
||||
|
||||
const { websiteId, replayId } = await params;
|
||||
const searchParams = new URL(request.url).searchParams;
|
||||
const until = parseOptionalInteger(searchParams.get('until'));
|
||||
const endChunkIndex = parseOptionalInteger(searchParams.get('chunkIndex'));
|
||||
const endEventIndex = parseOptionalInteger(searchParams.get('eventIndex'));
|
||||
const endAt = until !== undefined ? new Date(until) : undefined;
|
||||
|
||||
if (!(await canViewWebsite(auth, websiteId))) {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
const chunks = await getReplayChunks(websiteId, replayId);
|
||||
|
||||
const allEvents = chunks
|
||||
.flatMap(chunk => chunk.events)
|
||||
.sort((a, b) => a.timestamp - b.timestamp);
|
||||
const chunks = await getReplayChunks(websiteId, replayId, { endAt, endChunkIndex });
|
||||
const allEvents = mergeReplayEvents(chunks, { until, endChunkIndex, endEventIndex });
|
||||
const sessionId = chunks.length > 0 ? chunks[0].sessionId : null;
|
||||
const startedAt = chunks.length > 0 ? chunks[0].startedAt : null;
|
||||
const endedAt = chunks.length > 0 ? chunks[chunks.length - 1].endedAt : null;
|
||||
|
||||
@@ -1,12 +1,23 @@
|
||||
import { useApi } from '../useApi';
|
||||
|
||||
export function useReplayQuery(websiteId: string, replayId: string) {
|
||||
interface ReplayQueryOptions {
|
||||
until?: number;
|
||||
chunkIndex?: number;
|
||||
eventIndex?: number;
|
||||
}
|
||||
|
||||
export function useReplayQuery(
|
||||
websiteId: string,
|
||||
replayId: string,
|
||||
options: ReplayQueryOptions = {},
|
||||
) {
|
||||
const { get, useQuery } = useApi();
|
||||
const { until, chunkIndex, eventIndex } = options;
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['replay', { websiteId, replayId }],
|
||||
queryKey: ['replay', { websiteId, replayId, until, chunkIndex, eventIndex }],
|
||||
queryFn: () => {
|
||||
return get(`/websites/${websiteId}/replays/${replayId}`);
|
||||
return get(`/websites/${websiteId}/replays/${replayId}`, { until, chunkIndex, eventIndex });
|
||||
},
|
||||
enabled: Boolean(websiteId && replayId),
|
||||
});
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Box, ToggleGroup, ToggleGroupItem } from '@umami/react-zen';
|
||||
import { useState } from 'react';
|
||||
|
||||
export interface FilterButtonsProps {
|
||||
items: { id: string; label: string }[];
|
||||
@@ -8,18 +7,17 @@ export interface FilterButtonsProps {
|
||||
}
|
||||
|
||||
export function FilterButtons({ items, value, onChange }: FilterButtonsProps) {
|
||||
const [selected, setSelected] = useState(value);
|
||||
|
||||
const handleChange = (value: string) => {
|
||||
setSelected(value);
|
||||
onChange?.(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<ToggleGroup
|
||||
value={[selected]}
|
||||
onChange={e => handleChange(e[0])}
|
||||
value={[value]}
|
||||
onChange={e => {
|
||||
const nextValue = e[0];
|
||||
|
||||
if (nextValue) {
|
||||
onChange?.(nextValue);
|
||||
}
|
||||
}}
|
||||
disallowEmptySelection={true}
|
||||
>
|
||||
{items.map(({ id, label }) => (
|
||||
|
||||
@@ -28,7 +28,10 @@ export function FilterEditForm({ websiteId, onChange, onClose }: FilterEditFormP
|
||||
const [currentMatch, setCurrentMatch] = useState<string>(match || 'all');
|
||||
const { isMobile } = useMobile();
|
||||
const isPixelLink = !websiteId || pathname.includes('/pixels') || pathname.includes('/links');
|
||||
const excludeEvent = !pathname.endsWith('/events') && !pathname.endsWith('/replays');
|
||||
const excludeEvent =
|
||||
!pathname.endsWith('/events') &&
|
||||
!pathname.endsWith('/replays') &&
|
||||
!pathname.endsWith('/heatmaps');
|
||||
const isPerformance = pathname.includes('/performance');
|
||||
|
||||
const excludedFields = isPixelLink
|
||||
|
||||
+1
-1
@@ -315,7 +315,7 @@ export const reportSchema = reportBaseSchema;
|
||||
export const reportResultSchema = z.intersection(
|
||||
z.object({
|
||||
websiteId: z.uuid(),
|
||||
filters: z.object({ ...filterParams }),
|
||||
filters: z.object({ ...filterParams }).passthrough(),
|
||||
}),
|
||||
reportTypeSchema,
|
||||
);
|
||||
|
||||
@@ -18,6 +18,13 @@ export interface ExtractedHeatmapEvent {
|
||||
scrollPct: number | null;
|
||||
urlPath: string;
|
||||
createdAt: Date;
|
||||
replayChunkIndex: number | null;
|
||||
replayEventIndex: number | null;
|
||||
replayTimeMs: number | null;
|
||||
}
|
||||
|
||||
interface ExtractHeatmapEventOptions {
|
||||
chunkIndex?: number;
|
||||
}
|
||||
|
||||
function safePathname(href: unknown): string | null {
|
||||
@@ -29,7 +36,10 @@ function safePathname(href: unknown): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
export function extractHeatmapEvents(events: any[]): ExtractedHeatmapEvent[] {
|
||||
export function extractHeatmapEvents(
|
||||
events: any[],
|
||||
{ chunkIndex }: ExtractHeatmapEventOptions = {},
|
||||
): ExtractedHeatmapEvent[] {
|
||||
if (!Array.isArray(events) || events.length === 0) return [];
|
||||
|
||||
let urlPath: string | null = null;
|
||||
@@ -37,8 +47,10 @@ export function extractHeatmapEvents(events: any[]): ExtractedHeatmapEvent[] {
|
||||
let viewportH: number | null = null;
|
||||
const out: ExtractedHeatmapEvent[] = [];
|
||||
|
||||
for (const ev of events) {
|
||||
for (const [eventIndex, ev] of events.entries()) {
|
||||
if (!ev || typeof ev !== 'object') continue;
|
||||
const replayTimeMs =
|
||||
typeof ev.timestamp === 'number' && Number.isFinite(ev.timestamp) ? ev.timestamp : null;
|
||||
|
||||
if (ev.type === RRWEB_TYPE_META && ev.data) {
|
||||
const path = safePathname(ev.data.href);
|
||||
@@ -72,7 +84,10 @@ export function extractHeatmapEvents(events: any[]): ExtractedHeatmapEvent[] {
|
||||
? Math.max(0, Math.min(100, Math.round(p.scrollPct)))
|
||||
: null,
|
||||
urlPath: path,
|
||||
createdAt: new Date(typeof ev.timestamp === 'number' ? ev.timestamp : Date.now()),
|
||||
createdAt: new Date(replayTimeMs ?? Date.now()),
|
||||
replayChunkIndex: chunkIndex ?? null,
|
||||
replayEventIndex: eventIndex,
|
||||
replayTimeMs,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
@@ -103,7 +118,10 @@ export function extractHeatmapEvents(events: any[]): ExtractedHeatmapEvent[] {
|
||||
pageH: null,
|
||||
scrollPct: null,
|
||||
urlPath,
|
||||
createdAt: new Date(typeof ev.timestamp === 'number' ? ev.timestamp : Date.now()),
|
||||
createdAt: new Date(replayTimeMs ?? Date.now()),
|
||||
replayChunkIndex: chunkIndex ?? null,
|
||||
replayEventIndex: eventIndex,
|
||||
replayTimeMs,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -18,6 +18,9 @@ export interface HeatmapEventRow {
|
||||
pageH: number | null;
|
||||
scrollPct: number | null;
|
||||
createdAt: Date;
|
||||
replayChunkIndex: number | null;
|
||||
replayEventIndex: number | null;
|
||||
replayTimeMs: number | null;
|
||||
}
|
||||
|
||||
export async function saveHeatmapEvents(rows: HeatmapEventRow[]) {
|
||||
@@ -46,7 +49,10 @@ async function relationalQuery(rows: HeatmapEventRow[]) {
|
||||
pageH: r.pageH,
|
||||
scrollPct: r.scrollPct,
|
||||
createdAt: r.createdAt,
|
||||
})),
|
||||
replayChunkIndex: r.replayChunkIndex,
|
||||
replayEventIndex: r.replayEventIndex,
|
||||
replayTimeMs: r.replayTimeMs,
|
||||
})) as any,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -69,6 +75,9 @@ async function clickhouseQuery(rows: HeatmapEventRow[]) {
|
||||
page_h: r.pageH,
|
||||
scroll_pct: r.scrollPct,
|
||||
created_at: getUTCString(r.createdAt),
|
||||
replay_chunk_index: r.replayChunkIndex,
|
||||
replay_event_index: r.replayEventIndex,
|
||||
replay_time_ms: r.replayTimeMs,
|
||||
}));
|
||||
|
||||
if (kafka.enabled) {
|
||||
|
||||
@@ -15,15 +15,39 @@ export interface ReplayChunk {
|
||||
endedAt: Date;
|
||||
}
|
||||
|
||||
export async function getReplayChunks(websiteId: string, visitId: string): Promise<ReplayChunk[]> {
|
||||
interface GetReplayChunksOptions {
|
||||
endAt?: Date;
|
||||
endChunkIndex?: number;
|
||||
}
|
||||
|
||||
export async function getReplayChunks(
|
||||
websiteId: string,
|
||||
visitId: string,
|
||||
options: GetReplayChunksOptions = {},
|
||||
): Promise<ReplayChunk[]> {
|
||||
return runQuery({
|
||||
[PRISMA]: () => relationalQuery(websiteId, visitId),
|
||||
[CLICKHOUSE]: () => clickhouseQuery(websiteId, visitId),
|
||||
[PRISMA]: () => relationalQuery(websiteId, visitId, options),
|
||||
[CLICKHOUSE]: () => clickhouseQuery(websiteId, visitId, options),
|
||||
});
|
||||
}
|
||||
|
||||
async function relationalQuery(websiteId: string, visitId: string): Promise<ReplayChunk[]> {
|
||||
async function relationalQuery(
|
||||
websiteId: string,
|
||||
visitId: string,
|
||||
{ endAt, endChunkIndex }: GetReplayChunksOptions,
|
||||
): Promise<ReplayChunk[]> {
|
||||
const { rawQuery } = prisma;
|
||||
const endAtFilter = endAt
|
||||
? `
|
||||
and started_at <= {{endAt}}
|
||||
`
|
||||
: '';
|
||||
const endChunkFilter =
|
||||
endChunkIndex !== undefined
|
||||
? `
|
||||
and chunk_index <= {{endChunkIndex}}
|
||||
`
|
||||
: '';
|
||||
|
||||
const chunks: {
|
||||
sessionId: string;
|
||||
@@ -46,9 +70,11 @@ async function relationalQuery(websiteId: string, visitId: string): Promise<Repl
|
||||
from session_replay
|
||||
where website_id = {{websiteId::uuid}}
|
||||
and visit_id = {{visitId::uuid}}
|
||||
${endAtFilter}
|
||||
${endChunkFilter}
|
||||
order by chunk_index asc
|
||||
`,
|
||||
{ websiteId, visitId },
|
||||
{ websiteId, visitId, endAt, endChunkIndex },
|
||||
FUNCTION_NAME,
|
||||
);
|
||||
|
||||
@@ -58,8 +84,23 @@ async function relationalQuery(websiteId: string, visitId: string): Promise<Repl
|
||||
}));
|
||||
}
|
||||
|
||||
async function clickhouseQuery(websiteId: string, visitId: string): Promise<ReplayChunk[]> {
|
||||
async function clickhouseQuery(
|
||||
websiteId: string,
|
||||
visitId: string,
|
||||
{ endAt, endChunkIndex }: GetReplayChunksOptions,
|
||||
): Promise<ReplayChunk[]> {
|
||||
const { rawQuery } = clickhouse;
|
||||
const endAtFilter = endAt
|
||||
? `
|
||||
and started_at <= {endAt:DateTime64}
|
||||
`
|
||||
: '';
|
||||
const endChunkFilter =
|
||||
endChunkIndex !== undefined
|
||||
? `
|
||||
and chunk_index <= {endChunkIndex:UInt32}
|
||||
`
|
||||
: '';
|
||||
|
||||
const results = await rawQuery<
|
||||
{
|
||||
@@ -82,11 +123,13 @@ async function clickhouseQuery(websiteId: string, visitId: string): Promise<Repl
|
||||
started_at,
|
||||
ended_at
|
||||
from session_replay
|
||||
where website_id = {websiteId:UUID}
|
||||
prewhere website_id = {websiteId:UUID}
|
||||
and visit_id = {visitId:UUID}
|
||||
${endAtFilter}
|
||||
${endChunkFilter}
|
||||
order by chunk_index asc
|
||||
`,
|
||||
{ websiteId, visitId },
|
||||
{ websiteId, visitId, endAt, endChunkIndex },
|
||||
FUNCTION_NAME,
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user