Game share-loop: instrument funnel, deep-link shares, /play metadata

Sharpen the existing daily-game share loop into something measurable (per Codex's
"instrument what you have, then feed people into it" plan), ahead of a Show HN launch.

Analytics:
- Per-game funnel events <game>_{arrival,started,completed,shared} (article_id=0).
  arrival = landed via a shared link (utm_source=game_share); started = first move
  (guess/find/flip); completed = solved/cleared/Full Bloom; shared = on share success.
- trackVisit() moved into the global layout so direct /play landings count; the
  server-rendered /a/ share page now creates a visitor token + sends a daily visit
  beacon (first-time /a/-only visitors were previously dropped).
- Admin "Games funnel" panel: arrivals / engaged / completed / shared, per game.

Sharing:
- Memory Match gains a Share button (it was the only game without one).
- All shares deep-link to the exact game+variant with a full https:// URL +
  utm_source=game_share (gameShareUrl helper), instead of a bare /play.
- "shared" is counted only after navigator.share()/clipboard.writeText() succeeds.

/play social metadata:
- /play served homepage canonical/OG (static SPA, ssr=false). postbuild script
  patches build/play.html's head to /play canonical/title/description/OG; fails the
  build if the homepage tags drift. Caddy try_files now serves {path}.html so /play
  is served from the patched file (snapshot in deploy/caddy/).

Tests: backend 352, frontend 27.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
jay
2026-06-18 16:22:06 -04:00
parent 89c0fbe1f6
commit 59ff48ae90
17 changed files with 360 additions and 21 deletions
+9 -1
View File
@@ -588,13 +588,21 @@ class SourceReviewBody(BaseModel):
_FEEDBACK_CATEGORIES = {"idea", "concern", "bug", "praise", "other"}
# The only event kinds we record. All aggregate, non-personal.
# Per-game funnel events (article_id is reused as 0 — no article dimension). Per-game
# kinds (not a generic "game_started") so the admin kind-count breakdown shows which
# game drives play and, crucially, shares — the growth loop we're instrumenting.
_GAME_NAMES = ("word", "wordsearch", "bloom", "match")
# arrival = landed on the game via a shared link (utm_source=game_share) — the share
# loop's acquisition signal; started/completed/shared are the engagement funnel.
_GAME_EVENT_KINDS = {f"{g}_{e}" for g in _GAME_NAMES for e in ("started", "completed", "shared", "arrival")}
_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",
"client_error", # boot-failure seatbelt beacon (blank-screen risk signal)
}
} | _GAME_EVENT_KINDS
def _fts_query(q: str) -> str:
+14
View File
@@ -655,6 +655,19 @@ def admin_stats(conn: sqlite3.Connection, days: int = 30) -> dict:
}
replace = {"used": kc.get("replace_used", 0), "none": kc.get("replace_none", 0)}
# Game funnel — the growth loop we're instrumenting. Each count is distinct
# visitor-days (events dedupe per kind/day), so it reads as "people", not actions.
_GAME_NAMES = ("word", "wordsearch", "bloom", "match")
_GAME_EVENTS = ("arrival", "started", "completed", "shared") # arrival = share-loop acquisition
games_by = {
g: {e: kc.get(f"{g}_{e}", 0) for e in _GAME_EVENTS}
for g in _GAME_NAMES
}
games = {
"by_game": games_by,
"totals": {e: sum(games_by[g][e] for g in _GAME_NAMES) for e in _GAME_EVENTS},
}
# Accounts — aggregate counts only (no emails, no per-user listing).
accounts = {
"total": scalar("SELECT COUNT(*) FROM users"),
@@ -700,6 +713,7 @@ def admin_stats(conn: sqlite3.Connection, days: int = 30) -> dict:
"emotional_mix": emotional_mix,
"paywall": paywall,
"replace": replace,
"games": games,
"top_articles": top_articles,
"top_groupings": top_groupings,
"top_topics": top_topics,
+8 -3
View File
@@ -216,9 +216,14 @@ def render_share_page(article: dict, base_url: str, summary: str | None = None,
<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'}}));
var v=localStorage.getItem('goodnews:visitor');
if(!v){{v=crypto.randomUUID?crypto.randomUUID():String(Math.random()).slice(2)+Date.now();localStorage.setItem('goodnews:visitor',v);}}
function beacon(o){{var b=JSON.stringify(o);if(navigator.sendBeacon)navigator.sendBeacon('/api/events',new Blob([b],{{type:'application/json'}}));}}
beacon({{kind:'summary_viewed',article_id:{aid},visitor:v}});
// This page is server-rendered (outside the Svelte app), so the SPA's daily
// visit isn't recorded for a /a/ landing — count it here, once per day per device.
var t=new Date().toISOString().slice(0,10);
if(localStorage.getItem('goodnews:visitday')!==t){{localStorage.setItem('goodnews:visitday',t);beacon({{kind:'visit',article_id:0,visitor:v}});}}
}}catch(e){{}}
}})();
</script>