initial commit of the paytime session frontend
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user