59ff48ae90
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>
42 lines
2.3 KiB
JavaScript
42 lines
2.3 KiB
JavaScript
// Post-build: give build/play.html its own social/canonical metadata.
|
|
//
|
|
// The app is a static SPA (ssr=false), so every prerendered shell ships app.html's
|
|
// HOMEPAGE <head> — meaning a shared /play link previews as the news homepage. Client
|
|
// svelte:head can't fix that for non-JS social scrapers (Twitter/Slack/iMessage/etc.).
|
|
// So we rewrite the static head of play.html here, at build time. Deep-linked variants
|
|
// (/play?game=…) are served the same file, so they inherit this games-hub preview.
|
|
import { readFile, writeFile } from 'node:fs/promises';
|
|
|
|
const FILE = new URL('../build/play.html', import.meta.url);
|
|
const URL_PLAY = 'https://upbeatbytes.com/play';
|
|
const TITLE = 'Play · Upbeat Bytes — calm daily games';
|
|
const DESC =
|
|
'A calm set of daily games — Daily Word, Word Search, Bloom, and Memory Match. ' +
|
|
'A friendly little break from the doomscroll.';
|
|
|
|
const subs = [
|
|
[/<title>[\s\S]*?<\/title>/, `<title>${TITLE}</title>`],
|
|
[/<meta name="description" content="[^"]*"\s*\/>/, `<meta name="description" content="${DESC}" />`],
|
|
[/<link rel="canonical" href="https:\/\/upbeatbytes\.com\/"\s*\/>/, `<link rel="canonical" href="${URL_PLAY}" />`],
|
|
[/<meta property="og:title" content="[^"]*"\s*\/>/, `<meta property="og:title" content="${TITLE}" />`],
|
|
[/<meta property="og:description" content="[^"]*"\s*\/>/, `<meta property="og:description" content="${DESC}" />`],
|
|
[/<meta property="og:url" content="https:\/\/upbeatbytes\.com\/"\s*\/>/, `<meta property="og:url" content="${URL_PLAY}" />`],
|
|
[/<meta name="twitter:title" content="[^"]*"\s*\/>/, `<meta name="twitter:title" content="${TITLE}" />`],
|
|
[/<meta name="twitter:description" content="[^"]*"\s*\/>/, `<meta name="twitter:description" content="${DESC}" />`],
|
|
];
|
|
|
|
let html = await readFile(FILE, 'utf8');
|
|
const missed = [];
|
|
for (const [re, repl] of subs) {
|
|
if (!re.test(html)) { missed.push(re.source.slice(0, 40)); continue; }
|
|
html = html.replace(re, repl);
|
|
}
|
|
// Fail loudly if the homepage head drifted — better a broken build than silently
|
|
// shipping the wrong /play preview again.
|
|
if (missed.length) {
|
|
console.error('patch-play-head: these head tags were not found (app.html changed?):\n ' + missed.join('\n '));
|
|
process.exit(1);
|
|
}
|
|
await writeFile(FILE, html);
|
|
console.log('patch-play-head: rewrote build/play.html head → /play metadata');
|