diff --git a/frontend/src/lib/analytics.js b/frontend/src/lib/analytics.js
index bff4f44..81f724c 100644
--- a/frontend/src/lib/analytics.js
+++ b/frontend/src/lib/analytics.js
@@ -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) {
diff --git a/frontend/src/lib/components/ArticleCard.svelte b/frontend/src/lib/components/ArticleCard.svelte
index 9f1f528..492e66e 100644
--- a/frontend/src/lib/components/ArticleCard.svelte
+++ b/frontend/src/lib/components/ArticleCard.svelte
@@ -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) {
diff --git a/frontend/src/lib/components/FeedbackModal.svelte b/frontend/src/lib/components/FeedbackModal.svelte
new file mode 100644
index 0000000..784da61
--- /dev/null
+++ b/frontend/src/lib/components/FeedbackModal.svelte
@@ -0,0 +1,103 @@
+
+
+
+
+
+
e.stopPropagation()}>
+
+
+ {#if status === 'sent'}
+
Thank you π
+
Your note is on its way to us. We read every one.
+
+ {:else}
+
Share feedback
+
An idea, a concern, a bug, or just a hello β we'd love to hear it.
+
+ {#if status === 'error'}
{error}
{/if}
+ {/if}
+
+
+
+
diff --git a/frontend/src/lib/components/Header.svelte b/frontend/src/lib/components/Header.svelte
index 0430256..2af6030 100644
--- a/frontend/src/lib/components/Header.svelte
+++ b/frontend/src/lib/components/Header.svelte
@@ -1,5 +1,6 @@
@@ -11,24 +12,29 @@
@@ -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; }
}
diff --git a/frontend/src/lib/feedback.svelte.js b/frontend/src/lib/feedback.svelte.js
new file mode 100644
index 0000000..2af8be1
--- /dev/null
+++ b/frontend/src/lib/feedback.svelte.js
@@ -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;
+}
diff --git a/frontend/src/lib/history.svelte.js b/frontend/src/lib/history.svelte.js
index 55a2677..e299b1f 100644
--- a/frontend/src/lib/history.svelte.js
+++ b/frontend/src/lib/history.svelte.js
@@ -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(() => {});
}
}
diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte
index dd3fc69..abd592a 100644
--- a/frontend/src/routes/+layout.svelte
+++ b/frontend/src/routes/+layout.svelte
@@ -1,12 +1,18 @@
{@render children()}
+{#if fb.open}{/if}
+
@@ -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)); }
diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte
index 39f08dc..55339f3 100644
--- a/frontend/src/routes/+page.svelte
+++ b/frontend/src/routes/+page.svelte
@@ -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]);
diff --git a/frontend/src/routes/account/+page.svelte b/frontend/src/routes/account/+page.svelte
index dd2c4fe..54da7ee 100644
--- a/frontend/src/routes/account/+page.svelte
+++ b/frontend/src/routes/account/+page.svelte
@@ -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 @@
@@ -73,7 +79,7 @@
{#each historyItems as a (a.id)}
-
- track('open', a.id)}>{a.title}
+ track('full_story', a.id)}>{a.title}
{a.source}
removeOne(a.id)}>Γ
@@ -90,7 +96,7 @@
{:else if savedShown.length}
{#each savedShown as a (a.id)}
-
track('open', x.id)} />
+
{/each}
{: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; }
diff --git a/frontend/src/routes/admin/+page.svelte b/frontend/src/routes/admin/+page.svelte
index 8cff0bd..ab6f393 100644
--- a/frontend/src/routes/admin/+page.svelte
+++ b/frontend/src/routes/admin/+page.svelte
@@ -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 @@
{stats.visitors.today}Visitors today
{stats.visitors.d7}Last 7 days
{stats.visitors.d30}Last 30 days
- {stats.returning}Returning
- {stats.once}One-and-done
+
+ Accounts
+
+
{stats.accounts.total}Total
+
{stats.accounts.new_today}New today
+
{stats.accounts.new_7d}New this week
+
{stats.accounts.active_7d}Active this week
+
+
+
+
+ Returning visitors (30d)
+
+
{stats.retention.new}New (1 day)
+
{stats.retention['2-3']}2β3 days
+
{stats.retention['4-7']}4β7 days
+
{stats.retention['8+']}8+ days
+
+
+
+
+ Reading funnel
+
+
{stats.funnel.summary_viewed}Summaries read
+
{stats.funnel.source_click}β Source (from summary)
+
{stats.funnel.source_rate}%Summary β source rate
+
{stats.funnel.full_story}Straight to source
+
+
+
+
+ Emotional mix & friction
+
+
{stats.emotional_mix.not_today}Not today
+
{stats.emotional_mix.less_like_this}Less like this
+
{stats.emotional_mix.hide_topic}Hide topic
+
{stats.replace.used}Replaces
+
{stats.replace.none}No replacement
+
{stats.paywall.paywall_replace}Paywall replaces
+
{stats.paywall.paywalled_source_open}Paywalled opens
+
+
+
Most-opened articles
{#if stats.top_articles.length}
@@ -128,6 +176,24 @@
visits opens
{:else}No data yet.
{/if}
+
+
+ Feedback {#if feedback.length}({feedback.length}){/if}
+ {#if feedback.length}
+
+ {#each feedback as f (f.id)}
+ -
+
+
{f.category}
+
{fdate(f.created_at)}
+ {#if f.contact_email}
{f.contact_email}{/if}
+
+ {f.message}
+
+ {/each}
+
+ {:else}No feedback yet.
{/if}
+
{/if}
@@ -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; }
diff --git a/goodnews/api.py b/goodnews/api.py
index b3532ec..9b62e76 100644
--- a/goodnews/api.py
+++ b/goodnews/api.py
@@ -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:
diff --git a/goodnews/db.py b/goodnews/db.py
index f478f19..f30b4a1 100644
--- a/goodnews/db.py
+++ b/goodnews/db.py
@@ -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);
"""
diff --git a/goodnews/email_send.py b/goodnews/email_send.py
index 09a4be2..84d54c9 100644
--- a/goodnews/email_send.py
+++ b/goodnews/email_send.py
@@ -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"
diff --git a/goodnews/queries.py b/goodnews/queries.py
index b05e67d..6262f9a 100644
--- a/goodnews/queries.py
+++ b/goodnews/queries.py
@@ -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,
diff --git a/goodnews/share.py b/goodnews/share.py
index e7457fd..388e580 100644
--- a/goodnews/share.py
+++ b/goodnews/share.py
@@ -162,6 +162,15 @@ def render_share_page(article: dict, base_url: str, summary: str | None = None)
+
{src_click}