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
+11 -4
View File
@@ -1,7 +1,13 @@
<script>
import { auth, savedIds, toggleSave } from '$lib/auth.svelte.js';
import { track } from '$lib/analytics.js';
let { article, onaction, onreplace, ontag, onimageerror, onview, hero = false } = $props();
function opened() {
onview?.(article);
track('open', article.id);
}
let isSaved = $derived(savedIds.has(article.id));
const humanize = (s) => (s || '').replace(/-/g, ' ');
@@ -52,6 +58,7 @@
async function nativeShare() {
try {
await navigator.share({ title: article.title, url: shareUrl });
track('native_share', article.id);
} catch {
/* user cancelled */
}
@@ -80,7 +87,7 @@
data-topic={article.topic ?? ''}
>
{#if showImage}
<a class="media" href={safeHref} target="_blank" rel="noopener" onclick={() => onview?.(article)}>
<a class="media" href={safeHref} target="_blank" rel="noopener" onclick={opened}>
<img src={article.image_url} alt="" loading="lazy" referrerpolicy="no-referrer"
onerror={() => { failed = true; onimageerror?.(); }} />
</a>
@@ -101,7 +108,7 @@
</div>
<div class="src">{article.source}</div>
<h3><a href={safeHref} target="_blank" rel="noopener" onclick={() => onview?.(article)}>{article.title}</a></h3>
<h3><a href={safeHref} target="_blank" rel="noopener" onclick={opened}>{article.title}</a></h3>
{#if hero && article.description}
<p class="desc">{article.description}</p>
@@ -139,10 +146,10 @@
{#if canNativeShare}
<button role="menuitem" onclick={nativeShare}>Share…</button>
{/if}
<button role="menuitem" onclick={() => copy(shareUrl, 'link')}>
<button role="menuitem" onclick={() => { copy(shareUrl, 'link'); track('share_ub', article.id); }}>
{copied === 'link' ? 'Copied!' : 'Copy link'}
</button>
<button role="menuitem" onclick={() => copy(article.url, 'source')}>
<button role="menuitem" onclick={() => { copy(article.url, 'source'); track('copy_source', article.id); }}>
{copied === 'source' ? 'Copied!' : 'Copy source link'}
</button>
</div>