initial commit of the paytime session frontend
This commit is contained in:
@@ -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 (A–Z)</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 };
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user