diff --git a/deploy/caddy/Caddyfile.snapshot b/deploy/caddy/Caddyfile.snapshot new file mode 100644 index 0000000..0ebf024 --- /dev/null +++ b/deploy/caddy/Caddyfile.snapshot @@ -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 + } +} diff --git a/frontend/package.json b/frontend/package.json index 666116a..b406604 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,6 +6,7 @@ "scripts": { "dev": "vite dev --host", "build": "vite build", + "postbuild": "node scripts/patch-play-head.mjs", "preview": "vite preview", "test": "vitest run" }, diff --git a/frontend/scripts/patch-play-head.mjs b/frontend/scripts/patch-play-head.mjs new file mode 100644 index 0000000..6ac14e6 --- /dev/null +++ b/frontend/scripts/patch-play-head.mjs @@ -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 — 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 = [ + [/[\s\S]*?<\/title>/, `<title>${TITLE}`], + [//, ``], + [//, ``], + [//, ``], + [//, ``], + [//, ``], + [//, ``], + [//, ``], +]; + +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'); diff --git a/frontend/src/lib/analytics.js b/frontend/src/lib/analytics.js index 81f724c..75f7c86 100644 --- a/frontend/src/lib/analytics.js +++ b/frontend/src/lib/analytics.js @@ -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. export function trackVisit() { try { diff --git a/frontend/src/lib/components/BloomGame.svelte b/frontend/src/lib/components/BloomGame.svelte index 4358b69..bab396b 100644 --- a/frontend/src/lib/components/BloomGame.svelte +++ b/frontend/src/lib/components/BloomGame.svelte @@ -1,6 +1,7 @@ {@render children()} diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index d667e40..e73eada 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -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, diff --git a/frontend/src/routes/admin/+page.svelte b/frontend/src/routes/admin/+page.svelte index b2a701b..573ec4e 100644 --- a/frontend/src/routes/admin/+page.svelte +++ b/frontend/src/routes/admin/+page.svelte @@ -1050,6 +1050,26 @@
{stats.funnel.full_story}Straight to source
+ {#if stats.games} +
+

Games funnel · the share loop

+
+
{stats.games.totals.arrival}Share arrivals
+
{stats.games.totals.started}Engaged (first move)
+
{stats.games.totals.completed}Completed
+
{stats.games.totals.shared}Shared
+
+ + + + {#each [['word', 'Daily Word'], ['wordsearch', 'Word Search'], ['bloom', 'Bloom'], ['match', 'Memory Match']] as [k, label] (k)} + + {/each} + +
GameArrivalsEngagedCompletedShared
{label}{stats.games.by_game[k].arrival}{stats.games.by_game[k].started}{stats.games.by_game[k].completed}{stats.games.by_game[k].shared}
+

“Share arrivals” = landings via a shared game link (utm_source=game_share). “Engaged” = first move (guess/find/flip), not just opening the page.

+
+ {/if}

Emotional mix & friction

@@ -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; } } diff --git a/frontend/src/routes/play/+page.svelte b/frontend/src/routes/play/+page.svelte index f0a7cdc..be4ffc8 100644 --- a/frontend/src/routes/play/+page.svelte +++ b/frontend/src/routes/play/+page.svelte @@ -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 @@ - Play · Upbeat Bytes + + Play · Upbeat Bytes — calm daily games {#if isDevGated(game)}{/if} diff --git a/goodnews/api.py b/goodnews/api.py index 2817764..4b04750 100644 --- a/goodnews/api.py +++ b/goodnews/api.py @@ -588,13 +588,21 @@ class SourceReviewBody(BaseModel): _FEEDBACK_CATEGORIES = {"idea", "concern", "bug", "praise", "other"} # 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 = { "visit", "open", "summary_viewed", "full_story", "source_click", "share_ub", "copy_source", "native_share", "not_today", "less_like_this", "hide_topic", "replace_used", "replace_none", "paywall_replace", "paywalled_source_open", "client_error", # boot-failure seatbelt beacon (blank-screen risk signal) -} +} | _GAME_EVENT_KINDS def _fts_query(q: str) -> str: diff --git a/goodnews/queries.py b/goodnews/queries.py index 69d9e46..3fc5736 100644 --- a/goodnews/queries.py +++ b/goodnews/queries.py @@ -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)} + # 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 = { "total": scalar("SELECT COUNT(*) FROM users"), @@ -700,6 +713,7 @@ def admin_stats(conn: sqlite3.Connection, days: int = 30) -> dict: "emotional_mix": emotional_mix, "paywall": paywall, "replace": replace, + "games": games, "top_articles": top_articles, "top_groupings": top_groupings, "top_topics": top_topics, diff --git a/goodnews/share.py b/goodnews/share.py index 0bdd53c..28c1d3b 100644 --- a/goodnews/share.py +++ b/goodnews/share.py @@ -216,9 +216,14 @@ def render_share_page(article: dict, base_url: str, summary: str | None = None, diff --git a/tests/test_events.py b/tests/test_events.py index 4ba5c40..91571e5 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -51,3 +51,34 @@ def test_unknown_kind_is_ignored(app_db): app, db = app_db assert TestClient(app).post("/api/events", json={"kind": "evil", "visitor": "x"}).json() == {"ok": True} 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 diff --git a/tests/test_share.py b/tests/test_share.py index 6f4d926..6bc96dc 100644 --- a/tests/test_share.py +++ b/tests/test_share.py @@ -51,6 +51,16 @@ def test_share_page_duplicate_redirects_to_canonical(client): 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): # article 1 has an image → large card assert 'summary_large_image' in TestClient(client).get("/a/1").text