diff --git a/discord_verificiation/playtime-frontend/.env.example b/discord_verificiation/playtime-frontend/.env.example new file mode 100644 index 0000000..801dbd7 --- /dev/null +++ b/discord_verificiation/playtime-frontend/.env.example @@ -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= diff --git a/discord_verificiation/playtime-frontend/.gitignore b/discord_verificiation/playtime-frontend/.gitignore new file mode 100644 index 0000000..29937a8 --- /dev/null +++ b/discord_verificiation/playtime-frontend/.gitignore @@ -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* diff --git a/discord_verificiation/playtime-frontend/README.md b/discord_verificiation/playtime-frontend/README.md new file mode 100644 index 0000000..5fee785 --- /dev/null +++ b/discord_verificiation/playtime-frontend/README.md @@ -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 ` won't pick up the new env var. diff --git a/discord_verificiation/playtime-frontend/app/api/map-population/players/route.ts b/discord_verificiation/playtime-frontend/app/api/map-population/players/route.ts new file mode 100644 index 0000000..188d08f --- /dev/null +++ b/discord_verificiation/playtime-frontend/app/api/map-population/players/route.ts @@ -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(); + 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 }); +} diff --git a/discord_verificiation/playtime-frontend/app/api/map-population/route.ts b/discord_verificiation/playtime-frontend/app/api/map-population/route.ts new file mode 100644 index 0000000..883443d --- /dev/null +++ b/discord_verificiation/playtime-frontend/app/api/map-population/route.ts @@ -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 }); +} diff --git a/discord_verificiation/playtime-frontend/app/api/maps/route.ts b/discord_verificiation/playtime-frontend/app/api/maps/route.ts new file mode 100644 index 0000000..cd36d6d --- /dev/null +++ b/discord_verificiation/playtime-frontend/app/api/maps/route.ts @@ -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 }); +} diff --git a/discord_verificiation/playtime-frontend/app/api/players/route.ts b/discord_verificiation/playtime-frontend/app/api/players/route.ts new file mode 100644 index 0000000..1404d0d --- /dev/null +++ b/discord_verificiation/playtime-frontend/app/api/players/route.ts @@ -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( + `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( + `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 }); +} diff --git a/discord_verificiation/playtime-frontend/app/api/population/current/route.ts b/discord_verificiation/playtime-frontend/app/api/population/current/route.ts new file mode 100644 index 0000000..3e75e7e --- /dev/null +++ b/discord_verificiation/playtime-frontend/app/api/population/current/route.ts @@ -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(); + 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 }); +} diff --git a/discord_verificiation/playtime-frontend/app/api/population/players/route.ts b/discord_verificiation/playtime-frontend/app/api/population/players/route.ts new file mode 100644 index 0000000..a9da1d4 --- /dev/null +++ b/discord_verificiation/playtime-frontend/app/api/population/players/route.ts @@ -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(); + 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 }); +} diff --git a/discord_verificiation/playtime-frontend/app/api/population/route.ts b/discord_verificiation/playtime-frontend/app/api/population/route.ts new file mode 100644 index 0000000..5f4b345 --- /dev/null +++ b/discord_verificiation/playtime-frontend/app/api/population/route.ts @@ -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( + `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( + `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 }); +} diff --git a/discord_verificiation/playtime-frontend/app/countries/[code]/page.tsx b/discord_verificiation/playtime-frontend/app/countries/[code]/page.tsx new file mode 100644 index 0000000..9e3151a --- /dev/null +++ b/discord_verificiation/playtime-frontend/app/countries/[code]/page.tsx @@ -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 ( +
+ + ← All countries + + +
+
+

+ {countryCodeToFlag(code)} + {countryCodeToName(code)} +

+

+ {players.length} player{players.length === 1 ? '' : 's'} +

+
+ +
+ + Most recently played + + + Highest playtime + +
+
+ +
+ {players.map((p) => ( + +
{p.current_name}
+
+ {p.steamid} + + {formatMinutes(p.total_minutes)} + +
+ + ))} +
+
+ ); +} diff --git a/discord_verificiation/playtime-frontend/app/countries/page.tsx b/discord_verificiation/playtime-frontend/app/countries/page.tsx new file mode 100644 index 0000000..f614e88 --- /dev/null +++ b/discord_verificiation/playtime-frontend/app/countries/page.tsx @@ -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 ( +
+
+

Countries

+

+ Where the server's players connect from, most players first. +

+
+ + {countries.length === 0 ? ( +
No countries recorded yet.
+ ) : ( +
+ {countries.map((c) => ( + +
+ + {countryCodeToFlag(c.current_country)} + + {countryCodeToName(c.current_country)} + {c.current_country} +
+
+ {c.player_count} player{c.player_count === 1 ? '' : 's'} +
+ + ))} +
+ )} +
+ ); +} diff --git a/discord_verificiation/playtime-frontend/app/favicon.ico b/discord_verificiation/playtime-frontend/app/favicon.ico new file mode 100644 index 0000000..e7d0595 Binary files /dev/null and b/discord_verificiation/playtime-frontend/app/favicon.ico differ diff --git a/discord_verificiation/playtime-frontend/app/globals.css b/discord_verificiation/playtime-frontend/app/globals.css new file mode 100644 index 0000000..84853a0 --- /dev/null +++ b/discord_verificiation/playtime-frontend/app/globals.css @@ -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; + } +} diff --git a/discord_verificiation/playtime-frontend/app/layout.tsx b/discord_verificiation/playtime-frontend/app/layout.tsx new file mode 100644 index 0000000..85a3a21 --- /dev/null +++ b/discord_verificiation/playtime-frontend/app/layout.tsx @@ -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 ( + + +
+
+
+ + + + unloze playtime sessions + + + +
+
+
{children}
+
+
+ unloze playtime stats +
+
+
+ + + ); +} diff --git a/discord_verificiation/playtime-frontend/app/maps/[mapname]/page.tsx b/discord_verificiation/playtime-frontend/app/maps/[mapname]/page.tsx new file mode 100644 index 0000000..a201d1a --- /dev/null +++ b/discord_verificiation/playtime-frontend/app/maps/[mapname]/page.tsx @@ -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 ( +
+ + ← Back to maps + + +
+

{mapName}

+

+ Played {periods.length} time{periods.length === 1 ? '' : 's'}. +

+
+ +
+ {periods.map((p) => ( + +
+
+ {new Date(p.map_start_dt.replace(' ', 'T')).toLocaleString()} +
+ {!p.map_end_dt &&
currently running
} +
+
+ {p.map_end_dt ? formatMinutes(diffMinutes(p.map_start_dt, p.map_end_dt)) : '—'} +
+ + ))} +
+
+ ); +} diff --git a/discord_verificiation/playtime-frontend/app/maps/page.tsx b/discord_verificiation/playtime-frontend/app/maps/page.tsx new file mode 100644 index 0000000..12863e2 --- /dev/null +++ b/discord_verificiation/playtime-frontend/app/maps/page.tsx @@ -0,0 +1,13 @@ +import MapSearch from '@/components/MapSearch'; + +export default function MapsPage() { + return ( +
+
+

Maps

+

Search for a map to see when it was played.

+
+ +
+ ); +} diff --git a/discord_verificiation/playtime-frontend/app/maps/period/[mapId]/page.tsx b/discord_verificiation/playtime-frontend/app/maps/period/[mapId]/page.tsx new file mode 100644 index 0000000..4547716 --- /dev/null +++ b/discord_verificiation/playtime-frontend/app/maps/period/[mapId]/page.tsx @@ -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(); + 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 ( +
+ + ← All plays of {period.map_name} + + + {/* Header with prev/next navigation */} +
+
+
+ {adjacent.prev ? ( + + ← {adjacent.prev.map_name} + + ) : ( + start of history + )} +
+ +
+

{period.map_name}

+
+ {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 && ( + + ({formatMinutes(diffMinutes(period.map_start_dt, period.map_end_dt))}) + + )} +
+
+ +
+ {adjacent.next ? ( + + {adjacent.next.map_name} → + + ) : ( + end of history + )} +
+
+
+ + {/* Players present */} +
+
+ Players present ({players.length}) +
+ {players.length === 0 ? ( +
No recorded players during this period.
+ ) : ( +
+ {players.map((p, i) => ( + +
+ {countryCodeToFlag(p.current_country)} + {p.current_name} +
+
+ {new Date(p.session_start_dt.replace(' ', 'T')).toLocaleTimeString()} + {' – '} + {p.session_end_dt + ? new Date(p.session_end_dt.replace(' ', 'T')).toLocaleTimeString() + : 'now'} +
+ + ))} +
+ )} +
+ + {/* Votes */} +
+
Map votes
+ {voteEvents.size === 0 ? ( +
No recorded vote during this period.
+ ) : ( +
+ {[...voteEvents.entries()].map(([voteId, event]) => ( +
+
+
+ {new Date(event.vote_time.replace(' ', 'T')).toLocaleString()} +
+
Result: {event.result ?? '—'}
+
+
+ {event.choices.map((c, i) => ( + + {c.current_name} + + voted {c.vote_choice} + {c.vote_weight !== 1 && ( + (weight {c.vote_weight}) + )} + + + ))} +
+
+ ))} +
+ )} +
+
+ ); +} diff --git a/discord_verificiation/playtime-frontend/app/page.tsx b/discord_verificiation/playtime-frontend/app/page.tsx new file mode 100644 index 0000000..3f99636 --- /dev/null +++ b/discord_verificiation/playtime-frontend/app/page.tsx @@ -0,0 +1,15 @@ +import PopulationChart from '@/components/PopulationChart'; +import MapPopulationChart from '@/components/MapPopulationChart'; + +export default function OverviewPage() { + return ( +
+
+

Overview

+

Population and activity across the server.

+
+ + +
+ ); +} diff --git a/discord_verificiation/playtime-frontend/app/players/[steamid]/page.tsx b/discord_verificiation/playtime-frontend/app/players/[steamid]/page.tsx new file mode 100644 index 0000000..03413f1 --- /dev/null +++ b/discord_verificiation/playtime-frontend/app/players/[steamid]/page.tsx @@ -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 ( +
+ + ← Back to players + + + {/* Header: avatar, name, steamid, profile link, total playtime */} +
+
+ {/* eslint-disable-next-line @next/next/no-img-element */} + +
+ +
+
+

{player.current_name}

+ + {countryCodeToFlag(player.current_country)} + +
+
{steamid}
+
+ {profileUrl && ( + + View Steam profile ↗ + + )} + {raceTimer && ( + + View RaceTimer profile ↗ + + )} +
+
+ +
+
Total playtime
+
+ {totalMinutes != null ? formatMinutes(totalMinutes) : '—'} +
+
+ + {raceTimer && ( +
+
RaceTimer
+
Lv {raceTimer.level}
+
Rank #{raceTimer.rank}
+
+ )} + +
+
Player Tier
+
{player.player_tier}
+
+
+ + {/* Previous names */} + {nameHistory.length > 1 && ( +
+
Previous names
+
+ {nameHistory + .filter((n) => n.player_name !== player.current_name) + .map((n) => ( + + {n.player_name} + + ))} +
+
+ )} + + {/* Sessions */} +
+
Play sessions
+ {sessions.length === 0 ? ( +
No recorded sessions yet.
+ ) : ( +
+
+
Started
+
Map(s)
+
Playtime
+
+ {sessions.map((s) => ( +
+
+
+ {new Date(s.session_start_dt.replace(' ', 'T')).toLocaleString()} +
+
+ {s.session_end_dt + ? `until ${new Date(s.session_end_dt.replace(' ', 'T')).toLocaleTimeString()}` + : 'still connected'} +
+
+
{s.maps_played ?? '—'}
+
+ {formatMinutes(s.session_active_minutes)} +
+
+ ))} +
+ )} +
+
+ ); +} diff --git a/discord_verificiation/playtime-frontend/app/players/page.tsx b/discord_verificiation/playtime-frontend/app/players/page.tsx new file mode 100644 index 0000000..3f26272 --- /dev/null +++ b/discord_verificiation/playtime-frontend/app/players/page.tsx @@ -0,0 +1,15 @@ +import PlayerSearch from '@/components/PlayerSearch'; + +export default function PlayersPage() { + return ( +
+
+

Players

+

+ Search by current or previous name, or SteamID. +

+
+ +
+ ); +} diff --git a/discord_verificiation/playtime-frontend/components/CountryPlayerList.tsx b/discord_verificiation/playtime-frontend/components/CountryPlayerList.tsx new file mode 100644 index 0000000..47bd045 --- /dev/null +++ b/discord_verificiation/playtime-frontend/components/CountryPlayerList.tsx @@ -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([]); + 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 ( +
+
+ +
+ +
+ {loading ? ( +
loading…
+ ) : players.length === 0 ? ( +
No players found.
+ ) : ( + players.map((p) => ( + +
{p.current_name}
+
+ {formatMinutes(p.total_minutes)} + {new Date(p.last_seen.replace(' ', 'T')).toLocaleDateString()} +
+ + )) + )} +
+
+ ); +} diff --git a/discord_verificiation/playtime-frontend/components/MapPopulationChart.tsx b/discord_verificiation/playtime-frontend/components/MapPopulationChart.tsx new file mode 100644 index 0000000..9167b4b --- /dev/null +++ b/discord_verificiation/playtime-frontend/components/MapPopulationChart.tsx @@ -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(); + const [points, setPoints] = useState([]); + const [loading, setLoading] = useState(true); + const [hoverIndex, setHoverIndex] = useState(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(null); + const [selectedPlayers, setSelectedPlayers] = useState([]); + 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) { + const rect = e.currentTarget.getBoundingClientRect(); + dragStartRef.current = { x: e.clientX - rect.left, startIdx: viewStartIdx, endIdx: viewEndIdx }; + dragMovedRef.current = false; + } + + function handleMouseMove(e: React.MouseEvent) { + 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) { + 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 ( +
+
+
Population by map
+

+ {visiblePoints.length} of {points.length} maps +

+
+ +
+
+ + + + + Scroll to zoom, drag to pan, click a bar for who was online +
+ {isZoomed && ( + + )} +
+ +
+ {loading ? ( +
+ loading… +
+ ) : points.length === 0 ? ( +
+ No map history recorded yet. +
+ ) : ( + <> + { + 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 ( + + + + {v} + + + ); + })} + + {visiblePoints.map((p, i) => ( + + ))} + + {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 ( + + {label} + + ); + })} + + + + + {hoverIndex != null && visiblePoints[hoverIndex] && ( +
+
{visiblePoints[hoverIndex].map_name}
+
+ {new Date(visiblePoints[hoverIndex].map_start_dt.replace(' ', 'T')).toLocaleString()} +
+
+ {visiblePoints[hoverIndex].players_online_at_start} players at map start +
+
+ )} + + )} +
+ + {points.length > 0 && ( +
+ Visit the{' '} + + maps page + {' '} + for full detail (votes, prev/next map). +
+ )} + + {selectedMap && ( +
+
+ Players during{' '} + + {selectedMap.map_name} + {' '} + ({selectedPlayers.length} player{selectedPlayers.length === 1 ? '' : 's'}) +
+ +
+ )} +
+ ); +} diff --git a/discord_verificiation/playtime-frontend/components/MapSearch.tsx b/discord_verificiation/playtime-frontend/components/MapSearch.tsx new file mode 100644 index 0000000..e103307 --- /dev/null +++ b/discord_verificiation/playtime-frontend/components/MapSearch.tsx @@ -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([]); + 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 ( +
+ 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" + /> + +
+ {loading ? ( +
loading…
+ ) : maps.length === 0 ? ( +
No maps found.
+ ) : ( + maps.map((m) => ( + +
{m.map_name}
+
+ {m.times_played}× played + last {new Date(m.last_played.replace(' ', 'T')).toLocaleDateString()} +
+ + )) + )} +
+
+ ); +} diff --git a/discord_verificiation/playtime-frontend/components/PlayerChipList.tsx b/discord_verificiation/playtime-frontend/components/PlayerChipList.tsx new file mode 100644 index 0000000..a6d4b4d --- /dev/null +++ b/discord_verificiation/playtime-frontend/components/PlayerChipList.tsx @@ -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
loading players…
; + } + + if (players.length === 0) { + return
No players recorded at this point.
; + } + + return ( +
+ {players.map((p) => ( + + {/* eslint-disable-next-line @next/next/no-img-element */} + + {p.name} + + ))} +
+ ); +} diff --git a/discord_verificiation/playtime-frontend/components/PlayerSearch.tsx b/discord_verificiation/playtime-frontend/components/PlayerSearch.tsx new file mode 100644 index 0000000..a47d808 --- /dev/null +++ b/discord_verificiation/playtime-frontend/components/PlayerSearch.tsx @@ -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([]); + 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 ( +
+
+ 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" + /> + +
+ +
+ {loading ? ( +
loading…
+ ) : players.length === 0 ? ( +
No players found.
+ ) : ( + players.map((p) => ( + +
+ + {countryCodeToFlag(p.current_country)} + +
+
{p.current_name}
+ {p.matched_name && p.matched_name !== p.current_name && ( +
previously: {p.matched_name}
+ )} +
+
+
+ {formatMinutes(p.total_minutes)} + {p.steamid} +
+ + )) + )} +
+
+ ); +} diff --git a/discord_verificiation/playtime-frontend/components/PopulationChart.tsx b/discord_verificiation/playtime-frontend/components/PopulationChart.tsx new file mode 100644 index 0000000..7f116b4 --- /dev/null +++ b/discord_verificiation/playtime-frontend/components/PopulationChart.tsx @@ -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(); + 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([]); + const [mapBoundaries, setMapBoundaries] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(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(null); + const [hoverX, setHoverX] = useState(null); + + // Currently online — authoritative, always-live, independent of the chart's date range. + const [currentPlayers, setCurrentPlayers] = useState([]); + const [currentCount, setCurrentCount] = useState(null); + const [loadingCurrent, setLoadingCurrent] = useState(true); + const [currentUpdatedAt, setCurrentUpdatedAt] = useState(null); + const [secondsAgo, setSecondsAgo] = useState(0); + + // A specific HISTORICAL point clicked on the chart. + const [selectedPoint, setSelectedPoint] = useState(null); + const [selectedPlayers, setSelectedPlayers] = useState([]); + 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) { + const rect = e.currentTarget.getBoundingClientRect(); + dragStartRef.current = { x: e.clientX - rect.left, viewStart, viewEnd }; + dragMovedRef.current = false; + } + + function handleMouseMove(e: React.MouseEvent) { + 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) { + 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) { + 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 ( +
+ {/* Currently online — always visible, always live, not tied to the date range below */} +
+
+
+ +
+ Currently online{currentCount != null ? ` — ${currentCount} player${currentCount === 1 ? '' : 's'}` : ''} +
+
+ {currentUpdatedAt &&
updated {secondsAgo}s ago
} +
+ +
+ +
+
+
Concurrent players
+

Population over time

+
+
{ + e.preventDefault(); + load(); + }} + > + + + +
+
+ + {error &&
{error}
} + +
+
+ + + + + Scroll to zoom, drag to pan, click a point for who was online +
+ {isZoomed && ( + + )} +
+ +
+ {loading ? ( +
loading…
+ ) : points.length === 0 ? ( +
No data in this range.
+ ) : ( + + {yTicks.map((v) => { + const y = yForValue(v); + return ( + + + + {v} + + + ); + })} + + {visibleBoundaries.map((b) => ( + + ))} + + {xTicks.map((epoch, i) => ( + + {new Date(epoch).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })} + + ))} + + + + + + {hoverPoint && ( + + )} + + )} + + {hoverPoint && hoverX != null && !loading && ( +
+
{new Date(hoverPoint.t).toLocaleString()}
+
{hoverPoint.players} players
+ {hoverPoint.mapName &&
{hoverPoint.mapName}
} +
+ )} +
+ + {selectedPoint && ( +
+
+ Online at {new Date(selectedPoint.t).toLocaleString()} + {selectedPoint.mapName && ( + <> + {' '} + — playing {selectedPoint.mapName} + + )}{' '} + ({selectedPoint.players} player{selectedPoint.players === 1 ? '' : 's'} — from historical data, may lag slightly) +
+ +
+ )} +
+ ); +} diff --git a/discord_verificiation/playtime-frontend/ecosystem.config.js b/discord_verificiation/playtime-frontend/ecosystem.config.js new file mode 100644 index 0000000..07715e5 --- /dev/null +++ b/discord_verificiation/playtime-frontend/ecosystem.config.js @@ -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, + }, + ], +}; diff --git a/discord_verificiation/playtime-frontend/lib/dates.ts b/discord_verificiation/playtime-frontend/lib/dates.ts new file mode 100644 index 0000000..33bcf84 --- /dev/null +++ b/discord_verificiation/playtime-frontend/lib/dates.ts @@ -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); +} diff --git a/discord_verificiation/playtime-frontend/lib/db.ts b/discord_verificiation/playtime-frontend/lib/db.ts new file mode 100644 index 0000000..f8f6633 --- /dev/null +++ b/discord_verificiation/playtime-frontend/lib/db.ts @@ -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(sql: string, params: any[] = []): Promise { + const [rows] = await getPool().query(sql, params); + return rows as T[]; +} diff --git a/discord_verificiation/playtime-frontend/lib/queries.ts b/discord_verificiation/playtime-frontend/lib/queries.ts new file mode 100644 index 0000000..38b8014 --- /dev/null +++ b/discord_verificiation/playtime-frontend/lib/queries.ts @@ -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 { + const rows = await query( + `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 { + return query( + `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 { + // 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( + `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 { + // 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( + `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 { + // Players with no resolved country are excluded — there's no meaningful + // "unknown" country page to select. + return query( + `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 { + const orderBy = sort === 'playtime' ? 'total_minutes DESC' : 'p.last_seen DESC'; + return query( + `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 { + const like = `%${q}%`; + return query( + `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 { + return query( + `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 { + const rows = await query( + `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 { + return query( + `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 { + return query( + `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( + `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( + `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 { + // 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( + `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 { + return query( + `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 { + 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; +} diff --git a/discord_verificiation/playtime-frontend/lib/racetimer.ts b/discord_verificiation/playtime-frontend/lib/racetimer.ts new file mode 100644 index 0000000..b77f228 --- /dev/null +++ b/discord_verificiation/playtime-frontend/lib/racetimer.ts @@ -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 { + 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; + } +} diff --git a/discord_verificiation/playtime-frontend/lib/steam.ts b/discord_verificiation/playtime-frontend/lib/steam.ts new file mode 100644 index 0000000..8d0b9ea --- /dev/null +++ b/discord_verificiation/playtime-frontend/lib/steam.ts @@ -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; + } +} diff --git a/discord_verificiation/playtime-frontend/lib/steamApi.ts b/discord_verificiation/playtime-frontend/lib/steamApi.ts new file mode 100644 index 0000000..89b6497 --- /dev/null +++ b/discord_verificiation/playtime-frontend/lib/steamApi.ts @@ -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 { + 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> { + const result = new Map(); + 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; +} diff --git a/discord_verificiation/playtime-frontend/lib/timezone.ts b/discord_verificiation/playtime-frontend/lib/timezone.ts new file mode 100644 index 0000000..56b4fb4 --- /dev/null +++ b/discord_verificiation/playtime-frontend/lib/timezone.ts @@ -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 { + const parts: Record = {}; + 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}`; +} diff --git a/discord_verificiation/playtime-frontend/lib/useContainerWidth.ts b/discord_verificiation/playtime-frontend/lib/useContainerWidth.ts new file mode 100644 index 0000000..1fac523 --- /dev/null +++ b/discord_verificiation/playtime-frontend/lib/useContainerWidth.ts @@ -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(fallback = 600) { + const ref = useRef(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 }; +} diff --git a/discord_verificiation/playtime-frontend/next-env.d.ts b/discord_verificiation/playtime-frontend/next-env.d.ts new file mode 100644 index 0000000..830fb59 --- /dev/null +++ b/discord_verificiation/playtime-frontend/next-env.d.ts @@ -0,0 +1,6 @@ +/// +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/discord_verificiation/playtime-frontend/next.config.js b/discord_verificiation/playtime-frontend/next.config.js new file mode 100644 index 0000000..91ef62f --- /dev/null +++ b/discord_verificiation/playtime-frontend/next.config.js @@ -0,0 +1,6 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + reactStrictMode: true, +}; + +module.exports = nextConfig; diff --git a/discord_verificiation/playtime-frontend/nginx.example.conf b/discord_verificiation/playtime-frontend/nginx.example.conf new file mode 100644 index 0000000..6c1166d --- /dev/null +++ b/discord_verificiation/playtime-frontend/nginx.example.conf @@ -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; + } +} diff --git a/discord_verificiation/playtime-frontend/package.json b/discord_verificiation/playtime-frontend/package.json new file mode 100644 index 0000000..68239b7 --- /dev/null +++ b/discord_verificiation/playtime-frontend/package.json @@ -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" + } +} diff --git a/discord_verificiation/playtime-frontend/postcss.config.js b/discord_verificiation/playtime-frontend/postcss.config.js new file mode 100644 index 0000000..12a703d --- /dev/null +++ b/discord_verificiation/playtime-frontend/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/discord_verificiation/playtime-frontend/tailwind.config.js b/discord_verificiation/playtime-frontend/tailwind.config.js new file mode 100644 index 0000000..b4fae78 --- /dev/null +++ b/discord_verificiation/playtime-frontend/tailwind.config.js @@ -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: [], +}; diff --git a/discord_verificiation/playtime-frontend/tsconfig.json b/discord_verificiation/playtime-frontend/tsconfig.json new file mode 100644 index 0000000..13e20c2 --- /dev/null +++ b/discord_verificiation/playtime-frontend/tsconfig.json @@ -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"] +}