From 1f090a58439a2b1b029136f3499e1f144b83f0d7 Mon Sep 17 00:00:00 2001 From: kit Date: Fri, 24 Jul 2026 23:41:57 +0200 Subject: [PATCH] Add privacy-first tracking --- src/components/CodeEmbed/index.jsx | 3 + src/components/ReferenceTracker/index.tsx | 74 +++++++++++++++++++++++ src/components/SearchProvider/index.tsx | 19 ++++-- src/layouts/BaseLayout.astro | 6 ++ types/fathom.d.ts | 5 ++ 5 files changed, 102 insertions(+), 5 deletions(-) create mode 100644 src/components/ReferenceTracker/index.tsx create mode 100644 types/fathom.d.ts diff --git a/src/components/CodeEmbed/index.jsx b/src/components/CodeEmbed/index.jsx index f2db3c0897..a18dcbee30 100644 --- a/src/components/CodeEmbed/index.jsx +++ b/src/components/CodeEmbed/index.jsx @@ -63,6 +63,9 @@ export const CodeEmbed = (props) => { setPreviewCodeString(codeString); } announce("Sketch is running"); + + // Analytics for per-user (anonymized, no-cookie) conversion: did they run a sketch? + window.fathom.trackEvent(`Run Sketch`); }; const [previewCodeString, setPreviewCodeString] = useState(codeString); diff --git a/src/components/ReferenceTracker/index.tsx b/src/components/ReferenceTracker/index.tsx new file mode 100644 index 0000000000..20e31030d0 --- /dev/null +++ b/src/components/ReferenceTracker/index.tsx @@ -0,0 +1,74 @@ +import { useEffect, useRef } from "preact/hooks"; + +const TIME_LABELS: Record = { + 10000: "Focus 10s", + 30000: "Focus 30s", + 90000: "Focus 90s", + 300000: "Focus 5min", + 600000: "Focus 10min", +}; + +const SCROLL_LABELS: Record = { + 25: "Scroll 25%", + 50: "Scroll 50%", + 75: "Scroll 75%", + 100: "Scroll 100%", +}; + +const track = (label: string) => { + // Fathom analytics does not use cookies. The data collected + // is aggregate and minimal. The scope is currently only on + // the Reference page, but it can be generalized in the future. + if (window.fathom && window.location.pathname.startsWith("/reference/")) { + window.fathom.trackEvent(label); + } +}; + +const ReferenceTracker = ({ + timeThresholds, + scrollThresholds, +}: { + timeThresholds: number[]; + scrollThresholds: number[]; +}) => { + const hasFiredTime = useRef>(new Set()); + const hasFiredScroll = useRef>(new Set()); + const elapsed = useRef(0); + + useEffect(() => { + const tick = () => { + if (document.hidden) return; + elapsed.current += 1000; + for (const ms of timeThresholds) { + if (elapsed.current >= ms && !hasFiredTime.current.has(ms)) { + hasFiredTime.current.add(ms); + track(TIME_LABELS[ms]); + } + } + }; + + const onScroll = () => { + const max = document.documentElement.scrollHeight - document.documentElement.clientHeight; + if (max <= 0) return; + const pct = Math.round((window.scrollY / max) * 100); + for (const t of scrollThresholds) { + if (pct >= t && !hasFiredScroll.current.has(t)) { + hasFiredScroll.current.add(t); + track(SCROLL_LABELS[t]); + } + } + }; + + const interval = setInterval(tick, 1000); + window.addEventListener("scroll", onScroll, { passive: true }); + + return () => { + clearInterval(interval); + window.removeEventListener("scroll", onScroll); + }; + }, [timeThresholds, scrollThresholds]); + + return null; +}; + +export default ReferenceTracker; \ No newline at end of file diff --git a/src/components/SearchProvider/index.tsx b/src/components/SearchProvider/index.tsx index 05b9d28835..c818c537c4 100644 --- a/src/components/SearchProvider/index.tsx +++ b/src/components/SearchProvider/index.tsx @@ -101,11 +101,20 @@ const SearchProvider = ({ }; const fuse = new Fuse(flatData, fuseOptions); - - const searchResults = fuse - .search(searchTerm) - .map((result) => result.item); - + const fuseResults = fuse.search(searchTerm); + + const hasExactMatch = fuseResults.some(r => (r.score ?? 0) < 0.1); + if (fuseResults.length > 0 && window.fathom) { + if (hasExactMatch) { + // Only track search term if there is an exact match + window.fathom.trackEvent(`Search ${fuseResults.length} ${searchTerm}`); + } else { + // Otherwise, that a search occurred but without a match + window.fathom.trackEvent(`Search ${fuseResults.length} [FUZZY]`); + } + } + + const searchResults = fuseResults.map((result) => result.item); setResults(searchResults); }) .catch((error) => diff --git a/src/layouts/BaseLayout.astro b/src/layouts/BaseLayout.astro index 48e6929b24..b8e8db8002 100644 --- a/src/layouts/BaseLayout.astro +++ b/src/layouts/BaseLayout.astro @@ -14,6 +14,7 @@ import type { CollectionEntry } from "astro:content"; import { render } from "astro:content"; import { getCollectionInLocaleWithFallbacks } from "@pages/_utils"; import { removeLocalePrefix } from "@i18n/utils"; +import ReferenceTracker from "@components/ReferenceTracker"; interface Props { title: string; @@ -132,5 +133,10 @@ const headerTopic = topic