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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user