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:
@@ -0,0 +1,49 @@
|
||||
// Privacy-respecting, first-party analytics. The visitor token is a random
|
||||
// localStorage string — never email, IP, or a fingerprint. It only lets the
|
||||
// admin dashboard tell "new vs returning" in aggregate. Server hashes it.
|
||||
const VISITOR_KEY = 'goodnews:visitor';
|
||||
const VISITDAY_KEY = 'goodnews:visitday';
|
||||
|
||||
function visitorId() {
|
||||
try {
|
||||
let v = localStorage.getItem(VISITOR_KEY);
|
||||
if (!v) {
|
||||
v = crypto?.randomUUID ? crypto.randomUUID() : String(Math.random()).slice(2) + Date.now();
|
||||
localStorage.setItem(VISITOR_KEY, v);
|
||||
}
|
||||
return v;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export function track(kind, articleId = 0) {
|
||||
try {
|
||||
const body = JSON.stringify({ kind, article_id: articleId || 0, visitor: visitorId() });
|
||||
if (navigator.sendBeacon) {
|
||||
navigator.sendBeacon('/api/events', new Blob([body], { type: 'application/json' }));
|
||||
} else {
|
||||
fetch('/api/events', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body,
|
||||
keepalive: true,
|
||||
}).catch(() => {});
|
||||
}
|
||||
} catch {
|
||||
/* best-effort, never throws */
|
||||
}
|
||||
}
|
||||
|
||||
// Count a visit at most once per day per device.
|
||||
export function trackVisit() {
|
||||
try {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
if (localStorage.getItem(VISITDAY_KEY) !== today) {
|
||||
localStorage.setItem(VISITDAY_KEY, today);
|
||||
track('visit');
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user