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
+148
View File
@@ -0,0 +1,148 @@
# SNAPSHOT (read-only) of the live Caddy config.
# Live source of truth: /home/jay/srv/caddy/caddy-config/Caddyfile (mounted into the 'caddy' container).
# Captured so the upbeatbytes try_files {path} {path}.html change is tracked. Do not edit here expecting it to deploy.
{
email thejayman77@gmail.com
}
tjm77.com, www.tjm77.com {
tls {
dns cloudflare {env.CF_API_TOKEN}
}
root * /srv/sites/tjm77
file_server
encode gzip zstd
log {
output file /data/access-tjm77.log
}
}
jsj-designs.com, www.jsj-designs.com {
tls {
dns cloudflare {env.CF_API_TOKEN}
}
root * /srv/sites/jsj
file_server
encode gzip zstd
log {
output file /data/access-jsj.log
}
}
# Canonical host = apex. www redirects to it BEFORE the app, so OAuth always
# starts from the same host its callback uses (the ub_oauth cookie is host-only;
# starting from www then bouncing to the apex callback loses it → error=google).
www.upbeatbytes.com {
tls {
dns cloudflare {env.CF_API_TOKEN}
}
redir https://upbeatbytes.com{uri} permanent
}
upbeatbytes.com {
tls {
dns cloudflare {env.CF_API_TOKEN}
}
encode gzip zstd
# Dynamic API + server-rendered pages (share, digest, sitemap) → FastAPI.
@api path /api/* /healthz /docs /docs/* /openapi.json /a/* /today /sitemap.xml
handle @api {
reverse_proxy upbeatbytes-api:8000
}
# Everything else → the static SvelteKit SPA. try_files falls back to
# index.html so deep client routes (e.g. /auth/verify) boot the app
# instead of 404ing.
handle {
root * /srv/sites/upbeatbytes
# Content-hashed assets never change for a given URL — cache them forever.
@immutable path /_app/immutable/*
header @immutable Cache-Control "public, max-age=31536000, immutable"
# The SPA shell: "/" and extensionless client routes (try_files → index.html).
# Briefly cacheable at the CDN edge (s-maxage) so a first paint never depends
# on this origin's uplink; browsers still revalidate every visit (max-age=0).
# A deploy propagates within ≤2min and old immutable chunks are kept for a
# 14-day grace window, so a briefly-stale shell still boots cleanly.
# (Requires a Cloudflare Cache Rule marking these paths eligible — CF does
# not cache HTML by default.)
@shell {
not path /_app/immutable/*
not path *.*
}
header @shell Cache-Control "public, max-age=0, s-maxage=120, stale-while-revalidate=600"
# Mutable FILES (service worker, version manifest, webmanifest, word lists,
# icons) must revalidate every time — a pinned stale service worker is the
# classic blank-screen cause behind a CDN.
@revalidate {
not path /_app/immutable/*
path *.*
}
header @revalidate Cache-Control "no-cache"
# Serve a route's own prerendered HTML when it exists (e.g. /play -> play.html,
# which carries its own canonical/OG metadata), else fall back to the SPA shell.
# Cache-Control matchers above run on the ORIGINAL extensionless path, so /play
# still gets the @shell header before this rewrite.
try_files {path} {path}.html /index.html
file_server
}
log {
output file /data/access-upbeatbytes.log
}
}
git.tjm77.com {
tls {
dns cloudflare {env.CF_API_TOKEN}
}
reverse_proxy gitea:3000
encode gzip zstd
log {
output file /data/access-gitea.log
}
}
api.tjm77.com {
tls {
dns cloudflare {env.CF_API_TOKEN}
}
encode gzip zstd
# Recipe finder (What can I make? web tier) — must precede the arbiter catch-all.
@finder path /v1/find-recipes /v1/find-recipes/*
handle @finder {
reverse_proxy finder:8090
}
# Per-device token registration → auth service.
@register path /v1/register
handle @register {
reverse_proxy auth:8070
}
# In-app feedback relay → auth service (validates the device token + SMTP-sends).
# Keeps the arbiter a pure LLM gateway. Must precede the arbiter catch-all.
@feedback path /v1/feedback
handle @feedback {
reverse_proxy auth:8070
}
# App update policy — public static JSON (no secrets). Edit ~/srv/sites/api/app-version.json
# to change what testers are prompted to update to; no redeploy needed. Precedes the catch-all.
@appversion path /v1/app-version
handle @appversion {
root * /srv/sites
rewrite * /api/app-version.json
header Content-Type application/json
file_server
}
# LLM Arbiter (everything else); Bearer auth enforced by the arbiter.
handle {
reverse_proxy arbiter:8080
}
log {
output file /data/access-arbiter.log
}
}
}