diff --git a/frontend/src/lib/analytics.js b/frontend/src/lib/analytics.js index 75f7c86..8f44bf9 100644 --- a/frontend/src/lib/analytics.js +++ b/frontend/src/lib/analytics.js @@ -3,6 +3,7 @@ // admin dashboard tell "new vs returning" in aggregate. Server hashes it. const VISITOR_KEY = 'goodnews:visitor'; const VISITDAY_KEY = 'goodnews:visitday'; +const ENGAGEDDAY_KEY = 'goodnews:engagedday'; export function visitorId() { try { @@ -63,3 +64,38 @@ export function trackVisit() { /* ignore */ } } + +// "Engaged reader" signal — what separates real readers from a JS-capable bot that +// trips a raw visit. Fire 'engaged' at most once/day, and ONLY once the page has been +// VISIBLE for ~8s AND we've seen a genuine gesture (scroll/pointer/key/touch). Captures +// nothing beyond the day-deduped visitor token. Returns a cleanup fn. (A deliberate +// action — source-click, share, game start — also counts as engagement, server-side.) +export function armEngaged() { + let fired = false, gesture = false, visibleSecs = 0; + function fire() { + if (fired || !gesture || visibleSecs < 8) return; + fired = true; + try { + const today = new Date().toISOString().slice(0, 10); + if (localStorage.getItem(ENGAGEDDAY_KEY) !== today) { + localStorage.setItem(ENGAGEDDAY_KEY, today); + track('engaged'); + } + } catch { /* ignore */ } + cleanup(); + } + const onGesture = () => { gesture = true; fire(); }; + const tick = setInterval(() => { + if (typeof document === 'undefined' || document.visibilityState === 'visible') { + visibleSecs += 1; + fire(); + } + }, 1000); + const evs = ['scroll', 'pointerdown', 'keydown', 'touchstart']; + function cleanup() { + clearInterval(tick); + if (typeof window !== 'undefined') evs.forEach((e) => window.removeEventListener(e, onGesture)); + } + if (typeof window !== 'undefined') evs.forEach((e) => window.addEventListener(e, onGesture, { passive: true })); + return cleanup; +} diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 25382bb..ce5a5b0 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -3,7 +3,7 @@ import { onMount } from 'svelte'; import FeedbackModal from '$lib/components/FeedbackModal.svelte'; import { fb, closeFeedback } from '$lib/feedback.svelte.js'; - import { trackVisit } from '$lib/analytics.js'; + import { trackVisit, armEngaged } from '$lib/analytics.js'; let { children } = $props(); // Tell the boot-failure seatbelt (app.html) the app mounted — clears the // recovery card + timeout as soon as the shell hydrates. @@ -12,6 +12,8 @@ // Count the daily visit at the LAYOUT level so every landing page counts — // direct /play and /a/ arrivals included, not just the news homepage. trackVisit(); + // Arm the once-daily "engaged reader" signal (≈8s visible + a real gesture). + return armEngaged(); // cleanup on layout teardown }); diff --git a/frontend/src/routes/admin/+page.svelte b/frontend/src/routes/admin/+page.svelte index b429694..4007681 100644 --- a/frontend/src/routes/admin/+page.svelte +++ b/frontend/src/routes/admin/+page.svelte @@ -744,9 +744,10 @@
export audience CSV ({range}d) ↓