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:
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev --host",
|
"dev": "vite dev --host",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
|
"postbuild": "node scripts/patch-play-head.mjs",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"test": "vitest run"
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
// 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');
|
||||||
@@ -35,6 +35,22 @@ export function track(kind, articleId = 0) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Deep link for a shared game result: full https:// URL straight to the exact game +
|
||||||
|
// variant (not generic /play), tagged so we can attribute arrivals to the share loop.
|
||||||
|
const SITE = 'https://upbeatbytes.com';
|
||||||
|
export function gameShareUrl(game, variant) {
|
||||||
|
const v = variant ? `&v=${encodeURIComponent(variant)}` : '';
|
||||||
|
return `${SITE}/play?game=${game}${v}&utm_source=game_share`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-game funnel: trackGame('word', 'started'|'completed'|'shared'). Kinds must mirror
|
||||||
|
// the backend allowlist (_GAME_EVENT_KINDS in api.py). Deduped per game/kind/day server-side.
|
||||||
|
const GAME_NAMES = ['word', 'wordsearch', 'bloom', 'match'];
|
||||||
|
const GAME_EVENTS = ['started', 'completed', 'shared', 'arrival'];
|
||||||
|
export function trackGame(game, event) {
|
||||||
|
if (GAME_NAMES.includes(game) && GAME_EVENTS.includes(event)) track(`${game}_${event}`);
|
||||||
|
}
|
||||||
|
|
||||||
// Count a visit at most once per day per device.
|
// Count a visit at most once per day per device.
|
||||||
export function trackVisit() {
|
export function trackVisit() {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script>
|
<script>
|
||||||
import { getJSON, postJSON } from '$lib/api.js';
|
import { getJSON, postJSON } from '$lib/api.js';
|
||||||
import { pushGameState, fetchGameStats } from '$lib/gamesync.js';
|
import { pushGameState, fetchGameStats } from '$lib/gamesync.js';
|
||||||
|
import { trackGame, gameShareUrl } from '$lib/analytics.js';
|
||||||
|
|
||||||
// mode: 'daily' (shared, synced, ritual) | 'free' (local-only, infinite wheels)
|
// mode: 'daily' (shared, synced, ritual) | 'free' (local-only, infinite wheels)
|
||||||
// format: 'center' (center letter required) | 'wild' (any word from the 7)
|
// format: 'center' (center letter required) | 'wild' (any word from the 7)
|
||||||
@@ -208,13 +209,14 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const pan = isPangram(w);
|
const pan = isPangram(w);
|
||||||
|
if (found.length === 0) trackGame('bloom', 'started'); // first word = started
|
||||||
found = [w, ...found];
|
found = [w, ...found];
|
||||||
flash(pan ? 'Pangram! 🌸 +' + scoreWord(w) : '+' + scoreWord(w));
|
flash(pan ? 'Pangram! 🌸 +' + scoreWord(w) : '+' + scoreWord(w));
|
||||||
if (pan) bloomPulse();
|
if (pan) bloomPulse();
|
||||||
persist();
|
persist();
|
||||||
if (!isFree) syncSoon();
|
if (!isFree) syncSoon();
|
||||||
if (found.reduce((s, x) => s + scoreWord(x), 0) >= maxScore && !fullShown) {
|
if (found.reduce((s, x) => s + scoreWord(x), 0) >= maxScore && !fullShown) {
|
||||||
fullShown = true; bloomPulse();
|
fullShown = true; bloomPulse(); trackGame('bloom', 'completed'); // Full Bloom
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,9 +227,10 @@
|
|||||||
found.forEach((w) => (byLen[w.length] = (byLen[w.length] || 0) + 1));
|
found.forEach((w) => (byLen[w.length] = (byLen[w.length] || 0) + 1));
|
||||||
const breakdown = Object.keys(byLen).sort((a, b) => b - a).map((l) => `${l}×${byLen[l]}`).join(' ');
|
const breakdown = Object.keys(byLen).sort((a, b) => b - a).map((l) => `${l}×${byLen[l]}`).join(' ');
|
||||||
const pang = found.some(isPangram) ? ' · pangram ✓' : '';
|
const pang = found.some(isPangram) ? ' · pangram ✓' : '';
|
||||||
const text = `Upbeat Bytes · Bloom ${date}\n${fullBloom ? 'Full Bloom 🌸' : tier.name} · ${found.length} words${pang}\n${breakdown}\nupbeatbytes.com/play`;
|
const bloomV = mode === 'daily' ? 'daily' : (format === 'wild' ? 'free-wild' : 'free-center');
|
||||||
if (navigator.share) navigator.share({ text }).catch(() => {});
|
const text = `Upbeat Bytes · Bloom ${date}\n${fullBloom ? 'Full Bloom 🌸' : tier.name} · ${found.length} words${pang}\n${breakdown}\n${gameShareUrl('bloom', bloomV)}`;
|
||||||
else navigator.clipboard?.writeText(text).then(() => { copied = true; setTimeout(() => (copied = false), 1500); });
|
if (navigator.share) navigator.share({ text }).then(() => trackGame('bloom', 'shared')).catch(() => {});
|
||||||
|
else navigator.clipboard?.writeText(text).then(() => { trackGame('bloom', 'shared'); copied = true; setTimeout(() => (copied = false), 1500); });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Quiet "this should count?" — flags a rejected word for the admin queue.
|
// Quiet "this should count?" — flags a rejected word for the admin queue.
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script>
|
<script>
|
||||||
import { untrack } from 'svelte';
|
import { untrack } from 'svelte';
|
||||||
import { pushGameState } from '$lib/gamesync.js';
|
import { pushGameState } from '$lib/gamesync.js';
|
||||||
|
import { trackGame, gameShareUrl } from '$lib/analytics.js';
|
||||||
import { COLOR_BY_KEY } from '$lib/games/match/palette.js';
|
import { COLOR_BY_KEY } from '$lib/games/match/palette.js';
|
||||||
import { buildBoard, freeSeed } from '$lib/games/match/board.js';
|
import { buildBoard, freeSeed } from '$lib/games/match/board.js';
|
||||||
import MatchIcon from './MatchIcon.svelte';
|
import MatchIcon from './MatchIcon.svelte';
|
||||||
@@ -104,6 +105,7 @@
|
|||||||
function flip(card) {
|
function flip(card) {
|
||||||
if (!ready || locked || done) return;
|
if (!ready || locked || done) return;
|
||||||
if (matchedKeys.has(card.key) || flipped.includes(card.id)) 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
|
// Match off the LOCAL `next`, not the just-assigned reactive `flipped`, so the
|
||||||
// evaluation never runs a click behind.
|
// evaluation never runs a click behind.
|
||||||
@@ -118,7 +120,7 @@
|
|||||||
matchedKeys = new Set([...matchedKeys, keys[0]]);
|
matchedKeys = new Set([...matchedKeys, keys[0]]);
|
||||||
flipped = [];
|
flipped = [];
|
||||||
persist();
|
persist();
|
||||||
if (matchedKeys.size >= board.faces.length && !celebrated) celebrated = true;
|
if (matchedKeys.size >= board.faces.length && !celebrated) { celebrated = true; trackGame('match', 'completed'); }
|
||||||
} else {
|
} else {
|
||||||
locked = true; // show the mismatch briefly, then flip back
|
locked = true; // show the mismatch briefly, then flip back
|
||||||
persist();
|
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) {
|
function faceLabel(card) {
|
||||||
if (format === 'colors') return COLOR_BY_KEY[card.key]?.name ?? 'color';
|
if (format === 'colors') return COLOR_BY_KEY[card.key]?.name ?? 'color';
|
||||||
return card.key;
|
return card.key;
|
||||||
@@ -190,6 +202,7 @@
|
|||||||
|
|
||||||
{#if done && celebrated}
|
{#if done && celebrated}
|
||||||
<p class="done">Lovely — you cleared {isFree ? 'the board' : "today's set"}.{isFree ? '' : ' Fresh one tomorrow.'}</p>
|
<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}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -249,6 +262,9 @@
|
|||||||
color: var(--accent-deep); font-weight: 700; }
|
color: var(--accent-deep); font-weight: 700; }
|
||||||
|
|
||||||
.done { margin: 4px 0 0; color: var(--accent-deep); font-family: var(--label); text-align: center; }
|
.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) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.inner { transition: none; }
|
.inner { transition: none; }
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script>
|
<script>
|
||||||
import { getJSON, postJSON } from '$lib/api.js';
|
import { getJSON, postJSON } from '$lib/api.js';
|
||||||
import { pushGameState, fetchGameStats } from '$lib/gamesync.js';
|
import { pushGameState, fetchGameStats } from '$lib/gamesync.js';
|
||||||
|
import { trackGame, gameShareUrl } from '$lib/analytics.js';
|
||||||
|
|
||||||
let { variant = '5', onstatus } = $props();
|
let { variant = '5', onstatus } = $props();
|
||||||
|
|
||||||
@@ -121,11 +122,12 @@
|
|||||||
submitting = true;
|
submitting = true;
|
||||||
try {
|
try {
|
||||||
const res = await postJSON('/api/puzzle/word/guess', { variant, guess: current, n: guesses.length + 1 });
|
const res = await postJSON('/api/puzzle/word/guess', { variant, guess: current, n: guesses.length + 1 });
|
||||||
|
if (guesses.length === 0) trackGame('word', 'started'); // first real guess = started
|
||||||
guesses = [...guesses, current];
|
guesses = [...guesses, current];
|
||||||
cols = [...cols, res.colors];
|
cols = [...cols, res.colors];
|
||||||
current = '';
|
current = '';
|
||||||
if (res.solved) { status = 'won'; answer = res.answer; why = res.why; recordStat(true); }
|
if (res.solved) { status = 'won'; answer = res.answer; why = res.why; recordStat(true); trackGame('word', 'completed'); }
|
||||||
else if (guesses.length >= maxGuesses) { status = 'lost'; answer = res.answer; why = res.why; recordStat(false); }
|
else if (guesses.length >= maxGuesses) { status = 'lost'; answer = res.answer; why = res.why; recordStat(false); trackGame('word', 'completed'); }
|
||||||
persist();
|
persist();
|
||||||
syncSoon(); // push this guess (and any completion) to the server, debounced
|
syncSoon(); // push this guess (and any completion) to the server, debounced
|
||||||
} catch {
|
} catch {
|
||||||
@@ -165,9 +167,11 @@
|
|||||||
const label = variant === '6' ? 'Long Word' : 'Daily Word';
|
const label = variant === '6' ? 'Long Word' : 'Daily Word';
|
||||||
const score = status === 'won' ? guesses.length : 'X';
|
const score = status === 'won' ? guesses.length : 'X';
|
||||||
const grid = cols.map((cs) => cs.map((c) => EMOJI[c]).join('')).join('\n');
|
const grid = cols.map((cs) => cs.map((c) => EMOJI[c]).join('')).join('\n');
|
||||||
const text = `Upbeat Bytes · ${label} ${date}\n${score}/${maxGuesses}\n${grid}\nupbeatbytes.com/play`;
|
const text = `Upbeat Bytes · ${label} ${date}\n${score}/${maxGuesses}\n${grid}\n${gameShareUrl('word', variant)}`;
|
||||||
if (navigator.share) navigator.share({ text }).catch(() => {});
|
// Count a share only once it actually happens (sheet completed / clipboard wrote),
|
||||||
else navigator.clipboard?.writeText(text).then(() => { copied = true; setTimeout(() => (copied = false), 1500); });
|
// never on a cancelled share sheet or denied clipboard.
|
||||||
|
if (navigator.share) navigator.share({ text }).then(() => trackGame('word', 'shared')).catch(() => {});
|
||||||
|
else navigator.clipboard?.writeText(text).then(() => { trackGame('word', 'shared'); copied = true; setTimeout(() => (copied = false), 1500); });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load on mount and whenever the variant toggles. Tracks ONLY `variant` — it
|
// Load on mount and whenever the variant toggles. Tracks ONLY `variant` — it
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { getJSON } from '$lib/api.js';
|
import { getJSON } from '$lib/api.js';
|
||||||
import { lineFrom, matchWord, cellFromPoint } from '$lib/wordsearch.js';
|
import { lineFrom, matchWord, cellFromPoint } from '$lib/wordsearch.js';
|
||||||
import { pushGameState, fetchGameStats } from '$lib/gamesync.js';
|
import { pushGameState, fetchGameStats } from '$lib/gamesync.js';
|
||||||
|
import { trackGame, gameShareUrl } from '$lib/analytics.js';
|
||||||
|
|
||||||
let { size = 'med', onstatus } = $props();
|
let { size = 'med', onstatus } = $props();
|
||||||
|
|
||||||
@@ -197,6 +198,7 @@
|
|||||||
function evaluate(cells) {
|
function evaluate(cells) {
|
||||||
const hit = matchWord(cells, grid, words, found);
|
const hit = matchWord(cells, grid, words, found);
|
||||||
if (!hit) return;
|
if (!hit) return;
|
||||||
|
if (foundWords.length === 0) trackGame('wordsearch', 'started'); // first word found = started
|
||||||
foundWords = [...foundWords, { word: hit, cells: cells.map((c) => [...c]), ci: foundWords.length % PALETTE.length }];
|
foundWords = [...foundWords, { word: hit, cells: cells.map((c) => [...c]), ci: foundWords.length % PALETTE.length }];
|
||||||
okFlash = true; setTimeout(() => (okFlash = false), 500);
|
okFlash = true; setTimeout(() => (okFlash = false), 500);
|
||||||
if (foundWords.length === words.length) finish();
|
if (foundWords.length === words.length) finish();
|
||||||
@@ -206,6 +208,7 @@
|
|||||||
|
|
||||||
function finish() {
|
function finish() {
|
||||||
pauseClock(false); // close the open segment; persist follows in evaluate()
|
pauseClock(false); // close the open segment; persist follows in evaluate()
|
||||||
|
trackGame('wordsearch', 'completed');
|
||||||
resultMs = playedMs;
|
resultMs = playedMs;
|
||||||
if (resultMs && (!best || resultMs < best)) {
|
if (resultMs && (!best || resultMs < best)) {
|
||||||
best = resultMs;
|
best = resultMs;
|
||||||
@@ -220,9 +223,9 @@
|
|||||||
|
|
||||||
function share() {
|
function share() {
|
||||||
const label = { small: 'Small', med: 'Medium', large: 'Large' }[size] || '';
|
const label = { small: 'Small', med: 'Medium', large: 'Large' }[size] || '';
|
||||||
const text = `Upbeat Bytes · Word Search (${label}) ${date}\n${theme} — cleared in ${fmt(resultMs)}\nupbeatbytes.com/play`;
|
const text = `Upbeat Bytes · Word Search (${label}) ${date}\n${theme} — cleared in ${fmt(resultMs)}\n${gameShareUrl('wordsearch', size)}`;
|
||||||
if (navigator.share) navigator.share({ text }).catch(() => {});
|
if (navigator.share) navigator.share({ text }).then(() => trackGame('wordsearch', 'shared')).catch(() => {});
|
||||||
else navigator.clipboard?.writeText(text).then(() => { copied = true; setTimeout(() => (copied = false), 1500); });
|
else navigator.clipboard?.writeText(text).then(() => { trackGame('wordsearch', 'shared'); copied = true; setTimeout(() => (copied = false), 1500); });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load on mount and whenever the size changes. Tracks ONLY `size`.
|
// Load on mount and whenever the size changes. Tracks ONLY `size`.
|
||||||
|
|||||||
@@ -3,10 +3,16 @@
|
|||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import FeedbackModal from '$lib/components/FeedbackModal.svelte';
|
import FeedbackModal from '$lib/components/FeedbackModal.svelte';
|
||||||
import { fb, openFeedback, closeFeedback } from '$lib/feedback.svelte.js';
|
import { fb, openFeedback, closeFeedback } from '$lib/feedback.svelte.js';
|
||||||
|
import { trackVisit } from '$lib/analytics.js';
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
// Tell the boot-failure seatbelt (app.html) the app mounted — clears the
|
// Tell the boot-failure seatbelt (app.html) the app mounted — clears the
|
||||||
// recovery card + timeout as soon as the shell hydrates.
|
// 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>
|
</script>
|
||||||
|
|
||||||
{@render children()}
|
{@render children()}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
import { auth, refresh as refreshAuth, isFollowing, toggleFollow, followKeys } from '$lib/auth.svelte.js';
|
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 { prefs, initPrefs, active as prefsActive, applyPrefAction, persistPrefs, syncPrefsOnLogin } from '$lib/prefs.svelte.js';
|
||||||
import { initHistory, deviceIds, record, loadServerHistory } from '$lib/history.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 { pwa, installApp, dismissPwa } from '$lib/pwa.svelte.js';
|
||||||
import { ritualState, markBriefSeen } from '$lib/ritual.js';
|
import { ritualState, markBriefSeen } from '$lib/ritual.js';
|
||||||
|
|
||||||
@@ -488,7 +488,7 @@
|
|||||||
seenIds = new Set(P.loadJSON(SEEN_KEY, []));
|
seenIds = new Set(P.loadJSON(SEEN_KEY, []));
|
||||||
dismissed = new Set(P.loadJSON(DISMISSED_KEY, []));
|
dismissed = new Set(P.loadJSON(DISMISSED_KEY, []));
|
||||||
refreshAuth();
|
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
|
if (selected === 'search') { searchText = searchQuery; searchOpen = true; } // prefill on direct/shared link
|
||||||
// Instant paint: render the last saved Today brief immediately and refresh
|
// Instant paint: render the last saved Today brief immediately and refresh
|
||||||
// it behind the scenes, so the first view never blocks on a (personalized,
|
// 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 class="stat"><span class="n">{stats.funnel.full_story}</span><span class="l">Straight to source</span></div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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>
|
<section>
|
||||||
<h2>Emotional mix & friction</h2>
|
<h2>Emotional mix & friction</h2>
|
||||||
<div class="cards">
|
<div class="cards">
|
||||||
@@ -1506,6 +1526,12 @@
|
|||||||
.stat .n { font-size: 1.7rem; font-weight: 700; font-family: var(--label); color: var(--ink); }
|
.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; }
|
.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; }
|
.two { display: grid; grid-template-columns: 1fr 1fr; gap: 30px; }
|
||||||
@media (max-width: 620px) { .two { grid-template-columns: 1fr; } }
|
@media (max-width: 620px) { .two { grid-template-columns: 1fr; } }
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
import { prefs, initPrefs } from '$lib/prefs.svelte.js';
|
import { prefs, initPrefs } from '$lib/prefs.svelte.js';
|
||||||
import { auth } from '$lib/auth.svelte.js';
|
import { auth } from '$lib/auth.svelte.js';
|
||||||
import { isDevGated, blockedForViewer } from '$lib/devgate.js';
|
import { isDevGated, blockedForViewer } from '$lib/devgate.js';
|
||||||
|
import { trackGame } from '$lib/analytics.js';
|
||||||
import WordGame from '$lib/components/WordGame.svelte';
|
import WordGame from '$lib/components/WordGame.svelte';
|
||||||
import WordSearchGame from '$lib/components/WordSearchGame.svelte';
|
import WordSearchGame from '$lib/components/WordSearchGame.svelte';
|
||||||
import BloomGame from '$lib/components/BloomGame.svelte';
|
import BloomGame from '$lib/components/BloomGame.svelte';
|
||||||
@@ -248,6 +249,9 @@
|
|||||||
let wsTheme = $state('');
|
let wsTheme = $state('');
|
||||||
|
|
||||||
onMount(async () => {
|
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
|
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 { date = (await getJSON('/api/puzzle/word?variant=5')).date; } catch { /* offline */ }
|
||||||
try { wsTheme = (await getJSON('/api/puzzle/wordsearch?variant=med')).theme; } catch { /* offline */ }
|
try { wsTheme = (await getJSON('/api/puzzle/wordsearch?variant=med')).theme; } catch { /* offline */ }
|
||||||
@@ -259,7 +263,10 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<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}
|
{#if isDevGated(game)}<meta name="robots" content="noindex" />{/if}
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
|
|||||||
+9
-1
@@ -588,13 +588,21 @@ class SourceReviewBody(BaseModel):
|
|||||||
_FEEDBACK_CATEGORIES = {"idea", "concern", "bug", "praise", "other"}
|
_FEEDBACK_CATEGORIES = {"idea", "concern", "bug", "praise", "other"}
|
||||||
|
|
||||||
# The only event kinds we record. All aggregate, non-personal.
|
# The only event kinds we record. All aggregate, non-personal.
|
||||||
|
# Per-game funnel events (article_id is reused as 0 — no article dimension). Per-game
|
||||||
|
# kinds (not a generic "game_started") so the admin kind-count breakdown shows which
|
||||||
|
# game drives play and, crucially, shares — the growth loop we're instrumenting.
|
||||||
|
_GAME_NAMES = ("word", "wordsearch", "bloom", "match")
|
||||||
|
# arrival = landed on the game via a shared link (utm_source=game_share) — the share
|
||||||
|
# loop's acquisition signal; started/completed/shared are the engagement funnel.
|
||||||
|
_GAME_EVENT_KINDS = {f"{g}_{e}" for g in _GAME_NAMES for e in ("started", "completed", "shared", "arrival")}
|
||||||
|
|
||||||
_EVENT_KINDS = {
|
_EVENT_KINDS = {
|
||||||
"visit", "open", "summary_viewed", "full_story", "source_click",
|
"visit", "open", "summary_viewed", "full_story", "source_click",
|
||||||
"share_ub", "copy_source", "native_share",
|
"share_ub", "copy_source", "native_share",
|
||||||
"not_today", "less_like_this", "hide_topic",
|
"not_today", "less_like_this", "hide_topic",
|
||||||
"replace_used", "replace_none", "paywall_replace", "paywalled_source_open",
|
"replace_used", "replace_none", "paywall_replace", "paywalled_source_open",
|
||||||
"client_error", # boot-failure seatbelt beacon (blank-screen risk signal)
|
"client_error", # boot-failure seatbelt beacon (blank-screen risk signal)
|
||||||
}
|
} | _GAME_EVENT_KINDS
|
||||||
|
|
||||||
|
|
||||||
def _fts_query(q: str) -> str:
|
def _fts_query(q: str) -> str:
|
||||||
|
|||||||
@@ -655,6 +655,19 @@ def admin_stats(conn: sqlite3.Connection, days: int = 30) -> dict:
|
|||||||
}
|
}
|
||||||
replace = {"used": kc.get("replace_used", 0), "none": kc.get("replace_none", 0)}
|
replace = {"used": kc.get("replace_used", 0), "none": kc.get("replace_none", 0)}
|
||||||
|
|
||||||
|
# Game funnel — the growth loop we're instrumenting. Each count is distinct
|
||||||
|
# visitor-days (events dedupe per kind/day), so it reads as "people", not actions.
|
||||||
|
_GAME_NAMES = ("word", "wordsearch", "bloom", "match")
|
||||||
|
_GAME_EVENTS = ("arrival", "started", "completed", "shared") # arrival = share-loop acquisition
|
||||||
|
games_by = {
|
||||||
|
g: {e: kc.get(f"{g}_{e}", 0) for e in _GAME_EVENTS}
|
||||||
|
for g in _GAME_NAMES
|
||||||
|
}
|
||||||
|
games = {
|
||||||
|
"by_game": games_by,
|
||||||
|
"totals": {e: sum(games_by[g][e] for g in _GAME_NAMES) for e in _GAME_EVENTS},
|
||||||
|
}
|
||||||
|
|
||||||
# Accounts — aggregate counts only (no emails, no per-user listing).
|
# Accounts — aggregate counts only (no emails, no per-user listing).
|
||||||
accounts = {
|
accounts = {
|
||||||
"total": scalar("SELECT COUNT(*) FROM users"),
|
"total": scalar("SELECT COUNT(*) FROM users"),
|
||||||
@@ -700,6 +713,7 @@ def admin_stats(conn: sqlite3.Connection, days: int = 30) -> dict:
|
|||||||
"emotional_mix": emotional_mix,
|
"emotional_mix": emotional_mix,
|
||||||
"paywall": paywall,
|
"paywall": paywall,
|
||||||
"replace": replace,
|
"replace": replace,
|
||||||
|
"games": games,
|
||||||
"top_articles": top_articles,
|
"top_articles": top_articles,
|
||||||
"top_groupings": top_groupings,
|
"top_groupings": top_groupings,
|
||||||
"top_topics": top_topics,
|
"top_topics": top_topics,
|
||||||
|
|||||||
+8
-3
@@ -216,9 +216,14 @@ def render_share_page(article: dict, base_url: str, summary: str | None = None,
|
|||||||
<script>
|
<script>
|
||||||
(function(){{
|
(function(){{
|
||||||
try{{
|
try{{
|
||||||
var v=localStorage.getItem('goodnews:visitor')||'';
|
var v=localStorage.getItem('goodnews:visitor');
|
||||||
var b=JSON.stringify({{kind:'summary_viewed',article_id:{aid},visitor:v}});
|
if(!v){{v=crypto.randomUUID?crypto.randomUUID():String(Math.random()).slice(2)+Date.now();localStorage.setItem('goodnews:visitor',v);}}
|
||||||
if(navigator.sendBeacon) navigator.sendBeacon('/api/events', new Blob([b],{{type:'application/json'}}));
|
function beacon(o){{var b=JSON.stringify(o);if(navigator.sendBeacon)navigator.sendBeacon('/api/events',new Blob([b],{{type:'application/json'}}));}}
|
||||||
|
beacon({{kind:'summary_viewed',article_id:{aid},visitor:v}});
|
||||||
|
// This page is server-rendered (outside the Svelte app), so the SPA's daily
|
||||||
|
// visit isn't recorded for a /a/ landing — count it here, once per day per device.
|
||||||
|
var t=new Date().toISOString().slice(0,10);
|
||||||
|
if(localStorage.getItem('goodnews:visitday')!==t){{localStorage.setItem('goodnews:visitday',t);beacon({{kind:'visit',article_id:0,visitor:v}});}}
|
||||||
}}catch(e){{}}
|
}}catch(e){{}}
|
||||||
}})();
|
}})();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -51,3 +51,34 @@ def test_unknown_kind_is_ignored(app_db):
|
|||||||
app, db = app_db
|
app, db = app_db
|
||||||
assert TestClient(app).post("/api/events", json={"kind": "evil", "visitor": "x"}).json() == {"ok": True}
|
assert TestClient(app).post("/api/events", json={"kind": "evil", "visitor": "x"}).json() == {"ok": True}
|
||||||
assert _count(db) == 0
|
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"
|
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):
|
def test_share_page_no_image_uses_summary_card(client, tmp_path, monkeypatch):
|
||||||
# article 1 has an image → large card
|
# article 1 has an image → large card
|
||||||
assert 'summary_large_image' in TestClient(client).get("/a/1").text
|
assert 'summary_large_image' in TestClient(client).get("/a/1").text
|
||||||
|
|||||||
Reference in New Issue
Block a user