From 8b1d196812dddd342d89bc4ad378fd59f5373b71 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Fri, 6 Mar 2026 20:00:22 +0100 Subject: [PATCH] feat: add automatic 404 detection, scroll depth tracking, and scroll depth dashboard card - 404 detection: checks document.title for "404" or "not found", fires custom event, SPA-aware - Scroll depth: passive scroll listener fires events at 25/50/75/100% thresholds - ScrollDepth dashboard card: progress bar visualization showing % of visitors reaching each threshold - Scroll events filtered out of GoalStats to avoid duplication - Both features on by default, opt-out via data-no-404 / data-no-scroll --- CHANGELOG.md | 2 + app/sites/[id]/page.tsx | 6 ++- components/dashboard/ScrollDepth.tsx | 80 ++++++++++++++++++++++++++++ public/script.js | 66 ++++++++++++++++++++++- 4 files changed, 151 insertions(+), 3 deletions(-) create mode 100644 components/dashboard/ScrollDepth.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 36f9ab0..a8d4725 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - **AI traffic source identification.** Pulse now automatically recognizes visitors coming from AI tools — ChatGPT, Perplexity, Claude, Gemini, Copilot, DeepSeek, Grok, Meta AI, You.com, and Phind. These sources appear in your Top Referrers with proper brand icons and display names instead of raw domain URLs. If someone clicks a link in an AI chat to visit your site, you'll see exactly which AI tool sent them. - **Automatic outbound link tracking.** Pulse now tracks when visitors click links that take them to other websites. These show up as "outbound link" events in your Goals & Events panel — no setup needed. You can turn this off in your tracking snippet settings if you prefer. - **Automatic file download tracking.** When a visitor clicks a link to a downloadable file — PDF, ZIP, Excel, Word, MP3, and 20+ other formats — Pulse records it as a "file download" event. Like outbound links, this works automatically with no setup required. +- **Automatic 404 error page detection.** Pulse now detects when a visitor lands on a page that doesn't exist and records it as a "404" event. You'll see these in your Goals & Events panel so you can find and fix broken links. Works automatically — no setup needed. +- **Automatic scroll depth tracking.** Pulse now tracks how far visitors scroll down each page — at 25%, 50%, 75%, and 100% milestones. These show up as scroll events in your Goals & Events panel, helping you understand which content keeps people reading. Works automatically with no configuration required. ### Improved diff --git a/app/sites/[id]/page.tsx b/app/sites/[id]/page.tsx index 31ace64..8651173 100644 --- a/app/sites/[id]/page.tsx +++ b/app/sites/[id]/page.tsx @@ -19,6 +19,7 @@ import TechSpecs from '@/components/dashboard/TechSpecs' import Chart from '@/components/dashboard/Chart' import PerformanceStats from '@/components/dashboard/PerformanceStats' import GoalStats from '@/components/dashboard/GoalStats' +import ScrollDepth from '@/components/dashboard/ScrollDepth' import Campaigns from '@/components/dashboard/Campaigns' import { useDashboardOverview, @@ -380,8 +381,9 @@ export default function SiteDashboardPage() { -
- +
+ !/^scroll_\d+$/.test(g.event_name))} /> +
() + for (const row of goalCounts) { + const match = row.event_name.match(/^scroll_(\d+)$/) + if (match) { + scrollCounts.set(Number(match[1]), row.count) + } + } + + const hasData = scrollCounts.size > 0 && totalPageviews > 0 + + return ( +
+
+

+ Scroll Depth +

+
+ + {hasData ? ( +
+ {THRESHOLDS.map((threshold) => { + const count = scrollCounts.get(threshold) ?? 0 + const pct = totalPageviews > 0 ? Math.round((count / totalPageviews) * 100) : 0 + const barWidth = Math.max(pct, 2) + + return ( +
+
+ + {threshold}% + +
+ + {formatNumber(count)} + + + {pct}% + +
+
+
+
+
+
+ ) + })} +
+ ) : ( +
+
+ +
+

+ No scroll data yet +

+

+ Scroll depth tracking is automatic — data will appear here once visitors start scrolling on your pages. +

+
+ )} +
+ ) +} diff --git a/public/script.js b/public/script.js index 990e0db..ee704b6 100644 --- a/public/script.js +++ b/public/script.js @@ -299,6 +299,10 @@ if (url !== lastUrl) { lastUrl = url; trackPageview(); + // * Check for 404 after SPA navigation (deferred so title updates first) + setTimeout(check404, 100); + // * Reset scroll depth tracking for the new page + if (trackScroll) scrollFired = {}; } } new MutationObserver(onUrlChange).observe(document, { subtree: true, childList: true }); @@ -308,7 +312,11 @@ history.replaceState = function() { _replace.apply(this, arguments); onUrlChange(); }; // * Track popstate (browser back/forward) - window.addEventListener('popstate', trackPageview); + window.addEventListener('popstate', function() { + trackPageview(); + setTimeout(check404, 100); + if (trackScroll) scrollFired = {}; + }); // * Custom events / goals: validate event name (letters, numbers, underscores only; max 64 chars) var EVENT_NAME_MAX = 64; @@ -346,6 +354,62 @@ window.pulse = window.pulse || {}; window.pulse.track = trackCustomEvent; + // * Auto-track 404 error pages (on by default) + // * Detects pages where document.title contains "404" or "not found" + // * Opt-out: add data-no-404 to the script tag + var track404 = !script.hasAttribute('data-no-404'); + var sent404ForUrl = ''; + + function check404() { + if (!track404) return; + // * Only fire once per URL + var currentUrl = location.href; + if (sent404ForUrl === currentUrl) return; + if (/404|not found/i.test(document.title)) { + sent404ForUrl = currentUrl; + trackCustomEvent('404'); + } + } + + // * Check on initial load (deferred so SPAs can set title) + setTimeout(check404, 0); + + // * Auto-track scroll depth at 25%, 50%, 75%, and 100% (on by default) + // * Each threshold fires once per pageview; resets on SPA navigation + // * Opt-out: add data-no-scroll to the script tag + var trackScroll = !script.hasAttribute('data-no-scroll'); + + if (trackScroll) { + var scrollThresholds = [25, 50, 75, 100]; + var scrollFired = {}; + var scrollTicking = false; + + function checkScroll() { + var docHeight = document.documentElement.scrollHeight; + var viewHeight = window.innerHeight; + // * Page fits in viewport — nothing to scroll + if (docHeight <= viewHeight) return; + var scrollTop = window.scrollY; + var scrollPercent = Math.round((scrollTop + viewHeight) / docHeight * 100); + + for (var i = 0; i < scrollThresholds.length; i++) { + var t = scrollThresholds[i]; + if (!scrollFired[t] && scrollPercent >= t) { + scrollFired[t] = true; + trackCustomEvent('scroll_' + t); + } + } + scrollTicking = false; + } + + window.addEventListener('scroll', function() { + if (!scrollTicking) { + scrollTicking = true; + requestAnimationFrame(checkScroll); + } + }, { passive: true }); + } + // * Auto-track outbound link clicks and file downloads (on by default) // * Opt-out: add data-no-outbound or data-no-downloads to the script tag var trackOutbound = !script.hasAttribute('data-no-outbound');