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:
co-authored by
Claude Opus 5
parent
3b9e6b3d50
commit
041f9e25f3
+25
-2
@@ -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 ? (
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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 /> },
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user