Admin step A: privacy-respecting first-party event logging

- events table (kind, article_id, visitor_hash, day) with a UNIQUE key that dedups
  to one row per visitor-day — caps volume and makes counts mean distinct
  visitor-days. NO ip/ua/referrer/url. Groupings derived from article_id at query
  time, never stored.
- POST /api/events (public): whitelisted kinds (visit/open/share_ub/copy_source/
  native_share/source_click); visitor token hashed server-side (never raw).
- Frontend analytics.js: random localStorage visitor token; track() via sendBeacon;
  visit once/day; open on article click; share_ub/copy_source/native_share from the
  share menu; /a landing pages fire source_click. 127 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jay
2026-06-03 18:21:49 +00:00
parent ab5caada0b
commit 1a778e1334
7 changed files with 180 additions and 4 deletions
+2
View File
@@ -10,6 +10,7 @@
import BoundariesPanel from '$lib/components/BoundariesPanel.svelte';
import SignIn from '$lib/components/SignIn.svelte';
import { auth, savedIds, refresh as refreshAuth } from '$lib/auth.svelte.js';
import { trackVisit } from '$lib/analytics.js';
let moods = $state([]);
let topics = $state([]);
@@ -302,6 +303,7 @@
dismissed = new Set(P.loadJSON(DISMISSED_KEY, []));
history = P.loadJSON(HISTORY_KEY, []);
refreshAuth(); // resolve any existing session (non-blocking)
trackVisit(); // anonymous, once/day
try {
moods = await getJSON('/api/moods');
topics = (await getJSON('/api/categories')).topics;