Heatmaps bug fixes, update schema to match overlay, use react-zen component to match ui/ux, polish

This commit is contained in:
Francis Cao
2026-05-14 14:23:41 -07:00
parent a22274bbbc
commit cd20b3aafd
18 changed files with 1262 additions and 628 deletions
@@ -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
+3
View File
@@ -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
View File
@@ -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>
);
+1 -1
View File
@@ -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,
+17 -2
View File
@@ -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;
+14 -3
View File
@@ -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),
});
+8 -10
View File
@@ -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 }) => (
+4 -1
View File
@@ -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
View File
@@ -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
+10 -1
View File
@@ -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) {
+51 -8
View File
@@ -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,
);