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:
@@ -51,3 +51,34 @@ def test_unknown_kind_is_ignored(app_db):
|
||||
app, db = app_db
|
||||
assert TestClient(app).post("/api/events", json={"kind": "evil", "visitor": "x"}).json() == {"ok": True}
|
||||
assert _count(db) == 0
|
||||
|
||||
|
||||
def test_game_event_kinds_are_allowed(app_db):
|
||||
app, db = app_db
|
||||
tc = TestClient(app)
|
||||
# the per-game funnel kinds (incl. the share-loop arrival) pass the allowlist
|
||||
for kind in ("word_started", "word_completed", "word_shared", "word_arrival", "match_arrival"):
|
||||
assert tc.post("/api/events", json={"kind": kind, "article_id": 0, "visitor": "t"}).json() == {"ok": True}
|
||||
assert _count(db, kind=kind) == 1
|
||||
# a bogus game kind is still rejected
|
||||
tc.post("/api/events", json={"kind": "chess_started", "visitor": "t"})
|
||||
assert _count(db, kind="chess_started") == 0
|
||||
|
||||
|
||||
def test_admin_stats_games_funnel_aggregates(app_db):
|
||||
app, db = app_db
|
||||
tc = TestClient(app)
|
||||
# two visitors arrive at Daily Word via a shared link; one engages + shares; a Match completes
|
||||
for v in ("a", "b"):
|
||||
tc.post("/api/events", json={"kind": "word_arrival", "article_id": 0, "visitor": v})
|
||||
tc.post("/api/events", json={"kind": "word_started", "article_id": 0, "visitor": "a"})
|
||||
tc.post("/api/events", json={"kind": "word_shared", "article_id": 0, "visitor": "a"})
|
||||
tc.post("/api/events", json={"kind": "match_completed", "article_id": 0, "visitor": "a"})
|
||||
from goodnews.db import connect
|
||||
from goodnews import queries
|
||||
c = connect(str(db))
|
||||
games = queries.admin_stats(c, days=30)["games"]
|
||||
c.close()
|
||||
assert games["by_game"]["word"] == {"arrival": 2, "started": 1, "completed": 0, "shared": 1}
|
||||
assert games["by_game"]["match"]["completed"] == 1
|
||||
assert games["totals"]["arrival"] == 2 and games["totals"]["shared"] == 1
|
||||
|
||||
@@ -51,6 +51,16 @@ def test_share_page_duplicate_redirects_to_canonical(client):
|
||||
assert r.status_code == 301 and r.headers["location"] == "/a/1"
|
||||
|
||||
|
||||
def test_share_page_creates_visitor_token_for_beacons(client):
|
||||
# /a/ is server-rendered outside the SPA, so its inline analytics must CREATE the
|
||||
# visitor token when absent (mirroring visitorId) — else a first-time /a/ landing
|
||||
# beacons an empty token and is dropped from visitor stats.
|
||||
html = TestClient(client).get("/a/1").text
|
||||
assert "localStorage.setItem('goodnews:visitor'" in html
|
||||
assert "crypto.randomUUID" in html
|
||||
assert "kind:'visit'" in html # daily visit beacon present
|
||||
|
||||
|
||||
def test_share_page_no_image_uses_summary_card(client, tmp_path, monkeypatch):
|
||||
# article 1 has an image → large card
|
||||
assert 'summary_large_image' in TestClient(client).get("/a/1").text
|
||||
|
||||
Reference in New Issue
Block a user