some more styling to the frontend
This commit is contained in:
@@ -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`;
|
||||
}
|
||||
|
||||
@@ -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<CountrySummary[]> {
|
||||
// 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<CountrySummary>(
|
||||
`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<CountrySummary>(
|
||||
`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<CountryPlayer[]> {
|
||||
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<MapPres
|
||||
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
|
||||
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<CurrentPl
|
||||
// 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
|
||||
`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<CurrentPl
|
||||
export interface PlayerAtTime {
|
||||
steamid: string;
|
||||
current_name: string;
|
||||
current_country: string | null;
|
||||
}
|
||||
|
||||
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
|
||||
`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 <= ?
|
||||
|
||||
@@ -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<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