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:
@@ -1,12 +1,18 @@
|
||||
<script>
|
||||
import '../app.css';
|
||||
import FeedbackModal from '$lib/components/FeedbackModal.svelte';
|
||||
import { fb, openFeedback, closeFeedback } from '$lib/feedback.svelte.js';
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
{@render children()}
|
||||
|
||||
{#if fb.open}<FeedbackModal onclose={closeFeedback} />{/if}
|
||||
|
||||
<footer class="site">
|
||||
<div class="container">
|
||||
<button class="fb" onclick={openFeedback}>Send feedback</button>
|
||||
<span class="dot">·</span>
|
||||
Upbeat Bytes · metadata & links only, no stored articles · <a href="/docs">API</a>
|
||||
</div>
|
||||
</footer>
|
||||
@@ -20,6 +26,8 @@
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
footer.site a { color: var(--accent-deep); }
|
||||
footer.site .fb { background: none; border: none; color: var(--accent-deep); font: inherit; font-size: 0.82rem; cursor: pointer; text-decoration: underline; padding: 0; }
|
||||
footer.site .dot { margin: 0 4px; }
|
||||
/* room for the mobile bottom tab bar */
|
||||
@media (max-width: 720px) {
|
||||
footer.site { padding-bottom: calc(34px + 64px + env(safe-area-inset-bottom)); }
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
import { auth, refresh as refreshAuth } from '$lib/auth.svelte.js';
|
||||
import { prefs, initPrefs, active as prefsActive, applyPrefAction, syncPrefsOnLogin } from '$lib/prefs.svelte.js';
|
||||
import { initHistory, deviceIds, record, loadServerHistory } from '$lib/history.svelte.js';
|
||||
import { trackVisit } from '$lib/analytics.js';
|
||||
import { trackVisit, track } from '$lib/analytics.js';
|
||||
|
||||
let moods = $state([]);
|
||||
let topics = $state([]);
|
||||
@@ -156,8 +156,10 @@
|
||||
if (typeof window !== 'undefined') window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
|
||||
const MIX_EVENT = { notToday: 'not_today', lessLikeThis: 'less_like_this', alwaysHide: 'hide_topic' };
|
||||
function applyAction(kind, value) {
|
||||
applyPrefAction(kind, value); // updates + persists + syncs to account
|
||||
if (MIX_EVENT[kind]) track(MIX_EVENT[kind]); // emotional-mix signal (no value stored)
|
||||
select(selected, true); // re-filter the feed
|
||||
}
|
||||
|
||||
@@ -182,8 +184,11 @@
|
||||
}
|
||||
if (!repl) {
|
||||
flash("That's everything fresh for now — nothing new to swap in.");
|
||||
track('replace_none');
|
||||
return;
|
||||
}
|
||||
track('replace_used', article.id);
|
||||
if (article.paywalled) track('paywall_replace', article.id);
|
||||
dismissed.add(article.id);
|
||||
seenIds.add(article.id);
|
||||
markDisplayed([repl]);
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { auth, refresh } from '$lib/auth.svelte.js';
|
||||
|
||||
let stats = $state(null);
|
||||
let feedback = $state([]);
|
||||
let error = $state('');
|
||||
|
||||
onMount(async () => {
|
||||
@@ -15,11 +16,17 @@
|
||||
}
|
||||
try {
|
||||
stats = await getJSON('/api/admin/stats');
|
||||
feedback = await getJSON('/api/admin/feedback');
|
||||
} catch {
|
||||
error = "Couldn't load stats.";
|
||||
}
|
||||
});
|
||||
|
||||
function fdate(s) {
|
||||
try { return new Date(s + 'Z').toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); }
|
||||
catch { return s; }
|
||||
}
|
||||
|
||||
const SHARE_LABEL = {
|
||||
share_ub: 'Copied UB link',
|
||||
native_share: 'Native share',
|
||||
@@ -53,10 +60,51 @@
|
||||
<div class="stat"><span class="n">{stats.visitors.today}</span><span class="l">Visitors today</span></div>
|
||||
<div class="stat"><span class="n">{stats.visitors.d7}</span><span class="l">Last 7 days</span></div>
|
||||
<div class="stat"><span class="n">{stats.visitors.d30}</span><span class="l">Last 30 days</span></div>
|
||||
<div class="stat"><span class="n">{stats.returning}</span><span class="l">Returning</span></div>
|
||||
<div class="stat"><span class="n">{stats.once}</span><span class="l">One-and-done</span></div>
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<h2>Accounts</h2>
|
||||
<div class="cards">
|
||||
<div class="stat"><span class="n">{stats.accounts.total}</span><span class="l">Total</span></div>
|
||||
<div class="stat"><span class="n">{stats.accounts.new_today}</span><span class="l">New today</span></div>
|
||||
<div class="stat"><span class="n">{stats.accounts.new_7d}</span><span class="l">New this week</span></div>
|
||||
<div class="stat"><span class="n">{stats.accounts.active_7d}</span><span class="l">Active this week</span></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Returning visitors (30d)</h2>
|
||||
<div class="cards">
|
||||
<div class="stat"><span class="n">{stats.retention.new}</span><span class="l">New (1 day)</span></div>
|
||||
<div class="stat"><span class="n">{stats.retention['2-3']}</span><span class="l">2–3 days</span></div>
|
||||
<div class="stat"><span class="n">{stats.retention['4-7']}</span><span class="l">4–7 days</span></div>
|
||||
<div class="stat"><span class="n">{stats.retention['8+']}</span><span class="l">8+ days</span></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Reading funnel</h2>
|
||||
<div class="cards">
|
||||
<div class="stat"><span class="n">{stats.funnel.summary_viewed}</span><span class="l">Summaries read</span></div>
|
||||
<div class="stat"><span class="n">{stats.funnel.source_click}</span><span class="l">→ Source (from summary)</span></div>
|
||||
<div class="stat"><span class="n">{stats.funnel.source_rate}%</span><span class="l">Summary → source rate</span></div>
|
||||
<div class="stat"><span class="n">{stats.funnel.full_story}</span><span class="l">Straight to source</span></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Emotional mix & friction</h2>
|
||||
<div class="cards">
|
||||
<div class="stat"><span class="n">{stats.emotional_mix.not_today}</span><span class="l">Not today</span></div>
|
||||
<div class="stat"><span class="n">{stats.emotional_mix.less_like_this}</span><span class="l">Less like this</span></div>
|
||||
<div class="stat"><span class="n">{stats.emotional_mix.hide_topic}</span><span class="l">Hide topic</span></div>
|
||||
<div class="stat"><span class="n">{stats.replace.used}</span><span class="l">Replaces</span></div>
|
||||
<div class="stat"><span class="n">{stats.replace.none}</span><span class="l">No replacement</span></div>
|
||||
<div class="stat"><span class="n">{stats.paywall.paywall_replace}</span><span class="l">Paywall replaces</span></div>
|
||||
<div class="stat"><span class="n">{stats.paywall.paywalled_source_open}</span><span class="l">Paywalled opens</span></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Most-opened articles</h2>
|
||||
{#if stats.top_articles.length}
|
||||
@@ -128,6 +176,24 @@
|
||||
<p class="legend"><span class="sw visits"></span> visits <span class="sw opens"></span> opens</p>
|
||||
{:else}<p class="muted">No data yet.</p>{/if}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Feedback {#if feedback.length}<span class="count">({feedback.length})</span>{/if}</h2>
|
||||
{#if feedback.length}
|
||||
<ul class="fb">
|
||||
{#each feedback as f (f.id)}
|
||||
<li>
|
||||
<div class="fhead">
|
||||
<span class="cat">{f.category}</span>
|
||||
<span class="when">{fdate(f.created_at)}</span>
|
||||
{#if f.contact_email}<a class="reply" href={'mailto:' + f.contact_email}>{f.contact_email}</a>{/if}
|
||||
</div>
|
||||
<p class="msg">{f.message}</p>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}<p class="muted">No feedback yet.</p>{/if}
|
||||
</section>
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
@@ -176,4 +242,13 @@
|
||||
.legend .sw { display: inline-block; width: 10px; height: 10px; border-radius: 2px; vertical-align: middle; }
|
||||
.legend .sw.visits { background: var(--accent-soft); }
|
||||
.legend .sw.opens { background: var(--accent); }
|
||||
|
||||
.count { color: var(--muted); font-weight: 400; font-size: 0.9rem; }
|
||||
ul.fb { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
|
||||
ul.fb li { background: var(--surface); border: 1px solid var(--line); border-radius: 12px; padding: 12px 14px; }
|
||||
.fhead { display: flex; align-items: center; gap: 10px; margin-bottom: 5px; font-size: 0.78rem; }
|
||||
.fhead .cat { background: var(--accent-soft); color: var(--accent-deep); border-radius: 999px; padding: 2px 9px; text-transform: capitalize; }
|
||||
.fhead .when { color: var(--muted); }
|
||||
.fhead .reply { color: var(--accent-deep); margin-left: auto; }
|
||||
.msg { margin: 0; color: var(--ink); font-size: 0.92rem; white-space: pre-wrap; }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user