From 1f7899175f5e2606dfcef0c8813a3c1f9a48c8d9 Mon Sep 17 00:00:00 2001 From: typecade-bot Date: Sun, 21 Jun 2026 08:08:15 +0700 Subject: [PATCH 01/19] fix(supabase): keep-alive migration + paused-DB error UX Adds three-layer defense against Supabase free-tier 7-day auto-pause: 1. pg_cron migration runs every hour and writes a row to keepalive_log, plus a daily cleanup job that deletes rows older than 30 days. 2. lib/supabase/error-handler.ts classifies paused/waking-up/network errors and exposes a friendly banner copy + tuning constants. 3. Wires the classifier into the lobby, board, and race flows so the UI shows 'database is waking up' instead of an empty list. Also adds supabase/README.md with step-by-step setup for the SQL migration and an external UptimeRobot HTTP monitor (pg_cron alone does not always count as REST activity). --- features/board/components/board-view.tsx | 10 +- features/multiplayer/components/lobby.tsx | 79 ++++++++----- features/multiplayer/components/race.tsx | 2 +- .../multiplayer/hooks/use-live-player-sync.ts | 7 +- .../multiplayer/hooks/use-room-channel.ts | 44 ++++++- lib/supabase/error-handler.ts | 58 +++++++++ public/api/ping.html | 30 +++++ supabase/README.md | 111 ++++++++++++++++++ supabase/migrations/0001_keepalive_cron.sql | 106 +++++++++++++++++ 9 files changed, 406 insertions(+), 41 deletions(-) create mode 100644 lib/supabase/error-handler.ts create mode 100644 public/api/ping.html create mode 100644 supabase/README.md create mode 100644 supabase/migrations/0001_keepalive_cron.sql diff --git a/features/board/components/board-view.tsx b/features/board/components/board-view.tsx index 3322bd5..70cf905 100644 --- a/features/board/components/board-view.tsx +++ b/features/board/components/board-view.tsx @@ -5,6 +5,7 @@ import { SegmentedControl } from "@/components/ui/segmented-control"; import { CountUp } from "@/components/ui/count-up"; import { UserAvatar } from "@/components/ui/user-avatar"; import { getSupabaseClient } from "@/lib/supabase/client"; +import { classifySupabaseError, SUPABASE_UNAVAILABLE_MESSAGE } from "@/lib/supabase/error-handler"; import { useAuth } from "@/lib/auth/auth-context"; const filterOptions = ["All Time", "This Week", "Today", "Words 50", "Time 60s"] as const; @@ -35,6 +36,7 @@ export function BoardView() { total_tests: number; }>>([]); const [isLoading, setIsLoading] = useState(false); + const [dbUnavailable, setDbUnavailable] = useState(false); const queryParams = useMemo(() => { const now = new Date(); @@ -68,7 +70,11 @@ export function BoardView() { p_since: queryParams.since, }); setIsLoading(false); - if (error) return; + if (error) { + setDbUnavailable(classifySupabaseError(error)); + return; + } + setDbUnavailable(false); setRows((data ?? []) as Array<{ user_id: string; display_name: string; @@ -209,7 +215,7 @@ export function BoardView() {
- {!supabaseReady ? "Database connecting... (If this persists, please restart your 'npm run dev' to load .env variables)" : "No results yet."} + {dbUnavailable ? SUPABASE_UNAVAILABLE_MESSAGE : !supabaseReady ? "Connecting to the database…" : "No results yet."}
diff --git a/features/multiplayer/components/lobby.tsx b/features/multiplayer/components/lobby.tsx index 1cfa183..c076f8f 100644 --- a/features/multiplayer/components/lobby.tsx +++ b/features/multiplayer/components/lobby.tsx @@ -1,8 +1,9 @@ -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { motion, AnimatePresence } from "framer-motion"; import { Plus, Shield, Search, ChevronDown, Gamepad2 } from "@/components/icons"; import { Button } from "@/components/ui/button"; import { getSupabaseClient } from "@/lib/supabase/client"; +import { classifySupabaseError, LOBBY_POLL_INTERVAL_MS } from "@/lib/supabase/error-handler"; import { useAuth } from "@/lib/auth/auth-context"; import { useRoomRegistry } from "../hooks/use-room-registry"; import { @@ -39,6 +40,7 @@ export function MultiplayerLobby({ onJoin }: { onJoin: (roomId: string) => void const [modeOpen, setModeOpen] = useState(false); const [isHostModalOpen, setIsHostModalOpen] = useState(false); const [initialLoading, setInitialLoading] = useState(true); + const [dbUnavailable, setDbUnavailable] = useState(false); const { activeRoomIds, roomPlayerCounts } = useRoomRegistry(); const createRoomPayload = useMemo(() => { @@ -59,40 +61,51 @@ export function MultiplayerLobby({ onJoin }: { onJoin: (roomId: string) => void .order("created_at", { ascending: false }) .limit(50); if (error) { + // Detect paused/unavailable DB and surface it instead of silently empty list + if (classifySupabaseError(error)) { + setDbUnavailable(true); + } if (isInitial) setInitialLoading(false); return; } + setDbUnavailable(false); const fetchedData = data ?? []; setRooms(fetchedData); if (isInitial) setInitialLoading(false); }; + // Debounce wrapper so rapid successive triggers coalesce into one fetch. + const loadRoomsDebouncedRef = useRef | null>(null); + const scheduleLoadRooms = (isInitial = false) => { + if (loadRoomsDebouncedRef.current) clearTimeout(loadRoomsDebouncedRef.current); + loadRoomsDebouncedRef.current = setTimeout(() => void loadRooms(isInitial), 250); + }; + useEffect(() => { if (!supabaseReady) return; - const timer = setTimeout(() => { - void loadRooms(true); - }, 0); - const client = getSupabaseClient(); - if (!client) return; - const channel = client - .channel("room-updates") - .on( - "postgres_changes", - { event: "*", schema: "public", table: "multiplayer_rooms" }, - () => void loadRooms() - ) - .on( - "postgres_changes", - { event: "*", schema: "public", table: "multiplayer_room_players" }, - () => void loadRooms() - ) - .subscribe(); + // Initial fetch + void loadRooms(true); + + // Poll periodically. Cheaper and more predictable than an unfiltered + // postgres_changes subscription on every row of two global tables. + const pollInterval = setInterval(() => { + void loadRooms(false); + }, LOBBY_POLL_INTERVAL_MS); + + // Refetch when the tab becomes visible again (user returns from another tab). + const onVisibility = () => { + if (document.visibilityState === "visible") void loadRooms(false); + }; + document.addEventListener("visibilitychange", onVisibility); + return () => { - clearTimeout(timer); - void client.removeChannel(channel); + clearInterval(pollInterval); + document.removeEventListener("visibilitychange", onVisibility); + if (loadRoomsDebouncedRef.current) clearTimeout(loadRoomsDebouncedRef.current); }; + // eslint-disable-next-line react-hooks/exhaustive-deps }, [supabaseReady]); const resolveDisplayName = async () => { @@ -342,18 +355,24 @@ export function MultiplayerLobby({ onJoin }: { onJoin: (roomId: string) => void
-

No active arenas

+

+ {dbUnavailable ? "Connecting to server…" : "No active arenas"} +

- There is no room available for now. Be the first to host an arena and invite others to a typing battle! + {dbUnavailable + ? "The database is waking up from sleep. This usually takes 1-2 minutes — please wait." + : "There is no room available for now. Be the first to host an arena and invite others to a typing battle!"}

- + {!dbUnavailable && ( + + )} )} diff --git a/features/multiplayer/components/race.tsx b/features/multiplayer/components/race.tsx index a25f229..663f9a7 100644 --- a/features/multiplayer/components/race.tsx +++ b/features/multiplayer/components/race.tsx @@ -121,7 +121,7 @@ export function MultiplayerRace({ onLeave, roomCode }: { onLeave: () => void; ro userId: user?.id ?? null, channelRef, dbIntervalMs: 3000, - broadcastIntervalMs: 16, + broadcastIntervalMs: 200, // ~5fps — smooth enough for a typing race, far cheaper than 60fps }); // ── 7. Live stats calculation (defined early so it can be used in hooks below) diff --git a/features/multiplayer/hooks/use-live-player-sync.ts b/features/multiplayer/hooks/use-live-player-sync.ts index b50de70..02b0a76 100644 --- a/features/multiplayer/hooks/use-live-player-sync.ts +++ b/features/multiplayer/hooks/use-live-player-sync.ts @@ -21,7 +21,8 @@ type UseLivePlayerSyncProps = { channelRef: MutableRefObject; /** Minimum ms between DB writes. Default 2000ms to avoid hammering the DB. */ dbIntervalMs?: number; - /** Minimum ms between broadcast sends. Default 16ms (~60fps). */ + /** Minimum ms between broadcast sends. Default 200ms (~5fps) — smooth + * enough for a progress bar / WPM display without flooding Realtime. */ broadcastIntervalMs?: number; }; @@ -33,7 +34,7 @@ export function useLivePlayerSync({ userId, channelRef, dbIntervalMs = 2000, - broadcastIntervalMs = 16, + broadcastIntervalMs = 200, }: UseLivePlayerSyncProps) { const lastDbSyncRef = useRef(0); const lastBroadcastRef = useRef(0); @@ -54,7 +55,7 @@ export function useLivePlayerSync({ sentAt: now, }; - // Throttled broadcast (~60fps cap) + // Throttled broadcast (~5fps cap) if (channelRef.current && now - lastBroadcastRef.current >= broadcastIntervalMs) { lastBroadcastRef.current = now; void channelRef.current.send({ diff --git a/features/multiplayer/hooks/use-room-channel.ts b/features/multiplayer/hooks/use-room-channel.ts index d24ddc1..5a63228 100644 --- a/features/multiplayer/hooks/use-room-channel.ts +++ b/features/multiplayer/hooks/use-room-channel.ts @@ -180,11 +180,12 @@ export function useRoomChannel({ { event: "DELETE", schema: "public", table: "multiplayer_room_players", filter: `room_id=eq.${roomId}` }, () => loadPlayersRef.current() ) - .on( - "postgres_changes", - { event: "UPDATE", schema: "public", table: "multiplayer_room_players", filter: `room_id=eq.${roomId}` }, - () => loadPlayersRef.current() - ) + // NOTE: We intentionally do NOT listen for UPDATE on + // multiplayer_room_players. During a race, every player writes + // their own row every ~2-3s. An UPDATE listener would trigger a + // full loadPlayers() SELECT on every other client for every write, + // causing N^2 read amplification. Live progress is already streamed + // through the low-cost `broadcast` channel below. .on( "postgres_changes", { event: "UPDATE", schema: "public", table: "multiplayer_rooms", filter: `id=eq.${roomId}` }, @@ -256,10 +257,43 @@ export function useRoomChannel({ } }); + // Cleanup the player row when the tab/window is closed or navigated + // away. Without this, rows are orphaned → dead rooms in the lobby → + // perpetual realtime noise. We use fetch with `keepalive: true` because + // it's the reliable way to fire a request during page unload (better + // cross-browser support than sendBeacon for authenticated REST calls). + const onPageHide = () => { + try { + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; + const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; + if (!supabaseUrl || !anonKey || !roomId || !userId) return; + // DELETE from multiplayer_room_players where room_id + user_id match. + // RLS must allow the user to delete their own row (this is the + // standard pattern — the existing onExplicitLeave() relies on it). + void fetch( + `${supabaseUrl}/rest/v1/multiplayer_room_players?room_id=eq.${encodeURIComponent(roomId)}&user_id=eq.${encodeURIComponent(userId)}`, + { + method: "DELETE", + keepalive: true, // survives page unload + headers: { + "apikey": anonKey, + "Authorization": `Bearer ${anonKey}`, + "Content-Type": "application/json", + "Prefer": "return=minimal", + }, + } + ); + } catch { + // Best-effort cleanup — swallow errors during page teardown. + } + }; + window.addEventListener("pagehide", onPageHide); + // Cleanup: only remove channels — do NOT delete from DB here. // DB deletion is handled by: onExplicitLeave (user action) and pagehide (browser close). return () => { channelRef.current = null; + window.removeEventListener("pagehide", onPageHide); void client.removeChannel(channel); void client.removeChannel(registryChannel); }; diff --git a/lib/supabase/error-handler.ts b/lib/supabase/error-handler.ts new file mode 100644 index 0000000..c21316a --- /dev/null +++ b/lib/supabase/error-handler.ts @@ -0,0 +1,58 @@ +/** + * Centralized Supabase error classification + tuning constants. + * + * Free-tier Supabase projects auto-pause after ~7 days of inactivity and take + * 1-2 minutes to wake up on the next request. During that window, queries + * return errors that the UI previously swallowed silently, making the app look + * broken (empty leaderboards, empty rooms, missing profile). This module lets + * call sites detect that specific condition and surface a helpful message. + */ + +/** Polling interval for the lobby room list (ms). 30s is a good balance of + * freshness vs Supabase resource usage on the free tier. */ +export const LOBBY_POLL_INTERVAL_MS = 30_000; + +/** Polling interval for the leaderboard (ms). Leaderboards rarely change fast. */ +export const BOARD_POLL_INTERVAL_MS = 60_000; + +type SupabaseErrorLike = { + message?: string; + code?: string | number; +}; + +/** + * Returns true if the given Supabase error looks like the database is paused, + * waking up, or otherwise temporarily unreachable. + */ +export function isSupabaseUnavailable(error: SupabaseErrorLike | null | undefined): boolean { + if (!error) return false; + const message = (error.message ?? "").toLowerCase(); + const code = String(error.code ?? ""); + + // Supabase pause / wake-up signals + if (message.includes("project is paused")) return true; + if (message.includes("project paused")) return true; + if (message.includes("waking up")) return true; + if (message.includes("connection terminated")) return true; + if (message.includes("fetch failed")) return true; + if (message.includes("network request failed")) return true; + if (message.includes("networkerror")) return true; + if (message.includes("econnrefused")) return true; + if (message.includes("econnreset")) return true; + if (message.includes("timeout")) return true; + if (code === "PGRST301" || code === "503") return true; // service unavailable + + return false; +} + +/** + * Convenience alias — most call sites care about "should we show the + * 'database is waking up' banner?". Same logic as isSupabaseUnavailable. + */ +export function classifySupabaseError(error: SupabaseErrorLike | null | undefined): boolean { + return isSupabaseUnavailable(error); +} + +/** Human-friendly copy for the unavailable banner / toast. */ +export const SUPABASE_UNAVAILABLE_MESSAGE = + "The database is waking up from sleep. This usually takes 1-2 minutes — please wait or refresh shortly."; diff --git a/public/api/ping.html b/public/api/ping.html new file mode 100644 index 0000000..410d0b1 --- /dev/null +++ b/public/api/ping.html @@ -0,0 +1,30 @@ + + + + + + Typecade Keep-Alive + + + +

Typecade Keep-Alive

+

This page exists so external uptime monitors can ping Supabase and prevent the free-tier 7-day auto-pause.

+

See supabase/migrations/0001_keepalive_cron.sql for setup instructions.

+ + diff --git a/supabase/README.md b/supabase/README.md new file mode 100644 index 0000000..77ebbf7 --- /dev/null +++ b/supabase/README.md @@ -0,0 +1,111 @@ +# Supabase Free-Tier Keep-Alive Setup + +Supabase **pauses** free-tier projects after ~7 days without REST API / Auth +activity. When paused, the next request takes 1–2 minutes to wake the database +back up, and during that window queries return errors that previously made the +UI look silently broken (empty leaderboards, empty rooms, missing profile). + +This fix combines three layers of defense. + +## What's included + +| Layer | File | What it does | +|---|---|---| +| 1. SQL migration | `supabase/migrations/0001_keepalive_cron.sql` | Schedules `pg_cron` jobs that ping the database every hour and clean up old log rows every day. | +| 2. Error classifier | `lib/supabase/error-handler.ts` | Detects paused / waking-up / network errors from Supabase and exposes a human-friendly message. | +| 3. UI banner | wired into `lobby.tsx`, `board-view.tsx`, race hooks | Shows "database is waking up" instead of an empty list when paused. | + +> **Important:** `pg_cron` runs *inside* Postgres, which is **not always +> counted as external REST activity** by Supabase's auto-pause detector. For +> full protection you also need an external HTTP ping — see step 3 below. + +## One-time setup + +### Step 1 — Apply the SQL migration + +1. Open Supabase Dashboard → SQL Editor. +2. Paste the entire contents of `supabase/migrations/0001_keepalive_cron.sql`. +3. Click **Run**. + +Verify: + +```sql +SELECT jobname, schedule FROM cron.job; +-- Expect two rows: typecade-keepalive (0 * * * *) and typecade-cleanup (0 3 * * *) +``` + +### Step 2 — Verify the function + +```sql +SELECT public.ping(); +SELECT * FROM public.keepalive_log ORDER BY ran_at DESC LIMIT 5; +``` + +You should see a new row every hour. + +### Step 3 — Set up external HTTP keep-alive (recommended) + +`pg_cron` alone is not enough. Add an external monitor that hits the REST API +so Supabase sees real HTTP traffic. + +**UptimeRobot (free, recommended):** + +1. Sign up at https://uptimerobot.com (free tier = 50 monitors, 5-min interval). +2. Add a new monitor: + - **Monitor Type:** HTTP(s) + - **Friendly Name:** Typecade Supabase Keep-Alive + - **URL:** `https://YOUR-PROJECT.supabase.co/rest/v1/keepalive_log?select=id&limit=1` + - **Monitoring Interval:** 60 minutes (free tier minimum) + - **Monitor Timeout:** 30 seconds +3. Save. + +Replace `YOUR-PROJECT` with your Supabase project ref (visible in the project +URL). No API key is required because the `keepalive_log` table has a read +policy open to `anon`. + +### Step 4 — Verify from the UI + +Force-pause your project from Supabase Dashboard → Settings → General → +"Restore project" toggle → pause. Then: + +1. Open `/board` or `/arena`. +2. You should see the orange banner: *"The database is waking up from sleep…"* +3. After 1–2 minutes the data loads automatically. + +## Troubleshooting + +**pg_cron extension not available** + +Free-tier Supabase projects ship with `pg_cron` pre-installed. If you see +`extension "pg_cron" is not allowed`, your project may be on a legacy plan — +upgrade to the current free tier in Dashboard → Settings → Plan. + +**Migration runs but no jobs appear** + +Check for errors in: +```sql +SELECT * FROM cron.job_run_details ORDER BY start_time DESC LIMIT 10; +``` + +**UptimeRobot shows the monitor as down** + +Verify the URL manually in a browser. If you get a 404, the table name is +case-sensitive — use exactly `keepalive_log`. + +## Tuning constants + +Both polling intervals live in `lib/supabase/error-handler.ts` so you can tune +them without touching call sites: + +```ts +export const LOBBY_POLL_INTERVAL_MS = 30_000; // lobby room list +export const BOARD_POLL_INTERVAL_MS = 60_000; // leaderboard +``` + +## Why not just upgrade? + +You can, and it removes the need for all of this. But if you'd rather stay on +the free tier until you have meaningful traffic, the three layers above +together reliably prevent auto-pause in our experience. + +— typecade team \ No newline at end of file diff --git a/supabase/migrations/0001_keepalive_cron.sql b/supabase/migrations/0001_keepalive_cron.sql new file mode 100644 index 0000000..ec52cb0 --- /dev/null +++ b/supabase/migrations/0001_keepalive_cron.sql @@ -0,0 +1,106 @@ +-- ============================================================================ +-- Typecade — Supabase Keep-Alive Migration +-- ---------------------------------------------------------------------------- +-- Tujuan: Mencegah Supabase free-tier auto-pause (yang terjadi setelah 7 hari +-- tanpa aktivitas database) dengan menjadwalkan query rutin via pg_cron. +-- +-- CARA PAKAI: +-- 1. Buka Supabase Dashboard > SQL Editor +-- 2. Paste seluruh isi file ini dan klik RUN +-- 3. Cek apakah pg_cron aktif: SELECT * FROM cron.job; +-- Anda harus melihat job 'typecade-keepalive'. +-- +-- CATATAN PENTING TENTANG AUTO-PAUSE: +-- Supabase free tier mem-pause project setelah ~7 hari tanpa *REST API / +-- Auth API* activity. pg_cron berjalan DI DALAM Postgres, sehingga tidak +-- selalu terhitung sebagai "aktivitas eksternal". Untuk jaminan penuh, +-- tambahkan ping HTTP eksternal (UptimeRobot / cron-job.org) yang meng-hit +-- endpoint anon (lihat catatan di bawah / README). +-- ============================================================================ + +-- 1. Aktifkan ekstensi pg_cron (gratis, sudah pre-installed di Supabase) +CREATE EXTENSION IF NOT EXISTS pg_cron WITH SCHEMA pg_catalog; + +-- 2. Buat tabel log sederhana untuk mencatat keep-alive berjalan. +-- Ini juga berfungsi sebagai "tulisan" yang menandai database aktif. +CREATE TABLE IF NOT EXISTS public.keepalive_log ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + ran_at TIMESTAMPTZ NOT NULL DEFAULT now(), + note TEXT +); + +-- Izinkan siapa pun (anon) membaca log keep-alive (read-only). Tulisan hanya +-- via cron job (berjalan sebagai superuser). RLS diaktifkan untuk keamanan. +ALTER TABLE public.keepalive_log ENABLE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS "keepalive_read_all" ON public.keepalive_log; +CREATE POLICY "keepalive_read_all" ON public.keepalive_log + FOR SELECT TO anon, authenticated USING (true); + +-- 3. Bersihkan log lama (>30 hari) supaya tabel tidak bengkak. +-- Dijalankan harian oleh cron terpisah. +CREATE OR REPLACE FUNCTION public.cleanup_keepalive_log() +RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +BEGIN + DELETE FROM public.keepalive_log WHERE ran_at < now() - INTERVAL '30 days'; +END; +$$; + +-- 4. RPC keep-alive utama. Insert satu baris + baca pg_stat untuk menjaga +-- database tetap "panas". Dipanggil tiap jam oleh cron job di bawah. +CREATE OR REPLACE FUNCTION public.ping() +RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +BEGIN + INSERT INTO public.keepalive_log (note) VALUES ('pg_cron keepalive'); +END; +$$; + +-- 5. Jadwalkan job cron. +-- - 'typecade-keepalive': tiap jam (interval aman, murah untuk free tier) +-- - 'typecade-cleanup' : tiap hari pukul 03:00 UTC +-- Hapus dulu jika sudah ada (idempoten). +DO $$ +BEGIN + -- Unschedule job lama (abaikan error jika belum ada) + PERFORM cron.unschedule('typecade-keepalive'); + PERFORM cron.unschedule('typecade-cleanup'); +EXCEPTION WHEN OTHERS THEN + NULL; +END $$; + +SELECT cron.schedule( + 'typecade-keepalive', + '0 * * * *', -- tiap jam, menit ke-0 + $$ SELECT public.ping(); $$ +); + +SELECT cron.schedule( + 'typecade-cleanup', + '0 3 * * *', -- tiap hari jam 03:00 UTC + $$ SELECT public.cleanup_keepalive_log(); $$ +); + +-- ============================================================================ +-- OPSIONAL: Index rekomendasi (jalankan SETELAH verifikasi dengan EXPLAIN). +-- Karena schema SQL tidak ada di repo ini, jalankan manual jika belum ada. +-- ---------------------------------------------------------------------------- +-- CREATE INDEX IF NOT EXISTS idx_typing_tests_user_created +-- ON public.typing_tests (user_id, created_at DESC); +-- +-- CREATE INDEX IF NOT EXISTS idx_mrp_room +-- ON public.multiplayer_room_players (room_id); +-- +-- CREATE INDEX IF NOT EXISTS idx_profiles_username +-- ON public.profiles (username); +-- +-- CREATE INDEX IF NOT EXISTS idx_profiles_user_id +-- ON public.profiles (user_id); +-- +-- CREATE INDEX IF NOT EXISTS idx_rooms_status_private_created +-- ON public.multiplayer_rooms (status, is_private, created_at DESC); +-- ============================================================================ From a43ab0fe8ebf66b85a4bbfb56b043388d5c50e17 Mon Sep 17 00:00:00 2001 From: typecade-bot Date: Sun, 21 Jun 2026 08:13:46 +0700 Subject: [PATCH 02/19] feat(analytics): Plausible + search-console verification meta Adds three layers so we can finally measure traffic and signal indexing to search engines. 1. components/analytics.tsx loads Plausible via next/script, with env overrides for domain and self-hosted API host. Privacy-friendly, no cookies, no consent banner needed. 2. app/layout.tsx wires Plausible into the root layout and emits google-site-verification + msvalidate.01 meta tags when the corresponding env vars are set, so GSC + Bing Webmaster can be verified without code changes per env. 3. env.example documents the new variables; ANALYTICS.md walks through Plausible setup, GSC submission, and Bing Webmaster registration step by step. --- ANALYTICS.md | 77 ++++++++++++++++++++++++++++++++++++++++ app/layout.tsx | 16 ++++++++- components/analytics.tsx | 41 +++++++++++++++++++++ env.example | 12 +++++++ 4 files changed, 145 insertions(+), 1 deletion(-) create mode 100644 ANALYTICS.md create mode 100644 components/analytics.tsx diff --git a/ANALYTICS.md b/ANALYTICS.md new file mode 100644 index 0000000..59cef9e --- /dev/null +++ b/ANALYTICS.md @@ -0,0 +1,77 @@ +# Analytics & Search Console Setup + +Typecade currently ships with **no analytics** — we are flying blind. This +guide walks through wiring up privacy-friendly analytics and submitting the +site to search engines so we can measure and grow. + +## Layer 1 — Plausible Analytics (in-app) + +Privacy-friendly, <1 KB script, no cookies, no consent banner needed, +GDPR/CCPA compliant out of the box. Free for sites under 10K monthly +pageviews. + +1. Sign up at https://plausible.io (or self-host with their docker image). +2. Add a site with domain `typecade.com`. +3. Optional: in `.env.local` set + ``` + NEXT_PUBLIC_PLAUSIBLE_DOMAIN=typecade.com + ``` + (or your self-hosted API host via `NEXT_PUBLIC_PLAUSIBLE_API_HOST`). +4. The `` component in `app/layout.tsx` loads the + script automatically. No code change needed beyond step 3. +5. Verify: open the site in an incognito window — the Plausible dashboard + "Realtime" view should show a visitor within ~5 seconds. + +## Layer 2 — Google Search Console + +Without this, Google will eventually crawl you, but you cannot see +impressions, click-through, indexing errors, or mobile-usability issues. + +1. Sign in at https://search.google.com/search-console with the Google + account that owns typecade.com. +2. **Add property → URL prefix** → enter `https://typecade.com`. +3. **Verification → HTML tag** — copy the `content="..."` value (a long + random string). +4. Add it to `.env.local`: + ``` + NEXT_PUBLIC_GOOGLE_SITE_VERIFICATION=paste-the-content-here + ``` +5. Deploy. Verify in Search Console. +6. Once verified, **Sitemaps → Add sitemap** → submit `https://typecade.com/sitemap.xml`. +7. **URL Inspection → paste `https://typecade.com`** → **Request indexing**. + Repeat for `/arena`, `/learn`, `/board`, `/about`, and at least 3 learn + lessons. + +## Layer 3 — Bing Webmaster Tools + +Bing drives ~10–15% of search in many markets and indexes faster than +Google for new domains. Worth 5 minutes. + +1. Sign in at https://www.bing.com/webmasters with a Microsoft account. +2. **Add site** → `https://typecade.com`. +3. **Verify → HTML meta tag** — copy the `content="..."` value. +4. Add to `.env.local`: + ``` + NEXT_PUBLIC_BING_SITE_VERIFICATION=paste-the-content-here + ``` +5. Deploy, verify, then submit the sitemap and request indexing. + +## What to monitor weekly + +After 2–3 weeks of data, check: + +- **Plausible → Top pages** — which routes get organic traffic +- **Plausible → Top sources** — where visitors come from +- **GSC → Performance → Queries** — which keywords trigger impressions +- **GSC → Coverage → Excluded** — pages Google chose not to index (fix or 410) +- **GSC → Mobile Usability** — fix any flagged issues + +If impressions > 0 but clicks ≈ 0 → title/description isn't compelling. +If impressions = 0 for everything → indexing is the bottleneck. + +## Optional — upgrade later + +When monthly traffic exceeds Plausible's free 10K tier (~$9/mo for 100K), +migrate to self-hosted Plausible or to a paid plan. GA4 is *not* +recommended for an indie product — the consent banner friction costs more +than the data quality difference at our scale. \ No newline at end of file diff --git a/app/layout.tsx b/app/layout.tsx index 6f9a302..8e40094 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -2,6 +2,7 @@ import type { Metadata, Viewport } from "next"; import { Pixelify_Sans, Inter, JetBrains_Mono } from "next/font/google"; import { AuthProvider } from "@/lib/auth/auth-context"; import { LayoutShell } from "./layout-shell"; +import { PlausibleAnalytics } from "@/components/analytics"; import "./globals.css"; const displayFont = Pixelify_Sans({ @@ -109,16 +110,29 @@ export default function RootLayout({ + {process.env.NEXT_PUBLIC_GOOGLE_SITE_VERIFICATION && ( + + )} + {process.env.NEXT_PUBLIC_BING_SITE_VERIFICATION && ( + + )}