initial commit of the paytime session frontend

This commit is contained in:
jenz
2026-08-22 14:53:27 +02:00
parent 4ad037bd13
commit 9e8e8378a2
43 changed files with 2936 additions and 0 deletions
@@ -0,0 +1,9 @@
DB_HOST=127.0.0.1
DB_PORT=3306
DB_USER=playtime_readonly
DB_PASSWORD=changeme
DB_NAME=unloze_playtimestats
# Required for avatars to load — without it, the fallback question-mark
# image is used instead. Get a key at https://steamcommunity.com/dev/apikey
STEAM_API_KEY=
@@ -0,0 +1,17 @@
# dependencies
node_modules/
# next.js build output
.next/
out/
# env files — never commit real secrets
.env
.env.local
.env.*.local
.env.production
# misc
.DS_Store
*.pem
npm-debug.log*
@@ -0,0 +1,80 @@
# unloze playtime display
Next.js dashboard for the `unloze_playtimestats` database. Runs as a single
Node process; nginx reverse-proxies a subdomain to it.
## Stack
- Next.js 14 (App Router) + TypeScript
- Tailwind CSS
- Recharts for graphs
- `mysql2` connecting directly to MySQL (server-side only, in API routes —
the browser never sees DB credentials)
## Pages built so far
- `/` — Overview: concurrent-player population graph (last 24h by default)
and a map-to-map population bar chart (last 40 maps). Click any point on
either chart to see the actual players (avatar + name) who were online
then.
- `/players` — search players by current/previous name or SteamID, sortable
by recent activity, name, or highest playtime
- `/players/[steamid]` — avatar, previous names, SteamID, Steam profile link,
RaceTimer rank/level + profile link, total playtime, and a session-by-session
history (with the map(s) each session overlapped)
- `/maps` — search maps by name
- `/maps/[mapname]` — every time period that map was played
- `/maps/period/[mapId]` — one specific occurrence: players present during
it, votes cast (grouped by vote event, since a map could theoretically see
more than one), and prev/next map navigation
- `/countries` — every country with at least one player, sorted by player
count descending
- `/countries/[code]` — players from that country, sortable by highest
playtime or most recently played
A `favicon.ico` in `app/` is picked up automatically by Next — no config needed.
Every feature from the original planning conversation is now built.
## Deploying on the Hetzner box
```bash
npm install
npm run build
pm2 start ecosystem.config.js
pm2 save # persist across reboots
pm2 startup # follow the printed instructions once, to enable on boot
```
Then point nginx at it — see `nginx.example.conf` for a working server block
(adjust the domain and cert paths). After linking it into
`/etc/nginx/sites-enabled/`, reload nginx to pick it up.
## Notes
- `session_end_dt` is refreshed roughly every 60s by the plugin as a
heartbeat while a player is connected (not just on disconnect) — see
`lib/db.ts` usage in `/api/population`, which treats a `NULL` or
recent `session_end_dt` as "still active."
- Running on **Next.js 15**. One thing to remember when building the
`/players/[steamid]` and `/maps/[mapname]` dynamic pages next: Next 15
made the `params` (and `searchParams`) prop passed to pages/route handlers
a `Promise` you need to `await`, e.g.
`export default async function Page({ params }: { params: Promise<{ steamid: string }> }) { const { steamid } = await params; ... }`.
Doesn't affect anything currently built (no dynamic segments yet), but
will the moment those pages get added.
- SteamIDs are stored in Steam2 format (`STEAM_0:1:12345678`) to match the
existing `player_time` table. `lib/steam.ts` converts to Steam64 on the
fly for profile links/avatars — nothing needs to be stored pre-converted.
- **Timezone**: `ecosystem.config.js` pins `TZ=Europe/Berlin`, confirmed
against `SELECT @@global.time_zone, @@session.time_zone, NOW();` on the
actual server (MySQL runs `SYSTEM`, which resolves to German local time).
All the datetime parsing (`lib/dates.ts`, `/api/population`) reads MySQL
`DATETIME` strings with no timezone info attached, so Node's own local
timezone determines how they're interpreted — it must match MySQL's, or
"how many players are online right now" ends up quietly wrong (this was
a real bug, not hypothetical — it undercounted the live player count by
roughly half before this fix). If the DB server ever moves or MySQL's
timezone changes, update this value to match. After changing it,
`pm2 restart ecosystem.config.js --update-env` is required — a plain
`pm2 restart <name>` won't pick up the new env var.
@@ -0,0 +1,30 @@
import { NextRequest, NextResponse } from 'next/server';
import { getPlayersPresentDuringMap } from '@/lib/queries';
import { getSteamSummariesBatch, DEFAULT_AVATAR_URL } from '@/lib/steamApi';
import { steam2ToSteam64 } from '@/lib/steam';
export async function GET(req: NextRequest) {
const mapIdParam = req.nextUrl.searchParams.get('mapId');
const mapId = Number(mapIdParam);
if (!mapIdParam || !Number.isInteger(mapId)) {
return NextResponse.json({ error: 'mapId query param is required' }, { status: 400 });
}
const players = await getPlayersPresentDuringMap(mapId);
const steam64ByPlayer = new Map<string, string>();
for (const p of players) {
const id64 = steam2ToSteam64(p.steamid);
if (id64) steam64ByPlayer.set(p.steamid, id64);
}
const avatars = await getSteamSummariesBatch([...steam64ByPlayer.values()]);
const result = players.map((p) => {
const id64 = steam64ByPlayer.get(p.steamid);
const avatarUrl = (id64 && avatars.get(id64)?.avatarUrl) || DEFAULT_AVATAR_URL;
return { steamid: p.steamid, name: p.current_name, avatarUrl };
});
return NextResponse.json({ players: result });
}
@@ -0,0 +1,9 @@
import { NextRequest, NextResponse } from 'next/server';
import { getMapPopulationSeries } from '@/lib/queries';
export async function GET(req: NextRequest) {
const limitParam = req.nextUrl.searchParams.get('limit');
const limit = Math.min(Math.max(Number(limitParam) || 40, 1), 200);
const points = await getMapPopulationSeries(limit);
return NextResponse.json({ points });
}
@@ -0,0 +1,8 @@
import { NextRequest, NextResponse } from 'next/server';
import { searchMapNames } from '@/lib/queries';
export async function GET(req: NextRequest) {
const q = req.nextUrl.searchParams.get('q')?.trim() ?? '';
const maps = await searchMapNames(q);
return NextResponse.json({ maps });
}
@@ -0,0 +1,64 @@
import { NextRequest, NextResponse } from 'next/server';
import { query } from '@/lib/db';
interface PlayerRow {
steamid: string;
current_name: string;
current_country: string | null;
matched_name: string | null;
total_minutes: number | null;
}
const TOTAL_MINUTES_SUBQUERY = `
(SELECT COALESCE(s.session_end_playtime_minutes, s.session_start_playtime_minutes)
FROM playtime_display_sessions s
WHERE s.steamid = p.steamid
ORDER BY s.session_id DESC
LIMIT 1) AS total_minutes
`;
export async function GET(req: NextRequest) {
const q = req.nextUrl.searchParams.get('q')?.trim() ?? '';
const sort = req.nextUrl.searchParams.get('sort') ?? 'recent';
const orderBy =
sort === 'playtime'
? 'total_minutes IS NULL, total_minutes DESC'
: sort === 'name'
? 'p.current_name'
: 'p.last_seen DESC';
// No search term: just list players, ordered per the chosen sort.
if (!q) {
const rows = await query<PlayerRow>(
`SELECT p.steamid, p.current_name, p.current_country, NULL AS matched_name,
${TOTAL_MINUTES_SUBQUERY}
FROM playtime_display_players p
ORDER BY ${orderBy}
LIMIT 50`,
);
return NextResponse.json({ players: rows });
}
// Search matches steamid directly, or current/previous names.
const like = `%${q}%`;
const rows = await query<PlayerRow>(
`SELECT p.steamid, p.current_name, p.current_country, h.player_name AS matched_name,
${TOTAL_MINUTES_SUBQUERY}
FROM playtime_display_players p
LEFT JOIN playtime_display_name_history h
ON h.steamid = p.steamid AND h.player_name LIKE ?
WHERE p.steamid LIKE ?
OR p.current_name LIKE ?
OR EXISTS (
SELECT 1 FROM playtime_display_name_history h2
WHERE h2.steamid = p.steamid AND h2.player_name LIKE ?
)
GROUP BY p.steamid
ORDER BY ${orderBy}
LIMIT 50`,
[like, like, like, like],
);
return NextResponse.json({ players: rows });
}
@@ -0,0 +1,24 @@
import { NextResponse } from 'next/server';
import { getCurrentPlayers } from '@/lib/queries';
import { getSteamSummariesBatch, DEFAULT_AVATAR_URL } from '@/lib/steamApi';
import { steam2ToSteam64 } from '@/lib/steam';
export async function GET() {
const players = await getCurrentPlayers(3);
const steam64ByPlayer = new Map<string, string>();
for (const p of players) {
const id64 = steam2ToSteam64(p.steamid);
if (id64) steam64ByPlayer.set(p.steamid, id64);
}
const avatars = await getSteamSummariesBatch([...steam64ByPlayer.values()]);
const result = players.map((p) => {
const id64 = steam64ByPlayer.get(p.steamid);
const avatarUrl = (id64 && avatars.get(id64)?.avatarUrl) || DEFAULT_AVATAR_URL;
return { steamid: p.steamid, name: p.current_name, avatarUrl };
});
return NextResponse.json({ players: result, count: result.length });
}
@@ -0,0 +1,35 @@
import { NextRequest, NextResponse } from 'next/server';
import { getPlayersAtTime } from '@/lib/queries';
import { getSteamSummariesBatch, DEFAULT_AVATAR_URL } from '@/lib/steamApi';
import { steam2ToSteam64 } from '@/lib/steam';
import { formatDbDateTime } from '@/lib/timezone';
export async function GET(req: NextRequest) {
const at = req.nextUrl.searchParams.get('at');
if (!at) {
return NextResponse.json({ error: 'at query param is required' }, { status: 400 });
}
const atDate = new Date(at);
if (Number.isNaN(atDate.getTime())) {
return NextResponse.json({ error: 'invalid at timestamp' }, { status: 400 });
}
const players = await getPlayersAtTime(formatDbDateTime(atDate));
const steam64ByPlayer = new Map<string, string>();
for (const p of players) {
const id64 = steam2ToSteam64(p.steamid);
if (id64) steam64ByPlayer.set(p.steamid, id64);
}
const avatars = await getSteamSummariesBatch([...steam64ByPlayer.values()]);
const result = players.map((p) => {
const id64 = steam64ByPlayer.get(p.steamid);
const avatarUrl = (id64 && avatars.get(id64)?.avatarUrl) || DEFAULT_AVATAR_URL;
return { steamid: p.steamid, name: p.current_name, avatarUrl };
});
return NextResponse.json({ players: result });
}
@@ -0,0 +1,133 @@
import { NextRequest, NextResponse } from 'next/server';
import { query } from '@/lib/db';
import { parseDbDate } from '@/lib/dates';
import { formatDbDateTime } from '@/lib/timezone';
interface SessionRow {
session_start_dt: string;
session_end_dt: string | null;
}
interface MapRow {
map_id: number;
map_name: string;
map_start_dt: string;
}
// Picks a bucket size that keeps the number of points in a sane range
// (roughly 60-300 buckets) regardless of how wide the requested range is.
function pickBucketMinutes(rangeMinutes: number): number {
const target = 150;
const raw = Math.ceil(rangeMinutes / target);
const steps = [1, 5, 15, 30, 60, 120, 240, 360, 720, 1440]; // minutes
return steps.find((s) => s >= raw) ?? steps[steps.length - 1];
}
export async function GET(req: NextRequest) {
const searchParams = req.nextUrl.searchParams;
const startParam = searchParams.get('start');
const endParam = searchParams.get('end');
if (!startParam || !endParam) {
return NextResponse.json({ error: 'start and end query params are required (ISO datetimes)' }, { status: 400 });
}
const rangeStart = new Date(startParam);
const rangeEnd = new Date(endParam);
if (Number.isNaN(rangeStart.getTime()) || Number.isNaN(rangeEnd.getTime()) || rangeStart >= rangeEnd) {
return NextResponse.json({ error: 'invalid or inverted start/end range' }, { status: 400 });
}
// Convert to MySQL-native "YYYY-MM-DD HH:MM:SS" strings (in the DB's own
// timezone) rather than passing raw ISO-Z strings as query parameters —
// MySQL DATETIME columns don't reliably parse the T/Z/milliseconds ISO
// format, which was silently dropping/mismatching rows in this fetch.
const formattedEnd = formatDbDateTime(rangeEnd);
const formattedStart = formatDbDateTime(rangeStart);
// Generous safety margin so the map fetch below doesn't scan the entire
// history table as it grows over months/years — 24h is far longer than
// any realistic single map duration, so this can never miss the map that
// was actually active at rangeStart.
const formattedLookback = formatDbDateTime(new Date(rangeStart.getTime() - 24 * 60 * 60 * 1000));
const [sessionRows, mapRows] = await Promise.all([
query<SessionRow>(
`SELECT session_start_dt, session_end_dt
FROM playtime_display_sessions
WHERE session_start_dt < ?
AND (session_end_dt IS NULL OR session_end_dt > ?)`,
[formattedEnd, formattedStart],
),
// map_end_dt was dropped from the schema (redundant with, and
// occasionally out of sync with, the next row's map_start_dt). Filter
// tickrate-restart artifacts (<2min) using the gap to the next map;
// the currently-running map (no next row yet) always passes.
query<MapRow>(
`SELECT m.map_id, m.map_name, m.map_start_dt
FROM playtime_display_map_history m
WHERE m.map_start_dt < ?
AND m.map_start_dt > ?
AND (
(SELECT MIN(m2.map_start_dt) FROM playtime_display_map_history m2 WHERE m2.map_start_dt > m.map_start_dt) IS NULL
OR TIMESTAMPDIFF(
MINUTE, m.map_start_dt,
(SELECT MIN(m2.map_start_dt) FROM playtime_display_map_history m2 WHERE m2.map_start_dt > m.map_start_dt)
) >= 2
)
ORDER BY m.map_start_dt`,
[formattedEnd, formattedLookback],
),
]);
const sessions = sessionRows.map((r) => ({
start: parseDbDate(r.session_start_dt),
end: r.session_end_dt ? parseDbDate(r.session_end_dt) : new Date(), // still-open session heartbeat, see plugin notes
}));
const maps = mapRows.map((r) => ({
mapId: r.map_id,
mapName: r.map_name,
start: parseDbDate(r.map_start_dt),
}));
// `maps` is sorted ascending by start_dt (from the ORDER BY in the query
// above). Deliberately does NOT check each row's own end time — a map
// whose map_end_dt never got closed (e.g. OnMapEnd not firing for a
// specific transition, due to a crash/restart/admin changelevel) would
// otherwise look "still running" forever and permanently shadow every
// real map that came after it. Instead: whichever map most recently
// started by time t is what was playing then, regardless of whether its
// own end timestamp was ever reliably recorded.
function mapNameAt(t: Date): string | null {
let result: string | null = null;
for (const m of maps) {
if (m.start <= t) {
result = m.mapName;
} else {
break;
}
}
return result;
}
const rangeMinutes = (rangeEnd.getTime() - rangeStart.getTime()) / 60000;
const bucketMinutes = pickBucketMinutes(rangeMinutes);
const points: { t: string; players: number; mapName: string | null }[] = [];
for (let t = rangeStart.getTime(); t < rangeEnd.getTime(); t += bucketMinutes * 60000) {
const bucketStart = new Date(t);
const bucketEnd = new Date(t + bucketMinutes * 60000);
const count = sessions.filter((s) => s.start < bucketEnd && s.end > bucketStart).length;
points.push({ t: bucketStart.toISOString(), players: count, mapName: mapNameAt(bucketStart) });
}
// Map-change boundaries within the visible range, for drawing vertical
// separator lines on the chart — only the ones whose start actually
// falls inside [rangeStart, rangeEnd).
const mapBoundaries = maps
.filter((m) => m.start >= rangeStart && m.start < rangeEnd)
.map((m) => ({ mapId: m.mapId, mapName: m.mapName, t: m.start.toISOString() }));
return NextResponse.json({ bucketMinutes, points, mapBoundaries });
}
@@ -0,0 +1,84 @@
import Link from 'next/link';
import { notFound } from 'next/navigation';
import { getPlayersByCountry } from '@/lib/queries';
import { countryCodeToFlag, countryCodeToName, formatMinutes } from '@/lib/steam';
export default async function CountryPlayersPage({
params,
searchParams,
}: {
params: Promise<{ code: string }>;
searchParams: Promise<{ sort?: string }>;
}) {
const { code: rawCode } = await params;
const code = decodeURIComponent(rawCode).toUpperCase();
const { sort: rawSort } = await searchParams;
const sort = rawSort === 'playtime' ? 'playtime' : 'recent';
const players = await getPlayersByCountry(code, sort);
if (players.length === 0) {
notFound();
}
return (
<div className="space-y-6">
<Link href="/countries" className="text-sm text-ink-muted hover:text-accent transition-colors">
All countries
</Link>
<div className="flex flex-wrap items-end justify-between gap-4">
<div>
<h1 className="text-2xl text-ink mb-1 flex items-center gap-2">
<span aria-hidden>{countryCodeToFlag(code)}</span>
{countryCodeToName(code)}
</h1>
<p className="text-sm text-ink-muted">
{players.length} player{players.length === 1 ? '' : 's'}
</p>
</div>
<div className="flex gap-2 text-sm">
<Link
href={`/countries/${encodeURIComponent(code)}?sort=recent`}
className={`px-3 py-1.5 rounded border transition-colors ${
sort === 'recent'
? 'border-accent/50 text-accent bg-accent-dim/20'
: 'border-base-border text-ink-muted hover:text-ink'
}`}
>
Most recently played
</Link>
<Link
href={`/countries/${encodeURIComponent(code)}?sort=playtime`}
className={`px-3 py-1.5 rounded border transition-colors ${
sort === 'playtime'
? 'border-accent/50 text-accent bg-accent-dim/20'
: 'border-base-border text-ink-muted hover:text-ink'
}`}
>
Highest playtime
</Link>
</div>
</div>
<div className="panel divide-y divide-base-border">
{players.map((p) => (
<Link
key={p.steamid}
href={`/players/${encodeURIComponent(p.steamid)}`}
className="flex items-center justify-between px-4 py-3 hover:bg-base transition-colors"
>
<div className="text-ink text-sm">{p.current_name}</div>
<div className="flex items-center gap-4 text-xs">
<span className="text-ink-faint mono">{p.steamid}</span>
<span className="text-ink-muted mono w-16 text-right">
{formatMinutes(p.total_minutes)}
</span>
</div>
</Link>
))}
</div>
</div>
);
}
@@ -0,0 +1,48 @@
import Link from 'next/link';
import { getCountriesSummary } from '@/lib/queries';
import { countryCodeToFlag, countryCodeToName } from '@/lib/steam';
// This page queries the DB directly with no dynamic route segment, so
// without this Next.js tries to statically pre-render it at BUILD time
// (requiring a live DB connection then, not just at request time).
export const dynamic = 'force-dynamic';
export default async function CountriesPage() {
const countries = await getCountriesSummary();
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl text-ink mb-1">Countries</h1>
<p className="text-sm text-ink-muted">
Where the server's players connect from, most players first.
</p>
</div>
{countries.length === 0 ? (
<div className="panel p-6 text-sm text-ink-muted">No countries recorded yet.</div>
) : (
<div className="panel divide-y divide-base-border">
{countries.map((c) => (
<Link
key={c.current_country}
href={`/countries/${encodeURIComponent(c.current_country)}`}
className="flex items-center justify-between px-4 py-3 hover:bg-base transition-colors"
>
<div className="flex items-center gap-3">
<span className="text-lg" aria-hidden>
{countryCodeToFlag(c.current_country)}
</span>
<span className="text-ink text-sm">{countryCodeToName(c.current_country)}</span>
<span className="text-ink-faint text-xs mono">{c.current_country}</span>
</div>
<div className="text-sm text-ink-muted mono">
{c.player_count} player{c.player_count === 1 ? '' : 's'}
</div>
</Link>
))}
</div>
)}
</div>
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -0,0 +1,33 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
body {
@apply bg-base text-ink font-sans antialiased;
}
::selection {
@apply bg-accent/30 text-ink;
}
a:focus-visible,
button:focus-visible,
input:focus-visible {
@apply outline-none ring-2 ring-accent ring-offset-2 ring-offset-base;
}
}
@layer components {
.panel {
@apply bg-base-panel border border-base-border rounded-lg;
}
.stat-label {
@apply text-xs uppercase tracking-wider text-ink-muted font-mono;
}
.mono {
@apply font-mono text-sm;
}
}
@@ -0,0 +1,53 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import './globals.css';
export const metadata: Metadata = {
title: 'unloze — server stats',
description: 'Playtime, population and map stats for the unloze server',
};
const NAV_ITEMS = [
{ href: '/', label: 'Overview' },
{ href: '/players', label: 'Players' },
{ href: '/maps', label: 'Maps' },
{ href: '/countries', label: 'Countries' },
];
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<div className="min-h-screen flex flex-col">
<header className="border-b border-base-border">
<div className="max-w-6xl mx-auto px-6 py-4 flex items-center justify-between">
<Link href="/" className="flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-accent shadow-[0_0_8px_theme(colors.accent.DEFAULT)]" />
<span className="font-mono text-sm tracking-widest text-ink uppercase">
unloze playtime sessions
</span>
</Link>
<nav className="flex gap-6">
{NAV_ITEMS.map((item) => (
<Link
key={item.href}
href={item.href}
className="text-sm text-ink-muted hover:text-accent transition-colors"
>
{item.label}
</Link>
))}
</nav>
</div>
</header>
<main className="flex-1 max-w-6xl w-full mx-auto px-6 py-8">{children}</main>
<footer className="border-t border-base-border py-4">
<div className="max-w-6xl mx-auto px-6 text-xs text-ink-faint font-mono">
unloze playtime stats
</div>
</footer>
</div>
</body>
</html>
);
}
@@ -0,0 +1,54 @@
import Link from 'next/link';
import { notFound } from 'next/navigation';
import { getMapPeriods } from '@/lib/queries';
import { diffMinutes } from '@/lib/dates';
import { formatMinutes } from '@/lib/steam';
export default async function MapPeriodsPage({
params,
}: {
params: Promise<{ mapname: string }>;
}) {
const { mapname: rawMapname } = await params;
const mapName = decodeURIComponent(rawMapname);
const periods = await getMapPeriods(mapName);
if (periods.length === 0) {
notFound();
}
return (
<div className="space-y-6">
<Link href="/maps" className="text-sm text-ink-muted hover:text-accent transition-colors">
Back to maps
</Link>
<div>
<h1 className="text-2xl text-ink mb-1">{mapName}</h1>
<p className="text-sm text-ink-muted">
Played {periods.length} time{periods.length === 1 ? '' : 's'}.
</p>
</div>
<div className="panel divide-y divide-base-border">
{periods.map((p) => (
<Link
key={p.map_id}
href={`/maps/period/${p.map_id}`}
className="flex items-center justify-between px-4 py-3 hover:bg-base transition-colors"
>
<div>
<div className="text-ink text-sm">
{new Date(p.map_start_dt.replace(' ', 'T')).toLocaleString()}
</div>
{!p.map_end_dt && <div className="text-xs text-accent">currently running</div>}
</div>
<div className="mono text-ink-faint text-sm">
{p.map_end_dt ? formatMinutes(diffMinutes(p.map_start_dt, p.map_end_dt)) : '—'}
</div>
</Link>
))}
</div>
</div>
);
}
@@ -0,0 +1,13 @@
import MapSearch from '@/components/MapSearch';
export default function MapsPage() {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl text-ink mb-1">Maps</h1>
<p className="text-sm text-ink-muted">Search for a map to see when it was played.</p>
</div>
<MapSearch />
</div>
);
}
@@ -0,0 +1,172 @@
import Link from 'next/link';
import { notFound } from 'next/navigation';
import {
getMapPeriod,
getPlayersPresentDuringMap,
getMapVotes,
getAdjacentMaps,
} from '@/lib/queries';
import { diffMinutes } from '@/lib/dates';
import { formatMinutes, countryCodeToFlag } from '@/lib/steam';
export default async function MapPeriodDetailPage({
params,
}: {
params: Promise<{ mapId: string }>;
}) {
const { mapId: rawMapId } = await params;
const mapId = Number(rawMapId);
if (!Number.isInteger(mapId)) {
notFound();
}
const period = await getMapPeriod(mapId);
if (!period) {
notFound();
}
const [players, votes, adjacent] = await Promise.all([
getPlayersPresentDuringMap(mapId),
getMapVotes(mapId),
getAdjacentMaps(period.map_start_dt),
]);
// Group vote rows by their vote event (a map can theoretically have more
// than one vote called during its runtime).
const voteEvents = new Map<number, { vote_time: string; result: string | null; choices: typeof votes }>();
for (const v of votes) {
if (!voteEvents.has(v.vote_id)) {
voteEvents.set(v.vote_id, { vote_time: v.vote_time, result: v.result, choices: [] });
}
voteEvents.get(v.vote_id)!.choices.push(v);
}
return (
<div className="space-y-6">
<Link
href={`/maps/${encodeURIComponent(period.map_name)}`}
className="text-sm text-ink-muted hover:text-accent transition-colors"
>
All plays of {period.map_name}
</Link>
{/* Header with prev/next navigation */}
<div className="panel p-6">
<div className="flex items-center justify-between gap-4">
<div className="flex-1">
{adjacent.prev ? (
<Link
href={`/maps/period/${adjacent.prev.map_id}`}
className="text-sm text-ink-muted hover:text-accent transition-colors"
>
{adjacent.prev.map_name}
</Link>
) : (
<span className="text-sm text-ink-faint">start of history</span>
)}
</div>
<div className="text-center">
<h1 className="text-2xl text-ink">{period.map_name}</h1>
<div className="text-xs text-ink-faint mt-1">
{new Date(period.map_start_dt.replace(' ', 'T')).toLocaleString()}
{' — '}
{period.map_end_dt
? new Date(period.map_end_dt.replace(' ', 'T')).toLocaleTimeString()
: 'still running'}
{period.map_end_dt && (
<span className="ml-2">
({formatMinutes(diffMinutes(period.map_start_dt, period.map_end_dt))})
</span>
)}
</div>
</div>
<div className="flex-1 text-right">
{adjacent.next ? (
<Link
href={`/maps/period/${adjacent.next.map_id}`}
className="text-sm text-ink-muted hover:text-accent transition-colors"
>
{adjacent.next.map_name}
</Link>
) : (
<span className="text-sm text-ink-faint">end of history</span>
)}
</div>
</div>
</div>
{/* Players present */}
<div className="panel p-6">
<div className="stat-label mb-3">
Players present ({players.length})
</div>
{players.length === 0 ? (
<div className="text-sm text-ink-muted">No recorded players during this period.</div>
) : (
<div className="divide-y divide-base-border">
{players.map((p, i) => (
<Link
key={`${p.steamid}-${i}`}
href={`/players/${encodeURIComponent(p.steamid)}`}
className="flex items-center justify-between py-2 text-sm hover:bg-base transition-colors px-2 -mx-2 rounded"
>
<div className="flex items-center gap-2">
<span aria-hidden>{countryCodeToFlag(p.current_country)}</span>
<span className="text-ink">{p.current_name}</span>
</div>
<div className="text-ink-faint text-xs mono">
{new Date(p.session_start_dt.replace(' ', 'T')).toLocaleTimeString()}
{' '}
{p.session_end_dt
? new Date(p.session_end_dt.replace(' ', 'T')).toLocaleTimeString()
: 'now'}
</div>
</Link>
))}
</div>
)}
</div>
{/* Votes */}
<div className="panel p-6">
<div className="stat-label mb-3">Map votes</div>
{voteEvents.size === 0 ? (
<div className="text-sm text-ink-muted">No recorded vote during this period.</div>
) : (
<div className="space-y-6">
{[...voteEvents.entries()].map(([voteId, event]) => (
<div key={voteId}>
<div className="flex items-center justify-between mb-2">
<div className="text-xs text-ink-faint mono">
{new Date(event.vote_time.replace(' ', 'T')).toLocaleString()}
</div>
<div className="text-sm text-accent">Result: {event.result ?? '—'}</div>
</div>
<div className="divide-y divide-base-border">
{event.choices.map((c, i) => (
<Link
key={`${c.steamid}-${i}`}
href={`/players/${encodeURIComponent(c.steamid)}`}
className="flex items-center justify-between py-2 text-sm hover:bg-base transition-colors px-2 -mx-2 rounded"
>
<span className="text-ink">{c.current_name}</span>
<span className="text-ink-muted">
voted <span className="text-ink">{c.vote_choice}</span>
{c.vote_weight !== 1 && (
<span className="text-ink-faint"> (weight {c.vote_weight})</span>
)}
</span>
</Link>
))}
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,15 @@
import PopulationChart from '@/components/PopulationChart';
import MapPopulationChart from '@/components/MapPopulationChart';
export default function OverviewPage() {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl text-ink mb-1">Overview</h1>
<p className="text-sm text-ink-muted">Population and activity across the server.</p>
</div>
<PopulationChart />
<MapPopulationChart />
</div>
);
}
@@ -0,0 +1,155 @@
import { notFound } from 'next/navigation';
import Link from 'next/link';
import { getPlayer, getNameHistory, getSessions, getTotalPlaytimeMinutes } from '@/lib/queries';
import { steam2ToSteam64, steamProfileUrl, formatMinutes, countryCodeToFlag } from '@/lib/steam';
import { getSteamSummary, DEFAULT_AVATAR_URL } from '@/lib/steamApi';
import { getRaceTimerSummary } from '@/lib/racetimer';
export default async function PlayerDetailPage({
params,
}: {
params: Promise<{ steamid: string }>;
}) {
const { steamid: rawSteamid } = await params;
const steamid = decodeURIComponent(rawSteamid);
const player = await getPlayer(steamid);
if (!player) {
notFound();
}
const steamId64 = steam2ToSteam64(steamid);
const profileUrl = steamProfileUrl(steamid);
const [nameHistory, sessions, totalMinutes, steamSummary, raceTimer] = await Promise.all([
getNameHistory(steamid),
getSessions(steamid),
getTotalPlaytimeMinutes(steamid),
steamId64 ? getSteamSummary(steamId64) : Promise.resolve(null),
getRaceTimerSummary(steamid),
]);
const avatarUrl = steamSummary?.avatarUrl ?? DEFAULT_AVATAR_URL;
return (
<div className="space-y-8">
<Link href="/players" className="text-sm text-ink-muted hover:text-accent transition-colors">
Back to players
</Link>
{/* Header: avatar, name, steamid, profile link, total playtime */}
<div className="panel p-6 flex flex-wrap items-center gap-6">
<div className="w-20 h-20 rounded-lg overflow-hidden bg-base border border-base-border shrink-0">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={avatarUrl} alt="" className="w-full h-full object-cover" />
</div>
<div className="flex-1 min-w-[200px]">
<div className="flex items-center gap-2">
<h1 className="text-2xl text-ink">{player.current_name}</h1>
<span className="text-lg" aria-hidden>
{countryCodeToFlag(player.current_country)}
</span>
</div>
<div className="mono text-ink-faint mt-1">{steamid}</div>
<div className="flex items-center gap-3 flex-wrap">
{profileUrl && (
<a
href={profileUrl}
target="_blank"
rel="noreferrer"
className="text-sm text-accent hover:underline"
>
View Steam profile
</a>
)}
{raceTimer && (
<a
href={raceTimer.profileUrl}
target="_blank"
rel="noreferrer"
className="text-sm text-accent hover:underline"
>
View RaceTimer profile
</a>
)}
</div>
</div>
<div className="text-right">
<div className="stat-label">Total playtime</div>
<div className="text-2xl text-ink mono">
{totalMinutes != null ? formatMinutes(totalMinutes) : '—'}
</div>
</div>
{raceTimer && (
<div className="text-right border-l border-base-border pl-6">
<div className="stat-label">RaceTimer</div>
<div className="text-2xl text-ink mono">Lv {raceTimer.level}</div>
<div className="text-xs text-ink-faint mono">Rank #{raceTimer.rank}</div>
</div>
)}
<div className="text-right border-l border-base-border pl-6">
<div className="stat-label">Player Tier</div>
<div className="text-2xl text-ink mono">{player.player_tier}</div>
</div>
</div>
{/* Previous names */}
{nameHistory.length > 1 && (
<div className="panel p-6">
<div className="stat-label mb-3">Previous names</div>
<div className="flex flex-wrap gap-2">
{nameHistory
.filter((n) => n.player_name !== player.current_name)
.map((n) => (
<span
key={n.player_name}
className="text-sm bg-base border border-base-border rounded px-3 py-1 text-ink-muted"
title={`Last seen ${new Date(n.last_seen.replace(' ', 'T')).toLocaleString()}`}
>
{n.player_name}
</span>
))}
</div>
</div>
)}
{/* Sessions */}
<div className="panel p-6">
<div className="stat-label mb-3">Play sessions</div>
{sessions.length === 0 ? (
<div className="text-sm text-ink-muted">No recorded sessions yet.</div>
) : (
<div className="divide-y divide-base-border">
<div className="grid grid-cols-[1fr_1fr_100px] gap-4 pb-2 text-xs text-ink-faint uppercase tracking-wide">
<div>Started</div>
<div>Map(s)</div>
<div className="text-right">Playtime</div>
</div>
{sessions.map((s) => (
<div key={s.session_id} className="grid grid-cols-[1fr_1fr_100px] gap-4 py-3 text-sm items-center">
<div>
<div className="text-ink">
{new Date(s.session_start_dt.replace(' ', 'T')).toLocaleString()}
</div>
<div className="text-xs text-ink-faint">
{s.session_end_dt
? `until ${new Date(s.session_end_dt.replace(' ', 'T')).toLocaleTimeString()}`
: 'still connected'}
</div>
</div>
<div className="text-ink-muted">{s.maps_played ?? '—'}</div>
<div className="text-right mono text-ink">
{formatMinutes(s.session_active_minutes)}
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,15 @@
import PlayerSearch from '@/components/PlayerSearch';
export default function PlayersPage() {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl text-ink mb-1">Players</h1>
<p className="text-sm text-ink-muted">
Search by current or previous name, or SteamID.
</p>
</div>
<PlayerSearch />
</div>
);
}
@@ -0,0 +1,63 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { formatMinutes } from '@/lib/steam';
interface CountryPlayer {
steamid: string;
current_name: string;
last_seen: string;
total_minutes: number | null;
}
export default function CountryPlayerList({ code }: { code: string }) {
const [sort, setSort] = useState<'playtime' | 'recent'>('playtime');
const [players, setPlayers] = useState<CountryPlayer[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
fetch(`/api/countries/${code}?sort=${sort}`)
.then((res) => res.json())
.then((data) => setPlayers(data.players))
.finally(() => setLoading(false));
}, [code, sort]);
return (
<div className="space-y-4">
<div className="flex justify-end">
<select
value={sort}
onChange={(e) => setSort(e.target.value as typeof sort)}
className="bg-base-panel border border-base-border rounded-lg px-3 py-2 text-sm text-ink-muted focus:border-accent/50 transition-colors"
>
<option value="playtime">Highest playtime</option>
<option value="recent">Most recently played</option>
</select>
</div>
<div className="panel divide-y divide-base-border">
{loading ? (
<div className="p-4 text-sm text-ink-faint font-mono">loading</div>
) : players.length === 0 ? (
<div className="p-4 text-sm text-ink-muted">No players found.</div>
) : (
players.map((p) => (
<Link
key={p.steamid}
href={`/players/${encodeURIComponent(p.steamid)}`}
className="flex items-center justify-between px-4 py-3 hover:bg-base transition-colors"
>
<div className="text-ink text-sm">{p.current_name}</div>
<div className="flex items-center gap-4 text-xs text-ink-faint">
<span className="mono">{formatMinutes(p.total_minutes)}</span>
<span>{new Date(p.last_seen.replace(' ', 'T')).toLocaleDateString()}</span>
</div>
</Link>
))
)}
</div>
</div>
);
}
@@ -0,0 +1,338 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import Link from 'next/link';
import { useContainerWidth } from '@/lib/useContainerWidth';
import PlayerChipList from './PlayerChipList';
interface MapPoint {
map_id: number;
map_name: string;
map_start_dt: string;
players_online_at_start: number;
}
interface PlayerChip {
steamid: string;
name: string;
avatarUrl: string;
}
const HEIGHT = 288;
const PAD_LEFT = 32;
const PAD_RIGHT = 8;
const PAD_TOP = 8;
const PAD_BOTTOM = 36;
const FETCH_LIMIT = 150; // pool of maps available to pan/zoom through
const DEFAULT_VISIBLE = 40;
const MIN_VISIBLE = 5;
export default function MapPopulationChart() {
const { ref: containerRef, width } = useContainerWidth<HTMLDivElement>();
const [points, setPoints] = useState<MapPoint[]>([]);
const [loading, setLoading] = useState(true);
const [hoverIndex, setHoverIndex] = useState<number | null>(null);
// Visible window (index range into `points`) — what scroll-to-zoom /
// drag-to-pan adjusts. Defaults to the most recent DEFAULT_VISIBLE maps.
const [viewStartIdx, setViewStartIdx] = useState(0);
const [viewEndIdx, setViewEndIdx] = useState(0);
const dragStartRef = useRef<{ x: number; startIdx: number; endIdx: number } | null>(null);
const dragMovedRef = useRef(false);
const [selectedMap, setSelectedMap] = useState<MapPoint | null>(null);
const [selectedPlayers, setSelectedPlayers] = useState<PlayerChip[]>([]);
const [loadingPlayers, setLoadingPlayers] = useState(false);
useEffect(() => {
fetch(`/api/map-population?limit=${FETCH_LIMIT}`)
.then((res) => res.json())
.then((data) => {
const pts: MapPoint[] = data.points ?? [];
setPoints(pts);
const startIdx = Math.max(0, pts.length - DEFAULT_VISIBLE);
setViewStartIdx(startIdx);
setViewEndIdx(pts.length);
const latest = pts[pts.length - 1];
if (latest) loadPlayersFor(latest);
})
.finally(() => setLoading(false));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
async function loadPlayersFor(point: MapPoint) {
setSelectedMap(point);
setLoadingPlayers(true);
try {
const res = await fetch(`/api/map-population/players?mapId=${point.map_id}`);
const data = await res.json();
setSelectedPlayers(data.players ?? []);
} catch {
setSelectedPlayers([]);
} finally {
setLoadingPlayers(false);
}
}
const visiblePoints = points.slice(viewStartIdx, viewEndIdx);
const plotWidth = Math.max(0, width - PAD_LEFT - PAD_RIGHT);
const plotHeight = HEIGHT - PAD_TOP - PAD_BOTTOM;
const maxValue = Math.max(1, ...visiblePoints.map((p) => p.players_online_at_start));
const axisMax = Math.ceil((maxValue * 1.15) / 5) * 5 || 5;
const slotWidth = visiblePoints.length > 0 ? plotWidth / visiblePoints.length : 0;
const barWidth = Math.max(2, slotWidth * 0.6);
function barX(i: number) {
return PAD_LEFT + i * slotWidth + (slotWidth - barWidth) / 2;
}
function barY(value: number) {
const h = (value / axisMax) * plotHeight;
return PAD_TOP + (plotHeight - h);
}
function barHeight(value: number) {
return Math.max(value > 0 ? 2 : 0, (value / axisMax) * plotHeight);
}
function indexForX(x: number): number {
if (slotWidth === 0) return 0;
return Math.max(0, Math.min(visiblePoints.length - 1, Math.floor((x - PAD_LEFT) / slotWidth)));
}
function handleMouseDown(e: React.MouseEvent<SVGSVGElement>) {
const rect = e.currentTarget.getBoundingClientRect();
dragStartRef.current = { x: e.clientX - rect.left, startIdx: viewStartIdx, endIdx: viewEndIdx };
dragMovedRef.current = false;
}
function handleMouseMove(e: React.MouseEvent<SVGSVGElement>) {
const rect = e.currentTarget.getBoundingClientRect();
const x = e.clientX - rect.left;
if (dragStartRef.current) {
const dx = x - dragStartRef.current.x;
if (Math.abs(dx) > 3) dragMovedRef.current = true;
if (dragMovedRef.current && slotWidth > 0) {
const shiftBars = -Math.round(dx / slotWidth);
const windowSize = dragStartRef.current.endIdx - dragStartRef.current.startIdx;
let newStart = dragStartRef.current.startIdx + shiftBars;
let newEnd = dragStartRef.current.endIdx + shiftBars;
if (newStart < 0) {
newStart = 0;
newEnd = windowSize;
}
if (newEnd > points.length) {
newEnd = points.length;
newStart = newEnd - windowSize;
}
setViewStartIdx(newStart);
setViewEndIdx(newEnd);
}
} else {
setHoverIndex(indexForX(x));
}
}
function handleMouseUp() {
dragStartRef.current = null;
}
function handleWheel(e: React.WheelEvent<SVGSVGElement>) {
e.preventDefault();
if (points.length === 0) return;
const rect = e.currentTarget.getBoundingClientRect();
const x = e.clientX - rect.left;
const anchorIdxInView = indexForX(x);
const anchorAbsIdx = viewStartIdx + anchorIdxInView;
const currentSize = viewEndIdx - viewStartIdx;
const zoomFactor = e.deltaY > 0 ? 1.2 : 1 / 1.2; // scroll down = zoom out (more bars), up = zoom in (fewer bars)
let newSize = Math.round(currentSize * zoomFactor);
newSize = Math.max(MIN_VISIBLE, Math.min(points.length, newSize));
const ratio = currentSize > 0 ? (anchorAbsIdx - viewStartIdx) / currentSize : 0.5;
let newStart = Math.round(anchorAbsIdx - ratio * newSize);
let newEnd = newStart + newSize;
if (newStart < 0) {
newStart = 0;
newEnd = newSize;
}
if (newEnd > points.length) {
newEnd = points.length;
newStart = newEnd - newSize;
}
setViewStartIdx(newStart);
setViewEndIdx(newEnd);
}
function resetZoom() {
const startIdx = Math.max(0, points.length - DEFAULT_VISIBLE);
setViewStartIdx(startIdx);
setViewEndIdx(points.length);
}
const isZoomed = viewStartIdx > 0 || viewEndIdx < points.length;
const yTicks = [0, Math.round(axisMax / 2), axisMax];
return (
<div className="panel p-6">
<div className="mb-6">
<div className="stat-label mb-1">Population by map</div>
<h2 className="text-lg text-ink">
{visiblePoints.length} of {points.length} maps
</h2>
</div>
<div className="flex items-center justify-between gap-4 mb-2">
<div className="flex items-center gap-1.5 text-xs text-accent">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M9 9l6 6M9 15l6-6" strokeLinecap="round" />
<circle cx="12" cy="12" r="9" />
</svg>
Scroll to zoom, drag to pan, click a bar for who was online
</div>
{isZoomed && (
<button onClick={resetZoom} className="text-xs text-ink-muted hover:text-accent transition-colors underline">
Reset zoom
</button>
)}
</div>
<div ref={containerRef} className="h-72 w-full relative">
{loading ? (
<div className="h-full flex items-center justify-center text-ink-faint text-sm font-mono">
loading
</div>
) : points.length === 0 ? (
<div className="h-full flex items-center justify-center text-ink-muted text-sm">
No map history recorded yet.
</div>
) : (
<>
<svg
width={width}
height={HEIGHT}
style={{ cursor: 'crosshair', touchAction: 'none' }}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={() => {
setHoverIndex(null);
dragStartRef.current = null;
}}
onWheel={handleWheel}
onClick={() => {
if (dragMovedRef.current) {
dragMovedRef.current = false;
return;
}
if (hoverIndex != null && visiblePoints[hoverIndex]) {
loadPlayersFor(visiblePoints[hoverIndex]);
}
}}
>
{yTicks.map((v) => {
const y = barY(v);
return (
<g key={v}>
<line x1={PAD_LEFT} y1={y} x2={width - PAD_RIGHT} y2={y} stroke="#2A332E" />
<text x={PAD_LEFT - 6} y={y + 3} textAnchor="end" fontSize={11} fill="#8A9691">
{v}
</text>
</g>
);
})}
{visiblePoints.map((p, i) => (
<rect
key={p.map_id}
x={barX(i)}
y={barY(p.players_online_at_start)}
width={barWidth}
height={barHeight(p.players_online_at_start)}
rx={2}
fill="#3FD37A"
fillOpacity={hoverIndex === i ? 1 : 0.85}
/>
))}
{visiblePoints.map((p, i) => {
const showEvery = Math.max(1, Math.ceil((visiblePoints.length * 70) / plotWidth));
if (i !== 0 && i !== visiblePoints.length - 1 && i % showEvery !== 0) return null;
const label = p.map_name.length > 12 ? `${p.map_name.slice(0, 11)}` : p.map_name;
return (
<text
key={p.map_id}
x={barX(i) + barWidth / 2}
y={HEIGHT - PAD_BOTTOM + 14}
textAnchor="middle"
fontSize={9}
fill="#8A9691"
>
{label}
</text>
);
})}
<line
x1={PAD_LEFT}
y1={PAD_TOP + plotHeight}
x2={width - PAD_RIGHT}
y2={PAD_TOP + plotHeight}
stroke="#4C5652"
/>
</svg>
{hoverIndex != null && visiblePoints[hoverIndex] && (
<div
className="absolute pointer-events-none"
style={{
left: Math.min(width - 180, Math.max(0, barX(hoverIndex) - 60)),
top: barY(visiblePoints[hoverIndex].players_online_at_start) - 60,
background: '#121715',
border: '1px solid #1F2723',
borderRadius: 6,
fontSize: 12,
padding: '8px 10px',
whiteSpace: 'nowrap',
}}
>
<div style={{ color: '#E4EBE7' }}>{visiblePoints[hoverIndex].map_name}</div>
<div style={{ color: '#8A9691' }}>
{new Date(visiblePoints[hoverIndex].map_start_dt.replace(' ', 'T')).toLocaleString()}
</div>
<div style={{ color: '#3FD37A' }}>
{visiblePoints[hoverIndex].players_online_at_start} players at map start
</div>
</div>
)}
</>
)}
</div>
{points.length > 0 && (
<div className="mt-2 text-xs text-ink-faint">
Visit the{' '}
<Link href="/maps" className="text-accent hover:underline">
maps page
</Link>{' '}
for full detail (votes, prev/next map).
</div>
)}
{selectedMap && (
<div className="mt-4 pt-4 border-t border-base-border">
<div className="stat-label mb-3">
Players during{' '}
<Link href={`/maps/period/${selectedMap.map_id}`} className="text-accent hover:underline">
{selectedMap.map_name}
</Link>{' '}
({selectedPlayers.length} player{selectedPlayers.length === 1 ? '' : 's'})
</div>
<PlayerChipList players={selectedPlayers} loading={loadingPlayers} />
</div>
)}
</div>
);
}
@@ -0,0 +1,62 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
interface MapSummary {
map_name: string;
times_played: number;
last_played: string;
}
export default function MapSearch() {
const [q, setQ] = useState('');
const [maps, setMaps] = useState<MapSummary[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const handle = setTimeout(async () => {
setLoading(true);
const res = await fetch(`/api/maps?q=${encodeURIComponent(q)}`);
const data = await res.json();
setMaps(data.maps);
setLoading(false);
}, 250);
return () => clearTimeout(handle);
}, [q]);
return (
<div className="space-y-4">
<input
type="text"
placeholder="Search by map name…"
value={q}
onChange={(e) => setQ(e.target.value)}
className="w-full bg-base-panel border border-base-border rounded-lg px-4 py-3 text-ink placeholder:text-ink-faint focus:border-accent/50 transition-colors"
/>
<div className="panel divide-y divide-base-border">
{loading ? (
<div className="p-4 text-sm text-ink-faint font-mono">loading</div>
) : maps.length === 0 ? (
<div className="p-4 text-sm text-ink-muted">No maps found.</div>
) : (
maps.map((m) => (
<Link
key={m.map_name}
href={`/maps/${encodeURIComponent(m.map_name)}`}
className="flex items-center justify-between px-4 py-3 hover:bg-base transition-colors"
>
<div className="text-ink text-sm">{m.map_name}</div>
<div className="flex items-center gap-4 text-xs text-ink-faint font-mono">
<span>{m.times_played}× played</span>
<span>last {new Date(m.last_played.replace(' ', 'T')).toLocaleDateString()}</span>
</div>
</Link>
))
)}
</div>
</div>
);
}
@@ -0,0 +1,41 @@
'use client';
import Link from 'next/link';
interface PlayerChip {
steamid: string;
name: string;
avatarUrl: string;
}
export default function PlayerChipList({
players,
loading,
}: {
players: PlayerChip[];
loading: boolean;
}) {
if (loading) {
return <div className="text-sm text-ink-faint font-mono">loading players</div>;
}
if (players.length === 0) {
return <div className="text-sm text-ink-muted">No players recorded at this point.</div>;
}
return (
<div className="flex flex-wrap gap-3">
{players.map((p) => (
<Link
key={p.steamid}
href={`/players/${encodeURIComponent(p.steamid)}`}
className="flex items-center gap-2 bg-base border border-base-border rounded-full pl-1 pr-3 py-1 hover:border-accent/50 transition-colors"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={p.avatarUrl} alt="" className="w-6 h-6 rounded-full object-cover" />
<span className="text-sm text-ink">{p.name}</span>
</Link>
))}
</div>
);
}
@@ -0,0 +1,88 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { countryCodeToFlag, formatMinutes } from '@/lib/steam';
interface Player {
steamid: string;
current_name: string;
current_country: string | null;
matched_name: string | null;
total_minutes: number | null;
}
export default function PlayerSearch() {
const [q, setQ] = useState('');
const [sort, setSort] = useState<'recent' | 'name' | 'playtime'>('recent');
const [players, setPlayers] = useState<Player[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const handle = setTimeout(async () => {
setLoading(true);
const params = new URLSearchParams({ q, sort });
const res = await fetch(`/api/players?${params.toString()}`);
const data = await res.json();
setPlayers(data.players);
setLoading(false);
}, 250); // debounce so we're not hitting the DB on every keystroke
return () => clearTimeout(handle);
}, [q, sort]);
return (
<div className="space-y-4">
<div className="flex gap-3">
<input
type="text"
placeholder="Search by name or SteamID…"
value={q}
onChange={(e) => setQ(e.target.value)}
className="flex-1 bg-base-panel border border-base-border rounded-lg px-4 py-3 text-ink placeholder:text-ink-faint focus:border-accent/50 transition-colors"
/>
<select
value={sort}
onChange={(e) => setSort(e.target.value as typeof sort)}
className="bg-base-panel border border-base-border rounded-lg px-3 text-sm text-ink-muted focus:border-accent/50 transition-colors"
>
<option value="recent">Recently active</option>
<option value="name">Name (AZ)</option>
<option value="playtime">Highest playtime</option>
</select>
</div>
<div className="panel divide-y divide-base-border">
{loading ? (
<div className="p-4 text-sm text-ink-faint font-mono">loading</div>
) : players.length === 0 ? (
<div className="p-4 text-sm text-ink-muted">No players found.</div>
) : (
players.map((p) => (
<Link
key={p.steamid}
href={`/players/${encodeURIComponent(p.steamid)}`}
className="flex items-center justify-between px-4 py-3 hover:bg-base transition-colors"
>
<div className="flex items-center gap-3">
<span className="text-lg" aria-hidden>
{countryCodeToFlag(p.current_country)}
</span>
<div>
<div className="text-ink text-sm">{p.current_name}</div>
{p.matched_name && p.matched_name !== p.current_name && (
<div className="text-xs text-ink-faint">previously: {p.matched_name}</div>
)}
</div>
</div>
<div className="flex items-center gap-4">
<span className="mono text-ink-faint text-xs">{formatMinutes(p.total_minutes)}</span>
<span className="mono text-ink-faint">{p.steamid}</span>
</div>
</Link>
))
)}
</div>
</div>
);
}
@@ -0,0 +1,474 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { useContainerWidth } from '@/lib/useContainerWidth';
import PlayerChipList from './PlayerChipList';
interface Point {
t: string;
tEpoch: number;
players: number;
mapName: string | null;
}
interface MapBoundary {
mapId: number;
mapName: string;
tEpoch: number;
}
interface PlayerChip {
steamid: string;
name: string;
avatarUrl: string;
}
const HEIGHT = 320;
const PAD_LEFT = 36;
const PAD_RIGHT = 8;
const PAD_TOP = 8;
const PAD_BOTTOM = 30;
const Y_MAX = 64; // game's hard player cap
const MIN_ZOOM_SPAN_MS = 5 * 60_000; // don't let scroll-zoom go tighter than 5 minutes
function toLocalInputValue(d: Date): string {
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
export default function PopulationChart() {
const { ref: containerRef, width } = useContainerWidth<HTMLDivElement>();
const now = new Date();
const dayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);
const [start, setStart] = useState(toLocalInputValue(dayAgo));
const [end, setEnd] = useState(toLocalInputValue(now));
const [points, setPoints] = useState<Point[]>([]);
const [mapBoundaries, setMapBoundaries] = useState<MapBoundary[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Full bounds of whatever's currently fetched (via the calendar Start/End)
const [dataMinEpoch, setDataMinEpoch] = useState(0);
const [dataMaxEpoch, setDataMaxEpoch] = useState(0);
// Currently VISIBLE window — what scroll-to-zoom / drag-to-pan adjusts.
// Starts equal to the full fetched range and narrows/shifts from there.
const [viewStart, setViewStart] = useState(0);
const [viewEnd, setViewEnd] = useState(0);
const dragStartRef = useRef<{ x: number; viewStart: number; viewEnd: number } | null>(null);
const dragMovedRef = useRef(false);
const [hoverPoint, setHoverPoint] = useState<Point | null>(null);
const [hoverX, setHoverX] = useState<number | null>(null);
// Currently online — authoritative, always-live, independent of the chart's date range.
const [currentPlayers, setCurrentPlayers] = useState<PlayerChip[]>([]);
const [currentCount, setCurrentCount] = useState<number | null>(null);
const [loadingCurrent, setLoadingCurrent] = useState(true);
const [currentUpdatedAt, setCurrentUpdatedAt] = useState<number | null>(null);
const [secondsAgo, setSecondsAgo] = useState(0);
// A specific HISTORICAL point clicked on the chart.
const [selectedPoint, setSelectedPoint] = useState<Point | null>(null);
const [selectedPlayers, setSelectedPlayers] = useState<PlayerChip[]>([]);
const [loadingSelected, setLoadingSelected] = useState(false);
async function load() {
setLoading(true);
setError(null);
try {
const params = new URLSearchParams({
start: new Date(start).toISOString(),
end: new Date(end).toISOString(),
});
const res = await fetch(`/api/population?${params.toString()}`);
if (!res.ok) throw new Error((await res.json()).error ?? 'failed to load');
const data = await res.json();
const pts: Point[] = (data.points as any[]).map((p) => ({ ...p, tEpoch: new Date(p.t).getTime() }));
const bounds: MapBoundary[] = (data.mapBoundaries as any[]).map((b) => ({ ...b, tEpoch: new Date(b.t).getTime() }));
setPoints(pts);
setMapBoundaries(bounds);
if (pts.length > 0) {
const minE = pts[0].tEpoch;
const maxE = pts[pts.length - 1].tEpoch;
setDataMinEpoch(minE);
setDataMaxEpoch(maxE);
setViewStart(minE);
setViewEnd(maxE);
}
} catch (e: any) {
setError(e.message ?? 'something went wrong');
} finally {
setLoading(false);
}
}
async function loadCurrentPlayers() {
setLoadingCurrent(true);
try {
const res = await fetch('/api/population/current');
const data = await res.json();
setCurrentPlayers(data.players ?? []);
setCurrentCount(data.count ?? 0);
setCurrentUpdatedAt(Date.now());
} catch {
setCurrentPlayers([]);
setCurrentCount(null);
} finally {
setLoadingCurrent(false);
}
}
useEffect(() => {
load();
loadCurrentPlayers();
const poll = setInterval(loadCurrentPlayers, 30_000);
return () => clearInterval(poll);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
const tick = setInterval(() => {
if (currentUpdatedAt) setSecondsAgo(Math.round((Date.now() - currentUpdatedAt) / 1000));
}, 1000);
return () => clearInterval(tick);
}, [currentUpdatedAt]);
async function loadPlayersAt(point: Point) {
setSelectedPoint(point);
setLoadingSelected(true);
try {
const res = await fetch(`/api/population/players?at=${encodeURIComponent(point.t)}`);
const data = await res.json();
setSelectedPlayers(data.players ?? []);
} catch {
setSelectedPlayers([]);
} finally {
setLoadingSelected(false);
}
}
const plotWidth = Math.max(0, width - PAD_LEFT - PAD_RIGHT);
const plotHeight = HEIGHT - PAD_TOP - PAD_BOTTOM;
function xForEpoch(epoch: number): number {
if (viewEnd === viewStart) return PAD_LEFT;
return PAD_LEFT + ((epoch - viewStart) / (viewEnd - viewStart)) * plotWidth;
}
function yForValue(v: number): number {
return PAD_TOP + (1 - Math.min(v, Y_MAX) / Y_MAX) * plotHeight;
}
function epochForX(x: number): number {
if (plotWidth === 0) return viewStart;
return viewStart + ((x - PAD_LEFT) / plotWidth) * (viewEnd - viewStart);
}
// Render a couple of points beyond each edge of the visible window too,
// so the line doesn't visibly truncate right at the viewport boundary.
let firstIdx = points.findIndex((p) => p.tEpoch >= viewStart);
if (firstIdx === -1) firstIdx = points.length - 1;
let lastIdx = -1;
for (let i = points.length - 1; i >= 0; i--) {
if (points[i].tEpoch <= viewEnd) {
lastIdx = i;
break;
}
}
const renderStart = Math.max(0, firstIdx - 1);
const renderEnd = Math.min(points.length - 1, Math.max(lastIdx, firstIdx) + 1);
const renderPoints = points.length > 0 ? points.slice(renderStart, renderEnd + 1) : [];
const linePath = renderPoints
.map((p, i) => `${i === 0 ? 'M' : 'L'} ${xForEpoch(p.tEpoch).toFixed(1)} ${yForValue(p.players).toFixed(1)}`)
.join(' ');
const visibleBoundaries = mapBoundaries.filter((b) => b.tEpoch >= viewStart && b.tEpoch <= viewEnd);
function findNearestPoint(x: number): Point | null {
if (renderPoints.length === 0) return null;
const targetEpoch = epochForX(x);
let nearest = renderPoints[0];
let bestDist = Math.abs(renderPoints[0].tEpoch - targetEpoch);
for (const p of renderPoints) {
const d = Math.abs(p.tEpoch - targetEpoch);
if (d < bestDist) {
bestDist = d;
nearest = p;
}
}
return nearest;
}
function handleMouseDown(e: React.MouseEvent<SVGSVGElement>) {
const rect = e.currentTarget.getBoundingClientRect();
dragStartRef.current = { x: e.clientX - rect.left, viewStart, viewEnd };
dragMovedRef.current = false;
}
function handleMouseMove(e: React.MouseEvent<SVGSVGElement>) {
const rect = e.currentTarget.getBoundingClientRect();
const x = e.clientX - rect.left;
setHoverX(x);
setHoverPoint(findNearestPoint(x));
if (dragStartRef.current) {
const dx = x - dragStartRef.current.x;
if (Math.abs(dx) > 3) dragMovedRef.current = true;
if (dragMovedRef.current && plotWidth > 0) {
const span = dragStartRef.current.viewEnd - dragStartRef.current.viewStart;
const epochDelta = -(dx / plotWidth) * span;
let newStart = dragStartRef.current.viewStart + epochDelta;
let newEnd = dragStartRef.current.viewEnd + epochDelta;
if (newStart < dataMinEpoch) {
newStart = dataMinEpoch;
newEnd = newStart + span;
}
if (newEnd > dataMaxEpoch) {
newEnd = dataMaxEpoch;
newStart = newEnd - span;
}
setViewStart(newStart);
setViewEnd(newEnd);
}
}
}
function handleMouseUp() {
dragStartRef.current = null;
}
function handleMouseLeave() {
setHoverPoint(null);
setHoverX(null);
dragStartRef.current = null;
}
function handleClick(e: React.MouseEvent<SVGSVGElement>) {
if (dragMovedRef.current) {
dragMovedRef.current = false;
return; // was a drag, not a click-to-select
}
const rect = e.currentTarget.getBoundingClientRect();
const p = findNearestPoint(e.clientX - rect.left);
if (p) loadPlayersAt(p);
}
function handleWheel(e: React.WheelEvent<SVGSVGElement>) {
e.preventDefault();
if (plotWidth === 0) return;
const rect = e.currentTarget.getBoundingClientRect();
const x = e.clientX - rect.left;
const anchorEpoch = epochForX(x);
const zoomFactor = e.deltaY > 0 ? 1.15 : 1 / 1.15; // scroll down = zoom out, up = zoom in
const fullSpan = dataMaxEpoch - dataMinEpoch || 1;
let newSpan = (viewEnd - viewStart) * zoomFactor;
newSpan = Math.min(newSpan, fullSpan);
newSpan = Math.max(newSpan, MIN_ZOOM_SPAN_MS);
const ratio = (anchorEpoch - viewStart) / ((viewEnd - viewStart) || 1);
let newStart = anchorEpoch - ratio * newSpan;
let newEnd = newStart + newSpan;
if (newStart < dataMinEpoch) {
newStart = dataMinEpoch;
newEnd = newStart + newSpan;
}
if (newEnd > dataMaxEpoch) {
newEnd = dataMaxEpoch;
newStart = newEnd - newSpan;
}
setViewStart(newStart);
setViewEnd(newEnd);
}
function resetZoom() {
setViewStart(dataMinEpoch);
setViewEnd(dataMaxEpoch);
}
const isZoomed = viewStart > dataMinEpoch + 1000 || viewEnd < dataMaxEpoch - 1000;
const yTicks = [0, 16, 32, 48, 64];
const xTickCount = 5;
const xTicks =
viewEnd > viewStart
? Array.from({ length: xTickCount }, (_, i) => viewStart + (i / (xTickCount - 1)) * (viewEnd - viewStart))
: [];
return (
<div className="panel p-6">
{/* Currently online — always visible, always live, not tied to the date range below */}
<div className="mb-6 pb-6 border-b border-base-border">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-accent shadow-[0_0_6px_theme(colors.accent.DEFAULT)]" />
<div className="stat-label">
Currently online{currentCount != null ? `${currentCount} player${currentCount === 1 ? '' : 's'}` : ''}
</div>
</div>
{currentUpdatedAt && <div className="text-xs text-ink-faint font-mono">updated {secondsAgo}s ago</div>}
</div>
<PlayerChipList players={currentPlayers} loading={loadingCurrent} />
</div>
<div className="flex flex-wrap items-end justify-between gap-4 mb-4">
<div>
<div className="stat-label mb-1">Concurrent players</div>
<h2 className="text-lg text-ink">Population over time</h2>
</div>
<form
className="flex flex-wrap items-center gap-3"
onSubmit={(e) => {
e.preventDefault();
load();
}}
>
<label className="flex flex-col text-xs text-ink-muted gap-1">
Start
<input
type="datetime-local"
value={start}
onChange={(e) => setStart(e.target.value)}
className="bg-base border border-base-border rounded px-2 py-1 text-ink text-sm font-mono"
/>
</label>
<label className="flex flex-col text-xs text-ink-muted gap-1">
End
<input
type="datetime-local"
value={end}
onChange={(e) => setEnd(e.target.value)}
className="bg-base border border-base-border rounded px-2 py-1 text-ink text-sm font-mono"
/>
</label>
<button
type="submit"
className="bg-accent-dim hover:bg-accent hover:text-base text-accent border border-accent/40 rounded px-4 py-2 text-sm font-medium transition-colors self-end"
>
Update
</button>
</form>
</div>
{error && <div className="text-sm text-red-400 mb-4">{error}</div>}
<div className="flex items-center justify-between gap-4 mb-2">
<div className="flex items-center gap-1.5 text-xs text-accent">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M9 9l6 6M9 15l6-6" strokeLinecap="round" />
<circle cx="12" cy="12" r="9" />
</svg>
Scroll to zoom, drag to pan, click a point for who was online
</div>
{isZoomed && (
<button onClick={resetZoom} className="text-xs text-ink-muted hover:text-accent transition-colors underline">
Reset zoom
</button>
)}
</div>
<div ref={containerRef} className="w-full relative" style={{ height: HEIGHT }}>
{loading ? (
<div className="h-full flex items-center justify-center text-ink-faint text-sm font-mono">loading</div>
) : points.length === 0 ? (
<div className="h-full flex items-center justify-center text-ink-muted text-sm">No data in this range.</div>
) : (
<svg
width={width}
height={HEIGHT}
style={{ cursor: 'crosshair', touchAction: 'none' }}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseLeave}
onClick={handleClick}
onWheel={handleWheel}
>
{yTicks.map((v) => {
const y = yForValue(v);
return (
<g key={v}>
<line x1={PAD_LEFT} y1={y} x2={width - PAD_RIGHT} y2={y} stroke="#2A332E" />
<text x={PAD_LEFT - 6} y={y + 3} textAnchor="end" fontSize={11} fill="#8A9691">
{v}
</text>
</g>
);
})}
{visibleBoundaries.map((b) => (
<line
key={b.mapId}
x1={xForEpoch(b.tEpoch)}
y1={PAD_TOP}
x2={xForEpoch(b.tEpoch)}
y2={PAD_TOP + plotHeight}
stroke="#4C5652"
strokeDasharray="3 3"
/>
))}
{xTicks.map((epoch, i) => (
<text key={i} x={xForEpoch(epoch)} y={HEIGHT - PAD_BOTTOM + 16} textAnchor="middle" fontSize={10} fill="#8A9691">
{new Date(epoch).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}
</text>
))}
<line x1={PAD_LEFT} y1={PAD_TOP + plotHeight} x2={width - PAD_RIGHT} y2={PAD_TOP + plotHeight} stroke="#4C5652" />
<path d={linePath} fill="none" stroke="#3FD37A" strokeWidth={2} />
{hoverPoint && (
<circle
cx={xForEpoch(hoverPoint.tEpoch)}
cy={yForValue(hoverPoint.players)}
r={4}
fill="#3FD37A"
stroke="#0B0F0E"
strokeWidth={1.5}
/>
)}
</svg>
)}
{hoverPoint && hoverX != null && !loading && (
<div
className="absolute pointer-events-none"
style={{
left: Math.min(Math.max(width, 200) - 190, Math.max(0, hoverX + 12)),
top: Math.max(0, yForValue(hoverPoint.players) - 70),
background: '#121715',
border: '1px solid #1F2723',
borderRadius: 6,
fontSize: 12,
padding: '8px 10px',
whiteSpace: 'nowrap',
}}
>
<div style={{ color: '#8A9691' }}>{new Date(hoverPoint.t).toLocaleString()}</div>
<div style={{ color: '#3FD37A', marginTop: 2 }}>{hoverPoint.players} players</div>
{hoverPoint.mapName && <div style={{ color: '#E4EBE7', marginTop: 2 }}>{hoverPoint.mapName}</div>}
</div>
)}
</div>
{selectedPoint && (
<div className="mt-4 pt-4 border-t border-base-border">
<div className="stat-label mb-3">
Online at {new Date(selectedPoint.t).toLocaleString()}
{selectedPoint.mapName && (
<>
{' '}
playing <span className="text-ink">{selectedPoint.mapName}</span>
</>
)}{' '}
({selectedPoint.players} player{selectedPoint.players === 1 ? '' : 's'} from historical data, may lag slightly)
</div>
<PlayerChipList players={selectedPlayers} loading={loadingSelected} />
</div>
)}
</div>
);
}
@@ -0,0 +1,21 @@
module.exports = {
apps: [
{
name: 'unloze-playtime-display',
cwd: __dirname,
script: 'npm',
args: 'start',
env: {
NODE_ENV: 'production',
PORT: 3547,
// No TZ var needed here — all DB datetime handling goes through
// lib/timezone.ts, which uses explicit Intl-based conversion
// hardcoded to Europe/Berlin (matching MySQL's configured
// timezone), independent of the Node process's own environment.
},
autorestart: true,
max_restarts: 10,
watch: false,
},
],
};
@@ -0,0 +1,14 @@
import { parseDbDateTime } from './timezone';
// Parses a MySQL DATETIME string correctly regardless of the Node
// process's own timezone — see lib/timezone.ts.
export function parseDbDate(s: string): Date {
return parseDbDateTime(s);
}
export function diffMinutes(startStr: string, endStr: string | null): number | null {
if (!endStr) return null;
const start = parseDbDate(startStr);
const end = parseDbDate(endStr);
return Math.round((end.getTime() - start.getTime()) / 60000);
}
@@ -0,0 +1,28 @@
import mysql from 'mysql2/promise';
// A single pooled connection reused across all API routes. mysql2's pool
// handles reconnects/idle connections itself — no need to manually manage
// connect/disconnect per request.
let pool: mysql.Pool | null = null;
export function getPool(): mysql.Pool {
if (!pool) {
pool = mysql.createPool({
host: process.env.DB_HOST,
port: Number(process.env.DB_PORT ?? 3306),
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME ?? 'unloze_playtimestats',
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0,
dateStrings: true, // return DATETIME columns as 'YYYY-MM-DD HH:MM:SS' strings, not JS Date objects with TZ surprises
});
}
return pool;
}
export async function query<T = any>(sql: string, params: any[] = []): Promise<T[]> {
const [rows] = await getPool().query(sql, params);
return rows as T[];
}
@@ -0,0 +1,360 @@
import { query } from '@/lib/db';
// map_end_dt was dropped from the schema — it was redundant with (and
// occasionally got out of sync with) the next row's map_start_dt, since a
// map's end is definitionally when the next one begins. This subquery
// derives the same information: NULL for the currently-running map (no
// later row exists yet), otherwise the next map's start time.
const NEXT_MAP_START = (alias: string) =>
`(SELECT MIN(m2.map_start_dt) FROM playtime_display_map_history m2 WHERE m2.map_start_dt > ${alias}.map_start_dt)`;
// Tickrate changes sometimes require a very quick map restart, which
// produces a second map_history row for the same map seconds later. Treat
// anything under 2 minutes as such an artifact rather than a real play —
// applied everywhere a map_history row is being counted/listed as an
// actual occurrence. The currently-running map (no next row yet) is always
// included since we can't know its eventual duration yet.
const REAL_MAP_PERIOD = `(
${NEXT_MAP_START('m')} IS NULL
OR TIMESTAMPDIFF(MINUTE, m.map_start_dt, ${NEXT_MAP_START('m')}) >= 2
)`;
export interface PlayerRecord {
steamid: string;
current_name: string;
current_country: string | null;
player_tier: number;
first_seen: string;
last_seen: string;
}
export interface NameHistoryEntry {
player_name: string;
first_seen: string;
last_seen: string;
}
export interface SessionEntry {
session_id: number;
session_start_dt: string;
session_end_dt: string | null;
session_active_minutes: number | null;
maps_played: string | null; // comma-separated map names overlapping this session
}
export async function getPlayer(steamid: string): Promise<PlayerRecord | null> {
const rows = await query<PlayerRecord>(
`SELECT steamid, current_name, current_country, player_tier, first_seen, last_seen
FROM playtime_display_players
WHERE steamid = ?`,
[steamid],
);
return rows[0] ?? null;
}
export async function getNameHistory(steamid: string): Promise<NameHistoryEntry[]> {
return query<NameHistoryEntry>(
`SELECT player_name, first_seen, last_seen
FROM playtime_display_name_history
WHERE steamid = ?
ORDER BY last_seen DESC`,
[steamid],
);
}
export async function getSessions(steamid: string, limit = 100): Promise<SessionEntry[]> {
// The maps_played subquery finds every map_history period that overlaps
// this session's [start, end] window (same overlap logic used elsewhere
// in the app), and concatenates the names. A session can span more than
// one map if the player stayed connected through a map change.
return query<SessionEntry>(
`SELECT
s.session_id,
s.session_start_dt,
s.session_end_dt,
s.session_active_minutes,
(
SELECT GROUP_CONCAT(DISTINCT m.map_name ORDER BY m.map_start_dt SEPARATOR ', ')
FROM playtime_display_map_history m
WHERE m.map_start_dt < COALESCE(s.session_end_dt, NOW())
AND COALESCE(${NEXT_MAP_START('m')}, NOW()) > s.session_start_dt
AND ${REAL_MAP_PERIOD}
) AS maps_played
FROM playtime_display_sessions s
WHERE s.steamid = ?
ORDER BY s.session_start_dt DESC
LIMIT ?`,
[steamid, limit],
);
}
export interface MapPopulationPoint {
map_id: number;
map_name: string;
map_start_dt: string;
players_online_at_start: number;
}
export async function getMapPopulationSeries(limit = 40): Promise<MapPopulationPoint[]> {
// Player count at the moment each map period started — one data point per
// map, letting the frontend chart whether population trends up or down
// across the rotation. Fetched most-recent-first (for the LIMIT), then
// the caller should reverse it back to chronological order for display.
const rows = await query<MapPopulationPoint>(
`SELECT m.map_id, m.map_name, m.map_start_dt,
(SELECT COUNT(*) FROM playtime_display_sessions s
WHERE s.session_start_dt < m.map_start_dt
AND (s.session_end_dt IS NULL OR s.session_end_dt > m.map_start_dt)
) AS players_online_at_start
FROM playtime_display_map_history m
WHERE ${REAL_MAP_PERIOD}
ORDER BY m.map_start_dt DESC
LIMIT ?`,
[limit],
);
return rows.reverse();
}
export interface CountrySummary {
current_country: string;
player_count: number;
}
export async function getCountriesSummary(): Promise<CountrySummary[]> {
// Players with no resolved country are excluded — there's no meaningful
// "unknown" country page to select.
return query<CountrySummary>(
`SELECT current_country, COUNT(*) AS player_count
FROM playtime_display_players
WHERE current_country IS NOT NULL
GROUP BY current_country
ORDER BY player_count DESC, current_country ASC`,
);
}
export interface CountryPlayer {
steamid: string;
current_name: string;
last_seen: string;
total_minutes: number | null;
}
export async function getPlayersByCountry(
countryCode: string,
sort: 'playtime' | 'recent',
): Promise<CountryPlayer[]> {
const orderBy = sort === 'playtime' ? 'total_minutes DESC' : 'p.last_seen DESC';
return query<CountryPlayer>(
`SELECT p.steamid, p.current_name, p.last_seen,
(SELECT COALESCE(session_end_playtime_minutes, session_start_playtime_minutes)
FROM playtime_display_sessions s
WHERE s.steamid = p.steamid
ORDER BY s.session_id DESC
LIMIT 1
) AS total_minutes
FROM playtime_display_players p
WHERE p.current_country = ?
ORDER BY ${orderBy}
LIMIT 200`,
[countryCode],
);
}
export interface MapSummary {
map_name: string;
times_played: number;
last_played: string;
}
export interface MapPeriod {
map_id: number;
map_name: string;
map_start_dt: string;
map_end_dt: string | null;
}
export interface MapPresentPlayer {
steamid: string;
current_name: string;
current_country: string | null;
session_start_dt: string;
session_end_dt: string | null;
}
export interface MapVoteRow {
vote_id: number;
vote_time: string;
result: string | null;
steamid: string;
current_name: string;
vote_choice: string;
vote_weight: number;
}
export async function searchMapNames(q: string, limit = 50): Promise<MapSummary[]> {
const like = `%${q}%`;
return query<MapSummary>(
`SELECT m.map_name, COUNT(*) AS times_played, MAX(m.map_start_dt) AS last_played
FROM playtime_display_map_history m
WHERE m.map_name LIKE ?
AND ${REAL_MAP_PERIOD}
GROUP BY m.map_name
ORDER BY last_played DESC
LIMIT ?`,
[like, limit],
);
}
export async function getMapPeriods(mapName: string): Promise<MapPeriod[]> {
return query<MapPeriod>(
`SELECT m.map_id, m.map_name, m.map_start_dt, ${NEXT_MAP_START('m')} AS map_end_dt
FROM playtime_display_map_history m
WHERE m.map_name = ?
AND ${REAL_MAP_PERIOD}
ORDER BY m.map_start_dt DESC`,
[mapName],
);
}
export async function getMapPeriod(mapId: number): Promise<MapPeriod | null> {
const rows = await query<MapPeriod>(
`SELECT m.map_id, m.map_name, m.map_start_dt, ${NEXT_MAP_START('m')} AS map_end_dt
FROM playtime_display_map_history m
WHERE m.map_id = ?`,
[mapId],
);
return rows[0] ?? null;
}
export async function getPlayersPresentDuringMap(mapId: number): Promise<MapPresentPlayer[]> {
return query<MapPresentPlayer>(
`SELECT s.steamid, COALESCE(p.current_name, s.player_name) AS current_name,
p.current_country, s.session_start_dt, s.session_end_dt
FROM playtime_display_sessions s
LEFT JOIN playtime_display_players p ON p.steamid = s.steamid
JOIN playtime_display_map_history m ON m.map_id = ?
WHERE s.session_start_dt < COALESCE(${NEXT_MAP_START('m')}, NOW())
AND (s.session_end_dt IS NULL OR s.session_end_dt > m.map_start_dt)
ORDER BY s.session_start_dt`,
[mapId],
);
}
export async function getMapVotes(mapId: number): Promise<MapVoteRow[]> {
return query<MapVoteRow>(
`SELECT e.vote_id, e.vote_time, e.result,
c.steamid, COALESCE(p.current_name, c.steamid) AS current_name, c.vote_choice, c.vote_weight
FROM playtime_display_map_vote_events e
JOIN playtime_display_map_vote_choices c ON c.vote_id = e.vote_id
LEFT JOIN playtime_display_players p ON p.steamid = c.steamid
WHERE e.map_id = ?
ORDER BY e.vote_time, c.vote_weight DESC`,
[mapId],
);
}
export async function getAdjacentMaps(
mapStartDt: string,
): Promise<{ prev: MapPeriod | null; next: MapPeriod | null }> {
const [prevRows, nextRows] = await Promise.all([
query<MapPeriod>(
`SELECT m.map_id, m.map_name, m.map_start_dt, ${NEXT_MAP_START('m')} AS map_end_dt
FROM playtime_display_map_history m
WHERE m.map_start_dt < ?
AND ${REAL_MAP_PERIOD}
ORDER BY m.map_start_dt DESC
LIMIT 1`,
[mapStartDt],
),
query<MapPeriod>(
`SELECT m.map_id, m.map_name, m.map_start_dt, ${NEXT_MAP_START('m')} AS map_end_dt
FROM playtime_display_map_history m
WHERE m.map_start_dt > ?
AND ${REAL_MAP_PERIOD}
ORDER BY m.map_start_dt ASC
LIMIT 1`,
[mapStartDt],
),
]);
return { prev: prevRows[0] ?? null, next: nextRows[0] ?? null };
}
export interface CurrentPlayer {
steamid: string;
current_name: string;
}
/**
* "Currently online" defined explicitly: a session whose last heartbeat
* (session_end_dt) was within the last N minutes. This runs NOW() inside
* MySQL itself rather than passing a JS-computed timestamp, so it's immune
* to any Node/MySQL timezone mismatch — same check the admin already
* validated by hand (27 vs a real count of 29).
*
* This is deliberately separate from the historical bucket-overlap queries
* used elsewhere (population-over-time chart, getPlayersAtTime): those have
* no freshness concept at all, which is correct for a genuinely historical
* point in time, but wrong for "right now" — a session whose heartbeat
* stopped an hour ago (player disconnected without a clean close) would
* still satisfy a historical overlap check forever, silently inflating
* "currently online" counts.
*/
export async function getCurrentPlayers(freshnessMinutes = 3): Promise<CurrentPlayer[]> {
// Capped at 64 — the game's hard player limit. In principle this query
// should never find more than that many truly-simultaneous players, but
// rapid reconnects within the freshness window could in theory produce
// more distinct steamids than were ever actually online at once. Ordering
// by most-recent heartbeat and capping keeps the freshest 64, discarding
// anything older if that edge case ever occurs.
//
// LEFT JOIN (not INNER) + COALESCE fallback to the session's own stored
// player_name: an INNER JOIN here was silently dropping any session whose
// matching players row wasn't found, undercounting "currently online"
// for reasons unrelated to whether the player is actually connected.
return query<CurrentPlayer>(
`SELECT s.steamid, COALESCE(MAX(p.current_name), MAX(s.player_name)) AS current_name
FROM playtime_display_sessions s
LEFT JOIN playtime_display_players p ON p.steamid = s.steamid
WHERE s.session_end_dt > NOW() - INTERVAL ? MINUTE
GROUP BY s.steamid
ORDER BY MAX(s.session_end_dt) DESC
LIMIT 64`,
[freshnessMinutes],
);
}
export interface PlayerAtTime {
steamid: string;
current_name: string;
}
export async function getPlayersAtTime(atIso: string): Promise<PlayerAtTime[]> {
return query<PlayerAtTime>(
`SELECT s.steamid, COALESCE(MAX(p.current_name), MAX(s.player_name)) AS current_name
FROM playtime_display_sessions s
LEFT JOIN playtime_display_players p ON p.steamid = s.steamid
WHERE s.session_start_dt <= ?
AND (s.session_end_dt IS NULL OR s.session_end_dt >= ?)
GROUP BY s.steamid`,
[atIso, atIso],
);
}
/**
* A player's current total playtime is just the counter snapshot on their
* most recent session row — session_end_playtime_minutes if that session
* has had at least one heartbeat/close, otherwise session_start_playtime_minutes
* (covers the brief window right after connect, before the first update).
* No need to touch `player_time` at all — this value already reflects it.
*/
export async function getTotalPlaytimeMinutes(steamid: string): Promise<number | null> {
const rows = await query<{ minutes: number | null }>(
`SELECT COALESCE(session_end_playtime_minutes, session_start_playtime_minutes) AS minutes
FROM playtime_display_sessions
WHERE steamid = ?
ORDER BY session_id DESC
LIMIT 1`,
[steamid],
);
return rows[0]?.minutes ?? null;
}
@@ -0,0 +1,39 @@
export interface RaceTimerSummary {
rank: number;
level: number; // PlayerPoints / 1000, per how the server defines "level"
profileUrl: string;
}
const RACETIMER_API_BASE = 'https://racebackend.unloze.com/racetimer_endpoints-1.0/api/timers/player';
const RACETIMER_PROFILE_BASE = 'https://racetimerweb.unloze.com/#/player';
/**
* Fetches a player's RaceTimer rank/points from the RaceTimer backend.
* Returns null if the player has no RaceTimer record (e.g. never played a
* timed map) or the request fails for any reason — this is a secondary,
* best-effort enrichment, not something that should ever break the page.
*
* Cached for an hour via Next's extended fetch() — same reasoning as the
* Steam avatar lookup.
*/
export async function getRaceTimerSummary(steamId2: string): Promise<RaceTimerSummary | null> {
try {
const res = await fetch(`${RACETIMER_API_BASE}/${encodeURIComponent(steamId2)}`, {
next: { revalidate: 3600 },
});
if (!res.ok) return null;
const data = await res.json();
if (typeof data?.PlayerPoints !== 'number' || typeof data?.Rank !== 'number') {
return null;
}
return {
rank: data.Rank,
level: Math.floor(data.PlayerPoints / 1000),
profileUrl: `${RACETIMER_PROFILE_BASE}/${encodeURIComponent(steamId2)}`,
};
} catch {
return null;
}
}
@@ -0,0 +1,44 @@
// Converts a Steam2 id ("STEAM_0:1:12345678", as stored by the plugin) into
// a Steam64 id, which is what profile URLs and avatar lookups need.
const STEAM64_BASE = 76561197960265728n;
export function steam2ToSteam64(steamId2: string): string | null {
const match = /^STEAM_[0-5]:([01]):(\d+)$/.exec(steamId2.trim());
if (!match) return null;
const y = BigInt(match[1]);
const z = BigInt(match[2]);
return (STEAM64_BASE + y + z * 2n).toString();
}
export function steamProfileUrl(steamId2: string): string | null {
const id64 = steam2ToSteam64(steamId2);
return id64 ? `https://steamcommunity.com/profiles/${id64}` : null;
}
// Formats total minutes as "123h 45m" for display.
export function formatMinutes(totalMinutes: number | null): string {
if (totalMinutes == null || Number.isNaN(totalMinutes)) return '—';
const h = Math.floor(totalMinutes / 60);
const m = totalMinutes % 60;
return `${h}h ${m}m`;
}
// ISO 3166-1 alpha-2 -> flag emoji, e.g. "DE" -> "🇩🇪". No external assets needed.
export function countryCodeToFlag(code: string | null): string {
if (!code || code.length !== 2) return '🏳️';
const codePoints = [...code.toUpperCase()].map((c) => 0x1f1a5 + c.charCodeAt(0));
return String.fromCodePoint(...codePoints);
}
// ISO 3166-1 alpha-2 -> English display name, e.g. "DE" -> "Germany".
// Uses the built-in Intl.DisplayNames (Node 14+ / all modern browsers) —
// no extra dependency or lookup table needed.
const regionNames = new Intl.DisplayNames(['en'], { type: 'region' });
export function countryCodeToName(code: string | null): string {
if (!code || code.length !== 2) return 'Unknown';
try {
return regionNames.of(code.toUpperCase()) ?? code;
} catch {
return code;
}
}
@@ -0,0 +1,94 @@
export interface SteamSummary {
avatarUrl: string;
personaName: string | null;
}
// Steam's generic "no avatar set" question-mark image — used whenever a
// player has no avatar, or the lookup fails for any reason.
export const DEFAULT_AVATAR_URL =
'https://steamcdn-a.akamaihd.net/steamcommunity/public/images/avatars/fe/fef49e7fa7e1997310d705b2a6158ff8dc1cdfeb_full.jpg';
/**
* Fetches avatar + persona name from Steam's GetPlayerSummaries endpoint.
* Always resolves to a usable avatarUrl (falls back to the default
* question-mark image on any failure — including a missing STEAM_API_KEY)
* rather than null, so callers don't need their own fallback logic.
*
* Cached for an hour via Next's extended fetch() — avatars rarely change,
* and this keeps calls well within Steam's rate limits without needing a
* separate DB cache table.
*/
export async function getSteamSummary(steamId64: string): Promise<SteamSummary> {
const apiKey = process.env.STEAM_API_KEY;
if (!apiKey) {
return { avatarUrl: DEFAULT_AVATAR_URL, personaName: null };
}
try {
const res = await fetch(
`https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v0002/?key=${apiKey}&steamids=${steamId64}`,
{ next: { revalidate: 3600 } },
);
if (!res.ok) {
return { avatarUrl: DEFAULT_AVATAR_URL, personaName: null };
}
const data = await res.json();
const player = data?.response?.players?.[0];
return {
avatarUrl: player?.avatarfull || DEFAULT_AVATAR_URL,
personaName: player?.personaname ?? null,
};
} catch {
return { avatarUrl: DEFAULT_AVATAR_URL, personaName: null };
}
}
/**
* Same as getSteamSummary, but for many players in one go — Steam's
* GetPlayerSummaries accepts up to 100 comma-separated steamids per call,
* so fetching a list of players (e.g. "who was online at this point in
* time") is one HTTP call per 100 players rather than one per player.
* Returns a map keyed by SteamID64; missing entries mean "use the default
* avatar" at the call site.
*/
export async function getSteamSummariesBatch(
steamId64List: string[],
): Promise<Map<string, SteamSummary>> {
const result = new Map<string, SteamSummary>();
const apiKey = process.env.STEAM_API_KEY;
if (!apiKey || steamId64List.length === 0) {
return result;
}
const chunks: string[][] = [];
for (let i = 0; i < steamId64List.length; i += 100) {
chunks.push(steamId64List.slice(i, i + 100));
}
await Promise.all(
chunks.map(async (chunk) => {
try {
const res = await fetch(
`https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v0002/?key=${apiKey}&steamids=${chunk.join(',')}`,
{ next: { revalidate: 3600 } },
);
if (!res.ok) return;
const data = await res.json();
const players = data?.response?.players ?? [];
for (const player of players) {
result.set(player.steamid, {
avatarUrl: player.avatarfull || DEFAULT_AVATAR_URL,
personaName: player.personaname ?? null,
});
}
} catch {
// this chunk's players just fall back to the default avatar downstream
}
}),
);
return result;
}
@@ -0,0 +1,58 @@
// The MySQL server stores/returns naive DATETIME strings (no timezone
// attached) representing wall-clock time in this zone. Hardcoded here and
// used via native Intl APIs rather than relying on the Node process's own
// TZ environment variable — this makes date handling correct regardless of
// how the process is deployed/restarted, sidestepping an entire class of
// "did the env var actually reload" deployment issues.
const DB_TIMEZONE = 'Europe/Berlin';
const partsFormatter = new Intl.DateTimeFormat('en-US', {
timeZone: DB_TIMEZONE,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
});
function getParts(date: Date): Record<string, string> {
const parts: Record<string, string> = {};
for (const p of partsFormatter.formatToParts(date)) {
if (p.type !== 'literal') parts[p.type] = p.value;
}
if (parts.hour === '24') parts.hour = '00'; // some locales report midnight as 24
return parts;
}
/**
* Parses a MySQL DATETIME string (e.g. "2026-08-21 15:31:15"), which
* represents a wall-clock moment in DB_TIMEZONE, into the correct absolute
* Date/instant — regardless of the Node process's own configured timezone.
*/
export function parseDbDateTime(dbString: string): Date {
const naiveUTC = new Date(`${dbString.replace(' ', 'T')}Z`);
const parts = getParts(naiveUTC);
const asIfLocal = Date.UTC(
Number(parts.year),
Number(parts.month) - 1,
Number(parts.day),
Number(parts.hour),
Number(parts.minute),
Number(parts.second),
);
const offsetMs = naiveUTC.getTime() - asIfLocal;
return new Date(naiveUTC.getTime() + offsetMs);
}
/**
* Converts an absolute Date/instant into a MySQL DATETIME string
* ("YYYY-MM-DD HH:MM:SS") representing that instant's wall-clock time in
* DB_TIMEZONE — for building query parameters that compare correctly
* against stored DATETIME columns.
*/
export function formatDbDateTime(date: Date): string {
const parts = getParts(date);
return `${parts.year}-${parts.month}-${parts.day} ${parts.hour}:${parts.minute}:${parts.second}`;
}
@@ -0,0 +1,31 @@
'use client';
import { useEffect, useRef, useState } from 'react';
/**
* Tracks a container element's width with a plain resize listener, instead
* of relying on Recharts' ResponsiveContainer (which uses a ResizeObserver
* internally and can silently never fire in some browser/extension setups
* — when that happens, ResponsiveContainer renders an empty div forever,
* with no error and no fallback). This sidesteps that failure mode
* entirely: charts get an explicit pixel width from plain DOM measurement,
* which always works.
*/
export function useContainerWidth<T extends HTMLElement>(fallback = 600) {
const ref = useRef<T>(null);
const [width, setWidth] = useState(fallback);
useEffect(() => {
function measure() {
if (ref.current) {
setWidth(ref.current.clientWidth || fallback);
}
}
measure();
window.addEventListener('resize', measure);
return () => window.removeEventListener('resize', measure);
}, [fallback]);
return { ref, width };
}
+6
View File
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
@@ -0,0 +1,6 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
};
module.exports = nextConfig;
@@ -0,0 +1,28 @@
# Example server block for stats.yourdomain.com
# Adjust the domain and cert paths, then symlink into sites-enabled.
server {
listen 80;
server_name stats.yourdomain.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name stats.yourdomain.com;
ssl_certificate /etc/letsencrypt/live/stats.yourdomain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/stats.yourdomain.com/privkey.pem;
location / {
proxy_pass http://127.0.0.1:3547;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
}
}
@@ -0,0 +1,30 @@
{
"name": "unloze-playtime-display",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "next dev -p 3547",
"build": "next build",
"start": "next start -p 3547",
"lint": "next lint"
},
"dependencies": {
"next": "15.5.23",
"react": "18.3.1",
"react-dom": "18.3.1",
"mysql2": "3.11.3"
},
"devDependencies": {
"typescript": "5.6.3",
"@types/node": "20.16.11",
"@types/react": "18.3.11",
"@types/react-dom": "18.3.1",
"tailwindcss": "3.4.13",
"postcss": "8.5.26",
"autoprefixer": "10.4.20"
},
"overrides": {
"postcss": "8.5.26",
"sharp": "0.35.3"
}
}
@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
@@ -0,0 +1,32 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
'./app/**/*.{js,ts,jsx,tsx,mdx}',
'./components/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
extend: {
colors: {
base: {
DEFAULT: '#0B0F0E', // near-black, slight green cast — server-ops feel
panel: '#121715',
border: '#1F2723',
},
accent: {
DEFAULT: '#3FD37A', // "server online" green
dim: '#245C3E',
},
ink: {
DEFAULT: '#E4EBE7',
muted: '#8A9691',
faint: '#4C5652',
},
},
fontFamily: {
sans: ['Inter', 'ui-sans-serif', 'system-ui', 'sans-serif'],
mono: ['"JetBrains Mono"', 'ui-monospace', 'SFMono-Regular', 'monospace'],
},
},
},
plugins: [],
};
@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"baseUrl": ".",
"paths": { "@/*": ["./*"] }
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}