diff --git a/frontend/src/lib/analytics.js b/frontend/src/lib/analytics.js
new file mode 100644
index 0000000..bff4f44
--- /dev/null
+++ b/frontend/src/lib/analytics.js
@@ -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 */
+ }
+}
diff --git a/frontend/src/lib/components/ArticleCard.svelte b/frontend/src/lib/components/ArticleCard.svelte
index 34f87bf..10f02e0 100644
--- a/frontend/src/lib/components/ArticleCard.svelte
+++ b/frontend/src/lib/components/ArticleCard.svelte
@@ -1,7 +1,13 @@
"""
+
+ # Fire a privacy-respecting 'source_click' when the reader heads to the source
+ # (reuses the SPA's anonymous visitor token from same-origin localStorage).
+ src_click = f""""""
return f"""
@@ -134,6 +149,7 @@ def render_share_page(article: dict, base_url: str, summary: str | None = None)
+ {src_click}
{poll}