Count the visits, and say whether anyone got anywhere

A hosted demo raises a question a local app never does: is anyone using it,
and do they reach the part that matters? `/analytics` answers it — visitors,
pages, referrers, countries, devices, which shared scenarios get played, turns
and demo-key spend, API and turn errors, and a funnel from visited to played a
turn to signed up.

Not a third-party script, for reasons specific to this one. The CSP allows
`script-src 'self'`, so a tracker means loosening it; adblockers eat the
popular ones, which silently biases exactly the technical audience this
project gets shown to; and none of them can see the measurement that actually
matters here, which is a turn, not a pageview.

**A visit is a write and never a read.** After the 189x egress fix it would be
perverse to add a feature that reads rows per request, so counts accumulate in
a process-local dict and flush every 60s as UPSERTs. Storage is a generic
`(day, metric, label) -> hits` counter, so measuring something new later costs
a constant rather than a migration, plus one row per visitor per day for the
funnel flags. Every dashboard query is a GROUP BY returning tens of rows
however much traffic sits behind it; a month reads back in a few kilobytes.
The buffer's cost is that a hard restart can lose up to a minute — the flusher
also runs on shutdown, and a tier that sleeps when idle sleeps on an empty
buffer anyway.

**The counters are anonymous; the access log beside them is not, on purpose.**
A visitor is `HMAC(secret, "visitor:<user id>")` truncated to 32 chars —
one-way, so `analytics_daily` and `analytics_visitor_days` cannot be joined
back to `users`, and keyed, so no client can compute one. Story content never
reaches that module, and the only content it ever names is a seeded public
scenario's title; a player's own titles are theirs. `accesslog.py` is the
identifying half and is a separate module writing a separate table so that
separation is a property of the code rather than a convention: `access_events`
records sessions, sign-ins, registrations and failed attempts with address,
email and device, read on a second tab of the same page behind the same gate.

Both halves are gated on `AIDND_ANALYTICS_EMAILS`, not `POWER_USERS`. An
unmetered tester is not automatically someone who should see the traffic. The
route 404s and the nav link is absent for everyone else, the same treatment
AI Chat gets; unset in a hosted deploy means nobody sees it, including me.

Three things came out of building it that a test would not have suggested.

**A failed turn is an HTTP 200 with a bad ending.** The status-code middleware
cannot see one, so a demo whose model had started refusing every request would
look perfectly healthy from outside. All five SSE error paths in
`_generate_turn` now go through a `turn_error()` helper that counts on the way
out. Error buckets elsewhere are labelled by the matched route template rather
than the requested path — one bucket per endpoint instead of one per adventure
id, and, the reason it isn't merely tidier, an unmatched path is entirely
attacker-chosen, so labelling by it would let anyone mint rows.

**The funnel counts people, not clicks.** A player who starts six adventures
is one person who started an adventure. That is the whole reason the
per-visitor-day table exists; its flags only ever turn on, and `is_new` is
settled by the first write of a visitor's first day.

**The tests run on SQLite and production is Neon.** A flush that raises is
caught and logged, so a dialect mistake in the UPSERTs would have stayed
invisible until the dashboard quietly never filled.
`test_the_upserts_compile_for_postgres` compiles both statements against the
Postgres dialect without connecting to one.

Two things this leans on elsewhere. `limits._client_ip` is now public
`client_ip`: the access log needs the same answer, and two functions both
deciding which hop is the caller's is how one of them ends up trusting a
header it shouldn't. And the cleanup sweeper now starts if *either* job has
work — a deployment can keep every guest forever and still want its
visitor-day rows aged out.

No migration. Both tables are new and `bootstrap()` calls `create_all` on
existing databases too, the route `branches` took in Phase 14, so
`LATEST_VERSION` is still 64.

