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:
@@ -4,7 +4,7 @@
|
||||
const VISITOR_KEY = 'goodnews:visitor';
|
||||
const VISITDAY_KEY = 'goodnews:visitday';
|
||||
|
||||
function visitorId() {
|
||||
export function visitorId() {
|
||||
try {
|
||||
let v = localStorage.getItem(VISITOR_KEY);
|
||||
if (!v) {
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
let { article, onaction, onreplace, ontag, onimageerror, onview, hero = false } = $props();
|
||||
|
||||
function opened() {
|
||||
// Records history; the /a page itself fires the summary_viewed event on load.
|
||||
onview?.(article);
|
||||
track('open', article.id);
|
||||
}
|
||||
let isSaved = $derived(savedIds.has(article.id));
|
||||
|
||||
@@ -47,7 +47,8 @@
|
||||
let summaryHref = $derived('/a/' + article.id);
|
||||
function openedSource() {
|
||||
onview?.(article);
|
||||
track('open', article.id);
|
||||
track('full_story', article.id);
|
||||
if (article.paywalled) track('paywalled_source_open', article.id);
|
||||
}
|
||||
|
||||
function act(kind, value) {
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
<script>
|
||||
import { postJSON } from '$lib/api.js';
|
||||
import { visitorId } from '$lib/analytics.js';
|
||||
|
||||
let { onclose } = $props();
|
||||
let category = $state('idea');
|
||||
let message = $state('');
|
||||
let email = $state('');
|
||||
let hp = $state(''); // honeypot — must stay empty
|
||||
let status = $state('idle'); // idle | sending | sent | error
|
||||
let error = $state('');
|
||||
|
||||
const CATEGORIES = [
|
||||
['idea', 'Idea'],
|
||||
['concern', 'Concern'],
|
||||
['bug', 'Bug'],
|
||||
['praise', 'Praise'],
|
||||
];
|
||||
|
||||
async function submit(e) {
|
||||
e.preventDefault();
|
||||
if (!message.trim() || status === 'sending') return;
|
||||
status = 'sending';
|
||||
error = '';
|
||||
try {
|
||||
await postJSON('/api/feedback', { category, message, email, hp, visitor: visitorId() });
|
||||
status = 'sent';
|
||||
} catch (err) {
|
||||
status = 'error';
|
||||
error = err?.message || 'Something went wrong — try again.';
|
||||
}
|
||||
}
|
||||
|
||||
function onkey(e) {
|
||||
if (e.key === 'Escape') onclose?.();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onkey} />
|
||||
|
||||
<div class="overlay" onclick={onclose} role="presentation">
|
||||
<div class="sheet rise" role="dialog" aria-modal="true" aria-label="Send feedback" onclick={(e) => e.stopPropagation()}>
|
||||
<button class="x" onclick={onclose} aria-label="Close">×</button>
|
||||
|
||||
{#if status === 'sent'}
|
||||
<h2>Thank you 💛</h2>
|
||||
<p class="sub">Your note is on its way to us. We read every one.</p>
|
||||
<button class="primary" onclick={onclose}>Done</button>
|
||||
{:else}
|
||||
<h2>Share feedback</h2>
|
||||
<p class="sub">An idea, a concern, a bug, or just a hello — we'd love to hear it.</p>
|
||||
<form onsubmit={submit}>
|
||||
<div class="cats">
|
||||
{#each CATEGORIES as [val, label] (val)}
|
||||
<button type="button" class="cat" class:on={category === val} onclick={() => (category = val)}>{label}</button>
|
||||
{/each}
|
||||
</div>
|
||||
<textarea bind:value={message} rows="4" placeholder="What's on your mind?" required></textarea>
|
||||
<input type="email" bind:value={email} placeholder="Email (optional, only if you'd like a reply)" autocomplete="email" />
|
||||
<!-- honeypot: hidden from people, tempting to bots -->
|
||||
<input class="hp" tabindex="-1" autocomplete="off" bind:value={hp} aria-hidden="true" />
|
||||
<button class="primary" type="submit" disabled={status === 'sending'}>
|
||||
{status === 'sending' ? 'Sending…' : 'Send feedback'}
|
||||
</button>
|
||||
</form>
|
||||
{#if status === 'error'}<p class="err">{error}</p>{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.overlay {
|
||||
position: fixed; inset: 0; background: rgba(10, 22, 38, 0.32);
|
||||
display: flex; align-items: center; justify-content: center; padding: 20px; z-index: 60;
|
||||
}
|
||||
.sheet {
|
||||
background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius);
|
||||
box-shadow: var(--shadow); width: 100%; max-width: 420px; padding: 26px 24px 22px; position: relative;
|
||||
}
|
||||
.x { position: absolute; top: 12px; right: 14px; background: none; border: none; font-size: 1.5rem; line-height: 1; color: var(--muted); cursor: pointer; }
|
||||
h2 { font-size: 1.4rem; margin: 0 0 6px; }
|
||||
.sub { margin: 0 0 16px; color: var(--muted); font-size: 0.92rem; }
|
||||
form { display: flex; flex-direction: column; gap: 10px; }
|
||||
.cats { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.cat {
|
||||
border: 1px solid var(--line); background: var(--bg); color: var(--ink);
|
||||
border-radius: 999px; padding: 5px 13px; font-size: 0.85rem; cursor: pointer;
|
||||
}
|
||||
.cat.on { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||||
textarea, input {
|
||||
font: inherit; padding: 10px 12px; border: 1px solid var(--line); border-radius: 12px;
|
||||
background: var(--bg); color: var(--ink); resize: vertical;
|
||||
}
|
||||
textarea:focus, input:focus { outline: none; border-color: var(--accent); }
|
||||
.hp { position: absolute; left: -9999px; width: 1px; height: 1px; opacity: 0; }
|
||||
.primary {
|
||||
font: inherit; font-weight: 600; background: var(--accent); color: #fff; border: none;
|
||||
border-radius: 999px; padding: 11px 16px; cursor: pointer;
|
||||
}
|
||||
.primary:hover { background: var(--accent-deep); }
|
||||
.primary:disabled { opacity: 0.6; cursor: default; }
|
||||
.err { margin: 10px 0 0; color: #9a3b3b; font-size: 0.86rem; }
|
||||
</style>
|
||||
@@ -1,5 +1,6 @@
|
||||
<script>
|
||||
import Avatar from './Avatar.svelte';
|
||||
import { openFeedback } from '$lib/feedback.svelte.js';
|
||||
let { onSaved, onaccount, user = null, boundariesActive = false } = $props();
|
||||
</script>
|
||||
|
||||
@@ -11,24 +12,29 @@
|
||||
|
||||
<nav class="utils" aria-label="Your controls">
|
||||
{#if user}
|
||||
<button class="util" onclick={onSaved} title="Saved articles">
|
||||
<button class="util desk" onclick={onSaved} title="Saved articles">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 3h12v18l-6-4-6 4z"
|
||||
fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round" /></svg>
|
||||
<span>Saved</span>
|
||||
</button>
|
||||
{/if}
|
||||
<a class="util shield" class:on={boundariesActive} href="/account?section=boundaries"
|
||||
<a class="util shield desk" class:on={boundariesActive} href="/account?section=boundaries"
|
||||
title={boundariesActive ? 'Boundaries are on' : 'Your boundaries'}>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3l7 3v5c0 4.4-3 7.6-7 9-4-1.4-7-4.6-7-9V6l7-3z"
|
||||
fill={boundariesActive ? 'currentColor' : 'none'} stroke="currentColor" stroke-width="1.8"
|
||||
stroke-linejoin="round" /></svg>
|
||||
</a>
|
||||
<!-- Feedback bubble: the one control kept on the mobile bar too. -->
|
||||
<button class="util" onclick={openFeedback} title="Share feedback" aria-label="Share feedback">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 5h16v11H8l-4 3z"
|
||||
fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round" /></svg>
|
||||
</button>
|
||||
{#if user}
|
||||
<button class="acct" onclick={onaccount} title={user.email} aria-label="Your account">
|
||||
<button class="acct desk" onclick={onaccount} title={user.email} aria-label="Your account">
|
||||
<Avatar {user} size={30} />
|
||||
</button>
|
||||
{:else}
|
||||
<button class="signin" onclick={onaccount}>Sign in</button>
|
||||
<button class="signin desk" onclick={onaccount}>Sign in</button>
|
||||
{/if}
|
||||
</nav>
|
||||
</div>
|
||||
@@ -64,10 +70,11 @@
|
||||
}
|
||||
.signin:hover { background: var(--accent-soft); }
|
||||
|
||||
/* On phones the bottom tab bar handles navigation; keep the bar to the logo. */
|
||||
/* On phones the bottom tab bar handles navigation; the top bar keeps just the
|
||||
logo (left) and the feedback bubble (right). Other utils move to bottom nav. */
|
||||
@media (max-width: 720px) {
|
||||
.bar { height: 66px; justify-content: center; }
|
||||
.utils { display: none; }
|
||||
.bar { height: 66px; }
|
||||
.utils .desk { display: none; }
|
||||
.logo { height: 46px; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
// App-wide feedback modal state, openable from any route (header, footer, account).
|
||||
export const fb = $state({ open: false });
|
||||
|
||||
export function openFeedback() {
|
||||
fb.open = true;
|
||||
}
|
||||
export function closeFeedback() {
|
||||
fb.open = false;
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
// the account when signed in. Only deliberate events live here — opened or
|
||||
// replaced-away articles — never everything merely shown.
|
||||
import { auth } from './auth.svelte.js';
|
||||
import { getJSON, postJSON, delJSON } from './api.js';
|
||||
import { getJSON, delJSON } from './api.js';
|
||||
|
||||
const KEY = 'goodnews:history';
|
||||
const CAP = 200;
|
||||
@@ -46,7 +46,15 @@ export function record(article) {
|
||||
if (!history.server.some((h) => h.id === article.id)) {
|
||||
history.server = [article, ...history.server];
|
||||
}
|
||||
postJSON('/api/history', { ids: [article.id] }).catch(() => {});
|
||||
// keepalive: this often fires on a click that navigates to /a, so it must
|
||||
// survive the page transition.
|
||||
fetch('/api/history', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ids: [article.id] }),
|
||||
credentials: 'same-origin',
|
||||
keepalive: true,
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
+64
-2
@@ -149,6 +149,13 @@ def _kick_summary(article_id: int, background_tasks: BackgroundTasks) -> None:
|
||||
background_tasks.add_task(_run_summary, article_id)
|
||||
|
||||
|
||||
def _feedback_email_safe(addr: str, category: str, message: str, contact: str | None, who: str) -> None:
|
||||
try:
|
||||
email_send.send_feedback(addr, category, message, contact, who)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _send_link_safe(email: str, link: str) -> None:
|
||||
"""Send the magic link, swallowing failures (runs off the request path)."""
|
||||
try:
|
||||
@@ -359,8 +366,23 @@ class EventBody(BaseModel):
|
||||
visitor: str | None = None
|
||||
|
||||
|
||||
# The only event kinds we record (per the agreed vocabulary).
|
||||
_EVENT_KINDS = {"visit", "open", "share_ub", "copy_source", "native_share", "source_click"}
|
||||
class FeedbackBody(BaseModel):
|
||||
category: str = "other"
|
||||
message: str = ""
|
||||
email: str | None = None
|
||||
visitor: str | None = None
|
||||
hp: str | None = None # honeypot — bots fill it, humans don't
|
||||
|
||||
|
||||
_FEEDBACK_CATEGORIES = {"idea", "concern", "bug", "praise", "other"}
|
||||
|
||||
# The only event kinds we record. All aggregate, non-personal.
|
||||
_EVENT_KINDS = {
|
||||
"visit", "open", "summary_viewed", "full_story", "source_click",
|
||||
"share_ub", "copy_source", "native_share",
|
||||
"not_today", "less_like_this", "hide_topic",
|
||||
"replace_used", "replace_none", "paywall_replace", "paywalled_source_open",
|
||||
}
|
||||
|
||||
|
||||
def _visitor_hash(token: str | None) -> str:
|
||||
@@ -723,6 +745,46 @@ def create_app() -> FastAPI:
|
||||
conn.commit()
|
||||
return {"ok": True} # always identical; dedup'd by the unique key
|
||||
|
||||
@app.post("/api/feedback")
|
||||
def submit_feedback(body: FeedbackBody, request: Request, background_tasks: BackgroundTasks) -> dict:
|
||||
if body.hp: # honeypot tripped → accept silently, store nothing
|
||||
return {"ok": True}
|
||||
message = (body.message or "").strip()[:4000]
|
||||
if not message:
|
||||
raise HTTPException(status_code=422, detail="Please add a short message.")
|
||||
category = body.category if body.category in _FEEDBACK_CATEGORIES else "other"
|
||||
email = ((body.email or "").strip()[:200]) or None
|
||||
vh = _visitor_hash(body.visitor)
|
||||
with get_conn() as conn:
|
||||
if vh: # light flood cap per anonymous token per day
|
||||
recent = conn.execute(
|
||||
"SELECT COUNT(*) FROM feedback WHERE visitor_hash = ? AND day = date('now')", (vh,)
|
||||
).fetchone()[0]
|
||||
if recent >= 8:
|
||||
return {"ok": True}
|
||||
user = _current_user(conn, request)
|
||||
conn.execute(
|
||||
"INSERT INTO feedback (category, message, contact_email, user_id, visitor_hash, day) "
|
||||
"VALUES (?, ?, ?, ?, ?, date('now'))",
|
||||
(category, message, email, user["id"] if user else None, vh),
|
||||
)
|
||||
conn.commit()
|
||||
who = user["email"] if user else "anonymous visitor"
|
||||
for addr in ADMIN_EMAILS:
|
||||
background_tasks.add_task(_feedback_email_safe, addr, category, message, email, who)
|
||||
return {"ok": True}
|
||||
|
||||
@app.get("/api/admin/feedback")
|
||||
def admin_feedback(request: Request) -> list[dict]:
|
||||
with get_conn() as conn:
|
||||
_require_admin(conn, request)
|
||||
rows = conn.execute(
|
||||
"SELECT f.id, f.category, f.message, f.contact_email, f.created_at, "
|
||||
"u.email AS user_email FROM feedback f LEFT JOIN users u ON u.id = f.user_id "
|
||||
"ORDER BY f.created_at DESC LIMIT 100"
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
@app.get("/api/admin/stats")
|
||||
def admin_stats(request: Request) -> dict:
|
||||
with get_conn() as conn:
|
||||
|
||||
@@ -224,6 +224,21 @@ CREATE TABLE IF NOT EXISTS events (
|
||||
CREATE INDEX IF NOT EXISTS idx_events_day ON events(day);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_kind ON events(kind);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_article ON events(article_id);
|
||||
|
||||
-- User feedback (idea / concern / bug / praise). Anonymous-friendly; optional
|
||||
-- contact email only if the person wants a reply. visitor_hash is for rate-limit
|
||||
-- only (the same hashed anonymous token used by analytics).
|
||||
CREATE TABLE IF NOT EXISTS feedback (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
category TEXT NOT NULL DEFAULT 'other',
|
||||
message TEXT NOT NULL,
|
||||
contact_email TEXT,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
visitor_hash TEXT NOT NULL DEFAULT '',
|
||||
day TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_feedback_created ON feedback(created_at);
|
||||
"""
|
||||
|
||||
|
||||
|
||||
@@ -50,6 +50,19 @@ def send_email(to: str, subject: str, text: str, html: str | None = None) -> Non
|
||||
server.send_message(msg)
|
||||
|
||||
|
||||
def send_feedback(to: str, category: str, message: str, contact: str | None, who: str) -> None:
|
||||
"""Notify the admin of new user feedback (plain text is plenty here)."""
|
||||
subject = f"Upbeat Bytes feedback · {category}"
|
||||
reply = contact or "(none given)"
|
||||
text = (
|
||||
f"New feedback ({category})\n"
|
||||
f"From: {who}\n"
|
||||
f"Reply to: {reply}\n\n"
|
||||
f"{message}\n"
|
||||
)
|
||||
send_email(to, subject, text)
|
||||
|
||||
|
||||
def send_magic_link(to: str, link: str) -> None:
|
||||
"""Send a calm, single-purpose sign-in email."""
|
||||
subject = "Your Upbeat Bytes sign-in link"
|
||||
|
||||
+64
-17
@@ -222,35 +222,76 @@ def admin_stats(conn: sqlite3.Connection, days: int = 30) -> dict:
|
||||
).fetchall()
|
||||
loyalty = {r["g"]: r["n"] for r in rows}
|
||||
|
||||
# An "open" = opening the article via our summary page (legacy 'open' + 'summary_viewed').
|
||||
OPEN = "e.kind IN ('open','summary_viewed')"
|
||||
|
||||
top_articles = [dict(r) for r in conn.execute(
|
||||
"SELECT e.article_id AS id, a.title, src.name AS source, COUNT(*) AS opens "
|
||||
"FROM events e JOIN articles a ON a.id=e.article_id JOIN sources src ON src.id=a.source_id "
|
||||
"WHERE e.kind='open' AND e.article_id>0 AND e.day>=date('now',?) "
|
||||
"GROUP BY e.article_id ORDER BY opens DESC LIMIT 12", (since,),
|
||||
f"SELECT e.article_id AS id, a.title, src.name AS source, COUNT(*) AS opens "
|
||||
f"FROM events e JOIN articles a ON a.id=e.article_id JOIN sources src ON src.id=a.source_id "
|
||||
f"WHERE {OPEN} AND e.article_id>0 AND e.day>=date('now',?) "
|
||||
f"GROUP BY e.article_id ORDER BY opens DESC LIMIT 12", (since,),
|
||||
)]
|
||||
|
||||
top_groupings = [dict(r) for r in conn.execute(
|
||||
"SELECT t.tag, COUNT(*) AS opens FROM events e JOIN article_tags t ON t.article_id=e.article_id "
|
||||
"WHERE e.kind='open' AND e.day>=date('now',?) GROUP BY t.tag ORDER BY opens DESC LIMIT 12", (since,),
|
||||
f"SELECT t.tag, COUNT(*) AS opens FROM events e JOIN article_tags t ON t.article_id=e.article_id "
|
||||
f"WHERE {OPEN} AND e.day>=date('now',?) GROUP BY t.tag ORDER BY opens DESC LIMIT 12", (since,),
|
||||
)]
|
||||
|
||||
top_topics = [dict(r) for r in conn.execute(
|
||||
"SELECT s.topic, COUNT(*) AS opens FROM events e JOIN article_scores s ON s.article_id=e.article_id "
|
||||
"WHERE e.kind='open' AND s.topic IS NOT NULL AND e.day>=date('now',?) "
|
||||
"GROUP BY s.topic ORDER BY opens DESC", (since,),
|
||||
f"SELECT s.topic, COUNT(*) AS opens FROM events e JOIN article_scores s ON s.article_id=e.article_id "
|
||||
f"WHERE {OPEN} AND s.topic IS NOT NULL AND e.day>=date('now',?) "
|
||||
f"GROUP BY s.topic ORDER BY opens DESC", (since,),
|
||||
)]
|
||||
|
||||
share_rows = conn.execute(
|
||||
"SELECT kind, COUNT(*) n FROM events "
|
||||
"WHERE kind IN ('share_ub','copy_source','native_share','source_click') AND day>=date('now',?) "
|
||||
"GROUP BY kind", (since,),
|
||||
# Counts per event kind over the window (each = distinct visitor-days, by dedup).
|
||||
kc_rows = conn.execute(
|
||||
"SELECT kind, COUNT(*) n FROM events WHERE day>=date('now',?) GROUP BY kind", (since,)
|
||||
).fetchall()
|
||||
shares = {k: 0 for k in ("share_ub", "copy_source", "native_share", "source_click")}
|
||||
shares.update({r["kind"]: r["n"] for r in share_rows})
|
||||
kc = {r["kind"]: r["n"] for r in kc_rows}
|
||||
|
||||
shares = {k: kc.get(k, 0) for k in ("share_ub", "copy_source", "native_share", "source_click")}
|
||||
|
||||
summary_views = kc.get("summary_viewed", 0)
|
||||
source_clicks = kc.get("source_click", 0)
|
||||
funnel = {
|
||||
"summary_viewed": summary_views,
|
||||
"source_click": source_clicks,
|
||||
"full_story": kc.get("full_story", 0),
|
||||
"source_rate": round(100 * source_clicks / summary_views) if summary_views else 0,
|
||||
}
|
||||
emotional_mix = {
|
||||
"not_today": kc.get("not_today", 0),
|
||||
"less_like_this": kc.get("less_like_this", 0),
|
||||
"hide_topic": kc.get("hide_topic", 0),
|
||||
}
|
||||
paywall = {
|
||||
"paywall_replace": kc.get("paywall_replace", 0),
|
||||
"paywalled_source_open": kc.get("paywalled_source_open", 0),
|
||||
}
|
||||
replace = {"used": kc.get("replace_used", 0), "none": kc.get("replace_none", 0)}
|
||||
|
||||
# Accounts — aggregate counts only (no emails, no per-user listing).
|
||||
accounts = {
|
||||
"total": scalar("SELECT COUNT(*) FROM users"),
|
||||
"new_today": scalar("SELECT COUNT(*) FROM users WHERE date(created_at)=date('now')"),
|
||||
"new_7d": scalar("SELECT COUNT(*) FROM users WHERE created_at>=date('now','-7 days')"),
|
||||
"new_30d": scalar("SELECT COUNT(*) FROM users WHERE created_at>=date('now',?)", (since,)),
|
||||
"active_7d": scalar("SELECT COUNT(DISTINCT user_id) FROM sessions WHERE last_seen_at>=date('now','-7 days')"),
|
||||
}
|
||||
|
||||
# Returning-visitor buckets by distinct active days in the window.
|
||||
bucket_rows = conn.execute(
|
||||
"SELECT CASE WHEN d>=8 THEN '8+' WHEN d>=4 THEN '4-7' WHEN d>=2 THEN '2-3' ELSE 'new' END b, "
|
||||
"COUNT(*) n FROM (SELECT visitor_hash, COUNT(DISTINCT day) d FROM events "
|
||||
"WHERE kind='visit' AND visitor_hash!='' AND day>=date('now',?) GROUP BY visitor_hash) GROUP BY b",
|
||||
(since,),
|
||||
).fetchall()
|
||||
buckets = {r["b"]: r["n"] for r in bucket_rows}
|
||||
retention = {k: buckets.get(k, 0) for k in ("new", "2-3", "4-7", "8+")}
|
||||
|
||||
daily = [dict(r) for r in conn.execute(
|
||||
"SELECT day, SUM(kind='open') AS opens, SUM(kind='visit') AS visits FROM events "
|
||||
"WHERE day>=date('now',?) GROUP BY day ORDER BY day", (since,),
|
||||
"SELECT day, SUM(kind IN ('open','summary_viewed')) AS opens, SUM(kind='visit') AS visits "
|
||||
"FROM events WHERE day>=date('now',?) GROUP BY day ORDER BY day", (since,),
|
||||
)]
|
||||
|
||||
return {
|
||||
@@ -258,6 +299,12 @@ def admin_stats(conn: sqlite3.Connection, days: int = 30) -> dict:
|
||||
"visitors": visitors,
|
||||
"returning": loyalty.get("returning", 0),
|
||||
"once": loyalty.get("once", 0),
|
||||
"retention": retention,
|
||||
"accounts": accounts,
|
||||
"funnel": funnel,
|
||||
"emotional_mix": emotional_mix,
|
||||
"paywall": paywall,
|
||||
"replace": replace,
|
||||
"top_articles": top_articles,
|
||||
"top_groupings": top_groupings,
|
||||
"top_topics": top_topics,
|
||||
|
||||
@@ -162,6 +162,15 @@ def render_share_page(article: dict, base_url: str, summary: str | None = None)
|
||||
</div>
|
||||
</article>
|
||||
</main>
|
||||
<script>
|
||||
(function(){{
|
||||
try{{
|
||||
var v=localStorage.getItem('goodnews:visitor')||'';
|
||||
var b=JSON.stringify({{kind:'summary_viewed',article_id:{aid},visitor:v}});
|
||||
if(navigator.sendBeacon) navigator.sendBeacon('/api/events', new Blob([b],{{type:'application/json'}}));
|
||||
}}catch(e){{}}
|
||||
}})();
|
||||
</script>
|
||||
{src_click}
|
||||
<script>
|
||||
(function(){{
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
def _app(tmp_path, monkeypatch, admin=""):
|
||||
db = tmp_path / "t.sqlite3"
|
||||
monkeypatch.setenv("GOODNEWS_DB", str(db))
|
||||
monkeypatch.setenv("GOODNEWS_PUBLIC_BASE_URL", "http://testserver")
|
||||
monkeypatch.setenv("GOODNEWS_ADMIN_EMAILS", admin)
|
||||
import importlib
|
||||
import goodnews.api as api
|
||||
importlib.reload(api)
|
||||
from goodnews.db import connect, init_db
|
||||
connect(str(db)).close()
|
||||
c = connect(str(db)); init_db(c); c.close()
|
||||
return api.create_app(), api, db
|
||||
|
||||
|
||||
def _count(db):
|
||||
from goodnews.db import connect
|
||||
c = connect(str(db)); n = c.execute("SELECT COUNT(*) FROM feedback").fetchone()[0]; c.close()
|
||||
return n
|
||||
|
||||
|
||||
def test_feedback_stored_and_validated(tmp_path, monkeypatch):
|
||||
app, api, db = _app(tmp_path, monkeypatch)
|
||||
monkeypatch.setattr(api.email_send, "send_feedback", lambda *a, **k: None)
|
||||
tc = TestClient(app)
|
||||
assert tc.post("/api/feedback", json={"message": " "}).status_code == 422 # empty
|
||||
assert tc.post("/api/feedback", json={"category": "idea", "message": "Love it", "visitor": "v"}).json() == {"ok": True}
|
||||
assert _count(db) == 1
|
||||
# honeypot tripped → accepted but NOT stored
|
||||
tc.post("/api/feedback", json={"message": "spam", "hp": "i am a bot", "visitor": "v"})
|
||||
assert _count(db) == 1
|
||||
|
||||
|
||||
def test_admin_feedback_gated(tmp_path, monkeypatch):
|
||||
app, api, db = _app(tmp_path, monkeypatch, admin="boss@x.com")
|
||||
monkeypatch.setattr(api.email_send, "send_feedback", lambda *a, **k: None)
|
||||
TestClient(app).post("/api/feedback", json={"message": "hi", "visitor": "v"})
|
||||
assert TestClient(app).get("/api/admin/feedback").status_code == 401 # anon
|
||||
|
||||
# sign in as admin
|
||||
tc = TestClient(app); sent = {}
|
||||
monkeypatch.setattr(api.email_send, "send_magic_link", lambda to, link: sent.update(link=link))
|
||||
tc.post("/api/auth/email/start", json={"email": "boss@x.com"})
|
||||
tc.post("/api/auth/email/verify", json={"token": sent["link"].split("token=")[1]})
|
||||
fb = tc.get("/api/admin/feedback").json()
|
||||
assert len(fb) == 1 and fb[0]["message"] == "hi"
|
||||
stats = tc.get("/api/admin/stats").json()
|
||||
assert {"funnel", "accounts", "retention", "emotional_mix", "paywall", "replace"} <= set(stats)
|
||||
assert stats["accounts"]["total"] >= 1
|
||||
Reference in New Issue
Block a user