diff --git a/db/clickhouse/migrations/12_add_heatmap.sql b/db/clickhouse/migrations/12_add_heatmap.sql index 1c983ab0d..a9253899b 100644 --- a/db/clickhouse/migrations/12_add_heatmap.sql +++ b/db/clickhouse/migrations/12_add_heatmap.sql @@ -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 diff --git a/db/clickhouse/schema.sql b/db/clickhouse/schema.sql index 0408d7885..0f7f84156 100644 --- a/db/clickhouse/schema.sql +++ b/db/clickhouse/schema.sql @@ -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 diff --git a/prisma/migrations/20_add_heatmap/migration.sql b/prisma/migrations/20_add_heatmap/migration.sql index c4d871305..6c73964af 100644 --- a/prisma/migrations/20_add_heatmap/migration.sql +++ b/prisma/migrations/20_add_heatmap/migration.sql @@ -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"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ced346e6d..bd6d851cf 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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") -} \ No newline at end of file +} diff --git a/src/app/(main)/websites/[websiteId]/(reports)/heatmaps/Heatmap.module.css b/src/app/(main)/websites/[websiteId]/(reports)/heatmaps/Heatmap.module.css index 269a52bba..33b1ca5b7 100644 --- a/src/app/(main)/websites/[websiteId]/(reports)/heatmaps/Heatmap.module.css +++ b/src/app/(main)/websites/[websiteId]/(reports)/heatmaps/Heatmap.module.css @@ -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 { diff --git a/src/app/(main)/websites/[websiteId]/(reports)/heatmaps/Heatmap.tsx b/src/app/(main)/websites/[websiteId]/(reports)/heatmaps/Heatmap.tsx index 3ebadf362..24bb63de4 100644 --- a/src/app/(main)/websites/[websiteId]/(reports)/heatmaps/Heatmap.tsx +++ b/src/app/(main)/websites/[websiteId]/(reports)/heatmaps/Heatmap.tsx @@ -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() { 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('heatmap', { +export function Heatmap({ websiteId, urlPath, onUrlPathChange, mode, search }: HeatmapProps) { + const { + data: pagesData, + error, + isLoading, + } = useResultQuery('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( + '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 ( - - - - - + + + +