497 tests green, frontend lint and build clean, driven by hand against a
synthetic 90-day fixture at 1568px. The narrow-screen layout follows the
existing 720px block but is unverified: `resize_window` is ignored on a
maximized Chrome and `frame-ancestors 'none'` rules out checking it in a sized
iframe. Also repaired here: a rename in test_ratelimit_hardening.py had run
through the test names themselves, leaving `testclient_ip_*` — still collected
by pytest, which is why it passed unnoticed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DfMCsN1KBLsTqMkj5hSgrY
This commit is contained in:
parththakkar106
2026-08-22 16:24:42 +05:30
co-authored by Claude Opus 5
parent 3b9e6b3d50
commit 041f9e25f3
24 changed files with 2698 additions and 45 deletions
+25 -2
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react'
import { NavLink, Outlet } from 'react-router-dom'
import { useEffect, useRef, useState } from 'react'
import { NavLink, Outlet, useLocation } from 'react-router-dom'
import { api } from './api'
import { AuthModal, ToastHost } from './components'
import Embers from './Embers.jsx'
@@ -9,11 +9,28 @@ export default function App() {
const [me, setMe] = useState(null)
const [authMode, setAuthMode] = useState(null) // 'register' | 'login' | null
const [navOpen, setNavOpen] = useState(false) // mobile hamburger menu
const location = useLocation()
const lastPath = useRef(null)
useEffect(() => {
api.getMe().then(setMe).catch(() => {})
}, [])
// One pageview per route the reader actually lands on. Guarded on the path
// rather than fired on every render: StrictMode runs effects twice in dev,
// and a re-render for unrelated state is not a new page.
useEffect(() => {
if (lastPath.current === location.pathname) return
const first = lastPath.current === null
lastPath.current = location.pathname
// document.referrer survives client-side navigation, so it is only honest
// on the first view — after that this was our own page, not a referral.
api.trackPageview(location.pathname, {
referrer: first ? document.referrer : '',
first,
})
}, [location.pathname])
const onAuthed = (newMe, mode) => {
setAuthMode(null)
if (mode === 'login') {
@@ -64,6 +81,12 @@ export default function App() {
AI Chat
</NavLink>
)}
{/* Owner only: the site's own traffic, on its own allowlist. */}
{me?.analytics && (
<NavLink to="/analytics" className={({ isActive }) => `navlink${isActive ? ' active' : ''}`}>
Visitors
</NavLink>
)}
{me?.multi_user && (
<div className="nav-account">
{me.is_guest ? (
+22
View File
@@ -61,6 +61,28 @@ async function streamSSE(path, payload, onEvent, signal, isRetry = false) {
}
export const api = {
// Analytics. The beacon is deliberately not a `request()`: it must never
// retry, never bootstrap a session, and never surface an error — a counter
// that can interrupt the app it is counting is worse than no counter.
trackPageview: (path, { referrer = '', first = false } = {}) => {
try {
fetch('/api/analytics/collect', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path, referrer, first }),
keepalive: true,
}).catch(() => {})
} catch { /* no beacon, no problem */ }
},
getAnalytics: (days) => request(`/analytics/summary?days=${days}`),
getAccessLog: ({ beforeId, kind, q, limit = 50 } = {}) => {
const params = new URLSearchParams({ limit })
if (beforeId != null) params.set('before_id', beforeId)
if (kind) params.set('kind', kind)
if (q) params.set('q', q)
return request(`/analytics/access?${params}`)
},
// Auth (Phase 8 — no-ops in local mode beyond getMe)
getMe: () => request('/auth/me'),
register: (email, password) =>
+309
View File
@@ -13,6 +13,13 @@
--accent-glow: rgba(212, 169, 78, 0.25);
--danger: #d06565;
--player: #9fc7d1;
/* Chart hues (analytics dashboard). Deeper and more saturated than --accent
and --player, which are tuned for text and borders and turn muddy once
they are a 10px bar: these are checked against --bg-panel for lightness,
chroma, contrast and colour-blind separation as a set. */
--chart-1: #b58a30;
--chart-2: #3d8ac4;
--chart-3: #a8608f;
--font-display: 'Cinzel', Georgia, serif;
--font-story: 'Crimson Pro', Georgia, 'Times New Roman', serif;
--font-ui: 'Inter', 'Segoe UI', system-ui, sans-serif;
@@ -2333,6 +2340,293 @@ button.primary.compact { padding: 3px 12px; font-size: 0.76rem; margin-left: aut
.card.tome:hover { transform: none; }
}
/* ---------- Visit analytics (owner dashboard) ---------- */
/* Wider than the reading pages: this one is a grid of small figures, not
prose, and 960px puts two charts and eight tiles into a column. */
.an-page { max-width: 1180px; }
.an-ranges { display: flex; gap: 6px; }
/* Four across, not auto-fit: there are eight tiles, and letting them flow
leaves a single orphan on the second row at most widths. */
.an-tiles {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
margin-bottom: 18px;
}
@media (max-width: 900px) {
.an-tiles { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
.an-tile {
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: 10px;
padding: 14px 16px;
}
.an-tile-value {
font-family: var(--font-display);
font-size: 1.7rem;
line-height: 1.1;
color: var(--accent-bright);
}
.an-tile-label { font-size: 0.82rem; color: var(--text); margin-top: 4px; }
.an-tile-hint { font-size: 0.72rem; color: var(--text-dim); margin-top: 2px; }
.an-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(340px, 1fr));
gap: 16px;
margin-bottom: 16px;
}
.an-card {
position: relative;
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: 12px;
padding: 16px 18px 14px;
margin-bottom: 16px;
}
.an-grid .an-card { margin-bottom: 0; }
.an-card-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
}
.an-card-head h2 {
margin: 0;
font-family: var(--font-display);
font-size: 0.95rem;
letter-spacing: 0.06em;
color: var(--accent);
}
.an-note { font-size: 0.72rem; color: var(--text-dim); }
.an-empty { color: var(--text-dim); font-style: italic; padding: 18px 0; text-align: center; }
.an-legend { display: flex; gap: 12px; font-size: 0.72rem; color: var(--text-dim); }
.an-legend-item { display: inline-flex; align-items: center; gap: 5px; }
.an-swatch { width: 9px; height: 9px; border-radius: 2px; display: inline-block; flex: none; }
/* ----- Day columns ----- */
.an-plot { position: relative; height: 170px; margin-top: 12px; }
.an-gridline {
position: absolute;
left: 0;
right: 0;
border-top: 1px dashed var(--border);
pointer-events: none;
}
.an-gridline span {
position: absolute;
right: 0;
top: -0.62em;
font-size: 0.68rem;
color: var(--text-dim);
background: var(--bg-panel);
padding: 0 4px;
/* The dashed rule belongs behind the bars; its value does not. Columns are
positioned, so without this the tallest bar swallows the label. */
z-index: 3;
}
.an-columns { display: flex; align-items: flex-end; gap: 2px; height: 100%; }
.an-column {
position: relative;
flex: 1 1 0;
min-width: 0;
height: 100%;
display: flex;
align-items: flex-end;
border-radius: 4px 4px 0 0;
outline: none;
}
.an-column.hot { background: rgba(212, 169, 78, 0.07); }
.an-stack {
width: 100%;
/* Capped so a 7-day range draws bars and not slabs; the column around it
stays full width, so the hover target does not shrink with the mark. */
max-width: 44px;
margin: 0 auto;
height: 100%;
display: flex;
flex-direction: column;
justify-content: flex-end;
/* A 2px gap of surface between stacked segments: the boundary reads as a
boundary without a border, which would eat a thin bar entirely. */
gap: 2px;
}
.an-bar { width: 100%; transition: filter 0.15s; }
.an-stack > .an-bar:first-child { border-radius: 4px 4px 0 0; }
.an-column.hot .an-bar { filter: brightness(1.2); }
.an-tip {
position: absolute;
bottom: calc(100% + 8px);
left: 50%;
transform: translateX(-50%);
display: flex;
flex-direction: column;
gap: 3px;
padding: 8px 10px;
background: var(--bg-panel);
border: 1px solid var(--border-bright);
border-radius: 8px;
box-shadow: 0 10px 26px rgba(0, 0, 0, 0.55);
font-size: 0.74rem;
white-space: nowrap;
pointer-events: none;
z-index: 4;
}
.an-tip strong { font-weight: 600; color: var(--accent-bright); }
.an-tip span { display: inline-flex; align-items: center; gap: 6px; color: var(--text-dim); }
.an-axis { display: flex; gap: 2px; margin-top: 7px; }
.an-axis span {
flex: 1 1 0;
min-width: 0;
font-size: 0.68rem;
color: var(--text-dim);
text-align: center;
white-space: nowrap;
}
.an-axis span:first-child { text-align: left; }
.an-axis span:last-child { text-align: right; }
.an-overlay-empty {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-dim);
font-style: italic;
font-size: 0.85rem;
pointer-events: none;
}
/* ----- Funnel ----- */
.an-funnel { display: flex; flex-direction: column; gap: 10px; }
.an-funnel-row {
display: grid;
grid-template-columns: 168px 1fr 96px;
align-items: center;
gap: 12px;
}
.an-funnel-label { font-size: 0.84rem; }
.an-funnel-track { height: 14px; background: var(--bg-input); border-radius: 7px; overflow: hidden; }
.an-funnel-bar {
height: 100%;
min-width: 2px;
background: var(--chart-1);
border-radius: 7px;
transition: width 0.4s ease;
}
.an-funnel-value {
text-align: right;
font-size: 0.86rem;
display: flex;
justify-content: flex-end;
align-items: baseline;
gap: 8px;
}
.an-funnel-share { font-size: 0.72rem; color: var(--text-dim); }
/* ----- Ranked lists ----- */
.an-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 3px; }
.an-list li {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 7px 10px;
border-radius: 6px;
font-size: 0.84rem;
}
/* The bar sits behind the row rather than beside it, so a long label keeps the
full width and the magnitude is still visible at a glance. */
.an-list-fill {
position: absolute;
top: 0;
bottom: 0;
left: 0;
background: rgba(181, 138, 48, 0.2);
border-radius: 6px;
}
.an-list-label {
position: relative;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.an-list-value { position: relative; color: var(--text-dim); flex: none; }
/* ----- Tabs, and the access log table ----- */
.an-tabs { display: flex; gap: 4px; margin-bottom: 16px; border-bottom: 1px solid var(--border); }
.an-tabs button {
background: none;
border: none;
border-bottom: 2px solid transparent;
border-radius: 0;
padding: 8px 14px;
color: var(--text-dim);
font-family: var(--font-display);
font-size: 0.82rem;
letter-spacing: 0.06em;
cursor: pointer;
}
.an-tabs button:hover { color: var(--text); }
.an-tabs button.active { color: var(--accent-bright); border-bottom-color: var(--accent); }
.an-log-head { flex-wrap: wrap; row-gap: 10px; }
.an-log-search { max-width: 260px; }
/* The table is the one thing here with a minimum width — six columns of real
data don't compress. It scrolls inside its own box so the page never does. */
.an-table-scroll { overflow-x: auto; }
.an-table { width: 100%; min-width: 620px; border-collapse: collapse; font-size: 0.82rem; }
.an-table th {
text-align: left;
font-weight: 500;
font-size: 0.72rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-dim);
padding: 0 10px 8px;
border-bottom: 1px solid var(--border);
white-space: nowrap;
}
.an-table td { padding: 8px 10px; border-bottom: 1px solid var(--border); white-space: nowrap; }
.an-table tr:last-child td { border-bottom: none; }
.an-table tbody tr:hover { background: rgba(212, 169, 78, 0.05); }
/* A failed attempt is the row you are scanning for; it gets the only colour in
the table, on a border rather than the text, which stays readable. */
.an-table tr.failed td:first-child { box-shadow: inset 2px 0 var(--danger); }
.an-table tr.failed td:nth-child(3) { color: var(--danger); }
.an-cell-dim { color: var(--text-dim); }
.an-cell-mono { font-family: ui-monospace, 'Cascadia Code', Consolas, monospace; }
.an-tag {
margin-left: 8px;
padding: 1px 6px;
border: 1px solid var(--border-bright);
border-radius: 10px;
font-size: 0.66rem;
color: var(--text-dim);
}
.an-more { display: flex; justify-content: center; padding-top: 14px; }
.an-footnote {
color: var(--text-dim);
font-size: 0.74rem;
line-height: 1.6;
margin: 22px 0 0;
max-width: 70ch;
}
/* ============================================================
Mobile / narrow screens (≤ 720px)
The desktop Play screen is a horizontal row of up to four columns
@@ -2536,4 +2830,19 @@ button.primary.compact { padding: 3px 12px; font-size: 0.76rem; margin-left: aut
.branch-map-canvas { padding: 4px 6px 8px; }
.branch-map-detail { padding: 12px 16px 16px; }
.branch-map-hint { padding: 0 16px 10px; }
/* ---------- Analytics dashboard ---------- */
.an-page .page-header { flex-direction: column; align-items: flex-start; gap: 10px; }
.an-tiles { gap: 8px; }
.an-tile { padding: 10px 12px; }
.an-tile-value { font-size: 1.35rem; }
.an-grid { grid-template-columns: 1fr; }
.an-plot { height: 140px; }
.an-tabs button { padding: 8px 10px; font-size: 0.78rem; }
.an-log-search { max-width: none; width: 100%; }
/* Three columns don't fit; the label goes above its own bar instead. */
.an-funnel-row { grid-template-columns: 1fr auto; gap: 4px 10px; }
.an-funnel-track { grid-column: 1; }
.an-funnel-label { grid-column: 1 / -1; }
.an-funnel-value { grid-column: 2; }
}
+3
View File
@@ -11,6 +11,7 @@ import Scripts from './pages/Scripts.jsx'
import ScriptEditor from './pages/ScriptEditor.jsx'
import Settings from './pages/Settings.jsx'
import Chat from './pages/Chat.jsx'
import Analytics from './pages/Analytics.jsx'
import { trackKeyboardInset } from './keyboard.js'
import './index.css'
@@ -31,6 +32,8 @@ const router = createBrowserRouter([
{ path: 'settings', element: <Settings /> },
// Power users only — the page redirects home and the API 404s otherwise.
{ path: 'chat', element: <Chat /> },
// Owner only, by a separate allowlist; same redirect-and-404 treatment.
{ path: 'analytics', element: <Analytics /> },
],
},
])
+476
View File
@@ -0,0 +1,476 @@
/* Visit analytics — the owner's view of who came by and what they did.
Everything on this page arrives in a single aggregate response (see
backend/app/analytics.py), so changing the range is one small request, not a
scan of anything. The page is hidden from everyone else: the nav link is
gated on `me.analytics`, this component bounces, and the API 404s.
Charts are plain HTML — a flex row of columns, a row of bars — rather than
SVG or a charting library. At this size that is less code, responsive for
free, and keeps the CSP as tight as it is. */
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useNavigate, useOutletContext } from 'react-router-dom'
import { api } from '../api'
const RANGES = [
{ days: 7, label: '7 days' },
{ days: 30, label: '30 days' },
{ days: 90, label: '90 days' },
]
const nf = new Intl.NumberFormat()
// Weekday + day for a short range, day + month for a long one: a 90-day axis
// has no room for "Mon".
function dayLabel(iso, days) {
const date = new Date(`${iso}T00:00:00Z`)
const opts = days <= 14
? { weekday: 'short', timeZone: 'UTC' }
: { day: 'numeric', month: 'short', timeZone: 'UTC' }
return date.toLocaleDateString(undefined, opts)
}
function fullDate(iso) {
return new Date(`${iso}T00:00:00Z`)
.toLocaleDateString(undefined, { dateStyle: 'medium', timeZone: 'UTC' })
}
/* ---------- Pieces ---------- */
function StatTile({ label, value, hint }) {
return (
<div className="an-tile" title={hint || undefined}>
<div className="an-tile-value">{nf.format(value ?? 0)}</div>
<div className="an-tile-label">{label}</div>
{hint && <div className="an-tile-hint">{hint}</div>}
</div>
)
}
/* A day-by-day column chart. `series` names the stacked segments bottom-up;
one segment means one plain bar and no legend, since the title already says
what it is. */
function DayChart({ title, data, series, days }) {
const [hover, setHover] = useState(null)
const max = Math.max(1, ...data.map((d) => series.reduce((sum, s) => sum + (d[s.key] || 0), 0)))
// Only ever three x labels. Every column labelled is unreadable at 90 days
// and redundant at 7 — the tooltip carries the exact date either way.
const ticks = new Set([0, Math.floor((data.length - 1) / 2), data.length - 1])
const empty = data.every((d) => series.every((s) => !d[s.key]))
return (
<section className="an-card an-chart">
<header className="an-card-head">
<h2>{title}</h2>
{series.length > 1 && (
<div className="an-legend">
{[...series].reverse().map((s) => (
<span key={s.key} className="an-legend-item">
<i className="an-swatch" style={{ background: s.color }} />
{s.label}
</span>
))}
</div>
)}
</header>
<div className="an-plot" onMouseLeave={() => setHover(null)}>
<div className="an-gridline" style={{ bottom: '100%' }}><span>{nf.format(max)}</span></div>
<div className="an-gridline" style={{ bottom: '50%' }}><span>{nf.format(Math.round(max / 2))}</span></div>
<div className="an-columns">
{data.map((point, i) => {
const total = series.reduce((sum, s) => sum + (point[s.key] || 0), 0)
return (
<div
key={point.day}
className={`an-column${hover === i ? ' hot' : ''}`}
onMouseEnter={() => setHover(i)}
onFocus={() => setHover(i)}
tabIndex={0}
aria-label={`${fullDate(point.day)}: ${total}`}
>
<div className="an-stack">
{[...series].reverse().map((s) => (
(point[s.key] || 0) > 0 && (
<div
key={s.key}
className="an-bar"
style={{
height: `${((point[s.key] || 0) / max) * 100}%`,
background: s.color,
}}
/>
)
))}
</div>
{hover === i && (
<div className="an-tip">
<strong>{fullDate(point.day)}</strong>
{series.map((s) => (
<span key={s.key}>
<i className="an-swatch" style={{ background: s.color }} />
{s.label}: {nf.format(point[s.key] || 0)}
</span>
))}
</div>
)}
</div>
)
})}
</div>
</div>
<div className="an-axis">
{data.map((point, i) => (
<span key={point.day}>{ticks.has(i) ? dayLabel(point.day, days) : ''}</span>
))}
</div>
{empty && <div className="an-overlay-empty">Nothing recorded in this range</div>}
</section>
)
}
/* The funnel. Each step's bar is drawn against the first step, so the shape of
the drop-off is the picture; the percentage beside it is of the step above,
which is the number you act on. */
function Funnel({ steps }) {
const top = steps[0]?.count || 0
return (
<section className="an-card">
<header className="an-card-head">
<h2>Where visitors get to</h2>
<span className="an-note">people, counted once each</span>
</header>
{top === 0 ? (
<div className="an-empty">No visitors in this range.</div>
) : (
<div className="an-funnel">
{steps.map((step, i) => {
const previous = i === 0 ? step.count : steps[i - 1].count
const share = previous ? Math.round((step.count / previous) * 100) : 0
return (
<div key={step.step} className="an-funnel-row">
<div className="an-funnel-label">{step.step}</div>
<div className="an-funnel-track">
<div
className="an-funnel-bar"
style={{ width: `${top ? (step.count / top) * 100 : 0}%` }}
/>
</div>
<div className="an-funnel-value">
{nf.format(step.count)}
{i > 0 && <span className="an-funnel-share">{share}%</span>}
</div>
</div>
)
})}
</div>
)}
</section>
)
}
function TopList({ title, rows, note, empty = 'Nothing yet' }) {
const max = Math.max(1, ...rows.map((r) => r.hits))
return (
<section className="an-card">
<header className="an-card-head">
<h2>{title}</h2>
{note && <span className="an-note">{note}</span>}
</header>
{rows.length === 0 ? (
<div className="an-empty">{empty}</div>
) : (
<ul className="an-list">
{rows.map((row) => (
<li key={row.label}>
{/* The bar is the row's own background, so a long label stays
readable on top of it instead of being squeezed beside it. */}
<span className="an-list-fill" style={{ width: `${(row.hits / max) * 100}%` }} />
<span className="an-list-label" title={row.label}>{row.label}</span>
<span className="an-list-value">{nf.format(row.hits)}</span>
</li>
))}
</ul>
)}
</section>
)
}
/* ---------- Access log ---------- */
const KINDS = [
{ key: '', label: 'Everything' },
{ key: 'session', label: 'Sessions' },
{ key: 'login', label: 'Sign-ins' },
{ key: 'register', label: 'Registrations' },
{ key: 'login_failed', label: 'Failed' },
]
const KIND_LABEL = {
session: 'Session',
login: 'Signed in',
register: 'Registered',
login_failed: 'Failed sign-in',
}
function when(iso) {
const date = new Date(iso.endsWith('Z') ? iso : `${iso}Z`)
return date.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })
}
function AccessLog() {
const [kind, setKind] = useState('')
const [search, setSearch] = useState('')
const [query, setQuery] = useState('')
const [page, setPage] = useState(null) // { events, has_more }
const [error, setError] = useState(null)
const [busy, setBusy] = useState(false)
// Typing shouldn't fire a request per keystroke against a table scan.
useEffect(() => {
const timer = setTimeout(() => setQuery(search.trim()), 350)
return () => clearTimeout(timer)
}, [search])
useEffect(() => {
let live = true
setPage(null)
api.getAccessLog({ kind, q: query }).then(
(result) => { if (live) { setPage(result); setError(null) } },
(err) => { if (live) setError(err.message) },
)
return () => { live = false }
}, [kind, query])
const loadMore = useCallback(async () => {
if (!page?.events.length || busy) return
setBusy(true)
try {
// Anchored on the oldest row already on screen, so rows arriving while
// this is open can't shift the next page.
const next = await api.getAccessLog({
kind, q: query, beforeId: page.events[page.events.length - 1].id,
})
setPage({ events: [...page.events, ...next.events], has_more: next.has_more })
} catch (err) {
setError(err.message)
} finally {
setBusy(false)
}
}, [page, kind, query, busy])
return (
<section className="an-card">
<header className="an-card-head an-log-head">
<div className="an-ranges">
{KINDS.map((option) => (
<button
key={option.key}
className={kind === option.key ? 'primary' : ''}
onClick={() => setKind(option.key)}
>
{option.label}
</button>
))}
</div>
<input
type="text"
className="search-input an-log-search"
placeholder="Search email, IP, country…"
value={search}
onChange={(event) => setSearch(event.target.value)}
/>
</header>
{error && <div className="an-empty">Couldn’t load the log: {error}</div>}
{!page && !error && <div className="an-empty">Reading…</div>}
{page && (page.events.length === 0 ? (
<div className="an-empty">
{query || kind ? 'Nothing matches that.' : 'Nothing logged yet.'}
</div>
) : (
<>
<div className="an-table-scroll">
<table className="an-table">
<thead>
<tr>
<th>When</th><th>Who</th><th>Event</th>
<th>IP</th><th>Country</th><th>Device</th>
</tr>
</thead>
<tbody>
{page.events.map((event) => (
<tr key={event.id} className={event.kind === 'login_failed' ? 'failed' : undefined}>
<td className="an-cell-dim">{when(event.at)}</td>
<td>
{event.who}
{event.is_guest && <span className="an-tag">guest</span>}
</td>
<td>{KIND_LABEL[event.kind] || event.kind}</td>
<td className="an-cell-mono">{event.ip || '—'}</td>
<td>{event.country || '—'}</td>
{/* The full user-agent is a wall of text; it lives on the
hover instead of in a column that would push the rest
of the table off screen. */}
<td className="an-cell-dim" title={event.user_agent || undefined}>
{event.device || '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
{page.has_more && (
<div className="an-more">
<button onClick={loadMore} disabled={busy}>
{busy ? 'Loading…' : 'Load older'}
</button>
</div>
)}
</>
))}
</section>
)
}
/* ---------- Page ---------- */
export default function Analytics() {
const { me } = useOutletContext() ?? {}
const navigate = useNavigate()
const [tab, setTab] = useState('overview')
const [days, setDays] = useState(30)
const [data, setData] = useState(null)
const [error, setError] = useState(null)
const loaded = useRef(false)
// me is null until /auth/me resolves; only bounce once we know.
useEffect(() => {
if (me && !me.analytics) navigate('/', { replace: true })
}, [me, navigate])
useEffect(() => {
if (tab !== 'overview') return undefined
let live = true
api.getAnalytics(days).then(
(result) => { if (live) { setData(result); setError(null); loaded.current = true } },
(err) => { if (live) setError(err.message) },
)
return () => { live = false }
}, [days, tab])
const totals = data?.totals ?? {}
const visitorSeries = useMemo(() => ([
{ key: 'returning', label: 'Returning', color: 'var(--chart-2)' },
{ key: 'new', label: 'New', color: 'var(--chart-1)' },
]), [])
// The server sends new-vs-total; the chart stacks, so it wants the remainder.
const visitorDays = useMemo(
() => (data?.series ?? []).map((d) => ({ ...d, returning: d.visitors - d.new })),
[data],
)
if (me && !me.analytics) return null
return (
<div className="page an-page">
<div className="page-header">
<h1>Visitors</h1>
{tab === 'overview' && (
<div className="an-ranges">
{RANGES.map((range) => (
<button
key={range.days}
className={days === range.days ? 'primary' : ''}
onClick={() => setDays(range.days)}
>
{range.label}
</button>
))}
</div>
)}
</div>
<div className="an-tabs">
<button
className={tab === 'overview' ? 'active' : ''}
onClick={() => setTab('overview')}
>
Overview
</button>
<button
className={tab === 'access' ? 'active' : ''}
onClick={() => setTab('access')}
>
Access log
</button>
</div>
{tab === 'access' && <AccessLog />}
{tab === 'overview' && error && (
<div className="an-empty">Couldn’t load analytics: {error}</div>
)}
{tab === 'overview' && !data && !error && <div className="an-empty">Counting…</div>}
{tab === 'overview' && data && (
<>
<div className="an-tiles">
<StatTile label="Visitors" value={totals.visitors}
hint={`${nf.format(totals.new_visitors || 0)} first-time`} />
<StatTile label="Visits" value={totals.visits}
hint={`${totals.pages_per_visit || 0} pages each`} />
<StatTile label="Pageviews" value={totals.pageviews} />
<StatTile label="Adventures started" value={totals.adventures} />
<StatTile label="Turns played" value={totals.turns}
hint={`${totals.turns_per_visit || 0} per visit`} />
<StatTile label="Sign-ups" value={totals.signups} />
<StatTile label="Demo-key turns" value={totals.demo_turns}
hint="billed to the shared key" />
<StatTile label="Failed turns" value={totals.turn_errors}
hint={`${nf.format(totals.errors || 0)} API errors`} />
</div>
<div className="an-grid">
<DayChart
title="Visitors per day"
data={visitorDays}
series={visitorSeries}
days={days}
/>
<DayChart
title="Turns played per day"
data={data.series}
series={[{ key: 'turns', label: 'Turns', color: 'var(--chart-3)' }]}
days={days}
/>
</div>
<Funnel steps={data.funnel} />
<div className="an-grid">
<TopList title="Pages" rows={data.pages} />
<TopList
title="Where they came from"
rows={data.referrers}
note="per visit"
empty="No referrals yet — every visit was typed or bookmarked."
/>
<TopList title="Scenarios started" rows={data.scenarios}
note="shared scenarios only" empty="No adventures started yet." />
<TopList title="Countries" rows={data.countries} />
<TopList title="Devices" rows={data.devices} />
<TopList title="API errors" rows={data.errors} empty="None — clean run." />
</div>
<p className="an-footnote">
{fullDate(data.since)} – {fullDate(data.until)}, UTC. Your own visits aren’t
counted here. These totals are anonymous — visitors are counted as one-way
hashes, and nothing on this tab can be traced back to a player or their
stories. The access log tab is the separate, identifying record.
</p>
</>
)}
</div>
)
}