From a2e635df24ceef60714f3f5c11a980d29f1e5187 Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Fri, 18 Sep 2026 23:51:53 -0400 Subject: [PATCH] [www] stats --- apps/www/src/client/App.tsx | 7 + apps/www/src/client/Stats.tsx | 469 ++++++++++++++++++++++ apps/www/src/client/styles.css | 270 +++++++++++++ apps/www/src/test/integration/api.test.ts | 47 +++ apps/www/src/www.app.ts | 53 +++ packages/domain/src/stats-db.ts | 41 ++ 6 files changed, 887 insertions(+) create mode 100644 apps/www/src/client/Stats.tsx diff --git a/apps/www/src/client/App.tsx b/apps/www/src/client/App.tsx index a334332..80842d6 100644 --- a/apps/www/src/client/App.tsx +++ b/apps/www/src/client/App.tsx @@ -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. + ) : 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. + ) : roomId !== null ? ( ) : ( diff --git a/apps/www/src/client/Stats.tsx b/apps/www/src/client/Stats.tsx new file mode 100644 index 0000000..58018e2 --- /dev/null +++ b/apps/www/src/client/Stats.tsx @@ -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(null) + const [error, setError] = useState('') + + useEffect(() => { + let cancelled = false + const load = () => + call(`/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 ( +
+
+

Players online

+

How many people are in the game, now and lately.

+
+ +
+ {RANGES.map((r) => ( + + ))} +
+ + {error && stats === null ? ( +

Couldn’t load the stats: {error}

+ ) : stats === null ? ( +

Loading…

+ ) : ( +
+
+
+
+
+
{formatCount(stats.online)}
+
+
+
Peak
+
{formatCount(stats.peak)}
+
+
+
Average
+
{formatAverage(stats.average)}
+
+
+ +
+

{chartTitle(stats.bucketSeconds)}

+

Over {shown}, in your local time.

+ {stats.points.length === 0 ? ( +

Nothing has been recorded in this window yet.

+ ) : ( + <> + +
+ View as a table +
+ + + + + + + + + {/* Newest first: the row someone opens a table for is the latest. */} + {[...stats.points].reverse().map((p) => ( + + + + + ))} + +
WhenPlayers
{bucketLabel(p.t, stats.bucketSeconds)}{formatCount(p.players)}
+
+
+ + )} +
+ {error &&

Couldn’t refresh: {error}

} +
+ )} +
+ ) +} + +// ---- 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(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(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 = { + 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 ( +
setHover(null)} + > + {width > 0 && ( + + )} + {hovered && ( +
+ + {formatCount(hovered.players)} {hovered.players === 1 ? 'player' : 'players'} + + {bucketLabel(hovered.t, bucketSeconds)} +
+ )} +
+ ) +} diff --git a/apps/www/src/client/styles.css b/apps/www/src/client/styles.css index 7c63392..570fb63 100644 --- a/apps/www/src/client/styles.css +++ b/apps/www/src/client/styles.css @@ -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; diff --git a/apps/www/src/test/integration/api.test.ts b/apps/www/src/test/integration/api.test.ts index 6622cde..7567487 100644 --- a/apps/www/src/test/integration/api.test.ts +++ b/apps/www/src/test/integration/api.test.ts @@ -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) diff --git a/apps/www/src/www.app.ts b/apps/www/src/www.app.ts index f00336e..c949800 100644 --- a/apps/www/src/www.app.ts +++ b/apps/www/src/www.app.ts @@ -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 = { + '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() .use( '*', @@ -181,6 +196,44 @@ const app = new Hono() }) }) + // 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 diff --git a/packages/domain/src/stats-db.ts b/packages/domain/src/stats-db.ts index e785fb7..53a4dda 100644 --- a/packages/domain/src/stats-db.ts +++ b/packages/domain/src/stats-db.ts @@ -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 { + 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() + return results +}