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
+17 -1
View File
@@ -1,6 +1,7 @@
<script>
import { untrack } from 'svelte';
import { pushGameState } from '$lib/gamesync.js';
import { trackGame, gameShareUrl } from '$lib/analytics.js';
import { COLOR_BY_KEY } from '$lib/games/match/palette.js';
import { buildBoard, freeSeed } from '$lib/games/match/board.js';
import MatchIcon from './MatchIcon.svelte';
@@ -104,6 +105,7 @@
function flip(card) {
if (!ready || locked || done) return;
if (matchedKeys.has(card.key) || flipped.includes(card.id)) return;
if (moves === 0 && flipped.length === 0 && matchedKeys.size === 0) trackGame('match', 'started');
// Match off the LOCAL `next`, not the just-assigned reactive `flipped`, so the
// evaluation never runs a click behind.
@@ -118,7 +120,7 @@
matchedKeys = new Set([...matchedKeys, keys[0]]);
flipped = [];
persist();
if (matchedKeys.size >= board.faces.length && !celebrated) celebrated = true;
if (matchedKeys.size >= board.faces.length && !celebrated) { celebrated = true; trackGame('match', 'completed'); }
} else {
locked = true; // show the mismatch briefly, then flip back
persist();
@@ -126,6 +128,16 @@
}
}
const TIER_LABEL = { gentle: 'Gentle', standard: 'Standard', expert: 'Expert' };
let copied = $state(false);
function share() {
const label = `${TIER_LABEL[tier] || tier} · ${format === 'colors' ? 'colors' : 'icons'}`;
const when = isFree ? 'Free play' : date;
const text = `Upbeat Bytes · Memory Match (${label}) ${when}\nCleared in ${moves} moves\n${gameShareUrl('match', `${mode}-${format}-${tier}`)}`;
if (navigator.share) navigator.share({ text }).then(() => trackGame('match', 'shared')).catch(() => {});
else navigator.clipboard?.writeText(text).then(() => { trackGame('match', 'shared'); copied = true; setTimeout(() => (copied = false), 1500); });
}
function faceLabel(card) {
if (format === 'colors') return COLOR_BY_KEY[card.key]?.name ?? 'color';
return card.key;
@@ -190,6 +202,7 @@
{#if done && celebrated}
<p class="done">Lovely — you cleared {isFree ? 'the board' : "today's set"}.{isFree ? '' : ' Fresh one tomorrow.'}</p>
<button class="share" onclick={share}>{copied ? 'Copied!' : 'Share result'}</button>
{/if}
{/if}
</div>
@@ -249,6 +262,9 @@
color: var(--accent-deep); font-weight: 700; }
.done { margin: 4px 0 0; color: var(--accent-deep); font-family: var(--label); text-align: center; }
.share { margin-top: 10px; background: var(--accent); color: #fff; border: none; border-radius: 999px;
padding: 9px 22px; font: inherit; font-weight: 600; cursor: pointer; }
.share:hover { background: var(--accent-deep); }
@media (prefers-reduced-motion: reduce) {
.inner { transition: none; }