User feedback + expanded privacy-respecting admin stats

Feedback:
- feedback table; POST /api/feedback (anonymous-ok, optional category/email,
  honeypot + per-day flood cap) stores + emails the admin; GET /api/admin/feedback.
- Shared feedback store + FeedbackModal; a speech-bubble opens it from the desktop
  header, the mobile top bar (logo moves left), the footer, and /account. Feedback
  section in /admin.

Stats (additive, same privacy model — no IP/UA/referrer/raw terms):
- Event vocab: summary_viewed (fired on /a load), full_story (card → source),
  not_today/less_like_this/hide_topic, replace_used/replace_none, paywall_replace,
  paywalled_source_open. Card title/image opens /a (no double-count); history
  records via keepalive so it survives the nav.
- Dashboard: Accounts card (counts only), reading funnel (summary→source rate),
  emotional-mix & friction, paywall, returning-visitor buckets. (Health metrics
  deferred to a future monitoring dashboard.) 131 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jay
2026-06-05 12:58:49 +00:00
parent cfde4e22db
commit 427210ac3e
16 changed files with 460 additions and 38 deletions
+12 -4
View File
@@ -4,8 +4,9 @@
import { getJSON } from '$lib/api.js';
import { auth, savedIds, refresh } from '$lib/auth.svelte.js';
import { prefs, initPrefs, persistPrefs } from '$lib/prefs.svelte.js';
import { history, initHistory, loadServerHistory, removeOne, clearAll } from '$lib/history.svelte.js';
import { history, initHistory, loadServerHistory, removeOne, clearAll, record } from '$lib/history.svelte.js';
import { track } from '$lib/analytics.js';
import { openFeedback } from '$lib/feedback.svelte.js';
import AccountPanel from '$lib/components/AccountPanel.svelte';
import BoundariesPanel from '$lib/components/BoundariesPanel.svelte';
import ArticleCard from '$lib/components/ArticleCard.svelte';
@@ -43,7 +44,12 @@
<header class="bar">
<div class="container inner">
<a class="brand" href="/" aria-label="Upbeat Bytes — home"><img class="logo" src="/logo.svg" alt="Upbeat Bytes" /></a>
<a class="back" href="/">← Back to news</a>
<div class="baractions">
<button class="fb" onclick={openFeedback} aria-label="Share feedback" title="Share feedback">
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true"><path d="M4 5h16v11H8l-4 3z" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round" /></svg>
</button>
<a class="back" href="/">← Back</a>
</div>
</div>
</header>
@@ -73,7 +79,7 @@
<ul class="hist">
{#each historyItems as a (a.id)}
<li>
<a href={a.url} target="_blank" rel="noopener" onclick={() => track('open', a.id)}>{a.title}</a>
<a href={a.url} target="_blank" rel="noopener" onclick={() => track('full_story', a.id)}>{a.title}</a>
<span class="hsrc">{a.source}</span>
<button class="hx" aria-label="Remove" onclick={() => removeOne(a.id)}>×</button>
</li>
@@ -90,7 +96,7 @@
{:else if savedShown.length}
<div class="grid">
{#each savedShown as a (a.id)}
<ArticleCard article={a} onview={(x) => track('open', x.id)} />
<ArticleCard article={a} onview={record} />
{/each}
</div>
{:else if savedReady}
@@ -115,6 +121,8 @@
.inner { display: flex; align-items: center; justify-content: space-between; height: 64px; }
.logo { height: 40px; display: block; }
.back { color: var(--accent-deep); font-size: 0.9rem; }
.baractions { display: flex; align-items: center; gap: 14px; }
.baractions .fb { background: none; border: none; color: var(--accent-deep); cursor: pointer; display: inline-flex; padding: 2px; }
.page { padding: 20px 20px 70px; }
h1 { font-size: clamp(2rem, 5vw, 2.6rem); margin: 6px 0 16px; }