diff --git a/public/script.js b/public/script.js index 9d3ad79..2794e9a 100644 --- a/public/script.js +++ b/public/script.js @@ -547,6 +547,127 @@ }, true); // * Capture phase } + // * Auto-track dead clicks (clicks on interactive elements that produce no effect) + // * Fires dead_click when an interactive element is clicked but no DOM change, navigation, + // * or network request occurs within 1 second + // * Opt-out: add data-no-dead to the script tag + if (!script.getAttribute('data-no-dead')) { + var INTERACTIVE_SELECTOR = 'a,button,input,select,textarea,[role="button"],[role="link"],[role="tab"],[role="menuitem"],[onclick],[tabindex]'; + var DEAD_CLICK_DEBOUNCE = 10000; + var DEAD_CLEANUP_INTERVAL = 30000; + var deadClickDebounce = {}; // * selector -> lastFiredTimestamp + + // * Cleanup stale dead click debounce entries every 30 seconds + setInterval(function() { + var now = Date.now(); + for (var key in deadClickDebounce) { + if (!deadClickDebounce.hasOwnProperty(key)) continue; + if (now - deadClickDebounce[key] > DEAD_CLEANUP_INTERVAL) { + delete deadClickDebounce[key]; + } + } + }, DEAD_CLEANUP_INTERVAL); + + // * Polyfill check for Element.matches + var matchesFn = (function() { + var ep = Element.prototype; + return ep.matches || ep.msMatchesSelector || ep.webkitMatchesSelector || null; + })(); + + // * Find the nearest interactive element by walking up max 3 levels + function findInteractiveElement(el) { + if (!matchesFn) return null; + var depth = 0; + var current = el; + while (current && depth <= 3) { + if (current.nodeType === 1 && matchesFn.call(current, INTERACTIVE_SELECTOR)) { + return current; + } + current = current.parentElement; + depth++; + } + return null; + } + + document.addEventListener('click', function(e) { + var target = findInteractiveElement(e.target); + if (!target) return; + + var selector = getElementIdentifier(target); + if (!selector) return; + + var now = Date.now(); + + // * Debounce: max one dead_click per element per 10 seconds + if (deadClickDebounce[selector] && now - deadClickDebounce[selector] < DEAD_CLICK_DEBOUNCE) { + return; + } + + var currentPath = window.location.pathname + window.location.search; + var clickX = String(Math.round(e.clientX)); + var clickY = String(Math.round(e.clientY)); + var effectDetected = false; + var hrefBefore = location.href; + var mutationObs = null; + var perfObs = null; + var cleanupTimer = null; + + function cleanup() { + if (mutationObs) { try { mutationObs.disconnect(); } catch (ex) {} mutationObs = null; } + if (perfObs) { try { perfObs.disconnect(); } catch (ex) {} perfObs = null; } + if (cleanupTimer) { clearTimeout(cleanupTimer); cleanupTimer = null; } + } + + function onEffect() { + effectDetected = true; + cleanup(); + } + + // * Set up MutationObserver to detect DOM changes on the element and its parent + if (typeof MutationObserver !== 'undefined') { + try { + mutationObs = new MutationObserver(function() { + onEffect(); + }); + var mutOpts = { childList: true, attributes: true, characterData: true, subtree: true }; + mutationObs.observe(target, mutOpts); + if (target.parentElement) { + mutationObs.observe(target.parentElement, mutOpts); + } + } catch (ex) { + mutationObs = null; + } + } + + // * Set up PerformanceObserver to detect network requests + if (typeof PerformanceObserver !== 'undefined') { + try { + perfObs = new PerformanceObserver(function() { + onEffect(); + }); + perfObs.observe({ type: 'resource' }); + } catch (ex) { + perfObs = null; + } + } + + // * After 1 second, check if any effect was detected + cleanupTimer = setTimeout(function() { + cleanup(); + // * Also check if navigation occurred + if (effectDetected || location.href !== hrefBefore) return; + + deadClickDebounce[selector] = Date.now(); + trackCustomEvent('dead_click', { + selector: selector, + page_path: currentPath, + x: clickX, + y: clickY + }); + }, 1000); + }, true); // * Capture phase + } + // * 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');