import type { SupabaseClient } from "@supabase/supabase-js"; import { countryCodeFromIp, countryFlagFromCode } from "@/lib/ip-geolocation"; import type { DbPingReport } from "@/types/database"; const BAD_PING_THRESHOLD_MS = 200; const TOP_ROWS_LIMIT = 20; type PingReportWithUser = DbPingReport & { user: | { id: number; username: string | null; ip_address: string | null }[] | null; }; export type PingGroupRow = { key: string; label: string; reports: number; players: number; avgPing: number; minPing: number; maxPing: number; badReports: number; badRatePercent: number; }; export type PingAnalyticsSummary = { totalReports: number; totalMatches: number; totalPlayers: number; avgPing: number | null; p95Ping: number | null; badThresholdMs: number; badReports: number; badRatePercent: number | null; }; export type PingAnalyticsResult = { summary: PingAnalyticsSummary; byMatches: PingGroupRow[]; byPlayers: PingGroupRow[]; byCountries: PingGroupRow[]; error: string | null; }; function percentile(sorted: number[], ratio: number): number | null { if (sorted.length === 0) return null; const idx = Math.min( sorted.length - 1, Math.max(0, Math.ceil(sorted.length * ratio) - 1), ); return sorted[idx] ?? null; } function toGroupRow( key: string, label: string, pings: number[], players: Set, ): PingGroupRow { const reports = pings.length; const sum = pings.reduce((acc, value) => acc + value, 0); const minPing = reports > 0 ? Math.min(...pings) : 0; const maxPing = reports > 0 ? Math.max(...pings) : 0; const badReports = pings.filter((value) => value >= BAD_PING_THRESHOLD_MS).length; const badRatePercent = reports > 0 ? (badReports / reports) * 100 : 0; return { key, label, reports, players: players.size, avgPing: reports > 0 ? sum / reports : 0, minPing, maxPing, badReports, badRatePercent, }; } export async function loadPingAnalytics( supabase: SupabaseClient, from: string, to: string, playerIds: number[], ): Promise { const rangeStartIso = `${from}T00:00:00.000Z`; const rangeEndIso = `${to}T23:59:59.999Z`; let query = supabase .from("ping_reports") .select("id, created_at, user_id, match_id, ip_address, ping, user:users(id, username, ip_address)") .gte("created_at", rangeStartIso) .lte("created_at", rangeEndIso) .order("created_at", { ascending: false }) .limit(10000); if (playerIds.length > 0) { query = query.in("user_id", playerIds); } const { data, error } = await query; if (error) { return { summary: { totalReports: 0, totalMatches: 0, totalPlayers: 0, avgPing: null, p95Ping: null, badThresholdMs: BAD_PING_THRESHOLD_MS, badReports: 0, badRatePercent: null, }, byMatches: [], byPlayers: [], byCountries: [], error: error.message, }; } const rows = (data ?? []) as PingReportWithUser[]; const allPings: number[] = []; const uniquePlayers = new Set(); const uniqueMatches = new Set(); const byMatch = new Map }>(); const byPlayer = new Map }>(); const byCountry = new Map }>(); const ipByRowIndex = new Map(); for (let i = 0; i < rows.length; i++) { const row = rows[i]!; const user = row.user?.[0] ?? null; ipByRowIndex.set(i, row.ip_address ?? user?.ip_address ?? null); } const countryByRowIndex = new Map(); await Promise.all( [...ipByRowIndex.entries()].map(async ([index, ip]) => { countryByRowIndex.set(index, await countryCodeFromIp(ip)); }), ); for (let i = 0; i < rows.length; i++) { const row = rows[i]!; const ping = Number(row.ping); const userId = Number(row.user_id); const matchId = Number(row.match_id); if (!Number.isFinite(ping) || !Number.isFinite(userId) || !Number.isFinite(matchId)) { continue; } allPings.push(ping); uniquePlayers.add(userId); uniqueMatches.add(matchId); const matchKey = String(matchId); const matchState = byMatch.get(matchKey) ?? { label: `M${matchId}`, pings: [], players: new Set(), }; matchState.pings.push(ping); matchState.players.add(userId); byMatch.set(matchKey, matchState); const user = row.user?.[0] ?? null; const username = user?.username?.trim(); const playerLabel = username ? `${username} (${userId})` : `User ${userId}`; const playerKey = String(userId); const playerState = byPlayer.get(playerKey) ?? { label: playerLabel, pings: [], players: new Set(), }; playerState.pings.push(ping); playerState.players.add(userId); byPlayer.set(playerKey, playerState); const country = countryByRowIndex.get(i) ?? "UNK"; const countryFlag = countryFlagFromCode(country); const countryWithFlag = countryFlag ? `${countryFlag} ${country}` : country; const countryState = byCountry.get(country) ?? { label: countryWithFlag, pings: [], players: new Set(), }; countryState.pings.push(ping); countryState.players.add(userId); byCountry.set(country, countryState); } const sortedPings = [...allPings].sort((a, b) => a - b); const badReports = allPings.filter((value) => value >= BAD_PING_THRESHOLD_MS).length; const toRankedRows = ( src: Map }>, ): PingGroupRow[] => [...src.entries()] .map(([key, state]) => toGroupRow(key, state.label, state.pings, state.players)) .sort((a, b) => b.avgPing - a.avgPing || b.reports - a.reports) .slice(0, TOP_ROWS_LIMIT); return { summary: { totalReports: allPings.length, totalMatches: uniqueMatches.size, totalPlayers: uniquePlayers.size, avgPing: allPings.length > 0 ? allPings.reduce((acc, value) => acc + value, 0) / allPings.length : null, p95Ping: percentile(sortedPings, 0.95), badThresholdMs: BAD_PING_THRESHOLD_MS, badReports, badRatePercent: allPings.length > 0 ? (badReports / allPings.length) * 100 : null, }, byMatches: toRankedRows(byMatch), byPlayers: toRankedRows(byPlayer), byCountries: toRankedRows(byCountry), error: null, }; }