diff --git a/next.config.ts b/next.config.ts index c9c717f94..922d128bd 100644 --- a/next.config.ts +++ b/next.config.ts @@ -44,7 +44,7 @@ const connectSrc = ["'self'", 'https:', apiUrlOrigin].filter(Boolean).join(' '); const contentSecurityPolicy = ` default-src 'self'; - img-src 'self' https: data:; + img-src 'self' https: data: blob:; script-src 'self' 'unsafe-eval' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; connect-src ${connectSrc}; diff --git a/package.json b/package.json index a95ce4aa6..bb0c77dda 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,7 @@ "@prisma/adapter-pg": "^7.8.0", "@prisma/client": "^7.8.0", "@prisma/extension-read-replicas": "^0.5.0", + "@playwright/test": "^1.60.0", "@svgr/cli": "^8.1.0", "@tanstack/react-query": "^5.100.10", "@umami/react-zen": "^0.245.0", @@ -122,7 +123,6 @@ "devDependencies": { "@biomejs/biome": "^2.4.15", "@netlify/plugin-nextjs": "^5.15.11", - "@playwright/test": "^1.60.0", "@rollup/plugin-alias": "^6.0.0", "@rollup/plugin-commonjs": "^29.0.2", "@rollup/plugin-json": "^6.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index da9dd0742..c4f918851 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: '@hello-pangea/dnd': specifier: ^18.0.1 version: 18.0.1(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@playwright/test': + specifier: ^1.60.0 + version: 1.60.0 '@prisma/adapter-pg': specifier: ^7.8.0 version: 7.8.0 @@ -213,9 +216,6 @@ importers: '@netlify/plugin-nextjs': specifier: ^5.15.11 version: 5.15.11 - '@playwright/test': - specifier: ^1.60.0 - version: 1.60.0 '@rollup/plugin-alias': specifier: ^6.0.0 version: 6.0.0(rollup@4.60.4) @@ -328,45 +328,6 @@ importers: specifier: ^4.1.6 version: 4.1.6(@types/node@25.8.0)(jsdom@29.1.1)(msw@2.14.6(@types/node@25.8.0)(typescript@6.0.3))(vite@8.0.11(@types/node@25.8.0)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.46.1)(tsx@4.22.1)) - dist: - dependencies: - chart.js: - specifier: ^4.5.0 - version: 4.5.1 - chartjs-adapter-date-fns: - specifier: ^3.0.0 - version: 3.0.0(chart.js@4.5.1)(date-fns@4.1.0) - colord: - specifier: ^2.9.2 - version: 2.9.3 - jsonwebtoken: - specifier: ^9.0.2 - version: 9.0.3 - lucide-react: - specifier: ^0.542.0 - version: 0.542.0(react@19.2.6) - pure-rand: - specifier: ^7.0.1 - version: 7.0.1 - react-simple-maps: - specifier: ^2.3.0 - version: 2.3.0(prop-types@15.8.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - react-use-measure: - specifier: ^2.0.4 - version: 2.1.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - react-window: - specifier: ^1.8.6 - version: 1.8.11(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - serialize-error: - specifier: ^12.0.0 - version: 12.0.0 - thenby: - specifier: ^1.3.4 - version: 1.4.1 - uuid: - specifier: ^11.1.0 - version: 11.1.1 - packages: '@adobe/css-tools@4.4.4': @@ -539,28 +500,24 @@ packages: engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] - libc: [musl] '@biomejs/cli-linux-arm64@2.4.15': resolution: {integrity: sha512-owaAMZD/T4LrD0ELNCk0Km3qrRHuM0X6EAyVE1FSqGY0rbLoiDLrO4Us2tllm6cAeB2Ioa9C2C08NZPdr8+0Ug==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] - libc: [glibc] '@biomejs/cli-linux-x64-musl@2.4.15': resolution: {integrity: sha512-CNq/9W38SYSH023lfcQ4KKU8K0YX8T//FZUhcgtMMRABDojx5XsMV7jlweAvGSl389wJQB29Qo6Zb/a+jdvt+w==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] - libc: [musl] '@biomejs/cli-linux-x64@2.4.15': resolution: {integrity: sha512-0jj7THz12GbUOLmMibktK6DZjqz2zV64KFxyBtcFTKPiiOIY0a7vns1elpO1dERvxpsZ5ik0oFfz0oGwFde1+g==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] - libc: [glibc] '@biomejs/cli-win32-arm64@2.4.15': resolution: {integrity: sha512-ouhkYdlhp/1GghEJPdWwD/Vi3gQ1nFxuSpMolWsbq3Lsq3QUR4jl6UdhhscdCugKU5vOEuMiJhvKj66O0OCq+w==} @@ -1541,105 +1498,89 @@ packages: resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-s390x@1.2.4': resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] - libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] - libc: [musl] '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] - libc: [glibc] '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] - libc: [glibc] '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] - libc: [glibc] '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [riscv64] os: [linux] - libc: [glibc] '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] - libc: [glibc] '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] - libc: [glibc] '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] - libc: [musl] '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] - libc: [musl] '@img/sharp-wasm32@0.34.5': resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} @@ -1781,28 +1722,24 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [glibc] '@next/swc-linux-arm64-musl@16.2.6': resolution: {integrity: sha512-URUTu1+dMkxJsPFgm+OeEvq9wf5sujw0EvgYy80TDGHTSLTnIHeqb0Eu8A3sC95IRgjejQL+kC4mw+4yPxiAXA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [musl] '@next/swc-linux-x64-gnu@16.2.6': resolution: {integrity: sha512-DOj182mPV8G3UkrayLoREM5YEYI+Dk5wv7Ox9xl1fFibAELEsFD0lDPfHIeILlutMMfdyhlzYPELG3peuKaurw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [glibc] '@next/swc-linux-x64-musl@16.2.6': resolution: {integrity: sha512-HKQ5SP/V/ub73UvF7n/zeJlxk2kLmtL7Wzrg4WfmkjmNos5onJ2tKu7yZOPdL18A6Svfn3max29ym+ry7NkK4g==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [musl] '@next/swc-win32-arm64-msvc@16.2.6': resolution: {integrity: sha512-LZXpTlPyS5v7HhSmnvsLGP3iIYgYOBnc8r8ArlT55sGHV89bR2HlDdBjWQ+PY6SJMmk8TuVGFuxalnP3k/0Dwg==} @@ -1872,42 +1809,36 @@ packages: engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] - libc: [glibc] '@parcel/watcher-linux-arm-musl@2.5.6': resolution: {integrity: sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==} engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] - libc: [musl] '@parcel/watcher-linux-arm64-glibc@2.5.6': resolution: {integrity: sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] - libc: [glibc] '@parcel/watcher-linux-arm64-musl@2.5.6': resolution: {integrity: sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] - libc: [musl] '@parcel/watcher-linux-x64-glibc@2.5.6': resolution: {integrity: sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] - libc: [glibc] '@parcel/watcher-linux-x64-musl@2.5.6': resolution: {integrity: sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] - libc: [musl] '@parcel/watcher-win32-arm64@2.5.6': resolution: {integrity: sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==} @@ -2222,42 +2153,36 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-arm64-musl@1.0.0-rc.18': resolution: {integrity: sha512-QWjdxN1HJCpBTAcZ5N5F7wju3gVPzRzSpmGzx7na0c/1qpN9CFil+xt+l9lV/1M6/gqHSNXCiqPfwhVJPeLnug==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [musl] '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.18': resolution: {integrity: sha512-ugCOyj7a4d9h3q9B+wXmf6g3a68UsjGh6dob5DHevHGMwDUbhsYNbSPxJsENcIttJZ9jv7qGM2UesLw5jqIhdg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.18': resolution: {integrity: sha512-kKWRhbsotpXkGbcd5dllUWg5gEXcDAa8u5YnP9AV5DYNbvJHGzzuwv7dpmhc8NqKMJldl0a+x76IHbspEpEmdA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] - libc: [glibc] '@rolldown/binding-linux-x64-gnu@1.0.0-rc.18': resolution: {integrity: sha512-uCo8ElcCIAMyYAZyuIZ81oFkhTSIllNvUCHCAlbhlN4ji3uC28h7IIdlXyIvGO7HsuqnV9p3rD/bpH7XhIyhRw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-x64-musl@1.0.0-rc.18': resolution: {integrity: sha512-XNOQZtuE6yUIvx4rwGemwh8kpL1xvU41FXy/s9K7T/3JVcqGzo3NfKM2HrbrGgfPYGFW42f07Wk++aOC6B9NWA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [musl] '@rolldown/binding-openharmony-arm64@1.0.0-rc.18': resolution: {integrity: sha512-tSn/kzrfa7tNOXr7sEacDBN4YsIqTyLqh45IO0nHDwtpKIDNDJr+VFojt+4klSpChxB29JLyduSsE0MKEwa65A==} @@ -2404,79 +2329,66 @@ packages: resolution: {integrity: sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==} cpu: [arm] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.60.4': resolution: {integrity: sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==} cpu: [arm] os: [linux] - libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.60.4': resolution: {integrity: sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==} cpu: [arm64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.60.4': resolution: {integrity: sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==} cpu: [arm64] os: [linux] - libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.60.4': resolution: {integrity: sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==} cpu: [loong64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.60.4': resolution: {integrity: sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==} cpu: [loong64] os: [linux] - libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.60.4': resolution: {integrity: sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==} cpu: [ppc64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.60.4': resolution: {integrity: sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==} cpu: [ppc64] os: [linux] - libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.60.4': resolution: {integrity: sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==} cpu: [riscv64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.60.4': resolution: {integrity: sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==} cpu: [riscv64] os: [linux] - libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.60.4': resolution: {integrity: sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==} cpu: [s390x] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.60.4': resolution: {integrity: sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==} cpu: [x64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-musl@4.60.4': resolution: {integrity: sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==} cpu: [x64] os: [linux] - libc: [musl] '@rollup/rollup-openbsd-x64@4.60.4': resolution: {integrity: sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==} @@ -2629,42 +2541,36 @@ packages: engines: {node: '>=10'} cpu: [arm64] os: [linux] - libc: [glibc] '@swc/core-linux-arm64-musl@1.15.33': resolution: {integrity: sha512-il7tYM+CpUNzieQbwAjFT1P8zqAhmGWNAGhQZBnxurXZ0aNn+5nqYFTEUKNZl7QibtT0uQXzTZrNGHCIj6Y1Og==} engines: {node: '>=10'} cpu: [arm64] os: [linux] - libc: [musl] '@swc/core-linux-ppc64-gnu@1.15.33': resolution: {integrity: sha512-ZtNBwN0Z7CFj9Il0FcPaKdjgP7URyKu/3RfH46vq+0paOBqLj4NYldD6Qo//Duif/7IOtAraUfDOmp0PLAufog==} engines: {node: '>=10'} cpu: [ppc64] os: [linux] - libc: [glibc] '@swc/core-linux-s390x-gnu@1.15.33': resolution: {integrity: sha512-De1IyajoOmhOYYjw/lx66bKlyDpHZTueqwpDrWgf5O7T6d1ODeJJO9/OqMBmrBQc5C+dNnlmIufHsp4QVCWufA==} engines: {node: '>=10'} cpu: [s390x] os: [linux] - libc: [glibc] '@swc/core-linux-x64-gnu@1.15.33': resolution: {integrity: sha512-mGTH0YxmUN+x6vRN/I6NOk5X0ogNktkwPnJ94IMvR7QjhRDwL0O8RXEDhyUM0YtwWrryBOqaJQBX4zruxEPRGw==} engines: {node: '>=10'} cpu: [x64] os: [linux] - libc: [glibc] '@swc/core-linux-x64-musl@1.15.33': resolution: {integrity: sha512-hj628ZkSEJf6zMf5VMbYrG2O6QqyTIp2qwY6VlCjvIa9lAEZ5c2lfPblCLVGYubTeLJDxadLB/CxqQYOQABeEQ==} engines: {node: '>=10'} cpu: [x64] os: [linux] - libc: [musl] '@swc/core-win32-arm64-msvc@1.15.33': resolution: {integrity: sha512-GV2oohtN2/5+KSccl86VULu3aT+LrISC8uzgSq0FRnikpD+Zwc+sBlXmoKQ+Db6jI57ITUOIB8jRkdGMABC29g==} @@ -4193,28 +4099,24 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [glibc] lightningcss-linux-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [musl] lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [glibc] lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [musl] lightningcss-win32-arm64-msvc@1.32.0: resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} @@ -4309,11 +4211,6 @@ packages: resolution: {integrity: sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==} engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'} - lucide-react@0.542.0: - resolution: {integrity: sha512-w3hD8/SQB7+lzU2r4VdFyzzOzKnUjTZIF/MQJGSSvni7Llewni4vuViRppfRAa2guOsY5k4jZyxw/i9DQHv+dw==} - peerDependencies: - react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 - lucide-react@0.555.0: resolution: {integrity: sha512-D8FvHUGbxWBRQM90NZeIyhAvkFfsh3u9ekrMvJ30Z6gnpBHS6HC6ldLg7tL45hwiIz/u66eKDtdA23gwwGsAHA==} peerDependencies: @@ -4354,9 +4251,6 @@ packages: mdn-data@2.27.1: resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} - memoize-one@5.2.1: - resolution: {integrity: sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==} - memorystream@0.3.1: resolution: {integrity: sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==} engines: {node: '>= 0.10.0'} @@ -5232,9 +5126,6 @@ packages: pure-rand@6.1.0: resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} - pure-rand@7.0.1: - resolution: {integrity: sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==} - pure-rand@8.4.0: resolution: {integrity: sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A==} @@ -5299,13 +5190,6 @@ packages: react: ^18.0.0 || ^19.0.0 react-dom: ^18.0.0 || ^19.0.0 - react-simple-maps@2.3.0: - resolution: {integrity: sha512-IZVeiPSRZKwD6I/2NvXpQ2uENYGDGZp8DvZjkapcxuJ/LQHTfl+Byb+KNgY7s+iatRA2ad8LnZ3AgqcjziCCsw==} - peerDependencies: - prop-types: ^15.7.2 - react: ^16.8.0 || 17.x - react-dom: ^16.8.0 || 17.x - react-simple-maps@3.0.0: resolution: {integrity: sha512-vKNFrvpPG8Vyfdjnz5Ne1N56rZlDfHXv5THNXOVZMqbX1rWZA48zQuYT03mx6PAKanqarJu/PDLgshIZAfHHqw==} peerDependencies: @@ -5327,13 +5211,6 @@ packages: react-dom: optional: true - react-window@1.8.11: - resolution: {integrity: sha512-+SRbUVT2scadgFSWx+R1P754xHPEqvcfSfVX10QYg6POOz+WNgkN48pS+BtZNIMGiL1HYrSEiCkwsMS15QogEQ==} - engines: {node: '>8.0.0'} - peerDependencies: - react: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - react-dom: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - react-window@2.2.7: resolution: {integrity: sha512-SH5nvfUQwGHYyriDUAOt7wfPsfG9Qxd6OdzQxl5oQ4dsSsUicqQvjV7dR+NqZ4coY0fUn3w1jnC5PwzIUWEg5w==} peerDependencies: @@ -5539,10 +5416,6 @@ packages: seq-queue@0.0.5: resolution: {integrity: sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==} - serialize-error@12.0.0: - resolution: {integrity: sha512-ZYkZLAvKTKQXWuh5XpBw7CdbSzagarX39WyZ2H07CDLC5/KfsRGlIXV8d4+tfqX1M7916mRqR1QfNHSij+c9Pw==} - engines: {node: '>=18'} - serialize-error@13.0.1: resolution: {integrity: sha512-bBZaRwLH9PN5HbLCjPId4dP5bNGEtumcErgOX952IsvOhVPrm3/AeK1y0UHA/QaPG701eg0yEnOKsCOC6X/kaA==} engines: {node: '>=20'} @@ -5939,10 +5812,6 @@ packages: engines: {node: '>=18.0.0'} hasBin: true - type-fest@4.41.0: - resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} - engines: {node: '>=16'} - type-fest@5.6.0: resolution: {integrity: sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA==} engines: {node: '>=20'} @@ -6025,10 +5894,6 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} - uuid@11.1.1: - resolution: {integrity: sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==} - hasBin: true - uuid@14.0.0: resolution: {integrity: sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==} hasBin: true @@ -9933,10 +9798,6 @@ snapshots: lru.min@1.1.4: {} - lucide-react@0.542.0(react@19.2.6): - dependencies: - react: 19.2.6 - lucide-react@0.555.0(react@19.2.6): dependencies: react: 19.2.6 @@ -9968,8 +9829,6 @@ snapshots: mdn-data@2.27.1: {} - memoize-one@5.2.1: {} - memorystream@0.3.1: {} merge2@1.4.1: {} @@ -10881,8 +10740,6 @@ snapshots: pure-rand@6.1.0: {} - pure-rand@7.0.1: {} - pure-rand@8.4.0: {} queue-microtask@1.2.3: {} @@ -10950,16 +10807,6 @@ snapshots: react: 19.2.6 react-dom: 19.2.6(react@19.2.6) - react-simple-maps@2.3.0(prop-types@15.8.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6): - dependencies: - d3-geo: 2.0.2 - d3-selection: 2.0.0 - d3-zoom: 2.0.0 - prop-types: 15.8.1 - react: 19.2.6 - react-dom: 19.2.6(react@19.2.6) - topojson-client: 3.1.0 - react-simple-maps@3.0.0(prop-types@15.8.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6): dependencies: d3-geo: 2.0.2 @@ -10986,13 +10833,6 @@ snapshots: optionalDependencies: react-dom: 19.2.6(react@19.2.6) - react-window@1.8.11(react-dom@19.2.6(react@19.2.6))(react@19.2.6): - dependencies: - '@babel/runtime': 7.29.2 - memoize-one: 5.2.1 - react: 19.2.6 - react-dom: 19.2.6(react@19.2.6) - react-window@2.2.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6): dependencies: react: 19.2.6 @@ -11268,10 +11108,6 @@ snapshots: seq-queue@0.0.5: {} - serialize-error@12.0.0: - dependencies: - type-fest: 4.41.0 - serialize-error@13.0.1: dependencies: non-error: 0.1.0 @@ -11712,8 +11548,6 @@ snapshots: optionalDependencies: fsevents: 2.3.3 - type-fest@4.41.0: {} - type-fest@5.6.0: dependencies: tagged-tag: 1.0.0 @@ -11806,8 +11640,6 @@ snapshots: util-deprecate@1.0.2: {} - uuid@11.1.1: {} - uuid@14.0.0: {} v8-compile-cache-lib@3.0.1: {} diff --git a/prisma/migrations/20_add_heatmap/migration.sql b/prisma/migrations/20_add_heatmap/migration.sql index 99506cbc3..e5d5dface 100644 --- a/prisma/migrations/20_add_heatmap/migration.sql +++ b/prisma/migrations/20_add_heatmap/migration.sql @@ -30,3 +30,30 @@ 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"); + +-- CreateTable +CREATE TABLE "heatmap_snapshot" ( + "snapshot_id" UUID NOT NULL, + "website_id" UUID NOT NULL, + "url_path" VARCHAR(500) NOT NULL, + "viewport_w" INTEGER NOT NULL, + "viewport_h" INTEGER NOT NULL, + "page_w" INTEGER NOT NULL, + "page_h" INTEGER NOT NULL, + "status" VARCHAR(20) NOT NULL, + "mime_type" VARCHAR(100), + "image_data" BYTEA, + "image_size" INTEGER, + "error" VARCHAR(500), + "created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(6), + + CONSTRAINT "heatmap_snapshot_pkey" PRIMARY KEY ("snapshot_id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "heatmap_snapshot_website_id_url_path_viewport_w_viewport_h_key" +ON "heatmap_snapshot"("website_id", "url_path", "viewport_w", "viewport_h"); +CREATE INDEX "heatmap_snapshot_website_id_idx" ON "heatmap_snapshot"("website_id"); +CREATE INDEX "heatmap_snapshot_website_id_url_path_idx" ON "heatmap_snapshot"("website_id", "url_path"); +CREATE INDEX "heatmap_snapshot_website_id_updated_at_idx" ON "heatmap_snapshot"("website_id", "updated_at"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 45ea6f169..23d29c964 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -89,6 +89,7 @@ model Website { sessionReplays SessionReplay[] sessionReplaysSaved SessionReplaySaved[] heatmapEvents HeatmapEvent[] + heatmapSnapshots HeatmapSnapshot[] @@index([userId]) @@index([teamId]) @@ -432,3 +433,28 @@ model HeatmapEvent { @@index([websiteId, visitId, replayChunkIndex, replayEventIndex]) @@map("heatmap_event") } + +model HeatmapSnapshot { + id String @id() @map("snapshot_id") @db.Uuid + websiteId String @map("website_id") @db.Uuid + urlPath String @map("url_path") @db.VarChar(500) + viewportW Int @map("viewport_w") @db.Integer + viewportH Int @map("viewport_h") @db.Integer + pageW Int @map("page_w") @db.Integer + pageH Int @map("page_h") @db.Integer + status String @db.VarChar(20) + mimeType String? @map("mime_type") @db.VarChar(100) + imageData Bytes? @map("image_data") + imageSize Int? @map("image_size") @db.Integer + error String? @db.VarChar(500) + createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6) + updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamptz(6) + + website Website @relation(fields: [websiteId], references: [id]) + + @@unique([websiteId, urlPath, viewportW, viewportH]) + @@index([websiteId]) + @@index([websiteId, urlPath]) + @@index([websiteId, updatedAt]) + @@map("heatmap_snapshot") +} 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 9db53fdfe..de6c640fa 100644 --- a/src/app/(main)/websites/[websiteId]/(reports)/heatmaps/Heatmap.module.css +++ b/src/app/(main)/websites/[websiteId]/(reports)/heatmaps/Heatmap.module.css @@ -78,6 +78,10 @@ white-space: nowrap; } +.snapshotMessage { + margin-top: -4px; +} + .scrollBand { position: absolute; left: 0; @@ -87,6 +91,7 @@ align-items: flex-start; justify-content: flex-end; padding: 2px 8px; + mix-blend-mode: multiply; } .scrollBandLabel { @@ -104,7 +109,8 @@ min-width: 0; max-width: 100%; max-height: 75vh; - overflow: auto; + overflow-x: hidden; + overflow-y: auto; overscroll-behavior: contain; } @@ -139,27 +145,20 @@ .snapshot { position: absolute; - top: 0; - left: 0; + inset: 0; z-index: 1; transform-origin: top left; pointer-events: none; overflow: hidden; } -.snapshot :global(.replayer-wrapper) { - border: 0; - overflow: visible; -} - -.snapshot :global(iframe) { - border: 0; - pointer-events: none; -} - -.snapshot :global(.replayer-mouse), -.snapshot :global(.replayer-mouse-tail) { - display: none !important; +.snapshotImage { + display: block; + width: 100%; + height: 100%; + object-fit: fill; + user-select: none; + -webkit-user-drag: none; } .overlay { diff --git a/src/app/(main)/websites/[websiteId]/(reports)/heatmaps/Heatmap.tsx b/src/app/(main)/websites/[websiteId]/(reports)/heatmaps/Heatmap.tsx index f8f3061ee..8294e01df 100644 --- a/src/app/(main)/websites/[websiteId]/(reports)/heatmaps/Heatmap.tsx +++ b/src/app/(main)/websites/[websiteId]/(reports)/heatmaps/Heatmap.tsx @@ -1,52 +1,15 @@ 'use client'; import { Column, Grid, Heading, Loading, Row, Switch, Text } from '@umami/react-zen'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { LoadingPanel } from '@/components/common/LoadingPanel'; import { useResultQuery } from '@/components/hooks'; -import { useReplayQuery } from '@/components/hooks/queries/useReplayQuery'; +import { getClientAuthToken } from '@/lib/client'; import { formatLongNumber } from '@/lib/format'; import type { HeatmapMode, HeatmapPoint, HeatmapResult, HeatmapSnapshot } from '@/queries/sql'; import styles from './Heatmap.module.css'; -import 'rrweb/dist/replay/rrweb-replay.css'; -const MAX_FIXED_WIDTH_OVERRUN = 160; -const CLICK_EDGE_PADDING = 4; - -function useElementWidth() { - const ref = useRef(null); - const [width, setWidth] = useState(0); - - useEffect(() => { - const el = ref.current; - if (!el) return; - - setWidth(el.getBoundingClientRect().width || el.clientWidth || 0); - - const ro = new ResizeObserver(entries => { - const nextWidth = entries[0]?.contentRect.width ?? 0; - setWidth(nextWidth); - }); - - ro.observe(el); - - return () => ro.disconnect(); - }, []); - - return [ref, width] as const; -} - -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; -} +const CLICK_EDGE_PERCENT = 1.5; +const SCROLL_BUCKET_SIZE = 10; interface ViewportBucket { width: number; @@ -147,16 +110,14 @@ export function Heatmap({ websiteId, urlPath, onUrlPathChange, mode, search }: H {urlPath ? ( mode === 'scroll' ? ( ) : ( - Pages {pages.length === 0 && hasSearch && No matching pages} - {pages.map(p => ( + {pages.map(page => ( ))} @@ -209,35 +172,48 @@ function PageList({ } function pickViewport(points: HeatmapPoint[]): ViewportBucket | null { - if (!points.length) return null; + if (!points.length) { + return null; + } + const viewportBuckets = new Map< string, ViewportBucket & { maxPageW: number; maxPageH: number } >(); - for (const p of points) { - const viewportKey = `${p.viewportW}x${p.viewportH}`; + + for (const point of points) { + const viewportKey = `${point.viewportW}x${point.viewportH}`; const existing = viewportBuckets.get(viewportKey); + if (existing) { - existing.count += p.count; - existing.maxPageW = Math.max(existing.maxPageW, p.pageW); - existing.maxPageH = Math.max(existing.maxPageH, p.pageH); - } else { - viewportBuckets.set(viewportKey, { - width: p.viewportW, - height: p.viewportH, - pageW: p.pageW, - pageH: p.pageH, - count: p.count, - maxPageW: p.pageW, - maxPageH: p.pageH, - }); + existing.count += point.count; + existing.maxPageW = Math.max(existing.maxPageW, point.pageW); + existing.maxPageH = Math.max(existing.maxPageH, point.pageH); + continue; + } + + viewportBuckets.set(viewportKey, { + width: point.viewportW, + height: point.viewportH, + pageW: point.pageW, + pageH: point.pageH, + count: point.count, + maxPageW: point.pageW, + maxPageH: point.pageH, + }); + } + + let best: (ViewportBucket & { maxPageW: number; maxPageH: number }) | null = null; + + for (const bucket of viewportBuckets.values()) { + if (!best || bucket.count > best.count) { + best = bucket; } } - let best: (ViewportBucket & { maxPageW: number; maxPageH: number }) | null = null; - for (const b of viewportBuckets.values()) { - if (!best || b.count > best.count) best = b; + + if (!best) { + return null; } - if (!best) return null; return { width: best.width, @@ -248,59 +224,51 @@ function pickViewport(points: HeatmapPoint[]): ViewportBucket | null { }; } -function HeatmapView({ +function ClickHeatmapView({ 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); - const viewport = useMemo(() => pickViewport(points), [points]); const visible = useMemo(() => { - if (!viewport) return []; - return points.filter(p => p.viewportW === viewport.width && p.viewportH === viewport.height); + if (!viewport) { + return []; + } + + return points.filter( + point => point.viewportW === viewport.width && point.viewportH === viewport.height, + ); }, [points, viewport]); const maxCount = useMemo( - () => visible.reduce((m, p) => (p.count > m ? p.count : m), 1), + () => visible.reduce((max, point) => (point.count > max ? point.count : max), 1), [visible], ); - const [containerRef, containerWidth] = useElementWidth(); const handleSnapshotReady = useCallback(() => setSnapshotReady(true), []); + const hasSnapshotImage = Boolean(snapshot?.imageUrl); useEffect(() => { - setSnapshotReady(!(showPage && snapshot)); - }, [showPage, snapshot]); - - if (isLoading) { - return ; - } - - if (!viewport || visible.length === 0) { - return ; - } - - const overlayGutter = Math.max(48, Math.round(viewport.width * 0.04)); + setSnapshotReady(!(showPage && hasSnapshotImage)); + }, [hasSnapshotImage, showPage, snapshot?.id]); + const overlayGutter = Math.max(48, Math.round((viewport?.width ?? 1920) * 0.04)); const maxPointX = visible.reduce((max, point) => Math.max(max, point.pageX), 0); const maxPointY = visible.reduce((max, point) => Math.max(max, point.pageY), 0); - const baseWidth = Math.max(viewport.pageW, maxPointX + overlayGutter); - const baseHeight = Math.max(viewport.pageH, maxPointY + overlayGutter); - const renderWidth = containerWidth > 0 ? Math.min(baseWidth, containerWidth) : baseWidth; - const scale = renderWidth / baseWidth; - const renderHeight = Math.round(baseHeight * scale); - const showSnapshot = renderWidth > 0 && showPage && !!snapshot; + const baseWidth = Math.max(snapshot?.pageW ?? 0, viewport?.pageW ?? 0, maxPointX + overlayGutter, 1200); + const baseHeight = Math.max(snapshot?.pageH ?? 0, viewport?.pageH ?? 0, maxPointY + overlayGutter, 640); + const showSnapshot = baseWidth > 0 && showPage && hasSnapshotImage; const showOverlay = !showSnapshot || snapshotReady; + const totalClicks = visible.reduce((sum, point) => sum + point.count, 0); + const showLoading = isLoading; return ( @@ -310,70 +278,91 @@ function HeatmapView({ {urlPath} - - - {visible.length} positions · {formatLongNumber(visible.reduce((s, p) => s + p.count, 0))}{' '} - clicks · viewport {viewport.width}×{viewport.height} - - + {showLoading ? ( + + + Loading Heatmap... + + + ) : ( + + + {viewport + ? `${visible.length} positions - ${formatLongNumber(totalClicks)} clicks - viewport ${viewport.width}x${viewport.height}` + : 'No click data for this page yet.'} + + + )} -
+ + {showPage && snapshot?.status === 'failed' && ( + + {snapshot.error || 'Page screenshot unavailable.'} + + )} + +
+ {showLoading ? ( + + ) : !viewport || visible.length === 0 ? ( + + ) : ( + <>
{showSnapshot && !snapshotReady && } - {showSnapshot && ( - viewport.width} - onReady={handleSnapshotReady} - /> - )} + {showSnapshot && snapshot?.imageUrl && }
{showOverlay && (
- {visible.map((p, i) => { - const intensity = Math.min(1, p.count / maxCount); + {visible.map((point, index) => { + const intensity = Math.min(1, point.count / maxCount); const desiredSize = 24 + intensity * 36; - const pointWidth = Math.max(baseWidth, p.pageW || 0, p.pageX); - const pointHeight = Math.max(baseHeight, p.pageH || 0, p.pageY); - const rawCenterX = (p.pageX / Math.max(1, pointWidth)) * renderWidth; - const rawCenterY = (p.pageY / Math.max(1, pointHeight)) * renderHeight; + const pointWidth = Math.max(baseWidth, point.pageW || 0, point.pageX); + const pointHeight = Math.max(baseHeight, point.pageH || 0, point.pageY); + const rawCenterX = (point.pageX / Math.max(1, pointWidth)) * 100; + const rawCenterY = (point.pageY / Math.max(1, pointHeight)) * 100; const size = desiredSize; const centerX = Math.max( - size / 2 + CLICK_EDGE_PADDING, - Math.min(renderWidth - size / 2 - CLICK_EDGE_PADDING, rawCenterX), + CLICK_EDGE_PERCENT, + Math.min(100 - CLICK_EDGE_PERCENT, rawCenterX), ); const centerY = Math.max( - size / 2 + CLICK_EDGE_PADDING, - Math.min(renderHeight - size / 2 - CLICK_EDGE_PADDING, rawCenterY), + CLICK_EDGE_PERCENT, + Math.min(100 - CLICK_EDGE_PERCENT, rawCenterY), ); + return (
); })}
)} + + )}
- {snapshot && ( + + {hasSnapshotImage && ( Show page @@ -386,65 +375,51 @@ function HeatmapView({ 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); - - const [containerRef, containerWidth] = useElementWidth(); const handleSnapshotReady = useCallback(() => setSnapshotReady(true), []); + const hasSnapshotImage = Boolean(snapshot?.imageUrl); useEffect(() => { - setSnapshotReady(!(showPage && snapshot)); - }, [showPage, snapshot]); - - if (isLoading) { - return ; - } - - if (!scroll || scroll.totalSessions === 0 || !scroll.pageW || !scroll.pageH || !scroll.viewportW) { - return ; - } - - const { buckets, totalSessions, pageW, pageH, viewportW, viewportH } = scroll; - const baseWidth = pageW; - const baseHeight = pageH; - const renderWidth = containerWidth > 0 ? Math.min(baseWidth, containerWidth) : baseWidth; - const scale = renderWidth / baseWidth; - const renderHeight = Math.round(baseHeight * scale); - const showSnapshot = renderWidth > 0 && showPage && !!snapshot; + setSnapshotReady(!(showPage && hasSnapshotImage)); + }, [hasSnapshotImage, showPage, snapshot?.id]); + const { buckets = [], totalSessions = 0, pageW = 0, pageH = 0, viewportW = 0, viewportH = 0 } = + scroll ?? {}; + const baseWidth = Math.max(snapshot?.pageW ?? 0, pageW, 1200); + const baseHeight = Math.max(snapshot?.pageH ?? 0, pageH, 640); + const showSnapshot = baseWidth > 0 && showPage && hasSnapshotImage; const showOverlay = !showSnapshot || snapshotReady; - - // Cumulative reach: % of sessions that scrolled at least to depth D. - const sortedBuckets = [...buckets].sort((a, b) => a.depth - b.depth); - let remaining = totalSessions; - const cumulative = sortedBuckets.map(b => { - const reached = remaining; - remaining -= b.sessions; - return { depth: b.depth, reached, ratio: reached / totalSessions }; - }); + const hasScrollData = Boolean(scroll && totalSessions > 0 && pageW && pageH && viewportW); + const showLoading = isLoading; type Band = { fromPct: number; toPct: number; reached: number; ratio: number }; const bands: Band[] = []; - const firstDepth = cumulative[0]?.depth ?? 100; - if (firstDepth > 0) { - bands.push({ fromPct: 0, toPct: firstDepth, reached: totalSessions, ratio: 1 }); - } - for (let i = 0; i < cumulative.length; i++) { - const c = cumulative[i]; - const toPct = cumulative[i + 1]?.depth ?? 100; - if (c.depth < toPct) { - bands.push({ fromPct: c.depth, toPct, reached: c.reached, ratio: c.ratio }); + const sessionsByDepth = new Map(buckets.map(bucket => [bucket.depth, bucket.sessions])); + let dropped = 0; + + for (let depth = 0; depth < 100; depth += SCROLL_BUCKET_SIZE) { + const reached = Math.max(0, totalSessions - dropped); + const ratio = totalSessions ? reached / totalSessions : 0; + + if (reached > 0) { + bands.push({ + fromPct: depth, + toPct: Math.min(100, depth + SCROLL_BUCKET_SIZE), + reached, + ratio, + }); } + + dropped += sessionsByDepth.get(depth) ?? 0; } return ( @@ -452,61 +427,76 @@ function ScrollHeatmapView({ {urlPath} - - - {formatLongNumber(totalSessions)} sessions · page {viewportW}×{pageH} - {viewportH ? ` · viewport ${viewportH}` : ''} + {showLoading ? ( + + + Loading Heatmap... + + + ) : ( + + + {hasScrollData + ? `${formatLongNumber(totalSessions)} sessions - page ${pageW}x${pageH}${viewportH ? ` - viewport ${viewportW}x${viewportH}` : ''}` + : 'No scroll data for this page yet.'} + + + )} + + {showPage && snapshot?.status === 'failed' && ( + + {snapshot.error || 'Page screenshot unavailable.'} - -
+ )} + +
-
+ {showLoading ? ( + + ) : !hasScrollData ? ( + + ) : ( +
{showSnapshot && !snapshotReady && } - {showSnapshot && ( - viewportW} - onReady={handleSnapshotReady} - /> - )} + {showSnapshot && snapshot?.imageUrl && } {showOverlay && (
- {bands.map(b => { - const top = Math.round((b.fromPct / 100) * renderHeight); - const bottom = Math.round((b.toPct / 100) * renderHeight); - const height = Math.max(0, bottom - top); - const intensity = b.ratio; - const hue = Math.round(60 - intensity * 60); // 60=yellow → 0=red - return ( -
- - {b.fromPct}% · {Math.round(intensity * 100)}% - -
- ); - })} + {bands.map(band => { + const intensity = band.ratio; + const hue = Math.round(60 - intensity * 60); + + return ( +
+ + {band.fromPct}% depth • {Math.round(intensity * 100)}% reached + +
+ ); + })}
)} -
+
+ )}
- {snapshot && ( + + {hasSnapshotImage && ( Show page @@ -517,272 +507,66 @@ function ScrollHeatmapView({ ); } -function ReplaySnapshot({ - websiteId, +function SnapshotImage({ snapshot, - width, - height, - scale, - allowWidthExpansion = true, onReady, }: { - websiteId: string; snapshot: HeatmapSnapshot; - width: number; - height: number; - scale: number; - allowWidthExpansion?: boolean; onReady: () => void; }) { - const containerRef = useRef(null); - const replayerRef = useRef(null); - const contentWidthRef = useRef(width); - const [contentWidth, setContentWidth] = useState(width); - const { data } = useReplayQuery(websiteId, snapshot.replayId, { - until: snapshot.timestamp, - chunkIndex: snapshot.chunkIndex, - eventIndex: snapshot.eventIndex, - }) as { data?: ReplayData }; + const [src, setSrc] = useState(null); useEffect(() => { - contentWidthRef.current = width; - setContentWidth(width); - }, [ - snapshot.chunkIndex, - snapshot.eventIndex, - snapshot.replayId, - snapshot.timestamp, - width, - ]); + if (!snapshot.imageUrl) { + setSrc(null); + return; + } - useEffect(() => { - const container = containerRef.current; - const events = data?.events; - if (!container || !events?.length) return; + const controller = new AbortController(); + const token = getClientAuthToken(); + let objectUrl: string | null = null; - let cancelled = false; - let finalizeTimer: ReturnType | null = null; - let readyTimer: ReturnType | null = null; - let widthObserver: ResizeObserver | null = null; + setSrc(null); - import('rrweb').then(({ Replayer }) => { - if (cancelled || !containerRef.current) return; - - container.innerHTML = ''; - - const replayer = new Replayer(events, { - root: container, - showWarning: false, - mouseTail: false, - triggerFocus: false, - pauseAnimation: true, - useVirtualDom: false, - loadTimeout: 3000, - }) as ReplayInstance; - - replayerRef.current = replayer; - let rebuilt = false; - let waitingForStyles = false; - let finalizeToken = 0; - let observingWidth = false; - - const getBoundedWidth = (nextWidth: number) => { - const maxWidth = allowWidthExpansion ? Number.POSITIVE_INFINITY : width + MAX_FIXED_WIDTH_OVERRUN; - return Math.min(Math.max(width, nextWidth), maxWidth); - }; - - const commitWidth = (nextWidth: number) => { - const stableWidth = Math.max(contentWidthRef.current, getBoundedWidth(nextWidth), width); - - if (stableWidth === contentWidthRef.current) { - return; + fetch(snapshot.imageUrl, { + signal: controller.signal, + headers: { + ...(token ? { authorization: `Bearer ${token}` } : {}), + }, + }) + .then(async response => { + if (!response.ok) { + throw new Error(`Snapshot image request failed: ${response.status}`); } - contentWidthRef.current = stableWidth; - resizeReplayFrame(replayer, stableWidth, height); - setContentWidth(stableWidth); - }; - - const scheduleReady = (delay = 250) => { - if (cancelled) { - return; - } - - if (readyTimer) { - clearTimeout(readyTimer); - } - - readyTimer = setTimeout(() => { - readyTimer = null; - if (!cancelled) { - onReady(); - } - }, delay); - }; - - const startWidthObserver = () => { - if (observingWidth || cancelled) { - return; - } - - const doc = replayer.iframe?.contentDocument; - const html = doc?.documentElement; - const body = doc?.body; - - if (!html && !body) { - return; - } - - observingWidth = true; - widthObserver = new ResizeObserver(() => { - commitWidth(measureReplayWidth(replayer, width)); - scheduleReady(); - }); - - if (html) { - widthObserver.observe(html); - } - - if (body) { - widthObserver.observe(body); - } - }; - - 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 finalize = async (token: number) => { - if (waitingForStyles || !rebuilt || cancelled) { - return; - } - - await waitForReplayLayout(replayer); - - if (cancelled || token !== finalizeToken) { - return; - } - - freeze(); - await waitForAnimationFrames(2); - - if (cancelled || token !== finalizeToken) { - return; - } - - freeze(); - await waitForAnimationFrames(2); - - if (cancelled || token !== finalizeToken) { - return; - } - - commitWidth( - await measureStableReplayWidth(replayer, width, { - maxWidth: allowWidthExpansion ? undefined : width + MAX_FIXED_WIDTH_OVERRUN, - samples: 12, - }), - ); - - if (cancelled || token !== finalizeToken) { - return; - } - - startWidthObserver(); - scheduleReady(); - }; - - const scheduleFinalize = (delay = 250) => { - if (cancelled) { - return; - } - - finalizeToken += 1; - const token = finalizeToken; - - if (finalizeTimer) { - clearTimeout(finalizeTimer); - } - - finalizeTimer = setTimeout(() => { - finalizeTimer = null; - void finalize(token); - }, delay); - }; - - replayer.on('load-stylesheet-start', () => { - waitingForStyles = true; + const blob = await response.blob(); + objectUrl = URL.createObjectURL(blob); + setSrc(objectUrl); + }) + .catch(() => { + setSrc(null); + onReady(); }); - replayer.on('load-stylesheet-end', () => { - waitingForStyles = false; - scheduleFinalize(); - }); - - replayer.on('fullsnapshot-rebuilded', () => { - rebuilt = true; - scheduleFinalize(); - }); - - replayer.on('resize', () => { - scheduleFinalize(); - }); - - setTimeout(() => { - waitingForStyles = false; - scheduleFinalize(0); - }, 3500); - }); - return () => { - cancelled = true; - if (finalizeTimer) { - clearTimeout(finalizeTimer); - } - if (readyTimer) { - clearTimeout(readyTimer); - } - if (widthObserver) { - widthObserver.disconnect(); - } - if (replayerRef.current) { - replayerRef.current.destroy(); - replayerRef.current = null; - } - if (container) { - container.innerHTML = ''; + controller.abort(); + + if (objectUrl) { + URL.revokeObjectURL(objectUrl); } }; - }, [allowWidthExpansion, data?.events, height, onReady, snapshot.timestamp, width]); + }, [snapshot.id, snapshot.imageUrl]); - useEffect(() => { - if (replayerRef.current) { - resizeReplayFrame(replayerRef.current, contentWidth, height); - } - }, [contentWidth, height]); - - const fitScaleX = contentWidth > width ? width / Math.max(1, contentWidth) : 1; - const snapshotScaleX = scale * fitScaleX; + const handleLoad = useCallback(() => onReady(), [onReady]); return ( -
-
+
); @@ -796,161 +580,6 @@ function CanvasLoading() { ); } -function waitForAnimationFrames(count = 2) { - return new Promise(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); -} - -async function measureStableReplayWidth( - replayer: ReplayInstance, - fallbackWidth: number, - { maxWidth, samples = 6 }: { maxWidth?: number; samples?: number } = {}, -) { - let stableWidth = fallbackWidth; - - for (let i = 0; i < samples; i++) { - await waitForAnimationFrames(1); - stableWidth = Math.max(stableWidth, measureReplayWidth(replayer, fallbackWidth)); - } - - return maxWidth ? Math.min(stableWidth, maxWidth) : stableWidth; -} - -function syncReplayDocumentViewport(replayer: ReplayInstance) { - const doc = replayer.iframe?.contentDocument; - const html = doc?.documentElement; - const body = doc?.body; - - if (html) { - html.style.margin = '0'; - html.style.overflowX = 'visible'; - html.style.overflowY = 'hidden'; - } - - if (body) { - body.style.margin = '0'; - body.style.overflowX = 'visible'; - body.style.overflowY = 'hidden'; - } -} - -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.minWidth = `${width}px`; - wrapper.style.minHeight = `${height}px`; - wrapper.style.maxWidth = `${width}px`; - wrapper.style.maxHeight = `${height}px`; - wrapper.style.margin = '0'; - wrapper.style.padding = '0'; - wrapper.style.overflow = 'visible'; - } - - if (iframe) { - iframe.setAttribute('width', String(width)); - iframe.setAttribute('height', String(height)); - iframe.style.width = `${width}px`; - iframe.style.height = `${height}px`; - iframe.style.minWidth = `${width}px`; - iframe.style.minHeight = `${height}px`; - iframe.style.maxWidth = `${width}px`; - iframe.style.maxHeight = `${height}px`; - iframe.style.margin = '0'; - iframe.style.display = 'block'; - } - - syncReplayDocumentViewport(replayer); -} - -function measureReplayWidth(replayer: ReplayInstance, fallbackWidth: number) { - const doc = replayer.iframe?.contentDocument; - const win = replayer.iframe?.contentWindow; - const root = doc?.documentElement as HTMLElement | undefined; - const body = doc?.body as HTMLElement | undefined; - const scrollingElement = doc?.scrollingElement as HTMLElement | undefined; - const firstChild = body?.firstElementChild as HTMLElement | null; - const wrapperWidth = replayer.wrapper?.scrollWidth || replayer.wrapper?.offsetWidth || 0; - const iframeWidth = replayer.iframe?.scrollWidth || replayer.iframe?.offsetWidth || 0; - - return Math.round( - Math.max( - fallbackWidth, - measureDocumentWidth(doc), - wrapperWidth, - iframeWidth, - win?.innerWidth || 0, - measureElementWidth(root), - measureElementWidth(body), - measureElementWidth(scrollingElement), - measureElementWidth(firstChild || undefined), - ), - ); -} - -function measureDocumentWidth(doc?: Document | null) { - const body = doc?.body; - - if (!body) { - return 0; - } - - let maxRight = 0; - const walker = doc.createTreeWalker(body, NodeFilter.SHOW_ELEMENT); - let node = walker.currentNode as Element | null; - - while (node) { - const rect = node.getBoundingClientRect?.(); - - if (rect && rect.width > 0) { - maxRight = Math.max(maxRight, rect.right); - } - - node = walker.nextNode() as Element | null; - } - - return Math.max(0, Math.round(maxRight)); -} - -function measureElementWidth(element?: HTMLElement | null) { - if (!element) { - return 0; - } - - const rect = element.getBoundingClientRect(); - - return Math.max(element.scrollWidth, element.offsetWidth, element.clientWidth, rect.width); -} - function EmptyState({ message }: { message?: string } = {}) { return ( diff --git a/src/app/api/websites/[websiteId]/heatmaps/snapshots/[snapshotId]/route.ts b/src/app/api/websites/[websiteId]/heatmaps/snapshots/[snapshotId]/route.ts new file mode 100644 index 000000000..98c8bb870 --- /dev/null +++ b/src/app/api/websites/[websiteId]/heatmaps/snapshots/[snapshotId]/route.ts @@ -0,0 +1,34 @@ +import { notFound, unauthorized } from '@/lib/response'; +import { canViewWebsite } from '@/permissions'; +import { parseRequest } from '@/lib/request'; +import { getHeatmapSnapshotImage } from '@/queries/sql/heatmap/ensureHeatmapSnapshot'; + +export async function GET( + request: Request, + { params }: { params: Promise<{ websiteId: string; snapshotId: string }> }, +) { + const { auth, error } = await parseRequest(request); + const { websiteId, snapshotId } = await params; + + if (error) { + return error(); + } + + if (!(await canViewWebsite(auth, websiteId))) { + return unauthorized(); + } + + const snapshot = await getHeatmapSnapshotImage(websiteId, snapshotId); + + if (!snapshot) { + return notFound({ message: 'Snapshot not found.' }); + } + + return new Response(new Uint8Array(snapshot.imageData), { + status: 200, + headers: { + 'Content-Type': snapshot.mimeType, + 'Cache-Control': 'private, max-age=300', + }, + }); +} diff --git a/src/queries/sql/heatmap/ensureHeatmapSnapshot.ts b/src/queries/sql/heatmap/ensureHeatmapSnapshot.ts new file mode 100644 index 000000000..90496563a --- /dev/null +++ b/src/queries/sql/heatmap/ensureHeatmapSnapshot.ts @@ -0,0 +1,524 @@ +import prisma from '@/lib/prisma'; +import { uuid } from '@/lib/crypto'; +import { getWebsite } from '@/queries/prisma'; + +const SNAPSHOT_STATUS = { + pending: 'pending', + ready: 'ready', + failed: 'failed', +} as const; + +const SNAPSHOT_RETRY_DELAY_MS = 15 * 60 * 1000; +const SNAPSHOT_PENDING_WINDOW_MS = 30 * 1000; + +export type HeatmapSnapshotStatus = (typeof SNAPSHOT_STATUS)[keyof typeof SNAPSHOT_STATUS]; + +export interface HeatmapSnapshotImage { + id: string; + imageUrl: string | null; + status: HeatmapSnapshotStatus; + mimeType: string | null; + pageW: number; + pageH: number; + viewportW: number; + viewportH: number; + error: string | null; +} + +interface SnapshotRecord { + id: string; + websiteId: string; + urlPath: string; + viewportW: number; + viewportH: number; + pageW: number; + pageH: number; + status: HeatmapSnapshotStatus; + mimeType: string | null; + imageSize: number | null; + error: string | null; + hasImage: boolean; + updatedAt: Date | string | null; +} + +interface EnsureHeatmapSnapshotOptions { + websiteId: string; + urlPath: string; + viewportW: number | null; + viewportH: number | null; + pageW: number | null; + pageH: number | null; +} + +interface CaptureResult { + imageData: Buffer; + mimeType: string; + pageW: number; + pageH: number; +} + +async function measurePage(page: any) { + return page.evaluate(() => { + const doc = document.documentElement; + const body = document.body; + const root = document.scrollingElement || doc || body; + let maxRight = 0; + let maxBottom = 0; + + if (body) { + const walker = document.createTreeWalker(body, NodeFilter.SHOW_ELEMENT); + let node = walker.currentNode as Element | null; + + while (node) { + const rect = node.getBoundingClientRect?.(); + + if (rect && (rect.width > 0 || rect.height > 0)) { + maxRight = Math.max(maxRight, rect.right); + maxBottom = Math.max(maxBottom, rect.bottom); + } + + node = walker.nextNode() as Element | null; + } + } + + const pageW = Math.max( + window.innerWidth, + root?.scrollWidth || 0, + doc?.scrollWidth || 0, + body?.scrollWidth || 0, + Math.ceil(maxRight), + ); + const pageH = Math.max( + window.innerHeight, + root?.scrollHeight || 0, + doc?.scrollHeight || 0, + body?.scrollHeight || 0, + Math.ceil(maxBottom), + ); + + return { + pageW: Math.ceil(pageW), + pageH: Math.ceil(pageH), + }; + }); +} + +function getSchema() { + const databaseUrl = process.env.DATABASE_URL; + + if (!databaseUrl) { + return null; + } + + try { + const connectionUrl = new URL(databaseUrl); + + return connectionUrl.searchParams.get('schema'); + } catch { + return null; + } +} + +async function rawExecute(sql: string, data: Record = {}) { + const params: any[] = []; + const schema = getSchema(); + + if (schema) { + await prisma.client.$executeRawUnsafe(`SET search_path TO "${schema}";`); + } + + const query = sql.replaceAll(/\{\{\s*(\w+)(::\w+)?\s*}}/g, (...args) => { + const [, name, type] = args; + + params.push(data[name]); + + return `$${params.length}${type ?? ''}`; + }); + + return prisma.client.$executeRawUnsafe(query, ...params); +} + +async function findSnapshot( + websiteId: string, + urlPath: string, + viewportW: number, + viewportH: number, +): Promise { + const rows = await prisma.rawQuery( + ` + select + snapshot_id as id, + website_id as "websiteId", + url_path as "urlPath", + viewport_w as "viewportW", + viewport_h as "viewportH", + page_w as "pageW", + page_h as "pageH", + status, + mime_type as "mimeType", + image_size as "imageSize", + error, + image_data is not null as "hasImage", + updated_at as "updatedAt" + from heatmap_snapshot + where website_id = {{websiteId::uuid}} + and url_path = {{urlPath}} + and viewport_w = {{viewportW}} + and viewport_h = {{viewportH}} + limit 1 + `, + { websiteId, urlPath, viewportW, viewportH }, + 'findHeatmapSnapshot', + ); + + return rows?.[0] ?? null; +} + +function getSnapshotImageUrl(websiteId: string, snapshotId: string) { + return `/api/websites/${websiteId}/heatmaps/snapshots/${snapshotId}`; +} + +function mapSnapshot(websiteId: string, row: SnapshotRecord): HeatmapSnapshotImage { + return { + id: row.id, + imageUrl: row.status === SNAPSHOT_STATUS.ready && row.hasImage ? getSnapshotImageUrl(websiteId, row.id) : null, + status: row.status, + mimeType: row.mimeType, + pageW: Number(row.pageW), + pageH: Number(row.pageH), + viewportW: Number(row.viewportW), + viewportH: Number(row.viewportH), + error: row.error, + }; +} + +function getFirstDomain(domain?: string | null) { + return domain?.split(',')[0]?.trim() || null; +} + +function getWebsiteOrigin(domain?: string | null) { + const host = getFirstDomain(domain); + + if (!host) { + return null; + } + + if (host.startsWith('http://') || host.startsWith('https://')) { + return new URL(host); + } + + const protocol = + host.startsWith('localhost') || host.startsWith('127.0.0.1') || host.startsWith('[::1]') + ? 'http' + : 'https'; + + return new URL(`${protocol}://${host}`); +} + +function buildCaptureUrl(domain: string | null | undefined, urlPath: string) { + const origin = getWebsiteOrigin(domain); + + if (!origin) { + return null; + } + + return new URL(urlPath || '/', origin).toString(); +} + +export function shouldSkipSnapshot(urlPath: string) { + // Internal Umami app routes cannot be rendered from the tracked website domain. + return urlPath.startsWith('/teams/'); +} + +async function upsertSnapshotRecord({ + id, + websiteId, + urlPath, + viewportW, + viewportH, + pageW, + pageH, + status, + mimeType, + imageData, + error, +}: { + id: string; + websiteId: string; + urlPath: string; + viewportW: number; + viewportH: number; + pageW: number; + pageH: number; + status: HeatmapSnapshotStatus; + mimeType: string | null; + imageData: Buffer | null; + error: string | null; +}) { + return rawExecute( + ` + insert into heatmap_snapshot ( + snapshot_id, + website_id, + url_path, + viewport_w, + viewport_h, + page_w, + page_h, + status, + mime_type, + image_data, + image_size, + error, + created_at, + updated_at + ) + values ( + {{id::uuid}}, + {{websiteId::uuid}}, + {{urlPath}}, + {{viewportW}}, + {{viewportH}}, + {{pageW}}, + {{pageH}}, + {{status}}, + {{mimeType}}, + {{imageData}}, + {{imageSize}}, + {{error}}, + now(), + now() + ) + on conflict (website_id, url_path, viewport_w, viewport_h) do update + set + page_w = excluded.page_w, + page_h = excluded.page_h, + status = excluded.status, + mime_type = excluded.mime_type, + image_data = excluded.image_data, + image_size = excluded.image_size, + error = excluded.error, + updated_at = now() + `, + { + id, + websiteId, + urlPath, + viewportW, + viewportH, + pageW, + pageH, + status, + mimeType, + imageData, + imageSize: imageData?.byteLength ?? null, + error, + }, + ); +} + +async function captureSnapshot( + url: string, + viewportW: number, + viewportH: number, + pageW?: number, +): Promise { + const { chromium } = await import('@playwright/test'); + const browser = await chromium.launch({ headless: true }); + const initialViewportW = Math.max(viewportW, pageW || 0); + + try { + const context = await browser.newContext({ + viewport: { width: initialViewportW, height: viewportH }, + screen: { width: initialViewportW, height: viewportH }, + deviceScaleFactor: 1, + ignoreHTTPSErrors: true, + }); + + const page = await context.newPage(); + await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 15000 }); + await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => undefined); + await page.waitForTimeout(500); + + let dimensions = await measurePage(page); + let currentWidth = initialViewportW; + let captureWidth = Math.max(initialViewportW, dimensions.pageW); + + for (let i = 0; i < 3; i++) { + if (captureWidth <= currentWidth) { + break; + } + + currentWidth = captureWidth; + + await page.setViewportSize({ + width: currentWidth, + height: viewportH, + }); + await page.waitForLoadState('networkidle', { timeout: 3000 }).catch(() => undefined); + await page.waitForTimeout(300); + + dimensions = await measurePage(page); + captureWidth = Math.max(currentWidth, dimensions.pageW); + } + + const imageData = Buffer.from(await page.screenshot({ fullPage: true, type: 'png' })); + + await context.close(); + + return { + imageData, + mimeType: 'image/png', + pageW: dimensions.pageW, + pageH: dimensions.pageH, + }; + } finally { + await browser.close(); + } +} + +export async function ensureHeatmapSnapshot({ + websiteId, + urlPath, + viewportW, + viewportH, + pageW, + pageH, +}: EnsureHeatmapSnapshotOptions): Promise { + if (!urlPath || !viewportW || !viewportH || !pageW || !pageH) { + return null; + } + + if (shouldSkipSnapshot(urlPath)) { + return null; + } + + const existing = await findSnapshot(websiteId, urlPath, viewportW, viewportH); + + if (existing?.status === SNAPSHOT_STATUS.ready && existing.hasImage) { + return mapSnapshot(websiteId, existing); + } + + const updatedAt = existing?.updatedAt ? new Date(existing.updatedAt) : null; + const ageMs = updatedAt ? Date.now() - updatedAt.getTime() : Number.POSITIVE_INFINITY; + + if ( + existing?.status === SNAPSHOT_STATUS.pending && + ageMs < SNAPSHOT_PENDING_WINDOW_MS + ) { + return mapSnapshot(websiteId, existing); + } + + if ( + existing?.status === SNAPSHOT_STATUS.failed && + ageMs < SNAPSHOT_RETRY_DELAY_MS + ) { + return mapSnapshot(websiteId, existing); + } + + const snapshotId = existing?.id ?? uuid(); + const website = await getWebsite(websiteId); + const captureUrl = buildCaptureUrl(website?.domain, urlPath); + + if (!captureUrl) { + await upsertSnapshotRecord({ + id: snapshotId, + websiteId, + urlPath, + viewportW, + viewportH, + pageW, + pageH, + status: SNAPSHOT_STATUS.failed, + mimeType: null, + imageData: null, + error: 'Website domain is not configured for screenshot capture.', + }); + + const failed = await findSnapshot(websiteId, urlPath, viewportW, viewportH); + + return failed ? mapSnapshot(websiteId, failed) : null; + } + + await upsertSnapshotRecord({ + id: snapshotId, + websiteId, + urlPath, + viewportW, + viewportH, + pageW, + pageH, + status: SNAPSHOT_STATUS.pending, + mimeType: null, + imageData: null, + error: null, + }); + + try { + const capture = await captureSnapshot(captureUrl, viewportW, viewportH, pageW); + + await upsertSnapshotRecord({ + id: snapshotId, + websiteId, + urlPath, + viewportW, + viewportH, + pageW: capture.pageW, + pageH: capture.pageH, + status: SNAPSHOT_STATUS.ready, + mimeType: capture.mimeType, + imageData: capture.imageData, + error: null, + }); + } catch (error) { + await upsertSnapshotRecord({ + id: snapshotId, + websiteId, + urlPath, + viewportW, + viewportH, + pageW, + pageH, + status: SNAPSHOT_STATUS.failed, + mimeType: null, + imageData: null, + error: error instanceof Error ? error.message.slice(0, 500) : 'Screenshot capture failed.', + }); + } + + const snapshot = await findSnapshot(websiteId, urlPath, viewportW, viewportH); + + return snapshot ? mapSnapshot(websiteId, snapshot) : null; +} + +export async function getHeatmapSnapshotImage( + websiteId: string, + snapshotId: string, +): Promise<{ mimeType: string; imageData: Buffer } | null> { + const rows = await prisma.rawQuery( + ` + select + mime_type as "mimeType", + image_data as "imageData" + from heatmap_snapshot + where snapshot_id = {{snapshotId::uuid}} + and website_id = {{websiteId::uuid}} + and status = 'ready' + and image_data is not null + limit 1 + `, + { websiteId, snapshotId }, + 'getHeatmapSnapshotImage', + ); + + const row = rows?.[0]; + + if (!row?.imageData || !row?.mimeType) { + return null; + } + + return { + mimeType: row.mimeType, + imageData: Buffer.from(row.imageData), + }; +} diff --git a/src/queries/sql/heatmap/getHeatmap.ts b/src/queries/sql/heatmap/getHeatmap.ts index cb79d64d9..0a6c4a932 100644 --- a/src/queries/sql/heatmap/getHeatmap.ts +++ b/src/queries/sql/heatmap/getHeatmap.ts @@ -3,12 +3,17 @@ import { HEATMAP_EVENT_TYPE } from '@/lib/constants'; import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db'; import prisma from '@/lib/prisma'; import type { QueryFilters } from '@/lib/types'; +import { + ensureHeatmapSnapshot, + shouldSkipSnapshot, + type HeatmapSnapshotImage, +} from './ensureHeatmapSnapshot'; const FUNCTION_NAME = 'getHeatmap'; const POINT_LIMIT = 5000; const PAGE_LIMIT = 100; -const SCROLL_BUCKET_SIZE = 5; +const SCROLL_BUCKET_SIZE = 10; export type HeatmapMode = 'click' | 'scroll'; @@ -41,12 +46,7 @@ export interface HeatmapScrollBucket { sessions: number; } -export interface HeatmapSnapshot { - replayId: string; - timestamp: number; - chunkIndex: number | null; - eventIndex: number | null; -} +export type HeatmapSnapshot = HeatmapSnapshotImage; export interface HeatmapResult { mode: HeatmapMode; @@ -73,13 +73,6 @@ export async function getHeatmap( }); } -interface SnapshotRow { - replayId: string; - timestamp: number | string; - chunkIndex: number | string | null; - eventIndex: number | string | null; -} - interface HeatmapFilterContext { joinQuery: string; queryParams: Record; @@ -107,7 +100,7 @@ async function relationalQuery( ` : ''; - const pages: HeatmapPage[] = await rawQuery( + const rawPages: HeatmapPage[] = await rawQuery( ` select h.url_path as "urlPath", @@ -126,6 +119,7 @@ async function relationalQuery( { ...filterContext.queryParams, websiteId, eventType, startDate, endDate }, FUNCTION_NAME, ); + const pages = rawPages.filter(page => !shouldSkipSnapshot(page.urlPath)); if (!urlPath) { return { mode, pages, points: [], snapshot: null, scroll: emptyScroll() }; @@ -190,17 +184,13 @@ async function relationalQuery( viewportW: dim?.viewportW ?? null, viewportH: dim?.viewportH ?? null, }; - const snapshot = await getRelationalSnapshot(rawQuery, { + const snapshot = await ensureHeatmapSnapshot({ websiteId, - eventType, urlPath, - startDate, - endDate, - pageW: scroll.pageW, - pageH: scroll.pageH, viewportW: scroll.viewportW, viewportH: scroll.viewportH, - filterContext, + pageW: scroll.pageW, + pageH: scroll.pageH, }); return { @@ -257,156 +247,18 @@ async function relationalQuery( ); const viewport = pickSnapshotViewport(rawPoints); - const snapshot = await getRelationalSnapshot(rawQuery, { + const snapshot = await ensureHeatmapSnapshot({ websiteId, - eventType, urlPath, - startDate, - endDate, - pageW: null, - pageH: null, viewportW: viewport?.width ?? null, viewportH: viewport?.height ?? null, - filterContext, + pageW: viewport?.pageW ?? null, + pageH: viewport?.pageH ?? null, }); return { mode, pages, points: rawPoints, snapshot, scroll: emptyScroll() }; } -async function getRelationalSnapshot( - rawQuery: typeof prisma.rawQuery, - { - websiteId, - eventType, - urlPath, - startDate, - endDate, - pageW, - pageH, - viewportW, - viewportH, - filterContext, - }: { - websiteId: string; - eventType: number; - urlPath: string; - startDate: Date; - endDate: Date; - pageW: number | null; - pageH: number | null; - viewportW: number | null; - viewportH: number | null; - filterContext: HeatmapFilterContext; - }, -): Promise { - const pageFilter = - pageW && pageH - ? ` - and h.page_w = {{pageW}} - and h.page_h = {{pageH}} - ` - : ''; - const viewportFilter = - viewportW && viewportH - ? ` - and h.viewport_w = {{viewportW}} - and h.viewport_h = {{viewportH}} - ` - : ''; - - const rows: SnapshotRow[] = await rawQuery( - ` - select - h.visit_id as "replayId", - coalesce(h.replay_time_ms, (extract(epoch from h.created_at) * 1000)::bigint) as "timestamp", - h.replay_chunk_index as "chunkIndex", - h.replay_event_index as "eventIndex" - from heatmap_event h - inner join ( - select distinct visit_id - from session_replay - where website_id = {{websiteId::uuid}} - ) sr on sr.visit_id = h.visit_id - ${filterContext.joinQuery} - where h.website_id = {{websiteId::uuid}} - and h.event_type = {{eventType}} - and h.url_path = {{urlPath}} - and h.created_at between {{startDate}} and {{endDate}} - and h.replay_chunk_index is not null - and h.replay_event_index is not null - and h.replay_time_ms is not null - ${pageFilter} - ${viewportFilter} - order by - h.replay_chunk_index asc nulls last, - h.replay_event_index asc nulls last, - h.replay_time_ms asc, - h.created_at asc - limit 1 - `, - { - ...filterContext.queryParams, - websiteId, - eventType, - urlPath, - startDate, - endDate, - pageW, - pageH, - viewportW, - viewportH, - }, - FUNCTION_NAME, - ); - - if (rows.length > 0) { - return mapSnapshot(rows[0]); - } - - const fallbackRows: SnapshotRow[] = await rawQuery( - ` - select - h.visit_id as "replayId", - coalesce(h.replay_time_ms, (extract(epoch from h.created_at) * 1000)::bigint) as "timestamp", - h.replay_chunk_index as "chunkIndex", - h.replay_event_index as "eventIndex" - from heatmap_event h - inner join ( - select distinct visit_id - from session_replay - where website_id = {{websiteId::uuid}} - ) sr on sr.visit_id = h.visit_id - ${filterContext.joinQuery} - where h.website_id = {{websiteId::uuid}} - and h.event_type = {{eventType}} - and h.url_path = {{urlPath}} - and h.created_at between {{startDate}} and {{endDate}} - ${pageFilter} - ${viewportFilter} - order by - h.replay_chunk_index asc nulls last, - h.replay_event_index asc nulls last, - h.created_at asc - limit 1 - `, - { - ...filterContext.queryParams, - websiteId, - eventType, - urlPath, - startDate, - endDate, - pageW, - pageH, - viewportW, - viewportH, - }, - FUNCTION_NAME, - ); - - return mapSnapshot(fallbackRows[0]); -} - async function clickhouseQuery( websiteId: string, parameters: HeatmapParameters, @@ -451,11 +303,13 @@ async function clickhouseQuery( FUNCTION_NAME, ); - const pages: HeatmapPage[] = pageRows.map(p => ({ - urlPath: p.urlPath, - count: Number(p.count), - sessions: Number(p.sessions), - })); + const pages: HeatmapPage[] = pageRows + .map(p => ({ + urlPath: p.urlPath, + count: Number(p.count), + sessions: Number(p.sessions), + })) + .filter(page => !shouldSkipSnapshot(page.urlPath)); if (!urlPath) { return { mode, pages, points: [], snapshot: null, scroll: emptyScroll() }; @@ -524,17 +378,13 @@ async function clickhouseQuery( viewportH: dim?.viewportH === null || dim?.viewportH === undefined ? null : Number(dim.viewportH), }; - const snapshot = await getClickhouseSnapshot(rawQuery, { + const snapshot = await ensureHeatmapSnapshot({ websiteId, - eventType, urlPath, - startDate, - endDate, - pageW: scroll.pageW, - pageH: scroll.pageH, viewportW: scroll.viewportW, viewportH: scroll.viewportH, - filterContext, + pageW: scroll.pageW, + pageH: scroll.pageH, }); return { @@ -617,156 +467,18 @@ async function clickhouseQuery( })); const viewport = pickSnapshotViewport(points); - const snapshot = await getClickhouseSnapshot(rawQuery, { + const snapshot = await ensureHeatmapSnapshot({ websiteId, - eventType, urlPath, - startDate, - endDate, - pageW: null, - pageH: null, viewportW: viewport?.width ?? null, viewportH: viewport?.height ?? null, - filterContext, + pageW: viewport?.pageW ?? null, + pageH: viewport?.pageH ?? null, }); return { mode, pages, points, snapshot, scroll: emptyScroll() }; } -async function getClickhouseSnapshot( - rawQuery: typeof clickhouse.rawQuery, - { - websiteId, - eventType, - urlPath, - startDate, - endDate, - pageW, - pageH, - viewportW, - viewportH, - filterContext, - }: { - websiteId: string; - eventType: number; - urlPath: string; - startDate: Date; - endDate: Date; - pageW: number | null; - pageH: number | null; - viewportW: number | null; - viewportH: number | null; - filterContext: HeatmapFilterContext; - }, -): Promise { - const pageFilter = - pageW && pageH - ? ` - and h.page_w = {pageW:UInt32} - and h.page_h = {pageH:UInt32} - ` - : ''; - const viewportFilter = - viewportW && viewportH - ? ` - and h.viewport_w = {viewportW:UInt32} - and h.viewport_h = {viewportH:UInt32} - ` - : ''; - - const rows = await rawQuery( - ` - select - toString(h.visit_id) as replayId, - ifNull(h.replay_time_ms, toInt64(toUnixTimestamp(h.created_at)) * 1000) as timestamp, - h.replay_chunk_index as chunkIndex, - h.replay_event_index as eventIndex - from heatmap_event h - inner join ( - select distinct visit_id - from session_replay - where website_id = {websiteId:UUID} - ) sr on sr.visit_id = h.visit_id - ${filterContext.joinQuery} - where h.website_id = {websiteId:UUID} - and h.event_type = {eventType:UInt8} - and h.url_path = {urlPath:String} - and h.created_at between {startDate:DateTime64} and {endDate:DateTime64} - and h.replay_chunk_index is not null - and h.replay_event_index is not null - and h.replay_time_ms is not null - ${pageFilter} - ${viewportFilter} - order by - h.replay_chunk_index asc, - h.replay_event_index asc, - h.replay_time_ms asc, - h.created_at asc - limit 1 - `, - { - ...filterContext.queryParams, - websiteId, - eventType, - urlPath, - startDate, - endDate, - pageW, - pageH, - viewportW, - viewportH, - }, - FUNCTION_NAME, - ); - - if (rows.length > 0) { - return mapSnapshot(rows[0]); - } - - const fallbackRows = await rawQuery( - ` - select - toString(h.visit_id) as replayId, - ifNull(h.replay_time_ms, toInt64(toUnixTimestamp(h.created_at)) * 1000) as timestamp, - h.replay_chunk_index as chunkIndex, - h.replay_event_index as eventIndex - from heatmap_event h - inner join ( - select distinct visit_id - from session_replay - where website_id = {websiteId:UUID} - ) sr on sr.visit_id = h.visit_id - ${filterContext.joinQuery} - where h.website_id = {websiteId:UUID} - and h.event_type = {eventType:UInt8} - and h.url_path = {urlPath:String} - and h.created_at between {startDate:DateTime64} and {endDate:DateTime64} - ${pageFilter} - ${viewportFilter} - order by - h.replay_chunk_index asc, - h.replay_event_index asc, - h.created_at asc - limit 1 - `, - { - ...filterContext.queryParams, - websiteId, - eventType, - urlPath, - startDate, - endDate, - pageW, - pageH, - viewportW, - viewportH, - }, - FUNCTION_NAME, - ); - - return mapSnapshot(fallbackRows[0]); -} - function emptyScroll(): HeatmapResult['scroll'] { return { buckets: [], @@ -839,21 +551,6 @@ function pickSnapshotViewport( }; } -function mapSnapshot(row?: SnapshotRow | null): HeatmapSnapshot | null { - if (!row) { - return null; - } - - return { - replayId: row.replayId, - timestamp: Number(row.timestamp), - chunkIndex: - row.chunkIndex === null || row.chunkIndex === undefined ? null : Number(row.chunkIndex), - eventIndex: - row.eventIndex === null || row.eventIndex === undefined ? null : Number(row.eventIndex), - }; -} - function getRelationalHeatmapFilterContext( websiteId: string, filters: QueryFilters, diff --git a/src/queries/sql/heatmap/saveHeatmapEvents.ts b/src/queries/sql/heatmap/saveHeatmapEvents.ts index 0229e418c..78988d0a5 100644 --- a/src/queries/sql/heatmap/saveHeatmapEvents.ts +++ b/src/queries/sql/heatmap/saveHeatmapEvents.ts @@ -29,12 +29,38 @@ export interface HeatmapEventRow { export async function saveHeatmapEvents(rows: HeatmapEventRow[]) { if (!rows?.length) return; + const normalizedRows = rows.map(r => ({ + ...r, + nodeId: toInt(r.nodeId), + x: toInt(r.x), + y: toInt(r.y), + pageX: toInt(r.pageX), + pageY: toInt(r.pageY), + pageW: toInt(r.pageW), + viewportW: toInt(r.viewportW), + viewportH: toInt(r.viewportH), + pageH: toInt(r.pageH), + scrollPct: toScrollPct(r.scrollPct), + })); + return runQuery({ - [PRISMA]: () => relationalQuery(rows), - [CLICKHOUSE]: () => clickhouseQuery(rows), + [PRISMA]: () => relationalQuery(normalizedRows), + [CLICKHOUSE]: () => clickhouseQuery(normalizedRows), }); } +function toInt(value: number | null) { + return typeof value === 'number' && Number.isFinite(value) ? Math.round(value) : null; +} + +function toScrollPct(value: number | null) { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return null; + } + + return Math.max(0, Math.min(100, Math.round(value))); +} + async function relationalQuery(rows: HeatmapEventRow[]) { return prisma.client.heatmapEvent.createMany({ data: rows.map(r => ({ diff --git a/src/recorder/index.js b/src/recorder/index.js index b740fc546..38c27bf41 100644 --- a/src/recorder/index.js +++ b/src/recorder/index.js @@ -244,6 +244,7 @@ import { record } from 'rrweb'; let scrollUrl = location.href; let maxScrollPct = 0; + let lastFlushedScrollPct = 0; let scrollTimer = null; const computePageMetrics = ({ includeBounds = false } = {}) => { @@ -286,7 +287,7 @@ import { record } from 'rrweb'; }; const flushScroll = () => { - if (maxScrollPct <= 0) return; + if (maxScrollPct <= 0 || maxScrollPct <= lastFlushedScrollPct) return; const { pageW, pageH } = computePageMetrics({ includeBounds: true }); addCustomEvent('scroll-progress', { @@ -297,6 +298,7 @@ import { record } from 'rrweb'; pageW, pageH, }); + lastFlushedScrollPct = maxScrollPct; maxScrollPct = 0; }; @@ -336,14 +338,16 @@ import { record } from 'rrweb'; scrollTimer = setTimeout(() => { const { pct } = computeScrollPct(); if (pct > maxScrollPct) maxScrollPct = pct; + flushScroll(); scrollTimer = null; - }, 200); + }, 400); }; const onUrlChange = () => { if (location.href === scrollUrl) return; flushScroll(); scrollUrl = location.href; + lastFlushedScrollPct = 0; addCustomEvent('url-change', { url: scrollUrl }); }; @@ -365,6 +369,7 @@ import { record } from 'rrweb'; { const { pct } = computeScrollPct(); if (pct > maxScrollPct) maxScrollPct = pct; + flushScroll(); } document.addEventListener('visibilitychange', () => {