0de85cbc51
Build and push image / build (push) Failing after 15m20s
Archive and answer pages now exist in all six languages and are prerendered instead of rendered per request, and each answer page carries a sentence of real context per player (club, league, nationality, former clubs, honours) plus Article and BreadcrumbList JSON-LD. The sitemap grows from 121 to 691 URLs, every one with hreflang alternates. Adds an optional display name to shared results, so a challenge link reads "<name> challenges you" in the page, the OG image and the head-to-head verdict; the name is sanitised both when written and when parsed back. Adds an opt-in daily reminder (Web Push without payload, so no content encryption) and a PWA install prompt shown once the day is finished, plus first-party event ingestion at /api/events that mirrors GA4 so the funnel survives ad blockers. Wires the missing N8N_PLAYS_WEBHOOK_URL into the deployment along with the new events, push and search-verification secrets, and ships the n8n workflows, the Postgres schema with funnel/retention views, and an IndexNow submitter. Refs #2 #3 #5 #6 #7 #8 #9 #10 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QvQ1ErZNRUcWgCEkJ9uyrn
91 lines
3.6 KiB
SQL
91 lines
3.6 KiB
SQL
-- First-party analytics store for hidden11 (see app/api/events/route.ts).
|
|
-- Runs on the shared Postgres (LXC 121 / 192.168.1.29), read by Metabase.
|
|
|
|
CREATE TABLE IF NOT EXISTS hidden11_events (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
name TEXT NOT NULL,
|
|
anonymous_id TEXT NOT NULL,
|
|
path TEXT NOT NULL DEFAULT '/',
|
|
properties JSONB NOT NULL DEFAULT '{}'::jsonb
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS hidden11_events_occurred_at_idx ON hidden11_events (occurred_at DESC);
|
|
CREATE INDEX IF NOT EXISTS hidden11_events_name_idx ON hidden11_events (name);
|
|
CREATE INDEX IF NOT EXISTS hidden11_events_anon_idx ON hidden11_events (anonymous_id);
|
|
|
|
-- Daily counter behind the social-proof line on the result screen.
|
|
CREATE TABLE IF NOT EXISTS hidden11_plays (
|
|
play_date DATE PRIMARY KEY,
|
|
count INTEGER NOT NULL DEFAULT 0
|
|
);
|
|
|
|
-- Daily-reminder push subscriptions.
|
|
CREATE TABLE IF NOT EXISTS hidden11_push_subscriptions (
|
|
endpoint TEXT PRIMARY KEY,
|
|
p256dh TEXT NOT NULL,
|
|
auth TEXT NOT NULL,
|
|
locale TEXT NOT NULL DEFAULT 'en',
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
last_sent TIMESTAMPTZ
|
|
);
|
|
|
|
-- One row per day per funnel step: the question the dashboard is built on.
|
|
CREATE OR REPLACE VIEW hidden11_funnel_daily AS
|
|
WITH per_visitor AS (
|
|
SELECT
|
|
date_trunc('day', occurred_at)::date AS day,
|
|
anonymous_id,
|
|
bool_or(name = 'game_started') AS started,
|
|
bool_or(name = 'guess_submitted') AS guessed,
|
|
bool_or(name = 'daily_completed') AS completed,
|
|
bool_or(name IN ('share_clicked', 'challenge_share_clicked')) AS shared,
|
|
bool_or(name = 'challenge_accepted') AS accepted_challenge,
|
|
bool_or(name = 'reminder_enabled') AS enabled_reminder
|
|
FROM hidden11_events
|
|
GROUP BY 1, 2
|
|
)
|
|
SELECT
|
|
day,
|
|
count(*) AS visitors,
|
|
count(*) FILTER (WHERE started) AS started,
|
|
count(*) FILTER (WHERE guessed) AS guessed,
|
|
count(*) FILTER (WHERE completed) AS completed,
|
|
count(*) FILTER (WHERE shared) AS shared,
|
|
count(*) FILTER (WHERE accepted_challenge) AS accepted_challenge,
|
|
count(*) FILTER (WHERE enabled_reminder) AS enabled_reminder,
|
|
round(100.0 * count(*) FILTER (WHERE completed) / nullif(count(*) FILTER (WHERE started), 0), 1) AS completion_rate,
|
|
round(100.0 * count(*) FILTER (WHERE shared) / nullif(count(*) FILTER (WHERE completed), 0), 1) AS share_rate
|
|
FROM per_visitor
|
|
GROUP BY day
|
|
ORDER BY day DESC;
|
|
|
|
-- Which share channel actually gets used.
|
|
CREATE OR REPLACE VIEW hidden11_share_channels AS
|
|
SELECT
|
|
date_trunc('day', occurred_at)::date AS day,
|
|
coalesce(properties ->> 'channel', 'unknown') AS channel,
|
|
count(*) AS shares
|
|
FROM hidden11_events
|
|
WHERE name IN ('share_clicked', 'challenge_share_clicked')
|
|
GROUP BY 1, 2
|
|
ORDER BY 1 DESC, 3 DESC;
|
|
|
|
-- D1 retention: of the visitors seen on a day, how many came back the next day.
|
|
CREATE OR REPLACE VIEW hidden11_retention_d1 AS
|
|
WITH days AS (
|
|
SELECT DISTINCT anonymous_id, date_trunc('day', occurred_at)::date AS day
|
|
FROM hidden11_events
|
|
)
|
|
SELECT
|
|
base.day,
|
|
count(*) AS visitors,
|
|
count(next.anonymous_id) AS returned_next_day,
|
|
round(100.0 * count(next.anonymous_id) / nullif(count(*), 0), 1) AS d1_retention
|
|
FROM days base
|
|
LEFT JOIN days next
|
|
ON next.anonymous_id = base.anonymous_id
|
|
AND next.day = base.day + 1
|
|
GROUP BY base.day
|
|
ORDER BY base.day DESC;
|