diff --git a/discord_verificiation/playtime-frontend/README.md b/discord_verificiation/playtime-frontend/README.md index 5fee785..e12465c 100644 --- a/discord_verificiation/playtime-frontend/README.md +++ b/discord_verificiation/playtime-frontend/README.md @@ -13,20 +13,23 @@ Node process; nginx reverse-proxies a subdomain to it. ## Pages built so far -- `/` — Overview: concurrent-player population graph (last 24h by default) - and a map-to-map population bar chart (last 40 maps). Click any point on - either chart to see the actual players (avatar + name) who were online - then. -- `/players` — search players by current/previous name or SteamID, sortable - by recent activity, name, or highest playtime +- `/` — Overview: concurrent-player population graph (hand-rolled SVG, not + a charting library — scroll to zoom, drag to pan all the way back to + 2015-07-16, click any point for who was online then), a map-to-map + population bar chart (same zoom/pan, pool of 150 maps), and a "recurring + window" chart for questions like "who's online 6–15h every Tuesday" + (pick days of week + time window + date range) +- `/players` — search players by current/previous name or SteamID (shows + which old name matched, if any), sortable by recent activity, name, or + highest playtime - `/players/[steamid]` — avatar, previous names, SteamID, Steam profile link, RaceTimer rank/level + profile link, total playtime, and a session-by-session history (with the map(s) each session overlapped) - `/maps` — search maps by name - `/maps/[mapname]` — every time period that map was played -- `/maps/period/[mapId]` — one specific occurrence: players present during - it, votes cast (grouped by vote event, since a map could theoretically see - more than one), and prev/next map navigation +- `/maps/period/[mapId]` — one specific occurrence: players present at any + point during it, votes cast (grouped by vote event, since a map could + theoretically see more than one), and prev/next map navigation - `/countries` — every country with at least one player, sorted by player count descending - `/countries/[code]` — players from that country, sortable by highest diff --git a/discord_verificiation/playtime-frontend/app/api/map-population/route.ts b/discord_verificiation/playtime-frontend/app/api/map-population/route.ts index 883443d..a557ed2 100644 --- a/discord_verificiation/playtime-frontend/app/api/map-population/route.ts +++ b/discord_verificiation/playtime-frontend/app/api/map-population/route.ts @@ -1,9 +1,21 @@ import { NextRequest, NextResponse } from 'next/server'; import { getMapPopulationSeries } from '@/lib/queries'; +import { formatDbDateTime } from '@/lib/timezone'; export async function GET(req: NextRequest) { const limitParam = req.nextUrl.searchParams.get('limit'); + const beforeParam = req.nextUrl.searchParams.get('before'); // ISO string — page further back for pan-to-load-more const limit = Math.min(Math.max(Number(limitParam) || 40, 1), 200); - const points = await getMapPopulationSeries(limit); + + let before: string | undefined; + if (beforeParam) { + const d = new Date(beforeParam); + if (Number.isNaN(d.getTime())) { + return NextResponse.json({ error: 'invalid before param' }, { status: 400 }); + } + before = formatDbDateTime(d); + } + + const points = await getMapPopulationSeries(limit, before); return NextResponse.json({ points }); } 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 new file mode 100644 index 0000000..5244667 --- /dev/null +++ b/discord_verificiation/playtime-frontend/app/api/population/recurring/players/route.ts @@ -0,0 +1,89 @@ +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 { parseDbDateTime } from '@/lib/timezone'; + +interface SessionRow { + steamid: string; + current_name: string; + session_start_dt: string; + session_end_dt: string | null; +} + +export async function GET(req: NextRequest) { + const sp = req.nextUrl.searchParams; + const date = sp.get('date'); + const startTime = sp.get('startTime'); + const endTime = sp.get('endTime'); + + if (!date || !startTime || !endTime) { + return NextResponse.json({ error: 'date, startTime, endTime are required' }, { status: 400 }); + } + + const windowStartStr = `${date} ${startTime}:00`; + const windowEndStr = `${date} ${endTime}:00`; + const windowStartMs = parseDbDateTime(windowStartStr).getTime(); + const windowEndMs = parseDbDateTime(windowEndStr).getTime(); + const windowMinutes = Math.round((windowEndMs - windowStartMs) / 60000); + + const rows = await query( + `SELECT s.steamid, COALESCE(p.current_name, s.player_name) AS current_name, + s.session_start_dt, s.session_end_dt + 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 > ?)`, + [windowEndStr, windowStartStr], + ); + + // Clip each session row to the window and accumulate per-player segments — + // a player can have more than one segment if they disconnected and + // reconnected within the window. + interface Segment { + startMin: number; + endMin: number; + } + const byPlayer = new Map(); + + for (const r of rows) { + const sStart = parseDbDateTime(r.session_start_dt).getTime(); + const sEnd = r.session_end_dt ? parseDbDateTime(r.session_end_dt).getTime() : Date.now(); + const clippedStart = Math.max(sStart, windowStartMs); + const clippedEnd = Math.min(sEnd, windowEndMs); + if (clippedEnd <= clippedStart) continue; + + const startMin = Math.round((clippedStart - windowStartMs) / 60000); + const endMin = Math.round((clippedEnd - windowStartMs) / 60000); + + if (!byPlayer.has(r.steamid)) { + byPlayer.set(r.steamid, { name: r.current_name, 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 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); + + return NextResponse.json({ windowMinutes, players }); +} diff --git a/discord_verificiation/playtime-frontend/app/api/population/recurring/route.ts b/discord_verificiation/playtime-frontend/app/api/population/recurring/route.ts new file mode 100644 index 0000000..1874419 --- /dev/null +++ b/discord_verificiation/playtime-frontend/app/api/population/recurring/route.ts @@ -0,0 +1,73 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { query } from '@/lib/db'; + +function pad(n: number) { + return String(n).padStart(2, '0'); +} + +const MAX_DATES = 120; // safety cap — one query per matching date + +export async function GET(req: NextRequest) { + const sp = req.nextUrl.searchParams; + const daysParam = sp.get('days'); // comma list, JS convention: 0=Sun..6=Sat + const startTime = sp.get('startTime'); // "06:00" + const endTime = sp.get('endTime'); // "15:00" + const startDate = sp.get('start'); // "2026-07-01" + const endDate = sp.get('end'); // "2026-08-22" + + if (!daysParam || !startTime || !endTime || !startDate || !endDate) { + return NextResponse.json({ error: 'days, startTime, endTime, start, end are all required' }, { status: 400 }); + } + + const days = daysParam.split(',').map(Number).filter((d) => d >= 0 && d <= 6); + if (days.length === 0) { + return NextResponse.json({ error: 'no valid days selected' }, { status: 400 }); + } + if (!/^\d{2}:\d{2}$/.test(startTime) || !/^\d{2}:\d{2}$/.test(endTime) || startTime >= endTime) { + return NextResponse.json({ error: 'startTime must be before endTime, both as HH:MM' }, { status: 400 }); + } + + const [startY, startM, startD] = startDate.split('-').map(Number); + const [endY, endM, endD] = endDate.split('-').map(Number); + if (!startY || !startM || !startD || !endY || !endM || !endD) { + return NextResponse.json({ error: 'invalid start/end date' }, { status: 400 }); + } + + // Calendar-date arithmetic anchored to UTC purely as a way to enumerate + // plain Y-M-D dates and their day-of-week unambiguously — these Date + // objects never represent a real instant, just a calendar day. + const cursor = new Date(Date.UTC(startY, startM - 1, startD)); + const endCursor = new Date(Date.UTC(endY, endM - 1, endD)); + if (cursor > endCursor) { + return NextResponse.json({ error: 'start date must be before end date' }, { status: 400 }); + } + + const matchingDates: string[] = []; + while (cursor <= endCursor && matchingDates.length < MAX_DATES) { + if (days.includes(cursor.getUTCDay())) { + matchingDates.push(`${cursor.getUTCFullYear()}-${pad(cursor.getUTCMonth() + 1)}-${pad(cursor.getUTCDate())}`); + } + cursor.setUTCDate(cursor.getUTCDate() + 1); + } + const truncated = matchingDates.length >= MAX_DATES && cursor <= endCursor; + + // Each window boundary is built directly as a "YYYY-MM-DD HH:MM:SS" + // string — already in the exact format the DB stores, so no Date + // round-trip (and no timezone ambiguity) is needed for these queries. + const points = await Promise.all( + matchingDates.map(async (dateStr) => { + const windowStart = `${dateStr} ${startTime}:00`; + const windowEnd = `${dateStr} ${endTime}:00`; + const rows = await query<{ steamid: string }>( + `SELECT DISTINCT s.steamid + FROM playtime_display_sessions s + WHERE s.session_start_dt < ? + AND (s.session_end_dt IS NULL OR s.session_end_dt > ?)`, + [windowEnd, windowStart], + ); + return { date: dateStr, count: rows.length }; + }), + ); + + return NextResponse.json({ points, truncated }); +} diff --git a/discord_verificiation/playtime-frontend/app/countries/page.tsx b/discord_verificiation/playtime-frontend/app/countries/page.tsx index f614e88..841c8e0 100644 --- a/discord_verificiation/playtime-frontend/app/countries/page.tsx +++ b/discord_verificiation/playtime-frontend/app/countries/page.tsx @@ -1,6 +1,6 @@ import Link from 'next/link'; import { getCountriesSummary } from '@/lib/queries'; -import { countryCodeToFlag, countryCodeToName } from '@/lib/steam'; +import { countryCodeToFlag, countryCodeToName, formatMinutes } from '@/lib/steam'; // This page queries the DB directly with no dynamic route segment, so // without this Next.js tries to statically pre-render it at BUILD time @@ -23,22 +23,29 @@ export default async function CountriesPage() {
No countries recorded yet.
) : (
+
+
Country
+
Total playtime
+
Avg per player
+
{countries.map((c) => ( -
- +
+ {countryCodeToFlag(c.current_country)} - {countryCodeToName(c.current_country)} - {c.current_country} -
-
- {c.player_count} player{c.player_count === 1 ? '' : 's'} + {countryCodeToName(c.current_country)} + {c.current_country} + + {c.player_count} player{c.player_count === 1 ? '' : 's'} +
+
{formatMinutes(c.total_minutes)}
+
{formatMinutes(Math.round(c.avg_minutes))}
))}
diff --git a/discord_verificiation/playtime-frontend/app/globals.css b/discord_verificiation/playtime-frontend/app/globals.css index 84853a0..c9c9490 100644 --- a/discord_verificiation/playtime-frontend/app/globals.css +++ b/discord_verificiation/playtime-frontend/app/globals.css @@ -5,6 +5,8 @@ @layer base { body { @apply bg-base text-ink font-sans antialiased; + background-image: radial-gradient(ellipse 70% 45% at 50% -10%, rgba(63, 211, 122, 0.07), transparent 70%); + background-attachment: fixed; } ::selection { @@ -20,7 +22,11 @@ @layer components { .panel { - @apply bg-base-panel border border-base-border rounded-lg; + @apply bg-base-panel border border-base-border rounded-xl; + background-image: linear-gradient(180deg, rgba(255, 255, 255, 0.025), rgba(255, 255, 255, 0) 40%); + box-shadow: + 0 1px 0 0 rgba(255, 255, 255, 0.04) inset, + 0 12px 32px -16px rgba(0, 0, 0, 0.65); } .stat-label { @@ -30,4 +36,13 @@ .mono { @apply font-mono text-sm; } + + /* Subtle lift + accent-tinted background on interactive rows, used + across search results, tables, and lists so it's consistent everywhere. */ + .row-interactive { + @apply transition-all duration-150; + } + .row-interactive:hover { + background-color: rgba(63, 211, 122, 0.05); + } } diff --git a/discord_verificiation/playtime-frontend/app/layout.tsx b/discord_verificiation/playtime-frontend/app/layout.tsx index 85a3a21..95491dc 100644 --- a/discord_verificiation/playtime-frontend/app/layout.tsx +++ b/discord_verificiation/playtime-frontend/app/layout.tsx @@ -19,7 +19,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
-
+
@@ -32,9 +32,10 @@ export default function RootLayout({ children }: { children: React.ReactNode }) {item.label} + ))} diff --git a/discord_verificiation/playtime-frontend/app/maps/[mapname]/page.tsx b/discord_verificiation/playtime-frontend/app/maps/[mapname]/page.tsx index a201d1a..4bc885a 100644 --- a/discord_verificiation/playtime-frontend/app/maps/[mapname]/page.tsx +++ b/discord_verificiation/playtime-frontend/app/maps/[mapname]/page.tsx @@ -17,6 +17,11 @@ export default async function MapPeriodsPage({ notFound(); } + const totalMinutes = periods.reduce((sum, p) => { + const end = p.map_end_dt ?? new Date().toISOString().slice(0, 19).replace('T', ' '); + return sum + (diffMinutes(p.map_start_dt, end) ?? 0); + }, 0); + return (
@@ -26,24 +31,49 @@ export default async function MapPeriodsPage({

{mapName}

- Played {periods.length} time{periods.length === 1 ? '' : 's'}. + Played {periods.length} time{periods.length === 1 ? '' : 's'} · {formatMinutes(totalMinutes)} total

+
+
Session
+
Players
+
Duration
+
{periods.map((p) => ( -
-
- {new Date(p.map_start_dt.replace(' ', 'T')).toLocaleString()} +
+
+
+ {new Date(p.map_start_dt.replace(' ', 'T')).toLocaleString()} + {p.map_end_dt && ( + + {' – '} + {new Date(p.map_end_dt.replace(' ', 'T')).toLocaleTimeString()} + + )} +
+ {!p.map_end_dt &&
currently running
}
- {!p.map_end_dt &&
currently running
} + + +
-
+
{p.player_count}
+
{p.map_end_dt ? formatMinutes(diffMinutes(p.map_start_dt, p.map_end_dt)) : '—'}
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 4547716..5d3ed8a 100644 --- a/discord_verificiation/playtime-frontend/app/maps/period/[mapId]/page.tsx +++ b/discord_verificiation/playtime-frontend/app/maps/period/[mapId]/page.tsx @@ -98,74 +98,75 @@ export default async function MapPeriodDetailPage({
- {/* Players present */} -
-
- Players present ({players.length}) -
- {players.length === 0 ? ( -
No recorded players during this period.
- ) : ( -
- {players.map((p, i) => ( - -
- {countryCodeToFlag(p.current_country)} - {p.current_name} -
-
- {new Date(p.session_start_dt.replace(' ', 'T')).toLocaleTimeString()} - {' – '} - {p.session_end_dt - ? new Date(p.session_end_dt.replace(' ', 'T')).toLocaleTimeString() - : 'now'} -
- - ))} + {/* Players present + Map votes, side by side */} +
+
+
+ Players present ({players.length})
- )} -
- - {/* Votes */} -
-
Map votes
- {voteEvents.size === 0 ? ( -
No recorded vote during this period.
- ) : ( -
- {[...voteEvents.entries()].map(([voteId, event]) => ( -
-
-
- {new Date(event.vote_time.replace(' ', 'T')).toLocaleString()} + {players.length === 0 ? ( +
No recorded players during this period.
+ ) : ( +
+ {players.map((p, i) => ( + +
+ {countryCodeToFlag(p.current_country)} + {p.current_name} +
+
+ {new Date(p.session_start_dt.replace(' ', 'T')).toLocaleTimeString()} + {' – '} + {p.session_end_dt + ? new Date(p.session_end_dt.replace(' ', 'T')).toLocaleTimeString() + : 'now'} +
+ + ))} +
+ )} +
+ +
+
Map votes
+ {voteEvents.size === 0 ? ( +
No recorded vote during this period.
+ ) : ( +
+ {[...voteEvents.entries()].map(([voteId, event]) => ( +
+
+
+ {new Date(event.vote_time.replace(' ', 'T')).toLocaleString()} +
+
Result: {event.result ?? '—'}
+
+
+ {event.choices.map((c, i) => ( + + {c.current_name} + + voted {c.vote_choice} + {c.vote_weight !== 1 && ( + (weight {c.vote_weight}) + )} + + + ))}
-
Result: {event.result ?? '—'}
-
- {event.choices.map((c, i) => ( - - {c.current_name} - - voted {c.vote_choice} - {c.vote_weight !== 1 && ( - (weight {c.vote_weight}) - )} - - - ))} -
-
- ))} -
- )} + ))} +
+ )} +
); diff --git a/discord_verificiation/playtime-frontend/app/page.tsx b/discord_verificiation/playtime-frontend/app/page.tsx index 3f99636..35703c1 100644 --- a/discord_verificiation/playtime-frontend/app/page.tsx +++ b/discord_verificiation/playtime-frontend/app/page.tsx @@ -1,5 +1,6 @@ import PopulationChart from '@/components/PopulationChart'; import MapPopulationChart from '@/components/MapPopulationChart'; +import RecurringWindowChart from '@/components/RecurringWindowChart'; export default function OverviewPage() { return ( @@ -10,6 +11,7 @@ export default function OverviewPage() {
+
); } diff --git a/discord_verificiation/playtime-frontend/components/MapPopulationChart.tsx b/discord_verificiation/playtime-frontend/components/MapPopulationChart.tsx index 9167b4b..c4a767e 100644 --- a/discord_verificiation/playtime-frontend/components/MapPopulationChart.tsx +++ b/discord_verificiation/playtime-frontend/components/MapPopulationChart.tsx @@ -9,7 +9,8 @@ interface MapPoint { map_id: number; map_name: string; map_start_dt: string; - players_online_at_start: number; + map_end_dt: string | null; + total_players: number; } interface PlayerChip { @@ -22,31 +23,39 @@ const HEIGHT = 288; const PAD_LEFT = 32; const PAD_RIGHT = 8; const PAD_TOP = 8; -const PAD_BOTTOM = 36; -const FETCH_LIMIT = 150; // pool of maps available to pan/zoom through +const PAD_BOTTOM = 12; // no map-name labels anymore, so far less bottom padding needed +const INITIAL_FETCH = 150; +const PAGE_FETCH = 100; // fetched when panning back beyond what's loaded const DEFAULT_VISIBLE = 40; const MIN_VISIBLE = 5; +const HARD_FLOOR_EPOCH = Date.UTC(2015, 6, 16); // won't page back further than July 16, 2015 + +function parseDt(s: string): number { + return new Date(s.replace(' ', 'T')).getTime(); +} export default function MapPopulationChart() { const { ref: containerRef, width } = useContainerWidth(); + const svgRef = useRef(null); const [points, setPoints] = useState([]); const [loading, setLoading] = useState(true); + const [fetchingMore, setFetchingMore] = useState(false); + const [reachedStart, setReachedStart] = useState(false); const [hoverIndex, setHoverIndex] = useState(null); - // Visible window (index range into `points`) — what scroll-to-zoom / - // drag-to-pan adjusts. Defaults to the most recent DEFAULT_VISIBLE maps. const [viewStartIdx, setViewStartIdx] = useState(0); const [viewEndIdx, setViewEndIdx] = useState(0); const dragStartRef = useRef<{ x: number; startIdx: number; endIdx: number } | null>(null); const dragMovedRef = useRef(false); + const fetchInFlightRef = useRef(false); const [selectedMap, setSelectedMap] = useState(null); const [selectedPlayers, setSelectedPlayers] = useState([]); const [loadingPlayers, setLoadingPlayers] = useState(false); useEffect(() => { - fetch(`/api/map-population?limit=${FETCH_LIMIT}`) + fetch(`/api/map-population?limit=${INITIAL_FETCH}`) .then((res) => res.json()) .then((data) => { const pts: MapPoint[] = data.points ?? []; @@ -75,10 +84,42 @@ export default function MapPopulationChart() { } } + // If panning/zooming brings the view near index 0, fetch more (older) + // maps and prepend them, shifting indices to keep the visual window + // stable. Stops once the earliest loaded map is at/before the hard floor. + function ensureMoreIfNeeded(nextStartIdx: number) { + if (fetchInFlightRef.current || reachedStart) return; + if (nextStartIdx > 5) return; // only bother once we're close to the loaded edge + const earliest = points[0]; + if (!earliest) return; + if (parseDt(earliest.map_start_dt) <= HARD_FLOOR_EPOCH) { + setReachedStart(true); + return; + } + fetchInFlightRef.current = true; + setFetchingMore(true); + fetch(`/api/map-population?limit=${PAGE_FETCH}&before=${encodeURIComponent(new Date(parseDt(earliest.map_start_dt)).toISOString())}`) + .then((res) => res.json()) + .then((data) => { + const older: MapPoint[] = data.points ?? []; + if (older.length === 0) { + setReachedStart(true); + return; + } + setPoints((prev) => [...older, ...prev]); + setViewStartIdx((v) => v + older.length); + setViewEndIdx((v) => v + older.length); + }) + .finally(() => { + fetchInFlightRef.current = false; + setFetchingMore(false); + }); + } + const visiblePoints = points.slice(viewStartIdx, viewEndIdx); const plotWidth = Math.max(0, width - PAD_LEFT - PAD_RIGHT); const plotHeight = HEIGHT - PAD_TOP - PAD_BOTTOM; - const maxValue = Math.max(1, ...visiblePoints.map((p) => p.players_online_at_start)); + const maxValue = Math.max(1, ...visiblePoints.map((p) => p.total_players)); const axisMax = Math.ceil((maxValue * 1.15) / 5) * 5 || 5; const slotWidth = visiblePoints.length > 0 ? plotWidth / visiblePoints.length : 0; @@ -127,6 +168,7 @@ export default function MapPopulationChart() { } setViewStartIdx(newStart); setViewEndIdx(newEnd); + ensureMoreIfNeeded(newStart); } } else { setHoverIndex(indexForX(x)); @@ -137,34 +179,44 @@ export default function MapPopulationChart() { dragStartRef.current = null; } - function handleWheel(e: React.WheelEvent) { - e.preventDefault(); - if (points.length === 0) return; - const rect = e.currentTarget.getBoundingClientRect(); - const x = e.clientX - rect.left; - const anchorIdxInView = indexForX(x); - const anchorAbsIdx = viewStartIdx + anchorIdxInView; + useEffect(() => { + const svg = svgRef.current; + if (!svg) return; - const currentSize = viewEndIdx - viewStartIdx; - const zoomFactor = e.deltaY > 0 ? 1.2 : 1 / 1.2; // scroll down = zoom out (more bars), up = zoom in (fewer bars) - let newSize = Math.round(currentSize * zoomFactor); - newSize = Math.max(MIN_VISIBLE, Math.min(points.length, newSize)); + function onWheel(e: WheelEvent) { + e.preventDefault(); + if (points.length === 0) return; + const rect = svg!.getBoundingClientRect(); + const x = e.clientX - rect.left; + const anchorIdxInView = indexForX(x); + const anchorAbsIdx = viewStartIdx + anchorIdxInView; - const ratio = currentSize > 0 ? (anchorAbsIdx - viewStartIdx) / currentSize : 0.5; - let newStart = Math.round(anchorAbsIdx - ratio * newSize); - let newEnd = newStart + newSize; + const currentSize = viewEndIdx - viewStartIdx; + const zoomFactor = e.deltaY > 0 ? 1.2 : 1 / 1.2; + let newSize = Math.round(currentSize * zoomFactor); + newSize = Math.max(MIN_VISIBLE, Math.min(points.length, newSize)); - if (newStart < 0) { - newStart = 0; - newEnd = newSize; + const ratio = currentSize > 0 ? (anchorAbsIdx - viewStartIdx) / currentSize : 0.5; + let newStart = Math.round(anchorAbsIdx - ratio * newSize); + let newEnd = newStart + newSize; + + if (newStart < 0) { + newStart = 0; + newEnd = newSize; + } + if (newEnd > points.length) { + newEnd = points.length; + newStart = newEnd - newSize; + } + setViewStartIdx(newStart); + setViewEndIdx(newEnd); + ensureMoreIfNeeded(newStart); } - if (newEnd > points.length) { - newEnd = points.length; - newStart = newEnd - newSize; - } - setViewStartIdx(newStart); - setViewEndIdx(newEnd); - } + + svg.addEventListener('wheel', onWheel, { passive: false }); + return () => svg.removeEventListener('wheel', onWheel); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [points.length, viewStartIdx, viewEndIdx]); function resetZoom() { const startIdx = Math.max(0, points.length - DEFAULT_VISIBLE); @@ -172,7 +224,7 @@ export default function MapPopulationChart() { setViewEndIdx(points.length); } - const isZoomed = viewStartIdx > 0 || viewEndIdx < points.length; + const isZoomed = viewStartIdx > Math.max(0, points.length - DEFAULT_VISIBLE) || viewEndIdx < points.length; const yTicks = [0, Math.round(axisMax / 2), axisMax]; return ( @@ -180,7 +232,8 @@ export default function MapPopulationChart() {
Population by map

- {visiblePoints.length} of {points.length} maps + {visiblePoints.length} of {points.length} + {reachedStart ? '' : '+'} maps

@@ -190,7 +243,8 @@ export default function MapPopulationChart() { - Scroll to zoom, drag to pan, click a bar for who was online + Scroll to zoom, drag to pan (all the way back to 2015), click a bar for who was online + {fetchingMore && — loading more…}
{isZoomed && (
)} @@ -323,13 +367,20 @@ export default function MapPopulationChart() { {selectedMap && (
-
+
Players during{' '} {selectedMap.map_name} {' '} ({selectedPlayers.length} player{selectedPlayers.length === 1 ? '' : 's'})
+
+ {new Date(selectedMap.map_start_dt.replace(' ', 'T')).toLocaleString()} + {' – '} + {selectedMap.map_end_dt + ? new Date(selectedMap.map_end_dt.replace(' ', 'T')).toLocaleString() + : 'still running'} +
)} diff --git a/discord_verificiation/playtime-frontend/components/MapSearch.tsx b/discord_verificiation/playtime-frontend/components/MapSearch.tsx index e103307..a957095 100644 --- a/discord_verificiation/playtime-frontend/components/MapSearch.tsx +++ b/discord_verificiation/playtime-frontend/components/MapSearch.tsx @@ -2,11 +2,13 @@ import { useEffect, useState } from 'react'; import Link from 'next/link'; +import { formatMinutes } from '@/lib/steam'; interface MapSummary { map_name: string; times_played: number; last_played: string; + total_minutes: number; } export default function MapSearch() { @@ -46,12 +48,25 @@ export default function MapSearch() {
{m.map_name}
-
- {m.times_played}× played - last {new Date(m.last_played.replace(' ', 'T')).toLocaleDateString()} +
+
+ {m.times_played}× played · {formatMinutes(m.total_minutes)} total + last played {new Date(m.last_played.replace(' ', 'T')).toLocaleString()} +
+ + +
)) diff --git a/discord_verificiation/playtime-frontend/components/PlayerChipList.tsx b/discord_verificiation/playtime-frontend/components/PlayerChipList.tsx index a6d4b4d..55f0d1c 100644 --- a/discord_verificiation/playtime-frontend/components/PlayerChipList.tsx +++ b/discord_verificiation/playtime-frontend/components/PlayerChipList.tsx @@ -29,10 +29,10 @@ export default function PlayerChipList({ {/* eslint-disable-next-line @next/next/no-img-element */} - + {p.name} ))} diff --git a/discord_verificiation/playtime-frontend/components/PlayerTimelineChart.tsx b/discord_verificiation/playtime-frontend/components/PlayerTimelineChart.tsx new file mode 100644 index 0000000..4048db7 --- /dev/null +++ b/discord_verificiation/playtime-frontend/components/PlayerTimelineChart.tsx @@ -0,0 +1,86 @@ +'use client'; + +import Link from 'next/link'; +import { formatMinutes } from '@/lib/steam'; + +interface Segment { + startMin: number; + endMin: number; +} + +interface TimelinePlayer { + steamid: string; + name: string; + avatarUrl: string; + totalMinutes: number; + segments: Segment[]; +} + +export default function PlayerTimelineChart({ + players, + windowMinutes, + windowStartLabel, + windowEndLabel, + loading, +}: { + players: TimelinePlayer[]; + windowMinutes: number; + windowStartLabel: string; + windowEndLabel: string; + loading: boolean; +}) { + if (loading) { + return
loading players…
; + } + if (players.length === 0) { + return
No players recorded during this window.
; + } + + return ( +
+ {/* time axis */} +
+ {windowStartLabel} + {windowEndLabel} +
+ +
+ {players.map((p) => ( +
+ + {/* eslint-disable-next-line @next/next/no-img-element */} + + {p.name} + + +
+ {p.segments.map((seg, i) => { + const left = (seg.startMin / windowMinutes) * 100; + const width = Math.max(0.6, ((seg.endMin - seg.startMin) / windowMinutes) * 100); + return ( +
+ ); + })} +
+ + + {formatMinutes(p.totalMinutes)} + +
+ ))} +
+
+ ); +} diff --git a/discord_verificiation/playtime-frontend/components/PopulationChart.tsx b/discord_verificiation/playtime-frontend/components/PopulationChart.tsx index 7f116b4..4f926c7 100644 --- a/discord_verificiation/playtime-frontend/components/PopulationChart.tsx +++ b/discord_verificiation/playtime-frontend/components/PopulationChart.tsx @@ -29,7 +29,9 @@ const PAD_RIGHT = 8; const PAD_TOP = 8; const PAD_BOTTOM = 30; const Y_MAX = 64; // game's hard player cap -const MIN_ZOOM_SPAN_MS = 5 * 60_000; // don't let scroll-zoom go tighter than 5 minutes +const MIN_ZOOM_SPAN_MS = 5 * 60_000; // don't zoom in tighter than 5 minutes +const HARD_FLOOR_EPOCH = Date.UTC(2015, 6, 16); // won't pan back further than July 16, 2015 +const FETCH_DEBOUNCE_MS = 400; function toLocalInputValue(d: Date): string { const pad = (n: number) => String(n).padStart(2, '0'); @@ -38,6 +40,7 @@ function toLocalInputValue(d: Date): string { export default function PopulationChart() { const { ref: containerRef, width } = useContainerWidth(); + const svgRef = useRef(null); const now = new Date(); const dayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000); @@ -46,19 +49,28 @@ export default function PopulationChart() { const [points, setPoints] = useState([]); const [mapBoundaries, setMapBoundaries] = useState([]); const [loading, setLoading] = useState(false); + const [fetchingMore, setFetchingMore] = useState(false); const [error, setError] = useState(null); - // Full bounds of whatever's currently fetched (via the calendar Start/End) + // Bounds of everything currently loaded (grows as panning/zooming reaches + // beyond it and triggers a new fetch — see ensureCoverage below). const [dataMinEpoch, setDataMinEpoch] = useState(0); const [dataMaxEpoch, setDataMaxEpoch] = useState(0); - // Currently VISIBLE window — what scroll-to-zoom / drag-to-pan adjusts. - // Starts equal to the full fetched range and narrows/shifts from there. + // The "home" range to return to on Reset — whatever was last explicitly + // requested via the calendar Start/End + Update, as opposed to wherever + // panning/zooming has since wandered off to. + const [homeStart, setHomeStart] = useState(0); + const [homeEnd, setHomeEnd] = useState(0); + + // Currently VISIBLE window. const [viewStart, setViewStart] = useState(0); const [viewEnd, setViewEnd] = useState(0); const dragStartRef = useRef<{ x: number; viewStart: number; viewEnd: number } | null>(null); const dragMovedRef = useRef(false); + const fetchInFlightRef = useRef(false); + const wheelDebounceRef = useRef | null>(null); const [hoverPoint, setHoverPoint] = useState(null); const [hoverX, setHoverX] = useState(null); @@ -70,24 +82,44 @@ export default function PopulationChart() { const [currentUpdatedAt, setCurrentUpdatedAt] = useState(null); const [secondsAgo, setSecondsAgo] = useState(0); - // A specific HISTORICAL point clicked on the chart. + // A specific HISTORICAL point clicked (or auto-selected as the latest) on the chart. const [selectedPoint, setSelectedPoint] = useState(null); const [selectedPlayers, setSelectedPlayers] = useState([]); const [loadingSelected, setLoadingSelected] = useState(false); - async function load() { - setLoading(true); + async function loadPlayersAt(point: Point) { + setSelectedPoint(point); + setLoadingSelected(true); + try { + const res = await fetch(`/api/population/players?at=${encodeURIComponent(point.t)}`); + const data = await res.json(); + setSelectedPlayers(data.players ?? []); + } catch { + setSelectedPlayers([]); + } finally { + setLoadingSelected(false); + } + } + + async function fetchRange( + rangeStartMs: number, + rangeEndMs: number, + opts: { preserveView?: boolean; selectLatest?: boolean } = {}, + ) { + const { preserveView = false, selectLatest = false } = opts; + if (preserveView) setFetchingMore(true); + else setLoading(true); setError(null); try { const params = new URLSearchParams({ - start: new Date(start).toISOString(), - end: new Date(end).toISOString(), + start: new Date(rangeStartMs).toISOString(), + end: new Date(rangeEndMs).toISOString(), }); const res = await fetch(`/api/population?${params.toString()}`); if (!res.ok) throw new Error((await res.json()).error ?? 'failed to load'); const data = await res.json(); - const pts: Point[] = (data.points as any[]).map((p) => ({ ...p, tEpoch: new Date(p.t).getTime() })); - const bounds: MapBoundary[] = (data.mapBoundaries as any[]).map((b) => ({ ...b, tEpoch: new Date(b.t).getTime() })); + const pts: Point[] = (data.points as any[]).map((p: any) => ({ ...p, tEpoch: new Date(p.t).getTime() })); + const bounds: MapBoundary[] = (data.mapBoundaries as any[]).map((b: any) => ({ ...b, tEpoch: new Date(b.t).getTime() })); setPoints(pts); setMapBoundaries(bounds); if (pts.length > 0) { @@ -95,13 +127,25 @@ export default function PopulationChart() { const maxE = pts[pts.length - 1].tEpoch; setDataMinEpoch(minE); setDataMaxEpoch(maxE); - setViewStart(minE); - setViewEnd(maxE); + // Deliberately NOT syncing the start/end calendar fields to minE/maxE + // here — those are bucket-boundary timestamps (up to one bucket + // width earlier than what was actually requested), not the real + // request range. Overwriting the inputs with them caused Update to + // silently shrink the End field by one bucket width on every click. + // The calendar fields should only ever reflect what was typed. + if (!preserveView) { + setViewStart(minE); + setViewEnd(maxE); + setHomeStart(minE); + setHomeEnd(maxE); + } + if (selectLatest) loadPlayersAt(pts[pts.length - 1]); } } catch (e: any) { setError(e.message ?? 'something went wrong'); } finally { setLoading(false); + setFetchingMore(false); } } @@ -122,7 +166,7 @@ export default function PopulationChart() { } useEffect(() => { - load(); + fetchRange(dayAgo.getTime(), now.getTime(), { selectLatest: true }); loadCurrentPlayers(); const poll = setInterval(loadCurrentPlayers, 30_000); return () => clearInterval(poll); @@ -136,18 +180,18 @@ export default function PopulationChart() { return () => clearInterval(tick); }, [currentUpdatedAt]); - async function loadPlayersAt(point: Point) { - setSelectedPoint(point); - setLoadingSelected(true); - try { - const res = await fetch(`/api/population/players?at=${encodeURIComponent(point.t)}`); - const data = await res.json(); - setSelectedPlayers(data.players ?? []); - } catch { - setSelectedPlayers([]); - } finally { - setLoadingSelected(false); - } + // If the visible window extends beyond what's loaded, fetch more — + // fires after a drag ends or a short pause after scroll-zooming, not on + // every intermediate frame. + function ensureCoverage(neededStart: number, neededEnd: number) { + if (fetchInFlightRef.current) return; + if (neededStart >= dataMinEpoch && neededEnd <= dataMaxEpoch) return; + fetchInFlightRef.current = true; + const fetchStart = Math.min(neededStart, dataMinEpoch); + const fetchEnd = Math.max(neededEnd, dataMaxEpoch); + fetchRange(fetchStart, fetchEnd, { preserveView: true }).finally(() => { + fetchInFlightRef.current = false; + }); } const plotWidth = Math.max(0, width - PAD_LEFT - PAD_RIGHT); @@ -165,8 +209,6 @@ export default function PopulationChart() { return viewStart + ((x - PAD_LEFT) / plotWidth) * (viewEnd - viewStart); } - // Render a couple of points beyond each edge of the visible window too, - // so the line doesn't visibly truncate right at the viewport boundary. let firstIdx = points.findIndex((p) => p.tEpoch >= viewStart); if (firstIdx === -1) firstIdx = points.length - 1; let lastIdx = -1; @@ -184,6 +226,12 @@ export default function PopulationChart() { .map((p, i) => `${i === 0 ? 'M' : 'L'} ${xForEpoch(p.tEpoch).toFixed(1)} ${yForValue(p.players).toFixed(1)}`) .join(' '); + const baselineY = PAD_TOP + plotHeight; + const areaPath = + renderPoints.length > 0 + ? `${linePath} L ${xForEpoch(renderPoints[renderPoints.length - 1].tEpoch).toFixed(1)} ${baselineY} L ${xForEpoch(renderPoints[0].tEpoch).toFixed(1)} ${baselineY} Z` + : ''; + const visibleBoundaries = mapBoundaries.filter((b) => b.tEpoch >= viewStart && b.tEpoch <= viewEnd); function findNearestPoint(x: number): Point | null { @@ -221,12 +269,14 @@ export default function PopulationChart() { const epochDelta = -(dx / plotWidth) * span; let newStart = dragStartRef.current.viewStart + epochDelta; let newEnd = dragStartRef.current.viewEnd + epochDelta; - if (newStart < dataMinEpoch) { - newStart = dataMinEpoch; + + if (newStart < HARD_FLOOR_EPOCH) { + newStart = HARD_FLOOR_EPOCH; newEnd = newStart + span; } - if (newEnd > dataMaxEpoch) { - newEnd = dataMaxEpoch; + const rightLimit = Date.now(); + if (newEnd > rightLimit) { + newEnd = rightLimit; newStart = newEnd - span; } setViewStart(newStart); @@ -236,6 +286,9 @@ export default function PopulationChart() { } function handleMouseUp() { + if (dragMovedRef.current) { + ensureCoverage(viewStart, viewEnd); + } dragStartRef.current = null; } @@ -255,41 +308,58 @@ export default function PopulationChart() { if (p) loadPlayersAt(p); } - function handleWheel(e: React.WheelEvent) { - e.preventDefault(); - if (plotWidth === 0) return; - const rect = e.currentTarget.getBoundingClientRect(); - const x = e.clientX - rect.left; - const anchorEpoch = epochForX(x); - const zoomFactor = e.deltaY > 0 ? 1.15 : 1 / 1.15; // scroll down = zoom out, up = zoom in + // Native (non-passive) wheel listener — React's onWheel is passive by + // default, so e.preventDefault() silently does nothing there and the + // page scrolls along with the zoom. This is the only way to actually + // stop that. + useEffect(() => { + const svg = svgRef.current; + if (!svg) return; - const fullSpan = dataMaxEpoch - dataMinEpoch || 1; - let newSpan = (viewEnd - viewStart) * zoomFactor; - newSpan = Math.min(newSpan, fullSpan); - newSpan = Math.max(newSpan, MIN_ZOOM_SPAN_MS); + function onWheel(e: WheelEvent) { + e.preventDefault(); + if (plotWidth === 0) return; + const rect = svg!.getBoundingClientRect(); + const x = e.clientX - rect.left; + const anchorEpoch = epochForX(x); + const zoomFactor = e.deltaY > 0 ? 1.15 : 1 / 1.15; - const ratio = (anchorEpoch - viewStart) / ((viewEnd - viewStart) || 1); - let newStart = anchorEpoch - ratio * newSpan; - let newEnd = newStart + newSpan; + const fullSpan = Date.now() - HARD_FLOOR_EPOCH; + let newSpan = (viewEnd - viewStart) * zoomFactor; + newSpan = Math.min(newSpan, fullSpan); + newSpan = Math.max(newSpan, MIN_ZOOM_SPAN_MS); - if (newStart < dataMinEpoch) { - newStart = dataMinEpoch; - newEnd = newStart + newSpan; + const ratio = (anchorEpoch - viewStart) / ((viewEnd - viewStart) || 1); + let newStart = anchorEpoch - ratio * newSpan; + let newEnd = newStart + newSpan; + + if (newStart < HARD_FLOOR_EPOCH) { + newStart = HARD_FLOOR_EPOCH; + newEnd = newStart + newSpan; + } + const rightLimit = Date.now(); + if (newEnd > rightLimit) { + newEnd = rightLimit; + newStart = newEnd - newSpan; + } + setViewStart(newStart); + setViewEnd(newEnd); + + if (wheelDebounceRef.current) clearTimeout(wheelDebounceRef.current); + wheelDebounceRef.current = setTimeout(() => ensureCoverage(newStart, newEnd), FETCH_DEBOUNCE_MS); } - if (newEnd > dataMaxEpoch) { - newEnd = dataMaxEpoch; - newStart = newEnd - newSpan; - } - setViewStart(newStart); - setViewEnd(newEnd); - } + + svg.addEventListener('wheel', onWheel, { passive: false }); + return () => svg.removeEventListener('wheel', onWheel); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [viewStart, viewEnd, plotWidth, dataMinEpoch, dataMaxEpoch]); function resetZoom() { - setViewStart(dataMinEpoch); - setViewEnd(dataMaxEpoch); + setViewStart(homeStart); + setViewEnd(homeEnd); } - const isZoomed = viewStart > dataMinEpoch + 1000 || viewEnd < dataMaxEpoch - 1000; + const isZoomed = viewStart > homeStart + 1000 || viewEnd < homeEnd - 1000; const yTicks = [0, 16, 32, 48, 64]; const xTickCount = 5; const xTicks = @@ -303,7 +373,10 @@ export default function PopulationChart() {
- + + + +
Currently online{currentCount != null ? ` — ${currentCount} player${currentCount === 1 ? '' : 's'}` : ''}
@@ -322,7 +395,7 @@ export default function PopulationChart() { className="flex flex-wrap items-center gap-3" onSubmit={(e) => { e.preventDefault(); - load(); + fetchRange(new Date(start).getTime(), new Date(end).getTime(), { selectLatest: true }); }} >
{isZoomed && ( + ))} +
+
+ + + + + + + +
+ + {error &&
{error}
} + {truncated && ( +
+ Showing the first 120 matching dates — narrow the date range for a complete picture. +
+ )} + + {!hasSearched ? ( +
Pick your days and time window, then hit Search.
+ ) : loading ? ( +
loading…
+ ) : points.length === 0 ? ( +
No matching dates in that range.
+ ) : ( + <> +
+ + + + + Click any bar to see each player's time within that window +
+
+ + + + + + + + {yTicks.map((v) => { + const plotHeight = HEIGHT - PAD_TOP - PAD_BOTTOM; + const y = PAD_TOP + (1 - v / axisMax) * plotHeight; + return ( + + + + {v} + + + ); + })} + {(() => { + const plotWidth = 800 - PAD_LEFT - PAD_RIGHT; + const plotHeight = HEIGHT - PAD_TOP - PAD_BOTTOM; + const slotWidth = plotWidth / points.length; + const barWidth = Math.max(2, slotWidth * 0.6); + return points.map((p, i) => { + const barH = Math.max(p.count > 0 ? 2 : 0, (p.count / axisMax) * plotHeight); + const x = PAD_LEFT + i * slotWidth + (slotWidth - barWidth) / 2; + const y = PAD_TOP + (plotHeight - barH); + const showEvery = Math.max(1, Math.ceil(points.length / 10)); + return ( + + loadPlayersFor(p)} + /> + {(i === 0 || i === points.length - 1 || i % showEvery === 0) && ( + + {new Date(`${p.date}T00:00:00Z`).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })} + + )} + + ); + }); + })()} + + +
+ + )} + + {selected && ( +
+
+ {new Date(`${selected.date}T00:00:00Z`).toLocaleDateString(undefined, { weekday: 'long', month: 'short', day: 'numeric' })}, {startTime}–{endTime} — {selectedPlayers.length} player + {selectedPlayers.length === 1 ? '' : 's'}, sorted by time spent in the window +
+ +
+ )} +
+ ); +} diff --git a/discord_verificiation/playtime-frontend/lib/queries.ts b/discord_verificiation/playtime-frontend/lib/queries.ts index 38b8014..ecf92ed 100644 --- a/discord_verificiation/playtime-frontend/lib/queries.ts +++ b/discord_verificiation/playtime-frontend/lib/queries.ts @@ -92,25 +92,29 @@ export interface MapPopulationPoint { map_id: number; map_name: string; map_start_dt: string; - players_online_at_start: number; + map_end_dt: string | null; + total_players: number; } -export async function getMapPopulationSeries(limit = 40): Promise { - // 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. +export async function getMapPopulationSeries(limit = 40, before?: string): Promise { + // Total DISTINCT players who were connected at any point during each map's + // full session (not just at its start) — one data point per map. Fetched + // most-recent-first (for the LIMIT), then the caller should reverse it + // back to chronological order for display. + // `before` (a DB-formatted datetime string) lets the caller page further + // back into history than the initial batch, for pan-to-load-more charts. const rows = await query( - `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 + `SELECT m.map_id, m.map_name, m.map_start_dt, ${NEXT_MAP_START('m')} AS map_end_dt, + (SELECT COUNT(DISTINCT s.steamid) FROM playtime_display_sessions s + 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) - ) AS players_online_at_start + ) AS total_players FROM playtime_display_map_history m WHERE ${REAL_MAP_PERIOD} + ${before ? 'AND m.map_start_dt < ?' : ''} ORDER BY m.map_start_dt DESC LIMIT ?`, - [limit], + before ? [before, limit] : [limit], ); return rows.reverse(); } @@ -118,17 +122,32 @@ export async function getMapPopulationSeries(limit = 40): Promise { // Players with no resolved country are excluded — there's no meaningful - // "unknown" country page to select. + // "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 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`, + `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`, ); } @@ -164,6 +183,7 @@ export interface MapSummary { map_name: string; times_played: number; last_played: string; + total_minutes: number; } export interface MapPeriod { @@ -171,6 +191,7 @@ export interface MapPeriod { map_name: string; map_start_dt: string; map_end_dt: string | null; + player_count: number; } export interface MapPresentPlayer { @@ -194,7 +215,8 @@ export interface MapVoteRow { export async function searchMapNames(q: string, limit = 50): Promise { const like = `%${q}%`; return query( - `SELECT m.map_name, COUNT(*) AS times_played, MAX(m.map_start_dt) AS last_played + `SELECT m.map_name, COUNT(*) AS times_played, MAX(m.map_start_dt) AS last_played, + SUM(TIMESTAMPDIFF(MINUTE, m.map_start_dt, COALESCE(${NEXT_MAP_START('m')}, NOW()))) AS total_minutes FROM playtime_display_map_history m WHERE m.map_name LIKE ? AND ${REAL_MAP_PERIOD} @@ -207,7 +229,11 @@ export async function searchMapNames(q: string, limit = 50): Promise { return query( - `SELECT m.map_id, m.map_name, m.map_start_dt, ${NEXT_MAP_START('m')} AS map_end_dt + `SELECT m.map_id, m.map_name, m.map_start_dt, ${NEXT_MAP_START('m')} AS map_end_dt, + (SELECT COUNT(DISTINCT s.steamid) FROM playtime_display_sessions s + 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) + ) AS player_count FROM playtime_display_map_history m WHERE m.map_name = ? AND ${REAL_MAP_PERIOD} @@ -218,7 +244,11 @@ export async function getMapPeriods(mapName: string): Promise { export async function getMapPeriod(mapId: number): Promise { const rows = await query( - `SELECT m.map_id, m.map_name, m.map_start_dt, ${NEXT_MAP_START('m')} AS map_end_dt + `SELECT m.map_id, m.map_name, m.map_start_dt, ${NEXT_MAP_START('m')} AS map_end_dt, + (SELECT COUNT(DISTINCT s.steamid) FROM playtime_display_sessions s + 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) + ) AS player_count FROM playtime_display_map_history m WHERE m.map_id = ?`, [mapId], diff --git a/discord_verificiation/playtime-frontend/lib/steam.ts b/discord_verificiation/playtime-frontend/lib/steam.ts index 8d0b9ea..a5a9b71 100644 --- a/discord_verificiation/playtime-frontend/lib/steam.ts +++ b/discord_verificiation/playtime-frontend/lib/steam.ts @@ -15,9 +15,12 @@ export function steamProfileUrl(steamId2: string): string | null { return id64 ? `https://steamcommunity.com/profiles/${id64}` : null; } -// Formats total minutes as "123h 45m" for display. +// Formats total minutes as "123h 45m" for display. Negative values (which +// shouldn't be possible, but have shown up from anomalous underlying data — +// e.g. a player_time counter that decreased between two session snapshots) +// are shown as "—" rather than a nonsensical negative duration. export function formatMinutes(totalMinutes: number | null): string { - if (totalMinutes == null || Number.isNaN(totalMinutes)) return '—'; + if (totalMinutes == null || Number.isNaN(totalMinutes) || totalMinutes < 0) return '—'; const h = Math.floor(totalMinutes / 60); const m = totalMinutes % 60; return `${h}h ${m}m`;