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:
@@ -3,10 +3,16 @@
|
||||
import { onMount } from 'svelte';
|
||||
import FeedbackModal from '$lib/components/FeedbackModal.svelte';
|
||||
import { fb, openFeedback, closeFeedback } from '$lib/feedback.svelte.js';
|
||||
import { trackVisit } from '$lib/analytics.js';
|
||||
let { children } = $props();
|
||||
// Tell the boot-failure seatbelt (app.html) the app mounted — clears the
|
||||
// recovery card + timeout as soon as the shell hydrates.
|
||||
onMount(() => window.__ubBooted?.());
|
||||
onMount(() => {
|
||||
window.__ubBooted?.();
|
||||
// Count the daily visit at the LAYOUT level so every landing page counts —
|
||||
// direct /play and /a/ arrivals included, not just the news homepage.
|
||||
trackVisit();
|
||||
});
|
||||
</script>
|
||||
|
||||
{@render children()}
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
import { auth, refresh as refreshAuth, isFollowing, toggleFollow, followKeys } from '$lib/auth.svelte.js';
|
||||
import { prefs, initPrefs, active as prefsActive, applyPrefAction, persistPrefs, syncPrefsOnLogin } from '$lib/prefs.svelte.js';
|
||||
import { initHistory, deviceIds, record, loadServerHistory } from '$lib/history.svelte.js';
|
||||
import { trackVisit, track } from '$lib/analytics.js';
|
||||
import { track } from '$lib/analytics.js';
|
||||
import { pwa, installApp, dismissPwa } from '$lib/pwa.svelte.js';
|
||||
import { ritualState, markBriefSeen } from '$lib/ritual.js';
|
||||
|
||||
@@ -488,7 +488,7 @@
|
||||
seenIds = new Set(P.loadJSON(SEEN_KEY, []));
|
||||
dismissed = new Set(P.loadJSON(DISMISSED_KEY, []));
|
||||
refreshAuth();
|
||||
trackVisit();
|
||||
// trackVisit() now fires once in the global layout (covers every landing page).
|
||||
if (selected === 'search') { searchText = searchQuery; searchOpen = true; } // prefill on direct/shared link
|
||||
// Instant paint: render the last saved Today brief immediately and refresh
|
||||
// it behind the scenes, so the first view never blocks on a (personalized,
|
||||
|
||||
@@ -1050,6 +1050,26 @@
|
||||
<div class="stat"><span class="n">{stats.funnel.full_story}</span><span class="l">Straight to source</span></div>
|
||||
</div>
|
||||
</section>
|
||||
{#if stats.games}
|
||||
<section>
|
||||
<h2>Games funnel <span class="muted small">· the share loop</span></h2>
|
||||
<div class="cards">
|
||||
<div class="stat"><span class="n">{stats.games.totals.arrival}</span><span class="l">Share arrivals</span></div>
|
||||
<div class="stat"><span class="n">{stats.games.totals.started}</span><span class="l">Engaged (first move)</span></div>
|
||||
<div class="stat"><span class="n">{stats.games.totals.completed}</span><span class="l">Completed</span></div>
|
||||
<div class="stat"><span class="n">{stats.games.totals.shared}</span><span class="l">Shared</span></div>
|
||||
</div>
|
||||
<table class="gfunnel">
|
||||
<thead><tr><th>Game</th><th>Arrivals</th><th>Engaged</th><th>Completed</th><th>Shared</th></tr></thead>
|
||||
<tbody>
|
||||
{#each [['word', 'Daily Word'], ['wordsearch', 'Word Search'], ['bloom', 'Bloom'], ['match', 'Memory Match']] as [k, label] (k)}
|
||||
<tr><td>{label}</td><td>{stats.games.by_game[k].arrival}</td><td>{stats.games.by_game[k].started}</td><td>{stats.games.by_game[k].completed}</td><td>{stats.games.by_game[k].shared}</td></tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="muted small">“Share arrivals” = landings via a shared game link (utm_source=game_share). “Engaged” = first move (guess/find/flip), not just opening the page.</p>
|
||||
</section>
|
||||
{/if}
|
||||
<section>
|
||||
<h2>Emotional mix & friction</h2>
|
||||
<div class="cards">
|
||||
@@ -1506,6 +1526,12 @@
|
||||
.stat .n { font-size: 1.7rem; font-weight: 700; font-family: var(--label); color: var(--ink); }
|
||||
.stat .l { color: var(--muted); font-size: 0.8rem; }
|
||||
|
||||
.gfunnel { width: 100%; border-collapse: collapse; margin-top: 12px; font-size: 0.9rem; }
|
||||
.gfunnel th, .gfunnel td { text-align: right; padding: 6px 10px; border-bottom: 1px solid var(--line); }
|
||||
.gfunnel th:first-child, .gfunnel td:first-child { text-align: left; }
|
||||
.gfunnel th { color: var(--muted); font-weight: 600; font-size: 0.78rem; }
|
||||
.gfunnel td { font-variant-numeric: tabular-nums; }
|
||||
|
||||
.two { display: grid; grid-template-columns: 1fr 1fr; gap: 30px; }
|
||||
@media (max-width: 620px) { .two { grid-template-columns: 1fr; } }
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import { prefs, initPrefs } from '$lib/prefs.svelte.js';
|
||||
import { auth } from '$lib/auth.svelte.js';
|
||||
import { isDevGated, blockedForViewer } from '$lib/devgate.js';
|
||||
import { trackGame } from '$lib/analytics.js';
|
||||
import WordGame from '$lib/components/WordGame.svelte';
|
||||
import WordSearchGame from '$lib/components/WordSearchGame.svelte';
|
||||
import BloomGame from '$lib/components/BloomGame.svelte';
|
||||
@@ -248,6 +249,9 @@
|
||||
let wsTheme = $state('');
|
||||
|
||||
onMount(async () => {
|
||||
// Share-loop acquisition: record an arrival when someone lands via a shared game
|
||||
// link (gameShareUrl tags it utm_source=game_share), attributed to that game.
|
||||
if ($page.url.searchParams.get('utm_source') === 'game_share') trackGame(game, 'arrival');
|
||||
initPrefs(); // so the reader's chosen calm set is available on a direct /play landing
|
||||
try { date = (await getJSON('/api/puzzle/word?variant=5')).date; } catch { /* offline */ }
|
||||
try { wsTheme = (await getJSON('/api/puzzle/wordsearch?variant=med')).theme; } catch { /* offline */ }
|
||||
@@ -259,7 +263,10 @@
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Play · Upbeat Bytes</title>
|
||||
<!-- Canonical/OG/description for /play are baked into the static play.html at build
|
||||
time (scripts/patch-play-head.mjs) so non-JS social scrapers get them; we keep
|
||||
only the browser-tab title + dev-gate noindex here to avoid duplicate tags. -->
|
||||
<title>Play · Upbeat Bytes — calm daily games</title>
|
||||
{#if isDevGated(game)}<meta name="robots" content="noindex" />{/if}
|
||||
</svelte:head>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user