[www] stats

This commit is contained in:
Devin Zuczek
2026-09-18 23:51:53 -04:00
parent d3a1a30e62
commit a2e635df24
6 changed files with 887 additions and 0 deletions
+7
View File
@@ -25,6 +25,7 @@ import {
where,
} from './api'
import { ModerationPage } from './Moderation'
import { StatsPage } from './Stats'
import type { ReactNode } from 'react'
import type { Hosts } from './api'
@@ -836,6 +837,12 @@ export function App() {
// on any of them falls through to the SPA shell; the page then gates itself on
// the token's role, and every endpoint behind it re-checks.
<ModerationPage account={account} path={path} search={search} navigate={navigate} />
) : path === '/stats' ? (
// Unlinked on purpose — nothing in the nav or footer points here; it's for whoever
// is handed the URL. Public all the same, and a client-side route like the rest:
// its data comes from `/api/stats/online`, which the existing `/api/*` allowlist
// entry already sends to the Worker.
<StatsPage search={search} navigate={navigate} />
) : roomId !== null ? (
<RoomPage account={account} roomId={roomId} navigate={navigate} />
) : (
+469
View File
@@ -0,0 +1,469 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { call } from './api'
import type { KeyboardEvent, PointerEvent } from 'react'
/**
* The public stats page, at `/stats`: how many players are online, and how that has
* moved. One series — the `online` samples the `match` presence cron writes to `stat` —
* read from `www`'s own `/api/stats/online` (see www.app.ts), which folds them into
* buckets so the quarter view doesn't ship 26,000 points.
*
* Its own file for the reason Moderation.tsx is: a whole surface rather than one more
* form. The chart is hand-drawn SVG — one line doesn't justify a charting dependency in a
* bundle whose homepage is a poster.
*/
type Range = '24h' | '7d' | '30d' | '90d'
// Shortest first: "what's it like right now" is the question most visitors arrive with.
const RANGES: Array<{ id: Range; label: string; title: string }> = [
{ id: '24h', label: '24 hours', title: 'the last 24 hours' },
{ id: '7d', label: '7 days', title: 'the last 7 days' },
{ id: '30d', label: '30 days', title: 'the last 30 days' },
{ id: '90d', label: '90 days', title: 'the last 90 days' },
]
interface Point {
/** Start of the bucket, epoch seconds. */
t: number
/** The bucket's peak. */
players: number
}
interface OnlineStats {
range: Range
bucketSeconds: number
from: number
to: number
online: number
peak: number
average: number
points: Point[]
}
/** How often the page re-asks. The cron samples every five minutes; the live count moves faster. */
const REFRESH_MS = 60_000
const DAY = 86_400
function rangeFromSearch(search: string): Range {
const asked = new URLSearchParams(search).get('range')
return RANGES.find((r) => r.id === asked)?.id ?? '24h'
}
export function StatsPage({
search,
navigate,
}: {
search: string
navigate: (to: string) => void
}) {
// The range lives in the URL so a view is linkable ("look at last month") and the back
// button walks back through the ranges, the way the moderation panel's filters do.
const range = rangeFromSearch(search)
const [stats, setStats] = useState<OnlineStats | null>(null)
const [error, setError] = useState('')
useEffect(() => {
let cancelled = false
const load = () =>
call<OnlineStats>(`/api/stats/online?range=${range}`)
.then((next) => {
if (cancelled) return
setStats(next)
setError('')
})
.catch((e: Error) => {
if (!cancelled) setError(e.message)
})
void load()
// Only while someone is looking: a backgrounded tab polling a public endpoint all
// night is traffic nobody reads.
const timer = setInterval(() => {
if (document.visibilityState === 'visible') void load()
}, REFRESH_MS)
return () => {
cancelled = true
clearInterval(timer)
}
}, [range])
// The previous range's numbers stay on screen (dimmed) until the new ones land, so
// flipping ranges doesn't collapse the page to a spinner and back.
const stale = stats !== null && stats.range !== range
// Named from the data on screen, not the range just clicked: while the new one loads,
// the old chart must not be captioned as the new window.
const shown = RANGES.find((r) => r.id === stats?.range)?.title ?? ''
return (
<main className="shell home stats">
<header className="stats-head">
<h1>Players online</h1>
<p className="muted">How many people are in the game, now and lately.</p>
</header>
<div className="stats-ranges" role="group" aria-label="Time range">
{RANGES.map((r) => (
<button
key={r.id}
className={r.id === range ? 'active' : ''}
aria-pressed={r.id === range}
onClick={() => navigate(r.id === '24h' ? '/stats' : `/stats?range=${r.id}`)}
>
{r.label}
</button>
))}
</div>
{error && stats === null ? (
<p className="error">Couldn’t load the stats: {error}</p>
) : stats === null ? (
<p className="muted">Loading…</p>
) : (
<div className={stale ? 'stats-body stale' : 'stats-body'}>
<dl className="stat-tiles">
<div className="stat-tile">
<dt>
<span className="stat-live-dot" aria-hidden="true" />
Online now
</dt>
<dd className="hero">{formatCount(stats.online)}</dd>
</div>
<div className="stat-tile">
<dt>Peak</dt>
<dd>{formatCount(stats.peak)}</dd>
</div>
<div className="stat-tile">
<dt>Average</dt>
<dd>{formatAverage(stats.average)}</dd>
</div>
</dl>
<section className="card stats-card">
<h2>{chartTitle(stats.bucketSeconds)}</h2>
<p className="muted">Over {shown}, in your local time.</p>
{stats.points.length === 0 ? (
<p className="stats-empty muted">Nothing has been recorded in this window yet.</p>
) : (
<>
<OnlineChart stats={stats} />
<details className="stats-table">
<summary>View as a table</summary>
<div className="stats-table-scroll">
<table>
<thead>
<tr>
<th scope="col">When</th>
<th scope="col">Players</th>
</tr>
</thead>
<tbody>
{/* Newest first: the row someone opens a table for is the latest. */}
{[...stats.points].reverse().map((p) => (
<tr key={p.t}>
<td>{bucketLabel(p.t, stats.bucketSeconds)}</td>
<td>{formatCount(p.players)}</td>
</tr>
))}
</tbody>
</table>
</div>
</details>
</>
)}
</section>
{error && <p className="error">Couldn’t refresh: {error}</p>}
</div>
)}
</main>
)
}
// ---- Formatting ------------------------------------------------------------
const countFormat = new Intl.NumberFormat()
const formatCount = (n: number) => countFormat.format(n)
/** One decimal while the numbers are small enough for it to mean something. */
const formatAverage = (n: number) =>
new Intl.NumberFormat(undefined, { maximumFractionDigits: n < 100 ? 1 : 0 }).format(n)
/** Says what a point IS: a raw sample on the day view, a bucket's peak on the rest. */
function chartTitle(bucketSeconds: number): string {
if (bucketSeconds <= 300) return 'Players online, every 5 minutes'
if (bucketSeconds >= DAY) return 'Peak players online, per day'
const hours = bucketSeconds / 3600
return hours === 1 ? 'Peak players online, per hour' : `Peak players online, per ${hours} hours`
}
const timeFormat = new Intl.DateTimeFormat(undefined, { hour: 'numeric', minute: '2-digit' })
const dayTimeFormat = new Intl.DateTimeFormat(undefined, {
weekday: 'short',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
})
// A day bucket is a UTC day (the server folds on epoch seconds), so it is NAMED in UTC —
// formatting its start in local time would call it "Sep 13, 8 PM" for half the world.
const utcDayFormat = new Intl.DateTimeFormat(undefined, {
weekday: 'short',
month: 'short',
day: 'numeric',
timeZone: 'UTC',
})
function bucketLabel(t: number, bucketSeconds: number): string {
if (bucketSeconds >= DAY) return `${utcDayFormat.format(t * 1000)} (UTC)`
const start = dayTimeFormat.format(t * 1000)
if (bucketSeconds <= 300) return start
return `${start} – ${timeFormat.format((t + bucketSeconds) * 1000)}`
}
// ---- Axes ------------------------------------------------------------------
/**
* Y ticks on whole, round numbers. Whole matters more than round here: this is a count of
* people, and a young server's axis topping out at "2.5" reads as a bug.
*/
function yTicks(max: number): number[] {
const rough = Math.max(max, 1) / 4
const magnitude = 10 ** Math.floor(Math.log10(rough))
const step = Math.max(1, [1, 2, 5, 10].map((m) => m * magnitude).find((s) => s >= rough) ?? 1)
const top = Math.max(step, Math.ceil(max / step) * step)
const ticks: number[] = []
for (let v = 0; v <= top; v += step) ticks.push(v)
return ticks
}
const HOUR_STEPS = [1, 2, 3, 4, 6, 12]
const DAY_STEPS = [1, 2, 3, 7, 14, 28]
const hourTick = new Intl.DateTimeFormat(undefined, { hour: 'numeric' })
const dayTick = new Intl.DateTimeFormat(undefined, { month: 'short', day: 'numeric' })
/**
* X ticks on LOCAL clock boundaries (midnights, or hours divisible by the step), as many
* as fit: the step is the smallest whose labels get `minGap` pixels each, so the same
* code serves a phone and a desktop without labels colliding.
*/
function xTicks(from: number, to: number, width: number, minGap: number) {
const fits = (stepSeconds: number) => ((to - from) / stepSeconds) * minGap <= width
const hours = HOUR_STEPS.find((h) => fits(h * 3600))
const ticks: Array<{ t: number; label: string }> = []
if (to - from <= 2 * DAY && hours !== undefined) {
const cursor = new Date(from * 1000)
cursor.setMinutes(0, 0, 0)
for (; cursor.getTime() / 1000 <= to; cursor.setHours(cursor.getHours() + 1)) {
const t = cursor.getTime() / 1000
if (t >= from && cursor.getHours() % hours === 0) {
ticks.push({ t, label: hourTick.format(cursor) })
}
}
return ticks
}
const days = DAY_STEPS.find((d) => fits(d * DAY)) ?? DAY_STEPS[DAY_STEPS.length - 1]!
const midnights: Date[] = []
const cursor = new Date(from * 1000)
cursor.setHours(0, 0, 0, 0)
// setDate rather than adding 86,400s: a DST day isn't 24 hours long.
for (; cursor.getTime() / 1000 <= to; cursor.setDate(cursor.getDate() + 1)) {
if (cursor.getTime() / 1000 >= from) midnights.push(new Date(cursor))
}
// Counted back from the newest so the most recent midnight is always labelled.
midnights.forEach((d, i) => {
if ((midnights.length - 1 - i) % days === 0) {
ticks.push({ t: d.getTime() / 1000, label: dayTick.format(d) })
}
})
return ticks
}
// ---- Chart -----------------------------------------------------------------
const HEIGHT = 300
const MARGIN = { top: 16, right: 16, bottom: 28, left: 40 }
/**
* The line. X spans the WINDOW (`from`–`to`), not the data, so a server with three days
* of history shows three days at the right of the 90-day view instead of stretching them
* across it. The line BREAKS where buckets are missing — the cron didn't run, which is
* an outage, and a straight line drawn across it would invent players for the gap.
*/
function OnlineChart({ stats }: { stats: OnlineStats }) {
const { points, from, to, bucketSeconds } = stats
const frame = useRef<HTMLDivElement>(null)
// Measured rather than scaled through a viewBox: scaling would shrink the axis type on
// a phone along with the plot.
const [width, setWidth] = useState(0)
const [hover, setHover] = useState<number | null>(null)
useLayoutEffect(() => {
const el = frame.current
if (!el) return
setWidth(el.clientWidth)
const observer = new ResizeObserver(() => setWidth(el.clientWidth))
observer.observe(el)
return () => observer.disconnect()
}, [])
// A hovered index means nothing against a different series.
useEffect(() => setHover(null), [stats])
const plot = useMemo(() => {
const innerW = Math.max(width - MARGIN.left - MARGIN.right, 1)
const innerH = HEIGHT - MARGIN.top - MARGIN.bottom
const ticks = yTicks(points.reduce((max, p) => Math.max(max, p.players), 0))
const top = ticks[ticks.length - 1] ?? 1
const x = (t: number) => MARGIN.left + ((t - from) / (to - from)) * innerW
const y = (v: number) => MARGIN.top + innerH - (v / top) * innerH
// Runs of consecutive buckets. 2.5× tolerates one late sample without breaking the
// line, and still breaks it for a real hole.
const runs: Point[][] = []
for (const p of points) {
const run = runs[runs.length - 1]
const last = run?.[run.length - 1]
if (run && last && p.t - last.t <= bucketSeconds * 2.5) run.push(p)
else runs.push([p])
}
const baseline = y(0)
const paths = runs.map((run) => {
const line = run.map((p, i) => `${i === 0 ? 'M' : 'L'}${x(p.t)},${y(p.players)}`).join('')
const first = run[0]!
const last = run[run.length - 1]!
return {
line,
area: `${line}L${x(last.t)},${baseline}L${x(first.t)},${baseline}Z`,
// A one-bucket run has no length to stroke; it gets a dot instead.
lone: run.length === 1 ? first : null,
}
})
return { x, y, ticks, paths, baseline, innerW }
}, [points, from, to, bucketSeconds, width])
const nearest = (clientX: number): number | null => {
const el = frame.current
if (!el || points.length === 0) return null
const px = clientX - el.getBoundingClientRect().left
let best = 0
for (let i = 1; i < points.length; i++) {
if (Math.abs(plot.x(points[i]!.t) - px) < Math.abs(plot.x(points[best]!.t) - px)) best = i
}
return best
}
const onPointerMove = (e: PointerEvent) => setHover(nearest(e.clientX))
// The same readout from the keyboard: arrows walk the points, Home/End jump, Escape
// puts it away.
const onKeyDown = (e: KeyboardEvent) => {
const last = points.length - 1
const moves: Record<string, number | null> = {
ArrowLeft: Math.max((hover ?? points.length) - 1, 0),
ArrowRight: Math.min((hover ?? -1) + 1, last),
Home: 0,
End: last,
Escape: null,
}
if (!(e.key in moves)) return
e.preventDefault()
setHover(moves[e.key] ?? null)
}
const hovered = hover === null ? undefined : points[hover]
const latest = points[points.length - 1]!
const tipX = hovered ? plot.x(hovered.t) : 0
// Past the midpoint the readout sits to the LEFT of the crosshair, so it never runs off
// the card.
const tipLeft = tipX > width / 2
return (
<div
ref={frame}
className="stats-chart"
tabIndex={0}
role="group"
aria-label={`Line chart of players online. Latest: ${formatCount(latest.players)}. Use the arrow keys to read each point; the same numbers are in the table below.`}
onKeyDown={onKeyDown}
onBlur={() => setHover(null)}
>
{width > 0 && (
<svg
width={width}
height={HEIGHT}
aria-hidden="true"
onPointerMove={onPointerMove}
onPointerDown={onPointerMove}
onPointerLeave={() => setHover(null)}
>
{plot.ticks.map((v) => (
<g key={v}>
<line
className="grid"
x1={MARGIN.left}
x2={width - MARGIN.right}
y1={plot.y(v)}
y2={plot.y(v)}
/>
<text className="tick" x={MARGIN.left - 8} y={plot.y(v)} dy="0.32em" textAnchor="end">
{formatCount(v)}
</text>
</g>
))}
{xTicks(from, to, plot.innerW, 72).map((tick) => (
<text
key={tick.t}
className="tick"
x={plot.x(tick.t)}
y={HEIGHT - 8}
// A label centred on a tick at the very edge would hang outside the plot.
textAnchor={plot.x(tick.t) > width - MARGIN.right - 24 ? 'end' : 'middle'}
>
{tick.label}
</text>
))}
{plot.paths.map((p, i) => (
<g key={i}>
<path className="area" d={p.area} />
<path className="series" d={p.line} />
{p.lone && (
<circle
className="series-dot"
cx={plot.x(p.lone.t)}
cy={plot.y(p.lone.players)}
r="2.5"
/>
)}
</g>
))}
{hovered && (
<line className="crosshair" x1={tipX} x2={tipX} y1={MARGIN.top} y2={plot.baseline} />
)}
{/* The end-dot marks "latest"; while reading another point it moves there. */}
<circle
className="marker"
cx={plot.x((hovered ?? latest).t)}
cy={plot.y((hovered ?? latest).players)}
r="5"
/>
</svg>
)}
{hovered && (
<div
className="stats-tip"
role="status"
style={tipLeft ? { right: width - tipX + 12 } : { left: tipX + 12 }}
>
<strong>
{formatCount(hovered.players)} {hovered.players === 1 ? 'player' : 'players'}
</strong>
<span>{bucketLabel(hovered.t, bucketSeconds)}</span>
</div>
)}
</div>
)
}
+270
View File
@@ -1097,6 +1097,253 @@ button[type='submit']:disabled {
color: var(--ok);
}
/* ---- Stats ---------------------------------------------------------------- */
/*
* `/stats`: the head-count and its history. The line wears `--live`, the same green as the
* homepage's "online" dot, because it IS that signal drawn over time — and not the
* orange, which this page keeps for things you can press. Everything that isn't data
* (grid, ticks, the table) stays in `--line`/`--muted` so the line is the only loud thing.
*/
.shell.stats {
padding-top: 40px;
}
.stats-head h1 {
margin-bottom: 4px;
}
.stats-head p {
margin: 0 0 22px;
}
/* One control, four positions — the auth card's `.tabs`, but hugging its labels instead
of filling the row: a full-width bar over a chart reads as a heading. */
.stats-ranges {
display: inline-flex;
gap: 4px;
margin-bottom: 18px;
padding: 4px;
background: var(--surface);
border: 1px solid var(--line);
border-radius: 8px;
}
.stats-ranges button {
background: transparent;
border: none;
color: var(--muted);
padding: 7px 14px;
border-radius: 6px;
cursor: pointer;
font-family: var(--body);
font-size: 0.9rem;
}
.stats-ranges button:hover {
color: var(--text);
}
.stats-ranges button.active {
background: var(--surface-hi);
color: var(--text);
font-weight: 600;
}
/* The last range's numbers, held while the next one loads. */
.stats-body {
transition: opacity 0.15s;
}
.stats-body.stale {
opacity: 0.5;
}
.stat-tiles {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
margin: 0 0 16px;
}
.stat-tile {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 16px 20px;
}
.stat-tile dt {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.875rem;
color: var(--muted);
}
.stat-tile dd {
margin: 2px 0 0;
font-weight: 600;
font-size: 1.7rem;
line-height: 1.25;
letter-spacing: -0.01em;
}
/* The one number the page leads with. */
.stat-tile dd.hero {
font-size: 3rem;
line-height: 1.1;
}
.stat-live-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--live);
box-shadow: 0 0 0 4px color-mix(in srgb, var(--live) 22%, transparent);
}
.stats-card h2 {
margin-bottom: 2px;
}
.stats-card > .muted {
margin: 0 0 14px;
}
.stats-empty {
padding: 60px 0;
text-align: center;
}
.stats-chart {
position: relative;
border-radius: 6px;
/* A finger dragging along the line reads it; it shouldn't also scroll the page sideways. */
touch-action: pan-y;
}
.stats-chart svg {
display: block;
cursor: crosshair;
}
.stats-chart .grid {
stroke: var(--line);
stroke-width: 1;
}
.stats-chart .tick {
fill: var(--muted);
font-size: 12px;
font-variant-numeric: tabular-nums;
}
.stats-chart .series {
fill: none;
stroke: var(--live);
stroke-width: 2;
stroke-linejoin: round;
stroke-linecap: round;
}
.stats-chart .series-dot {
fill: var(--live);
}
.stats-chart .area {
fill: var(--live);
opacity: 0.1;
}
.stats-chart .crosshair {
stroke: var(--muted);
stroke-width: 1;
}
/* Ringed in the surface colour so it stays legible where it sits on the line. */
.stats-chart .marker {
fill: var(--live);
stroke: var(--surface);
stroke-width: 2;
pointer-events: none;
}
.stats-tip {
position: absolute;
top: 8px;
display: flex;
flex-direction: column;
padding: 8px 12px;
background: var(--surface-hi);
border: 1px solid var(--line);
border-radius: 8px;
box-shadow: 0 6px 20px rgb(0 0 0 / 0.25);
font-size: 0.8rem;
color: var(--muted);
white-space: nowrap;
pointer-events: none;
}
.stats-tip strong {
font-size: 0.95rem;
font-weight: 600;
color: var(--text);
}
.stats-table {
margin-top: 14px;
font-size: 0.875rem;
}
.stats-table summary {
color: var(--muted);
cursor: pointer;
width: fit-content;
}
.stats-table summary:hover {
color: var(--text);
}
.stats-table-scroll {
max-height: 320px;
margin-top: 10px;
overflow-y: auto;
border: 1px solid var(--line);
border-radius: 8px;
}
.stats-table table {
width: 100%;
border-collapse: collapse;
}
.stats-table th,
.stats-table td {
padding: 7px 14px;
text-align: left;
border-bottom: 1px solid var(--line);
}
.stats-table th {
position: sticky;
top: 0;
background: var(--surface-hi);
font-weight: 600;
color: var(--muted);
}
.stats-table tr:last-child td {
border-bottom: none;
}
.stats-table th:last-child,
.stats-table td:last-child {
text-align: right;
font-variant-numeric: tabular-nums;
}
/* ---- Responsive --------------------------------------------------------- */
@media (max-width: 860px) {
@@ -1132,6 +1379,29 @@ button[type='submit']:disabled {
grid-template-columns: 1fr;
}
/* Now leads on its own row; the two range figures share the one under it. */
.stat-tiles {
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.stat-tile:first-child {
grid-column: 1 / -1;
}
.stats-ranges {
display: flex;
}
.stats-ranges button {
flex: 1;
padding: 7px 4px;
}
.stats-card {
padding: 16px 12px;
}
.vtabs {
flex-direction: row;
flex-wrap: wrap;
+47
View File
@@ -12,6 +12,7 @@ import { ROOM_INSTANCE_SCHEMA_DDL } from '@repo/domain/src/room-instance-db'
// Banning a player moves them into their own dorm, which is a room (created on demand) with
// a subroom — so those tables have to be here, as they are on the shared database.
import { ROOM_SCHEMA_DDL, SUBROOM_SCHEMA_DDL } from '@repo/domain/src/rooms-db'
import { recordStat, STAT_SCHEMA_DDL } from '@repo/domain/src/stats-db'
import { generateToken } from '@repo/jwt'
import {
@@ -92,6 +93,8 @@ beforeAll(async () => {
for (const stmt of ROOM_INSTANCE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
for (const stmt of ROOM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
for (const stmt of SUBROOM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// `stat` is written by the `match` presence cron; www only reads it, for `/stats`.
for (const stmt of STAT_SCHEMA_DDL) await env.DB.prepare(stmt).run()
})
// Web signup is open, but only behind the Turnstile check. These pin the closed door:
@@ -330,6 +333,50 @@ it('serves a public head-count of the players actually online', async () => {
expect(await res.json()).toEqual({ status: 'online', players: 2 })
})
// The series behind `/stats`. What's pinned: samples fold into the range's bucket as its
// PEAK, the average is over the raw samples (not over the buckets), samples outside the
// window and other stat types stay out, and the range is a closed list.
it('serves the online series bucketed by range, peak per bucket', async () => {
await env.DB.prepare('DELETE FROM stat').run()
// Anchored to the start of the previous hour so the three samples provably share one
// hourly bucket, whenever the test runs.
const hour = Math.floor(Date.now() / 3_600_000) * 3_600_000 - 3_600_000
await recordStat(env.DB, 'online', 4, new Date(hour))
await recordStat(env.DB, 'online', 9, new Date(hour + 5 * 60_000))
await recordStat(env.DB, 'online', 2, new Date(hour + 10 * 60_000))
await recordStat(env.DB, 'online', 50, new Date(hour - 8 * 86_400_000)) // before the 7d window
await recordStat(env.DB, 'rooms', 99, new Date(hour)) // another series entirely
let res = await SELF.fetch('https://example.com/api/stats/online?range=7d')
expect(res.status).toBe(200)
const week = await res.json<{
bucketSeconds: number
from: number
to: number
peak: number
average: number
points: Array<{ t: number; players: number }>
}>()
expect(week.bucketSeconds).toBe(3600)
expect(week.to - week.from).toBe(7 * 86_400)
expect(week.points).toEqual([{ t: hour / 1000, players: 9 }])
expect(week.peak).toBe(9)
expect(week.average).toBe(5)
// The default range is the raw five-minute series: one point per sample.
res = await SELF.fetch('https://example.com/api/stats/online')
const day = await res.json<{ range: string; points: Array<{ players: number }> }>()
expect(day.range).toBe('24h')
expect(day.points.map((p) => p.players)).toEqual([4, 9, 2])
// The 30-day window reaches the old sample the week didn't.
res = await SELF.fetch('https://example.com/api/stats/online?range=30d')
expect((await res.json<{ peak: number }>()).peak).toBe(50)
res = await SELF.fetch('https://example.com/api/stats/online?range=forever')
expect(res.status).toBe(400)
})
it('serves the aggregated docs page with a source per documented service', async () => {
const res = await SELF.fetch('https://example.com/docs')
expect(res.status).toBe(200)
+53
View File
@@ -4,6 +4,7 @@ import { useWorkersLogger } from 'workers-tagged-logger'
import { getAccount, updateAccount } from '@repo/domain/src/accounts-db'
import { PlatformType } from '@repo/domain/src/enums'
import { countOnlinePlayers } from '@repo/domain/src/presence-db'
import { getStatSeries } from '@repo/domain/src/stats-db'
import { logger, withDefaultCors, withOnError } from '@repo/hono-helpers'
import { validateAndGetAccountId } from '@repo/jwt'
@@ -117,6 +118,20 @@ function authorizeUrl(request: Request, clientId: string): string {
return url.toString()
}
/**
* The windows `/api/stats/online` serves, each with the bucket its samples are folded
* into. The `match` cron samples every five minutes, so `24h` is the raw series and the
* rest are sized to land between ~90 and ~290 points — what a chart the width of the page
* can actually draw. A closed list rather than a free `since`: every request scans the
* window it names, and this is a public route.
*/
const STAT_RANGES: Record<string, { seconds: number; bucketSeconds: number }> = {
'24h': { seconds: 86_400, bucketSeconds: 300 },
'7d': { seconds: 7 * 86_400, bucketSeconds: 3_600 },
'30d': { seconds: 30 * 86_400, bucketSeconds: 4 * 3_600 },
'90d': { seconds: 90 * 86_400, bucketSeconds: 86_400 },
}
const app = new Hono<App>()
.use(
'*',
@@ -181,6 +196,44 @@ const app = new Hono<App>()
})
})
// The history behind that head-count: the `online` series the `match` presence cron
// samples into `stat`, for the chart at `/stats`. Public and CORS-open on the same
// terms as `/server-status`.
//
// Each point is the PEAK of its bucket, not the mean — "how many were on" is a question
// about the busiest moment, and a mean over a four-hour bucket flattens an evening's
// spike into nothing. `average` is over the raw samples, not over the buckets, so a
// short bucket at the window's edge doesn't weigh the same as a full one. `online` is
// the live count rather than the last sample, which can be five minutes old.
.get('/api/stats/online', withDefaultCors(), async (c) => {
const range = c.req.query('range') ?? '24h'
const window = STAT_RANGES[range]
if (!window) return c.json({ error: 'Unknown range.' }, 400)
const now = Date.now()
const since = new Date(now - window.seconds * 1000)
const [online, buckets] = await Promise.all([
countOnlinePlayers(c.env.DB),
getStatSeries(c.env.DB, 'online', since, window.bucketSeconds),
])
const samples = buckets.reduce((n, b) => n + b.samples, 0)
// A new sample lands every five minutes, so a minute of browser cache costs nothing
// anyone can see and spares the scan when someone flips between ranges.
c.header('cache-control', 'public, max-age=60')
return c.json({
range,
bucketSeconds: window.bucketSeconds,
// The window itself, so the chart's x-axis spans what was ASKED for rather than
// what came back — a server with three days of history shows three days of line.
from: Math.floor(since.getTime() / 1000),
to: Math.floor(now / 1000),
online,
peak: buckets.reduce((max, b) => Math.max(max, b.peak), 0),
average: samples === 0 ? 0 : buckets.reduce((sum, b) => sum + b.sum, 0) / samples,
points: buckets.map((b) => ({ t: b.t, players: b.peak })),
})
})
// ---- Signup -------------------------------------------------------------
// Create an account from the website, behind a Turnstile bot check. The check is what
+41
View File
@@ -43,3 +43,44 @@ export async function getStats(db: D1Database, statType: string, limit = 1000):
.all<{ stat_type: string; value: number; datetime: string }>()
return results.map((r) => ({ statType: r.stat_type, value: r.value, datetime: r.datetime }))
}
/** One bucket of a {@link getStatSeries} result. */
export interface StatPoint {
/** Start of the bucket, epoch seconds. */
t: number
/** Highest sample in the bucket. */
peak: number
/** Sum and count of the bucket's samples — kept apart so a caller can average ACROSS buckets. */
sum: number
samples: number
}
/**
* The `statType` series since `since`, folded into `bucketSeconds`-wide buckets, oldest
* first. The cron samples every five minutes, so a quarter of raw rows is ~26k points —
* far more than a chart has pixels for; bucketing in SQL keeps the response small and
* the peak exact. A bucket nothing was sampled in is simply absent, which is how a
* reader tells an outage from a quiet hour.
*
* The range filter compares `datetime` as a string (it is ISO-8601 UTC, so it sorts
* lexically) to stay on `idx_stat_type_datetime`; only the bucketing parses it.
*/
export async function getStatSeries(
db: D1Database,
statType: string,
since: Date,
bucketSeconds: number
): Promise<StatPoint[]> {
const { results } = await db
.prepare(
// D1 binds a JS number as REAL, so the bucket width is cast back: without it the
// division is floating-point and every sample lands in a bucket of its own.
`SELECT (CAST(strftime('%s', datetime) AS INTEGER) / CAST(?3 AS INTEGER)) * CAST(?3 AS INTEGER) AS t,
MAX(value) AS peak, SUM(value) AS sum, COUNT(*) AS samples
FROM stat WHERE stat_type = ?1 AND datetime >= ?2
GROUP BY t ORDER BY t ASC`
)
.bind(statType, since.toISOString(), bucketSeconds)
.all<StatPoint>()
return results
}