some more styling to the frontend
This commit is contained in:
@@ -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
|
(adjust the domain and cert paths). After linking it into
|
||||||
`/etc/nginx/sites-enabled/`, reload nginx to pick it up.
|
`/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
|
## Notes
|
||||||
|
|
||||||
- `session_end_dt` is refreshed roughly every 60s by the plugin as a
|
- `session_end_dt` is refreshed roughly every 60s by the plugin as a
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { getPlayersPresentDuringMap } from '@/lib/queries';
|
import { getPlayersPresentDuringMap } from '@/lib/queries';
|
||||||
import { getSteamSummariesBatch, DEFAULT_AVATAR_URL } from '@/lib/steamApi';
|
import { attachAvatars } from '@/lib/steamApi';
|
||||||
import { steam2ToSteam64 } from '@/lib/steam';
|
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
const mapIdParam = req.nextUrl.searchParams.get('mapId');
|
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 });
|
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<string, string>();
|
return NextResponse.json({ players });
|
||||||
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 });
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { query } from '@/lib/db';
|
import { query } from '@/lib/db';
|
||||||
|
import { attachAvatars } from '@/lib/steamApi';
|
||||||
|
|
||||||
interface PlayerRow {
|
interface PlayerRow {
|
||||||
steamid: string;
|
steamid: string;
|
||||||
@@ -7,6 +8,7 @@ interface PlayerRow {
|
|||||||
current_country: string | null;
|
current_country: string | null;
|
||||||
matched_name: string | null;
|
matched_name: string | null;
|
||||||
total_minutes: number | null;
|
total_minutes: number | null;
|
||||||
|
last_connected: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TOTAL_MINUTES_SUBQUERY = `
|
const TOTAL_MINUTES_SUBQUERY = `
|
||||||
@@ -17,6 +19,12 @@ const TOTAL_MINUTES_SUBQUERY = `
|
|||||||
LIMIT 1) AS total_minutes
|
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) {
|
export async function GET(req: NextRequest) {
|
||||||
const q = req.nextUrl.searchParams.get('q')?.trim() ?? '';
|
const q = req.nextUrl.searchParams.get('q')?.trim() ?? '';
|
||||||
const sort = req.nextUrl.searchParams.get('sort') ?? 'recent';
|
const sort = req.nextUrl.searchParams.get('sort') ?? 'recent';
|
||||||
@@ -28,37 +36,41 @@ export async function GET(req: NextRequest) {
|
|||||||
? 'p.current_name'
|
? 'p.current_name'
|
||||||
: 'p.last_seen DESC';
|
: 'p.last_seen DESC';
|
||||||
|
|
||||||
// No search term: just list players, ordered per the chosen sort.
|
let rows: PlayerRow[];
|
||||||
|
|
||||||
if (!q) {
|
if (!q) {
|
||||||
const rows = await query<PlayerRow>(
|
// No search term: just list players, ordered per the chosen sort.
|
||||||
|
rows = await query<PlayerRow>(
|
||||||
`SELECT p.steamid, p.current_name, p.current_country, NULL AS matched_name,
|
`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
|
FROM playtime_display_players p
|
||||||
ORDER BY ${orderBy}
|
ORDER BY ${orderBy}
|
||||||
LIMIT 50`,
|
LIMIT 50`,
|
||||||
);
|
);
|
||||||
return NextResponse.json({ players: rows });
|
} else {
|
||||||
|
// Search matches steamid directly, or current/previous names.
|
||||||
|
const like = `%${q}%`;
|
||||||
|
rows = await query<PlayerRow>(
|
||||||
|
`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 players = await attachAvatars(rows);
|
||||||
const like = `%${q}%`;
|
return NextResponse.json({ players });
|
||||||
const rows = await query<PlayerRow>(
|
|
||||||
`SELECT p.steamid, p.current_name, p.current_country, h.player_name AS matched_name,
|
|
||||||
${TOTAL_MINUTES_SUBQUERY}
|
|
||||||
FROM playtime_display_players p
|
|
||||||
LEFT JOIN playtime_display_name_history h
|
|
||||||
ON h.steamid = p.steamid AND h.player_name LIKE ?
|
|
||||||
WHERE p.steamid LIKE ?
|
|
||||||
OR p.current_name LIKE ?
|
|
||||||
OR EXISTS (
|
|
||||||
SELECT 1 FROM playtime_display_name_history h2
|
|
||||||
WHERE h2.steamid = p.steamid AND h2.player_name LIKE ?
|
|
||||||
)
|
|
||||||
GROUP BY p.steamid
|
|
||||||
ORDER BY ${orderBy}
|
|
||||||
LIMIT 50`,
|
|
||||||
[like, like, like, like],
|
|
||||||
);
|
|
||||||
|
|
||||||
return NextResponse.json({ players: rows });
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,16 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { getCurrentPlayers } from '@/lib/queries';
|
import { getCurrentPlayers } from '@/lib/queries';
|
||||||
import { getSteamSummariesBatch, DEFAULT_AVATAR_URL } from '@/lib/steamApi';
|
import { attachAvatars } from '@/lib/steamApi';
|
||||||
import { steam2ToSteam64 } from '@/lib/steam';
|
|
||||||
|
|
||||||
export async function GET() {
|
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<string, string>();
|
return NextResponse.json({ players, count: players.length });
|
||||||
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 });
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { getPlayersAtTime } from '@/lib/queries';
|
import { getPlayersAtTime } from '@/lib/queries';
|
||||||
import { getSteamSummariesBatch, DEFAULT_AVATAR_URL } from '@/lib/steamApi';
|
import { attachAvatars } from '@/lib/steamApi';
|
||||||
import { steam2ToSteam64 } from '@/lib/steam';
|
|
||||||
import { formatDbDateTime } from '@/lib/timezone';
|
import { formatDbDateTime } from '@/lib/timezone';
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
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 });
|
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<string, string>();
|
return NextResponse.json({ players });
|
||||||
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 });
|
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-24
@@ -1,12 +1,12 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { query } from '@/lib/db';
|
import { query } from '@/lib/db';
|
||||||
import { getSteamSummariesBatch, DEFAULT_AVATAR_URL } from '@/lib/steamApi';
|
import { attachAvatars } from '@/lib/steamApi';
|
||||||
import { steam2ToSteam64 } from '@/lib/steam';
|
|
||||||
import { parseDbDateTime } from '@/lib/timezone';
|
import { parseDbDateTime } from '@/lib/timezone';
|
||||||
|
|
||||||
interface SessionRow {
|
interface SessionRow {
|
||||||
steamid: string;
|
steamid: string;
|
||||||
current_name: string;
|
current_name: string;
|
||||||
|
current_country: string | null;
|
||||||
session_start_dt: string;
|
session_start_dt: string;
|
||||||
session_end_dt: string | null;
|
session_end_dt: string | null;
|
||||||
}
|
}
|
||||||
@@ -28,7 +28,7 @@ export async function GET(req: NextRequest) {
|
|||||||
const windowMinutes = Math.round((windowEndMs - windowStartMs) / 60000);
|
const windowMinutes = Math.round((windowEndMs - windowStartMs) / 60000);
|
||||||
|
|
||||||
const rows = await query<SessionRow>(
|
const rows = await query<SessionRow>(
|
||||||
`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
|
s.session_start_dt, s.session_end_dt
|
||||||
FROM playtime_display_sessions s
|
FROM playtime_display_sessions s
|
||||||
LEFT JOIN playtime_display_players p ON p.steamid = s.steamid
|
LEFT JOIN playtime_display_players p ON p.steamid = s.steamid
|
||||||
@@ -44,7 +44,10 @@ export async function GET(req: NextRequest) {
|
|||||||
startMin: number;
|
startMin: number;
|
||||||
endMin: number;
|
endMin: number;
|
||||||
}
|
}
|
||||||
const byPlayer = new Map<string, { name: string; segments: Segment[]; totalMinutes: number }>();
|
const byPlayer = new Map<
|
||||||
|
string,
|
||||||
|
{ name: string; country: string | null; segments: Segment[]; totalMinutes: number }
|
||||||
|
>();
|
||||||
|
|
||||||
for (const r of rows) {
|
for (const r of rows) {
|
||||||
const sStart = parseDbDateTime(r.session_start_dt).getTime();
|
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);
|
const endMin = Math.round((clippedEnd - windowStartMs) / 60000);
|
||||||
|
|
||||||
if (!byPlayer.has(r.steamid)) {
|
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)!;
|
const entry = byPlayer.get(r.steamid)!;
|
||||||
entry.segments.push({ startMin, endMin });
|
entry.segments.push({ startMin, endMin });
|
||||||
entry.totalMinutes += endMin - startMin;
|
entry.totalMinutes += endMin - startMin;
|
||||||
}
|
}
|
||||||
|
|
||||||
const steam64ByPlayer = new Map<string, string>();
|
const unenriched = [...byPlayer.entries()].map(([steamid, data]) => ({
|
||||||
for (const steamid of byPlayer.keys()) {
|
steamid,
|
||||||
const id64 = steam2ToSteam64(steamid);
|
name: data.name,
|
||||||
if (id64) steam64ByPlayer.set(steamid, id64);
|
country: data.country,
|
||||||
}
|
totalMinutes: data.totalMinutes,
|
||||||
const avatars = await getSteamSummariesBatch([...steam64ByPlayer.values()]);
|
segments: data.segments,
|
||||||
|
}));
|
||||||
|
|
||||||
const players = [...byPlayer.entries()]
|
const players = (await attachAvatars(unenriched)).sort((a, b) => b.totalMinutes - a.totalMinutes);
|
||||||
.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);
|
|
||||||
|
|
||||||
return NextResponse.json({ windowMinutes, players });
|
return NextResponse.json({ windowMinutes, players });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { notFound } from 'next/navigation';
|
import { notFound } from 'next/navigation';
|
||||||
import { getPlayersByCountry } from '@/lib/queries';
|
import { getPlayersByCountry } from '@/lib/queries';
|
||||||
|
import { attachAvatars } from '@/lib/steamApi';
|
||||||
import { countryCodeToFlag, countryCodeToName, formatMinutes } from '@/lib/steam';
|
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({
|
export default async function CountryPlayersPage({
|
||||||
params,
|
params,
|
||||||
searchParams,
|
searchParams,
|
||||||
@@ -11,15 +16,20 @@ export default async function CountryPlayersPage({
|
|||||||
searchParams: Promise<{ sort?: string }>;
|
searchParams: Promise<{ sort?: string }>;
|
||||||
}) {
|
}) {
|
||||||
const { code: rawCode } = await params;
|
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 } = await searchParams;
|
||||||
const sort = rawSort === 'playtime' ? 'playtime' : 'recent';
|
const sort = rawSort === 'playtime' ? 'playtime' : 'recent';
|
||||||
|
|
||||||
const players = await getPlayersByCountry(code, sort);
|
const rows = await getPlayersByCountry(code, sort);
|
||||||
if (players.length === 0) {
|
if (rows.length === 0) {
|
||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
const players = await attachAvatars(rows);
|
||||||
|
|
||||||
|
const linkBase = isUnknown ? '/countries/unknown' : `/countries/${encodeURIComponent(code!)}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -40,7 +50,7 @@ export default async function CountryPlayersPage({
|
|||||||
|
|
||||||
<div className="flex gap-2 text-sm">
|
<div className="flex gap-2 text-sm">
|
||||||
<Link
|
<Link
|
||||||
href={`/countries/${encodeURIComponent(code)}?sort=recent`}
|
href={`${linkBase}?sort=recent`}
|
||||||
className={`px-3 py-1.5 rounded border transition-colors ${
|
className={`px-3 py-1.5 rounded border transition-colors ${
|
||||||
sort === 'recent'
|
sort === 'recent'
|
||||||
? 'border-accent/50 text-accent bg-accent-dim/20'
|
? 'border-accent/50 text-accent bg-accent-dim/20'
|
||||||
@@ -50,7 +60,7 @@ export default async function CountryPlayersPage({
|
|||||||
Most recently played
|
Most recently played
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href={`/countries/${encodeURIComponent(code)}?sort=playtime`}
|
href={`${linkBase}?sort=playtime`}
|
||||||
className={`px-3 py-1.5 rounded border transition-colors ${
|
className={`px-3 py-1.5 rounded border transition-colors ${
|
||||||
sort === 'playtime'
|
sort === 'playtime'
|
||||||
? 'border-accent/50 text-accent bg-accent-dim/20'
|
? 'border-accent/50 text-accent bg-accent-dim/20'
|
||||||
@@ -69,7 +79,15 @@ export default async function CountryPlayersPage({
|
|||||||
href={`/players/${encodeURIComponent(p.steamid)}`}
|
href={`/players/${encodeURIComponent(p.steamid)}`}
|
||||||
className="flex items-center justify-between px-4 py-3 hover:bg-base transition-colors"
|
className="flex items-center justify-between px-4 py-3 hover:bg-base transition-colors"
|
||||||
>
|
>
|
||||||
<div className="text-ink text-sm">{p.current_name}</div>
|
<div className="flex items-center gap-3">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={p.avatarUrl}
|
||||||
|
alt=""
|
||||||
|
className="w-9 h-9 rounded-full object-cover ring-1 ring-base-border shrink-0"
|
||||||
|
/>
|
||||||
|
<div className="text-ink text-sm">{p.current_name}</div>
|
||||||
|
</div>
|
||||||
<div className="flex items-center gap-4 text-xs">
|
<div className="flex items-center gap-4 text-xs">
|
||||||
<span className="text-ink-faint mono">{p.steamid}</span>
|
<span className="text-ink-faint mono">{p.steamid}</span>
|
||||||
<span className="text-ink-muted mono w-16 text-right">
|
<span className="text-ink-muted mono w-16 text-right">
|
||||||
|
|||||||
@@ -30,8 +30,8 @@ export default async function CountriesPage() {
|
|||||||
</div>
|
</div>
|
||||||
{countries.map((c) => (
|
{countries.map((c) => (
|
||||||
<Link
|
<Link
|
||||||
key={c.current_country}
|
key={c.current_country ?? 'unknown'}
|
||||||
href={`/countries/${encodeURIComponent(c.current_country)}`}
|
href={`/countries/${c.current_country ? encodeURIComponent(c.current_country) : 'unknown'}`}
|
||||||
className="grid grid-cols-[1fr_auto_auto] gap-6 items-center px-4 py-3 hover:bg-base transition-colors"
|
className="grid grid-cols-[1fr_auto_auto] gap-6 items-center px-4 py-3 hover:bg-base transition-colors"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3 min-w-0">
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
@@ -39,7 +39,9 @@ export default async function CountriesPage() {
|
|||||||
{countryCodeToFlag(c.current_country)}
|
{countryCodeToFlag(c.current_country)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-ink text-sm truncate">{countryCodeToName(c.current_country)}</span>
|
<span className="text-ink text-sm truncate">{countryCodeToName(c.current_country)}</span>
|
||||||
<span className="text-ink-faint text-xs mono shrink-0">{c.current_country}</span>
|
{c.current_country && (
|
||||||
|
<span className="text-ink-faint text-xs mono shrink-0">{c.current_country}</span>
|
||||||
|
)}
|
||||||
<span className="text-ink-muted text-xs mono shrink-0">
|
<span className="text-ink-muted text-xs mono shrink-0">
|
||||||
{c.player_count} player{c.player_count === 1 ? '' : 's'}
|
{c.player_count} player{c.player_count === 1 ? '' : 's'}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
getMapVotes,
|
getMapVotes,
|
||||||
getAdjacentMaps,
|
getAdjacentMaps,
|
||||||
} from '@/lib/queries';
|
} from '@/lib/queries';
|
||||||
|
import { attachAvatars } from '@/lib/steamApi';
|
||||||
import { diffMinutes } from '@/lib/dates';
|
import { diffMinutes } from '@/lib/dates';
|
||||||
import { formatMinutes, countryCodeToFlag } from '@/lib/steam';
|
import { formatMinutes, countryCodeToFlag } from '@/lib/steam';
|
||||||
|
|
||||||
@@ -26,11 +27,12 @@ export default async function MapPeriodDetailPage({
|
|||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
const [players, votes, adjacent] = await Promise.all([
|
const [playersRaw, votesRaw, adjacent] = await Promise.all([
|
||||||
getPlayersPresentDuringMap(mapId),
|
getPlayersPresentDuringMap(mapId),
|
||||||
getMapVotes(mapId),
|
getMapVotes(mapId),
|
||||||
getAdjacentMaps(period.map_start_dt),
|
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
|
// Group vote rows by their vote event (a map can theoretically have more
|
||||||
// than one vote called during its runtime).
|
// 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"
|
className="flex items-center justify-between py-2 text-sm hover:bg-base transition-colors px-2 -mx-2 rounded"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={p.avatarUrl}
|
||||||
|
alt=""
|
||||||
|
className="w-7 h-7 rounded-full object-cover ring-1 ring-base-border shrink-0"
|
||||||
|
/>
|
||||||
<span aria-hidden>{countryCodeToFlag(p.current_country)}</span>
|
<span aria-hidden>{countryCodeToFlag(p.current_country)}</span>
|
||||||
<span className="text-ink">{p.current_name}</span>
|
<span className="text-ink">{p.current_name}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -152,7 +160,16 @@ export default async function MapPeriodDetailPage({
|
|||||||
href={`/players/${encodeURIComponent(c.steamid)}`}
|
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"
|
className="flex items-center justify-between py-2 text-sm hover:bg-base transition-colors px-2 -mx-2 rounded"
|
||||||
>
|
>
|
||||||
<span className="text-ink">{c.current_name}</span>
|
<div className="flex items-center gap-2">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={c.avatarUrl}
|
||||||
|
alt=""
|
||||||
|
className="w-7 h-7 rounded-full object-cover ring-1 ring-base-border shrink-0"
|
||||||
|
/>
|
||||||
|
<span aria-hidden>{countryCodeToFlag(c.current_country)}</span>
|
||||||
|
<span className="text-ink">{c.current_name}</span>
|
||||||
|
</div>
|
||||||
<span className="text-ink-muted">
|
<span className="text-ink-muted">
|
||||||
voted <span className="text-ink">{c.vote_choice}</span>
|
voted <span className="text-ink">{c.vote_choice}</span>
|
||||||
{c.vote_weight !== 1 && (
|
{c.vote_weight !== 1 && (
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ interface PlayerChip {
|
|||||||
steamid: string;
|
steamid: string;
|
||||||
name: string;
|
name: string;
|
||||||
avatarUrl: string;
|
avatarUrl: string;
|
||||||
|
country?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const HEIGHT = 288;
|
const HEIGHT = 288;
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import { countryCodeToFlag } from '@/lib/steam';
|
||||||
|
|
||||||
interface PlayerChip {
|
interface PlayerChip {
|
||||||
steamid: string;
|
steamid: string;
|
||||||
name: string;
|
name: string;
|
||||||
avatarUrl: string;
|
avatarUrl: string;
|
||||||
|
country?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PlayerChipList({
|
export default function PlayerChipList({
|
||||||
@@ -33,6 +35,11 @@ export default function PlayerChipList({
|
|||||||
>
|
>
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
<img src={p.avatarUrl} alt="" className="w-6 h-6 rounded-full object-cover ring-1 ring-base-border" />
|
<img src={p.avatarUrl} alt="" className="w-6 h-6 rounded-full object-cover ring-1 ring-base-border" />
|
||||||
|
{p.country !== undefined && (
|
||||||
|
<span className="text-xs" aria-hidden>
|
||||||
|
{countryCodeToFlag(p.country)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<span className="text-sm text-ink">{p.name}</span>
|
<span className="text-sm text-ink">{p.name}</span>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { countryCodeToFlag, formatMinutes } from '@/lib/steam';
|
import { countryCodeToFlag, formatMinutes } from '@/lib/steam';
|
||||||
|
import { formatRelativeTime } from '@/lib/dates';
|
||||||
|
|
||||||
interface Player {
|
interface Player {
|
||||||
steamid: string;
|
steamid: string;
|
||||||
@@ -10,6 +11,8 @@ interface Player {
|
|||||||
current_country: string | null;
|
current_country: string | null;
|
||||||
matched_name: string | null;
|
matched_name: string | null;
|
||||||
total_minutes: number | null;
|
total_minutes: number | null;
|
||||||
|
last_connected: string | null;
|
||||||
|
avatarUrl: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PlayerSearch() {
|
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"
|
className="flex items-center justify-between px-4 py-3 hover:bg-base transition-colors"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className="text-lg" aria-hidden>
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
{countryCodeToFlag(p.current_country)}
|
<img
|
||||||
</span>
|
src={p.avatarUrl}
|
||||||
|
alt=""
|
||||||
|
className="w-9 h-9 rounded-full object-cover ring-1 ring-base-border shrink-0"
|
||||||
|
/>
|
||||||
|
<span aria-hidden>{countryCodeToFlag(p.current_country)}</span>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-ink text-sm">{p.current_name}</div>
|
<div className="text-ink text-sm">{p.current_name}</div>
|
||||||
{p.matched_name && p.matched_name !== p.current_name && (
|
{p.matched_name && p.matched_name !== p.current_name && (
|
||||||
@@ -76,7 +83,12 @@ export default function PlayerSearch() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<span className="mono text-ink-faint text-xs">{formatMinutes(p.total_minutes)}</span>
|
<span className="mono text-ink-faint text-xs w-16 text-right">
|
||||||
|
{formatRelativeTime(p.last_connected)}
|
||||||
|
</span>
|
||||||
|
<span className="mono text-ink-faint text-xs w-20 text-right">
|
||||||
|
{formatMinutes(p.total_minutes)}
|
||||||
|
</span>
|
||||||
<span className="mono text-ink-faint">{p.steamid}</span>
|
<span className="mono text-ink-faint">{p.steamid}</span>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { formatMinutes } from '@/lib/steam';
|
import { formatMinutes, countryCodeToFlag } from '@/lib/steam';
|
||||||
|
|
||||||
interface Segment {
|
interface Segment {
|
||||||
startMin: number;
|
startMin: number;
|
||||||
@@ -12,6 +12,7 @@ interface TimelinePlayer {
|
|||||||
steamid: string;
|
steamid: string;
|
||||||
name: string;
|
name: string;
|
||||||
avatarUrl: string;
|
avatarUrl: string;
|
||||||
|
country?: string | null;
|
||||||
totalMinutes: number;
|
totalMinutes: number;
|
||||||
segments: Segment[];
|
segments: Segment[];
|
||||||
}
|
}
|
||||||
@@ -53,6 +54,11 @@ export default function PlayerTimelineChart({
|
|||||||
>
|
>
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
<img src={p.avatarUrl} alt="" className="w-6 h-6 rounded-full object-cover ring-1 ring-base-border shrink-0" />
|
<img src={p.avatarUrl} alt="" className="w-6 h-6 rounded-full object-cover ring-1 ring-base-border shrink-0" />
|
||||||
|
{p.country !== undefined && (
|
||||||
|
<span className="text-xs shrink-0" aria-hidden>
|
||||||
|
{countryCodeToFlag(p.country)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<span className="text-sm text-ink truncate">{p.name}</span>
|
<span className="text-sm text-ink truncate">{p.name}</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ interface PlayerChip {
|
|||||||
steamid: string;
|
steamid: string;
|
||||||
name: string;
|
name: string;
|
||||||
avatarUrl: string;
|
avatarUrl: string;
|
||||||
|
country?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const HEIGHT = 320;
|
const HEIGHT = 320;
|
||||||
|
|||||||
@@ -12,3 +12,19 @@ export function diffMinutes(startStr: string, endStr: string | null): number | n
|
|||||||
const end = parseDbDate(endStr);
|
const end = parseDbDate(endStr);
|
||||||
return Math.round((end.getTime() - start.getTime()) / 60000);
|
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`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -120,35 +120,61 @@ export async function getMapPopulationSeries(limit = 40, before?: string): Promi
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface CountrySummary {
|
export interface CountrySummary {
|
||||||
current_country: string;
|
current_country: string | null; // null represents the "Unknown" bucket
|
||||||
player_count: number;
|
player_count: number;
|
||||||
total_minutes: number;
|
total_minutes: number;
|
||||||
avg_minutes: number;
|
avg_minutes: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getCountriesSummary(): Promise<CountrySummary[]> {
|
export async function getCountriesSummary(): Promise<CountrySummary[]> {
|
||||||
// Players with no resolved country are excluded — there's no meaningful
|
// "Unknown" covers both NULL and '' — the actual sentinel written by the
|
||||||
// "unknown" country page to select. Each player's playtime is the same
|
// plugin when GeoipCode2 can't resolve a country (private/local IP, GeoIP
|
||||||
// "latest session counter snapshot" logic used everywhere else, wrapped
|
// miss, etc.) is an empty string, not SQL NULL, since the upsert always
|
||||||
// in a derived table so it can be aggregated per country.
|
// writes some value rather than leaving the column untouched. NULL is
|
||||||
return query<CountrySummary>(
|
// also handled in case any row ever ends up there some other way (e.g.
|
||||||
`SELECT p.current_country, COUNT(*) AS player_count,
|
// the column's own DEFAULT).
|
||||||
SUM(pt.total_minutes) AS total_minutes,
|
const UNKNOWN = `(p.current_country IS NULL OR p.current_country = '')`;
|
||||||
AVG(pt.total_minutes) AS avg_minutes
|
const KNOWN = `(p.current_country IS NOT NULL AND p.current_country != '')`;
|
||||||
FROM playtime_display_players p
|
const rows = await query<CountrySummary>(
|
||||||
JOIN (
|
`SELECT * FROM (
|
||||||
SELECT p2.steamid,
|
SELECT p.current_country, COUNT(*) AS player_count,
|
||||||
(SELECT COALESCE(s.session_end_playtime_minutes, s.session_start_playtime_minutes)
|
SUM(pt.total_minutes) AS total_minutes,
|
||||||
FROM playtime_display_sessions s
|
AVG(pt.total_minutes) AS avg_minutes
|
||||||
WHERE s.steamid = p2.steamid
|
FROM playtime_display_players p
|
||||||
ORDER BY s.session_id DESC
|
JOIN (
|
||||||
LIMIT 1) AS total_minutes
|
SELECT p2.steamid,
|
||||||
FROM playtime_display_players p2
|
(SELECT COALESCE(s.session_end_playtime_minutes, s.session_start_playtime_minutes)
|
||||||
) pt ON pt.steamid = p.steamid
|
FROM playtime_display_sessions s
|
||||||
WHERE p.current_country IS NOT NULL
|
WHERE s.steamid = p2.steamid
|
||||||
GROUP BY p.current_country
|
ORDER BY s.session_id DESC
|
||||||
ORDER BY player_count DESC, p.current_country ASC`,
|
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 {
|
export interface CountryPlayer {
|
||||||
@@ -159,7 +185,7 @@ export interface CountryPlayer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getPlayersByCountry(
|
export async function getPlayersByCountry(
|
||||||
countryCode: string,
|
countryCode: string | null,
|
||||||
sort: 'playtime' | 'recent',
|
sort: 'playtime' | 'recent',
|
||||||
): Promise<CountryPlayer[]> {
|
): Promise<CountryPlayer[]> {
|
||||||
const orderBy = sort === 'playtime' ? 'total_minutes DESC' : 'p.last_seen DESC';
|
const orderBy = sort === 'playtime' ? 'total_minutes DESC' : 'p.last_seen DESC';
|
||||||
@@ -172,10 +198,10 @@ export async function getPlayersByCountry(
|
|||||||
LIMIT 1
|
LIMIT 1
|
||||||
) AS total_minutes
|
) AS total_minutes
|
||||||
FROM playtime_display_players p
|
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}
|
ORDER BY ${orderBy}
|
||||||
LIMIT 200`,
|
LIMIT 200`,
|
||||||
[countryCode],
|
countryCode === null ? [] : [countryCode],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,6 +234,7 @@ export interface MapVoteRow {
|
|||||||
result: string | null;
|
result: string | null;
|
||||||
steamid: string;
|
steamid: string;
|
||||||
current_name: string;
|
current_name: string;
|
||||||
|
current_country: string | null;
|
||||||
vote_choice: string;
|
vote_choice: string;
|
||||||
vote_weight: number;
|
vote_weight: number;
|
||||||
}
|
}
|
||||||
@@ -273,7 +300,8 @@ export async function getPlayersPresentDuringMap(mapId: number): Promise<MapPres
|
|||||||
export async function getMapVotes(mapId: number): Promise<MapVoteRow[]> {
|
export async function getMapVotes(mapId: number): Promise<MapVoteRow[]> {
|
||||||
return query<MapVoteRow>(
|
return query<MapVoteRow>(
|
||||||
`SELECT e.vote_id, e.vote_time, e.result,
|
`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
|
FROM playtime_display_map_vote_events e
|
||||||
JOIN playtime_display_map_vote_choices c ON c.vote_id = e.vote_id
|
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
|
LEFT JOIN playtime_display_players p ON p.steamid = c.steamid
|
||||||
@@ -312,6 +340,7 @@ export async function getAdjacentMaps(
|
|||||||
export interface CurrentPlayer {
|
export interface CurrentPlayer {
|
||||||
steamid: string;
|
steamid: string;
|
||||||
current_name: string;
|
current_name: string;
|
||||||
|
current_country: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -342,7 +371,8 @@ export async function getCurrentPlayers(freshnessMinutes = 3): Promise<CurrentPl
|
|||||||
// matching players row wasn't found, undercounting "currently online"
|
// matching players row wasn't found, undercounting "currently online"
|
||||||
// for reasons unrelated to whether the player is actually connected.
|
// for reasons unrelated to whether the player is actually connected.
|
||||||
return query<CurrentPlayer>(
|
return query<CurrentPlayer>(
|
||||||
`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
|
FROM playtime_display_sessions s
|
||||||
LEFT JOIN playtime_display_players p ON p.steamid = s.steamid
|
LEFT JOIN playtime_display_players p ON p.steamid = s.steamid
|
||||||
WHERE s.session_end_dt > NOW() - INTERVAL ? MINUTE
|
WHERE s.session_end_dt > NOW() - INTERVAL ? MINUTE
|
||||||
@@ -356,11 +386,13 @@ export async function getCurrentPlayers(freshnessMinutes = 3): Promise<CurrentPl
|
|||||||
export interface PlayerAtTime {
|
export interface PlayerAtTime {
|
||||||
steamid: string;
|
steamid: string;
|
||||||
current_name: string;
|
current_name: string;
|
||||||
|
current_country: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getPlayersAtTime(atIso: string): Promise<PlayerAtTime[]> {
|
export async function getPlayersAtTime(atIso: string): Promise<PlayerAtTime[]> {
|
||||||
return query<PlayerAtTime>(
|
return query<PlayerAtTime>(
|
||||||
`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
|
FROM playtime_display_sessions s
|
||||||
LEFT JOIN playtime_display_players p ON p.steamid = s.steamid
|
LEFT JOIN playtime_display_players p ON p.steamid = s.steamid
|
||||||
WHERE s.session_start_dt <= ?
|
WHERE s.session_start_dt <= ?
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { steam2ToSteam64 } from './steam';
|
||||||
|
|
||||||
export interface SteamSummary {
|
export interface SteamSummary {
|
||||||
avatarUrl: string;
|
avatarUrl: string;
|
||||||
personaName: string | null;
|
personaName: string | null;
|
||||||
@@ -92,3 +94,28 @@ export async function getSteamSummariesBatch(
|
|||||||
|
|
||||||
return result;
|
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<T extends { steamid: string }>(
|
||||||
|
players: T[],
|
||||||
|
): Promise<(T & { avatarUrl: string })[]> {
|
||||||
|
const steam64ByPlayer = new Map<string, string>();
|
||||||
|
for (const p of players) {
|
||||||
|
const id64 = steam2ToSteam64(p.steamid);
|
||||||
|
if (id64) steam64ByPlayer.set(p.steamid, id64);
|
||||||
|
}
|
||||||
|
|
||||||
|
const avatars = await getSteamSummariesBatch([...steam64ByPlayer.values()]);
|
||||||
|
|
||||||
|
return players.map((p) => {
|
||||||
|
const id64 = steam64ByPlayer.get(p.steamid);
|
||||||
|
const avatarUrl = (id64 && avatars.get(id64)?.avatarUrl) || DEFAULT_AVATAR_URL;
|
||||||
|
return { ...p, avatarUrl };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user