diff --git a/discord_verificiation/playtime-frontend/README.md b/discord_verificiation/playtime-frontend/README.md index e12465c..68985f2 100644 --- a/discord_verificiation/playtime-frontend/README.md +++ b/discord_verificiation/playtime-frontend/README.md @@ -53,6 +53,15 @@ 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. +## Redeploying + +``` +rm -rf node_modules .next +npm install +npm run build +pm2 restart unloze-playtime-display +``` + ## Notes - `session_end_dt` is refreshed roughly every 60s by the plugin as a 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 index 188d08f..1742b4e 100644 --- a/discord_verificiation/playtime-frontend/app/api/map-population/players/route.ts +++ b/discord_verificiation/playtime-frontend/app/api/map-population/players/route.ts @@ -1,7 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; import { getPlayersPresentDuringMap } from '@/lib/queries'; -import { getSteamSummariesBatch, DEFAULT_AVATAR_URL } from '@/lib/steamApi'; -import { steam2ToSteam64 } from '@/lib/steam'; +import { attachAvatars } from '@/lib/steamApi'; export async function GET(req: NextRequest) { const mapIdParam = req.nextUrl.searchParams.get('mapId'); @@ -10,21 +9,14 @@ export async function GET(req: NextRequest) { return NextResponse.json({ error: 'mapId query param is required' }, { status: 400 }); } - const players = await getPlayersPresentDuringMap(mapId); + const rows = await getPlayersPresentDuringMap(mapId); + const enriched = await attachAvatars(rows); + const players = enriched.map((p) => ({ + steamid: p.steamid, + name: p.current_name, + country: p.current_country, + avatarUrl: p.avatarUrl, + })); - 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 }); + return NextResponse.json({ players }); } diff --git a/discord_verificiation/playtime-frontend/app/api/players/route.ts b/discord_verificiation/playtime-frontend/app/api/players/route.ts index 1404d0d..2a1054e 100644 --- a/discord_verificiation/playtime-frontend/app/api/players/route.ts +++ b/discord_verificiation/playtime-frontend/app/api/players/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; import { query } from '@/lib/db'; +import { attachAvatars } from '@/lib/steamApi'; interface PlayerRow { steamid: string; @@ -7,6 +8,7 @@ interface PlayerRow { current_country: string | null; matched_name: string | null; total_minutes: number | null; + last_connected: string | null; } const TOTAL_MINUTES_SUBQUERY = ` @@ -17,6 +19,12 @@ const TOTAL_MINUTES_SUBQUERY = ` LIMIT 1) AS total_minutes `; +const LAST_CONNECTED_SUBQUERY = ` + (SELECT MAX(s.session_end_dt) + FROM playtime_display_sessions s + WHERE s.steamid = p.steamid) AS last_connected +`; + export async function GET(req: NextRequest) { const q = req.nextUrl.searchParams.get('q')?.trim() ?? ''; const sort = req.nextUrl.searchParams.get('sort') ?? 'recent'; @@ -28,37 +36,41 @@ export async function GET(req: NextRequest) { ? 'p.current_name' : 'p.last_seen DESC'; - // No search term: just list players, ordered per the chosen sort. + let rows: PlayerRow[]; + if (!q) { - const rows = await query( + // No search term: just list players, ordered per the chosen sort. + rows = await query( `SELECT p.steamid, p.current_name, p.current_country, NULL AS matched_name, - ${TOTAL_MINUTES_SUBQUERY} + ${TOTAL_MINUTES_SUBQUERY}, + ${LAST_CONNECTED_SUBQUERY} FROM playtime_display_players p ORDER BY ${orderBy} LIMIT 50`, ); - return NextResponse.json({ players: rows }); + } else { + // Search matches steamid directly, or current/previous names. + const like = `%${q}%`; + rows = await query( + `SELECT p.steamid, p.current_name, p.current_country, h.player_name AS matched_name, + ${TOTAL_MINUTES_SUBQUERY}, + ${LAST_CONNECTED_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], + ); } - // 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 }); + const players = await attachAvatars(rows); + return NextResponse.json({ players }); } diff --git a/discord_verificiation/playtime-frontend/app/api/population/current/route.ts b/discord_verificiation/playtime-frontend/app/api/population/current/route.ts index 3e75e7e..8a6d46e 100644 --- a/discord_verificiation/playtime-frontend/app/api/population/current/route.ts +++ b/discord_verificiation/playtime-frontend/app/api/population/current/route.ts @@ -1,24 +1,16 @@ import { NextResponse } from 'next/server'; import { getCurrentPlayers } from '@/lib/queries'; -import { getSteamSummariesBatch, DEFAULT_AVATAR_URL } from '@/lib/steamApi'; -import { steam2ToSteam64 } from '@/lib/steam'; +import { attachAvatars } from '@/lib/steamApi'; export async function GET() { - const players = await getCurrentPlayers(3); + const rows = await getCurrentPlayers(3); + const enriched = await attachAvatars(rows); + const players = enriched.map((p) => ({ + steamid: p.steamid, + name: p.current_name, + country: p.current_country, + avatarUrl: p.avatarUrl, + })); - 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 }); + return NextResponse.json({ players, count: players.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 index a9da1d4..8485e31 100644 --- a/discord_verificiation/playtime-frontend/app/api/population/players/route.ts +++ b/discord_verificiation/playtime-frontend/app/api/population/players/route.ts @@ -1,7 +1,6 @@ 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 { attachAvatars } from '@/lib/steamApi'; import { formatDbDateTime } from '@/lib/timezone'; export async function GET(req: NextRequest) { @@ -15,21 +14,14 @@ export async function GET(req: NextRequest) { return NextResponse.json({ error: 'invalid at timestamp' }, { status: 400 }); } - const players = await getPlayersAtTime(formatDbDateTime(atDate)); + const rows = await getPlayersAtTime(formatDbDateTime(atDate)); + const enriched = await attachAvatars(rows); + const players = enriched.map((p) => ({ + steamid: p.steamid, + name: p.current_name, + country: p.current_country, + avatarUrl: p.avatarUrl, + })); - 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 }); + return NextResponse.json({ players }); } diff --git a/discord_verificiation/playtime-frontend/app/api/population/recurring/players/route.ts b/discord_verificiation/playtime-frontend/app/api/population/recurring/players/route.ts index 5244667..d26a7d0 100644 --- a/discord_verificiation/playtime-frontend/app/api/population/recurring/players/route.ts +++ b/discord_verificiation/playtime-frontend/app/api/population/recurring/players/route.ts @@ -1,12 +1,12 @@ import { NextRequest, NextResponse } from 'next/server'; import { query } from '@/lib/db'; -import { getSteamSummariesBatch, DEFAULT_AVATAR_URL } from '@/lib/steamApi'; -import { steam2ToSteam64 } from '@/lib/steam'; +import { attachAvatars } from '@/lib/steamApi'; import { parseDbDateTime } from '@/lib/timezone'; interface SessionRow { steamid: string; current_name: string; + current_country: string | null; session_start_dt: string; session_end_dt: string | null; } @@ -28,7 +28,7 @@ export async function GET(req: NextRequest) { const windowMinutes = Math.round((windowEndMs - windowStartMs) / 60000); const rows = await query( - `SELECT s.steamid, COALESCE(p.current_name, s.player_name) AS current_name, + `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 @@ -44,7 +44,10 @@ export async function GET(req: NextRequest) { startMin: number; endMin: number; } - const byPlayer = new Map(); + const byPlayer = new Map< + string, + { name: string; country: string | null; segments: Segment[]; totalMinutes: number } + >(); for (const r of rows) { const sStart = parseDbDateTime(r.session_start_dt).getTime(); @@ -57,33 +60,22 @@ export async function GET(req: NextRequest) { const endMin = Math.round((clippedEnd - windowStartMs) / 60000); if (!byPlayer.has(r.steamid)) { - byPlayer.set(r.steamid, { name: r.current_name, segments: [], totalMinutes: 0 }); + byPlayer.set(r.steamid, { name: r.current_name, country: r.current_country, segments: [], totalMinutes: 0 }); } const entry = byPlayer.get(r.steamid)!; entry.segments.push({ startMin, endMin }); entry.totalMinutes += endMin - startMin; } - const steam64ByPlayer = new Map(); - for (const steamid of byPlayer.keys()) { - const id64 = steam2ToSteam64(steamid); - if (id64) steam64ByPlayer.set(steamid, id64); - } - const avatars = await getSteamSummariesBatch([...steam64ByPlayer.values()]); + const unenriched = [...byPlayer.entries()].map(([steamid, data]) => ({ + steamid, + name: data.name, + country: data.country, + totalMinutes: data.totalMinutes, + segments: data.segments, + })); - const players = [...byPlayer.entries()] - .map(([steamid, data]) => { - const id64 = steam64ByPlayer.get(steamid); - const avatarUrl = (id64 && avatars.get(id64)?.avatarUrl) || DEFAULT_AVATAR_URL; - return { - steamid, - name: data.name, - avatarUrl, - totalMinutes: data.totalMinutes, - segments: data.segments, - }; - }) - .sort((a, b) => b.totalMinutes - a.totalMinutes); + const players = (await attachAvatars(unenriched)).sort((a, b) => b.totalMinutes - a.totalMinutes); return NextResponse.json({ windowMinutes, players }); } diff --git a/discord_verificiation/playtime-frontend/app/countries/[code]/page.tsx b/discord_verificiation/playtime-frontend/app/countries/[code]/page.tsx index aeec0a9..c5a9aa8 100644 --- a/discord_verificiation/playtime-frontend/app/countries/[code]/page.tsx +++ b/discord_verificiation/playtime-frontend/app/countries/[code]/page.tsx @@ -1,8 +1,13 @@ import Link from 'next/link'; import { notFound } from 'next/navigation'; import { getPlayersByCountry } from '@/lib/queries'; +import { attachAvatars } from '@/lib/steamApi'; import { countryCodeToFlag, countryCodeToName, formatMinutes } from '@/lib/steam'; +// Direct DB query, no dynamic route data needed at build time — same +// reasoning as the /countries listing page. +export const dynamic = 'force-dynamic'; + export default async function CountryPlayersPage({ params, searchParams, @@ -11,15 +16,20 @@ export default async function CountryPlayersPage({ searchParams: Promise<{ sort?: string }>; }) { const { code: rawCode } = await params; - const code = decodeURIComponent(rawCode).toUpperCase(); + const decoded = decodeURIComponent(rawCode); + const isUnknown = decoded.toLowerCase() === 'unknown'; + const code = isUnknown ? null : decoded.toUpperCase(); const { sort: rawSort } = await searchParams; const sort = rawSort === 'playtime' ? 'playtime' : 'recent'; - const players = await getPlayersByCountry(code, sort); - if (players.length === 0) { + const rows = await getPlayersByCountry(code, sort); + if (rows.length === 0) { notFound(); } + const players = await attachAvatars(rows); + + const linkBase = isUnknown ? '/countries/unknown' : `/countries/${encodeURIComponent(code!)}`; return (
@@ -40,7 +50,7 @@ export default async function CountryPlayersPage({
-
{p.current_name}
+
+ {/* eslint-disable-next-line @next/next/no-img-element */} + +
{p.current_name}
+
{p.steamid} diff --git a/discord_verificiation/playtime-frontend/app/countries/page.tsx b/discord_verificiation/playtime-frontend/app/countries/page.tsx index 21ddf89..10dc361 100644 --- a/discord_verificiation/playtime-frontend/app/countries/page.tsx +++ b/discord_verificiation/playtime-frontend/app/countries/page.tsx @@ -30,8 +30,8 @@ export default async function CountriesPage() {
{countries.map((c) => (
@@ -39,7 +39,9 @@ export default async function CountriesPage() { {countryCodeToFlag(c.current_country)} {countryCodeToName(c.current_country)} - {c.current_country} + {c.current_country && ( + {c.current_country} + )} {c.player_count} player{c.player_count === 1 ? '' : 's'} diff --git a/discord_verificiation/playtime-frontend/app/maps/period/[mapId]/page.tsx b/discord_verificiation/playtime-frontend/app/maps/period/[mapId]/page.tsx index e75dd41..283dfd1 100644 --- a/discord_verificiation/playtime-frontend/app/maps/period/[mapId]/page.tsx +++ b/discord_verificiation/playtime-frontend/app/maps/period/[mapId]/page.tsx @@ -6,6 +6,7 @@ import { getMapVotes, getAdjacentMaps, } from '@/lib/queries'; +import { attachAvatars } from '@/lib/steamApi'; import { diffMinutes } from '@/lib/dates'; import { formatMinutes, countryCodeToFlag } from '@/lib/steam'; @@ -26,11 +27,12 @@ export default async function MapPeriodDetailPage({ notFound(); } - const [players, votes, adjacent] = await Promise.all([ + const [playersRaw, votesRaw, adjacent] = await Promise.all([ getPlayersPresentDuringMap(mapId), getMapVotes(mapId), getAdjacentMaps(period.map_start_dt), ]); + const [players, votes] = await Promise.all([attachAvatars(playersRaw), attachAvatars(votesRaw)]); // Group vote rows by their vote event (a map can theoretically have more // than one vote called during its runtime). @@ -115,6 +117,12 @@ export default async function MapPeriodDetailPage({ className="flex items-center justify-between py-2 text-sm hover:bg-base transition-colors px-2 -mx-2 rounded" >
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {countryCodeToFlag(p.current_country)} {p.current_name}
@@ -152,7 +160,16 @@ export default async function MapPeriodDetailPage({ href={`/players/${encodeURIComponent(c.steamid)}`} className="flex items-center justify-between py-2 text-sm hover:bg-base transition-colors px-2 -mx-2 rounded" > - {c.current_name} +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + + {countryCodeToFlag(c.current_country)} + {c.current_name} +
voted {c.vote_choice} {c.vote_weight !== 1 && ( diff --git a/discord_verificiation/playtime-frontend/components/MapPopulationChart.tsx b/discord_verificiation/playtime-frontend/components/MapPopulationChart.tsx index 0e13683..0019956 100644 --- a/discord_verificiation/playtime-frontend/components/MapPopulationChart.tsx +++ b/discord_verificiation/playtime-frontend/components/MapPopulationChart.tsx @@ -17,6 +17,7 @@ interface PlayerChip { steamid: string; name: string; avatarUrl: string; + country?: string | null; } const HEIGHT = 288; diff --git a/discord_verificiation/playtime-frontend/components/PlayerChipList.tsx b/discord_verificiation/playtime-frontend/components/PlayerChipList.tsx index 55f0d1c..390ef3f 100644 --- a/discord_verificiation/playtime-frontend/components/PlayerChipList.tsx +++ b/discord_verificiation/playtime-frontend/components/PlayerChipList.tsx @@ -1,11 +1,13 @@ 'use client'; import Link from 'next/link'; +import { countryCodeToFlag } from '@/lib/steam'; interface PlayerChip { steamid: string; name: string; avatarUrl: string; + country?: string | null; } export default function PlayerChipList({ @@ -33,6 +35,11 @@ export default function PlayerChipList({ > {/* eslint-disable-next-line @next/next/no-img-element */} + {p.country !== undefined && ( + + {countryCodeToFlag(p.country)} + + )} {p.name} ))} diff --git a/discord_verificiation/playtime-frontend/components/PlayerSearch.tsx b/discord_verificiation/playtime-frontend/components/PlayerSearch.tsx index 55f7955..8c8fbaa 100644 --- a/discord_verificiation/playtime-frontend/components/PlayerSearch.tsx +++ b/discord_verificiation/playtime-frontend/components/PlayerSearch.tsx @@ -3,6 +3,7 @@ import { useEffect, useState } from 'react'; import Link from 'next/link'; import { countryCodeToFlag, formatMinutes } from '@/lib/steam'; +import { formatRelativeTime } from '@/lib/dates'; interface Player { steamid: string; @@ -10,6 +11,8 @@ interface Player { current_country: string | null; matched_name: string | null; total_minutes: number | null; + last_connected: string | null; + avatarUrl: string; } export default function PlayerSearch() { @@ -65,9 +68,13 @@ export default function PlayerSearch() { className="flex items-center justify-between px-4 py-3 hover:bg-base transition-colors" >
- - {countryCodeToFlag(p.current_country)} - + {/* eslint-disable-next-line @next/next/no-img-element */} + + {countryCodeToFlag(p.current_country)}
{p.current_name}
{p.matched_name && p.matched_name !== p.current_name && ( @@ -76,7 +83,12 @@ export default function PlayerSearch() {
- {formatMinutes(p.total_minutes)} + + {formatRelativeTime(p.last_connected)} + + + {formatMinutes(p.total_minutes)} + {p.steamid}
diff --git a/discord_verificiation/playtime-frontend/components/PlayerTimelineChart.tsx b/discord_verificiation/playtime-frontend/components/PlayerTimelineChart.tsx index 4048db7..a5bca5e 100644 --- a/discord_verificiation/playtime-frontend/components/PlayerTimelineChart.tsx +++ b/discord_verificiation/playtime-frontend/components/PlayerTimelineChart.tsx @@ -1,7 +1,7 @@ 'use client'; import Link from 'next/link'; -import { formatMinutes } from '@/lib/steam'; +import { formatMinutes, countryCodeToFlag } from '@/lib/steam'; interface Segment { startMin: number; @@ -12,6 +12,7 @@ interface TimelinePlayer { steamid: string; name: string; avatarUrl: string; + country?: string | null; totalMinutes: number; segments: Segment[]; } @@ -53,6 +54,11 @@ export default function PlayerTimelineChart({ > {/* eslint-disable-next-line @next/next/no-img-element */} + {p.country !== undefined && ( + + {countryCodeToFlag(p.country)} + + )} {p.name} diff --git a/discord_verificiation/playtime-frontend/components/PopulationChart.tsx b/discord_verificiation/playtime-frontend/components/PopulationChart.tsx index f71fd19..b733fd8 100644 --- a/discord_verificiation/playtime-frontend/components/PopulationChart.tsx +++ b/discord_verificiation/playtime-frontend/components/PopulationChart.tsx @@ -21,6 +21,7 @@ interface PlayerChip { steamid: string; name: string; avatarUrl: string; + country?: string | null; } const HEIGHT = 320; diff --git a/discord_verificiation/playtime-frontend/lib/dates.ts b/discord_verificiation/playtime-frontend/lib/dates.ts index 33bcf84..0f48430 100644 --- a/discord_verificiation/playtime-frontend/lib/dates.ts +++ b/discord_verificiation/playtime-frontend/lib/dates.ts @@ -12,3 +12,19 @@ export function diffMinutes(startStr: string, endStr: string | null): number | n const end = parseDbDate(endStr); return Math.round((end.getTime() - start.getTime()) / 60000); } + +// "2h ago", "3d ago", etc. — used for "last connected" displays. +export function formatRelativeTime(dbDateStr: string | null): string { + if (!dbDateStr) return '—'; + const diffMs = Date.now() - parseDbDate(dbDateStr).getTime(); + const diffMin = Math.round(diffMs / 60_000); + if (diffMin < 1) return 'just now'; + if (diffMin < 60) return `${diffMin}m ago`; + const diffHr = Math.round(diffMin / 60); + if (diffHr < 24) return `${diffHr}h ago`; + const diffDay = Math.round(diffHr / 24); + if (diffDay < 30) return `${diffDay}d ago`; + const diffMonth = Math.round(diffDay / 30); + if (diffMonth < 12) return `${diffMonth}mo ago`; + return `${Math.round(diffMonth / 12)}y ago`; +} diff --git a/discord_verificiation/playtime-frontend/lib/queries.ts b/discord_verificiation/playtime-frontend/lib/queries.ts index ecf92ed..921e960 100644 --- a/discord_verificiation/playtime-frontend/lib/queries.ts +++ b/discord_verificiation/playtime-frontend/lib/queries.ts @@ -120,35 +120,61 @@ export async function getMapPopulationSeries(limit = 40, before?: string): Promi } export interface CountrySummary { - current_country: string; + current_country: string | null; // null represents the "Unknown" bucket player_count: number; total_minutes: number; avg_minutes: number; } export async function getCountriesSummary(): Promise { - // Players with no resolved country are excluded — there's no meaningful - // "unknown" country page to select. Each player's playtime is the same - // "latest session counter snapshot" logic used everywhere else, wrapped - // in a derived table so it can be aggregated per country. - return query( - `SELECT p.current_country, COUNT(*) AS player_count, - SUM(pt.total_minutes) AS total_minutes, - AVG(pt.total_minutes) AS avg_minutes - FROM playtime_display_players p - JOIN ( - SELECT p2.steamid, - (SELECT COALESCE(s.session_end_playtime_minutes, s.session_start_playtime_minutes) - FROM playtime_display_sessions s - WHERE s.steamid = p2.steamid - ORDER BY s.session_id DESC - LIMIT 1) AS total_minutes - FROM playtime_display_players p2 - ) pt ON pt.steamid = p.steamid - WHERE p.current_country IS NOT NULL - GROUP BY p.current_country - ORDER BY player_count DESC, p.current_country ASC`, + // "Unknown" covers both NULL and '' — the actual sentinel written by the + // plugin when GeoipCode2 can't resolve a country (private/local IP, GeoIP + // miss, etc.) is an empty string, not SQL NULL, since the upsert always + // writes some value rather than leaving the column untouched. NULL is + // also handled in case any row ever ends up there some other way (e.g. + // the column's own DEFAULT). + const UNKNOWN = `(p.current_country IS NULL OR p.current_country = '')`; + const KNOWN = `(p.current_country IS NOT NULL AND p.current_country != '')`; + const rows = await query( + `SELECT * FROM ( + SELECT p.current_country, COUNT(*) AS player_count, + SUM(pt.total_minutes) AS total_minutes, + AVG(pt.total_minutes) AS avg_minutes + FROM playtime_display_players p + JOIN ( + SELECT p2.steamid, + (SELECT COALESCE(s.session_end_playtime_minutes, s.session_start_playtime_minutes) + FROM playtime_display_sessions s + WHERE s.steamid = p2.steamid + ORDER BY s.session_id DESC + LIMIT 1) AS total_minutes + FROM playtime_display_players p2 + ) pt ON pt.steamid = p.steamid + WHERE ${KNOWN} + GROUP BY p.current_country + + UNION ALL + + SELECT NULL AS current_country, COUNT(*) AS player_count, + SUM(pt.total_minutes) AS total_minutes, + AVG(pt.total_minutes) AS avg_minutes + FROM playtime_display_players p + JOIN ( + SELECT p2.steamid, + (SELECT COALESCE(s.session_end_playtime_minutes, s.session_start_playtime_minutes) + FROM playtime_display_sessions s + WHERE s.steamid = p2.steamid + ORDER BY s.session_id DESC + LIMIT 1) AS total_minutes + FROM playtime_display_players p2 + ) pt ON pt.steamid = p.steamid + WHERE ${UNKNOWN} + ) t + ORDER BY player_count DESC, current_country IS NULL, current_country ASC`, ); + // Skip the Unknown bucket entirely if it's empty, rather than showing a + // dead "0 players" row. + return rows.filter((r) => r.current_country !== null || r.player_count > 0); } export interface CountryPlayer { @@ -159,7 +185,7 @@ export interface CountryPlayer { } export async function getPlayersByCountry( - countryCode: string, + countryCode: string | null, sort: 'playtime' | 'recent', ): Promise { const orderBy = sort === 'playtime' ? 'total_minutes DESC' : 'p.last_seen DESC'; @@ -172,10 +198,10 @@ export async function getPlayersByCountry( LIMIT 1 ) AS total_minutes FROM playtime_display_players p - WHERE p.current_country = ? + WHERE ${countryCode === null ? "(p.current_country IS NULL OR p.current_country = '')" : 'p.current_country = ?'} ORDER BY ${orderBy} LIMIT 200`, - [countryCode], + countryCode === null ? [] : [countryCode], ); } @@ -208,6 +234,7 @@ export interface MapVoteRow { result: string | null; steamid: string; current_name: string; + current_country: string | null; vote_choice: string; vote_weight: number; } @@ -273,7 +300,8 @@ export async function getPlayersPresentDuringMap(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 + c.steamid, COALESCE(p.current_name, c.steamid) AS current_name, p.current_country, + 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 @@ -312,6 +340,7 @@ export async function getAdjacentMaps( export interface CurrentPlayer { steamid: string; current_name: string; + current_country: string | null; } /** @@ -342,7 +371,8 @@ export async function getCurrentPlayers(freshnessMinutes = 3): Promise( - `SELECT s.steamid, COALESCE(MAX(p.current_name), MAX(s.player_name)) AS current_name + `SELECT s.steamid, COALESCE(MAX(p.current_name), MAX(s.player_name)) AS current_name, + MAX(p.current_country) AS current_country FROM playtime_display_sessions s LEFT JOIN playtime_display_players p ON p.steamid = s.steamid WHERE s.session_end_dt > NOW() - INTERVAL ? MINUTE @@ -356,11 +386,13 @@ export async function getCurrentPlayers(freshnessMinutes = 3): Promise { return query( - `SELECT s.steamid, COALESCE(MAX(p.current_name), MAX(s.player_name)) AS current_name + `SELECT s.steamid, COALESCE(MAX(p.current_name), MAX(s.player_name)) AS current_name, + MAX(p.current_country) AS current_country FROM playtime_display_sessions s LEFT JOIN playtime_display_players p ON p.steamid = s.steamid WHERE s.session_start_dt <= ? diff --git a/discord_verificiation/playtime-frontend/lib/steamApi.ts b/discord_verificiation/playtime-frontend/lib/steamApi.ts index 89b6497..89c8980 100644 --- a/discord_verificiation/playtime-frontend/lib/steamApi.ts +++ b/discord_verificiation/playtime-frontend/lib/steamApi.ts @@ -1,3 +1,5 @@ +import { steam2ToSteam64 } from './steam'; + export interface SteamSummary { avatarUrl: string; personaName: string | null; @@ -92,3 +94,28 @@ export async function getSteamSummariesBatch( return result; } + +/** + * Attaches avatarUrl to any list of objects that have a `steamid` (Steam2 + * format) field — the shared enrichment step used everywhere a list of + * players gets rendered, so every list in the app shows avatars + * consistently without duplicating the batch-fetch/fallback logic per call + * site. + */ +export async function attachAvatars( + players: T[], +): Promise<(T & { avatarUrl: string })[]> { + 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()]); + + return players.map((p) => { + const id64 = steam64ByPlayer.get(p.steamid); + const avatarUrl = (id64 && avatars.get(id64)?.avatarUrl) || DEFAULT_AVATAR_URL; + return { ...p, avatarUrl }; + }); +}