From 427210ac3e80f32d97bac17814f0e75921254cba Mon Sep 17 00:00:00 2001 From: jay Date: Fri, 5 Jun 2026 12:58:49 +0000 Subject: [PATCH] User feedback + expanded privacy-respecting admin stats MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- frontend/src/lib/analytics.js | 2 +- .../src/lib/components/ArticleCard.svelte | 5 +- .../src/lib/components/FeedbackModal.svelte | 103 ++++++++++++++++++ frontend/src/lib/components/Header.svelte | 21 ++-- frontend/src/lib/feedback.svelte.js | 9 ++ frontend/src/lib/history.svelte.js | 12 +- frontend/src/routes/+layout.svelte | 8 ++ frontend/src/routes/+page.svelte | 7 +- frontend/src/routes/account/+page.svelte | 16 ++- frontend/src/routes/admin/+page.svelte | 79 +++++++++++++- goodnews/api.py | 66 ++++++++++- goodnews/db.py | 15 +++ goodnews/email_send.py | 13 +++ goodnews/queries.py | 81 +++++++++++--- goodnews/share.py | 9 ++ tests/test_feedback.py | 52 +++++++++ 16 files changed, 460 insertions(+), 38 deletions(-) create mode 100644 frontend/src/lib/components/FeedbackModal.svelte create mode 100644 frontend/src/lib/feedback.svelte.js create mode 100644 tests/test_feedback.py 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 @@ + + + + + + + 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} +
+ + · Upbeat Bytes · metadata & links only, no stored articles · API
@@ -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 @@
- ← Back to news +
+ + ← Back +
@@ -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}
  • @@ -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}