'use client'; import { useState } from 'react'; import Link from 'next/link'; import PlayerTimelineChart from './PlayerTimelineChart'; import { formatMinutes, countryCodeToFlag } from '@/lib/steam'; interface RecurringPoint { date: string; // "2026-07-01" count: number; } interface Segment { startMin: number; endMin: number; } interface TimelinePlayer { steamid: string; name: string; avatarUrl: string; country?: string | null; totalMinutes: number; segments: Segment[]; } interface AggregatePlayer { steamid: string; name: string; avatarUrl: string; country?: string | null; totalMinutes: number; daysPresent: number; } const DAY_LABELS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; const HEIGHT = 220; const PAD_LEFT = 32; const PAD_RIGHT = 8; const PAD_TOP = 8; const PAD_BOTTOM = 30; function todayStr(): string { const d = new Date(); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; } function daysAgoStr(n: number): string { const d = new Date(); d.setDate(d.getDate() - n); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; } export default function RecurringWindowChart() { const [selectedDays, setSelectedDays] = useState([2]); // default: Tuesday const [startTime, setStartTime] = useState('06:00'); const [endTime, setEndTime] = useState('15:00'); const [rangeStart, setRangeStart] = useState(daysAgoStr(90)); const [rangeEnd, setRangeEnd] = useState(todayStr()); const [points, setPoints] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [truncated, setTruncated] = useState(false); const [hasSearched, setHasSearched] = useState(false); const [selected, setSelected] = useState(null); const [selectedPlayers, setSelectedPlayers] = useState([]); const [windowMinutes, setWindowMinutes] = useState(0); const [loadingPlayers, setLoadingPlayers] = useState(false); const [aggregatePlayers, setAggregatePlayers] = useState([]); const [aggregateDates, setAggregateDates] = useState(0); const [loadingAggregate, setLoadingAggregate] = useState(false); const [aggregateTruncated, setAggregateTruncated] = useState(false); function toggleDay(d: number) { setSelectedDays((prev) => (prev.includes(d) ? prev.filter((x) => x !== d) : [...prev, d].sort())); } async function search() { if (selectedDays.length === 0) { setError('Pick at least one day of the week.'); return; } setLoading(true); setLoadingAggregate(true); setError(null); setSelected(null); setHasSearched(true); const params = new URLSearchParams({ days: selectedDays.join(','), startTime, endTime, start: rangeStart, end: rangeEnd, }); const summaryPromise = (async () => { try { const res = await fetch(`/api/population/recurring?${params.toString()}`); if (!res.ok) throw new Error((await res.json()).error ?? 'failed to load'); const data = await res.json(); setPoints(data.points ?? []); setTruncated(!!data.truncated); } catch (e: any) { setError(e.message ?? 'something went wrong'); setPoints([]); } finally { setLoading(false); } })(); const aggregatePromise = (async () => { try { const res = await fetch(`/api/population/recurring/aggregate?${params.toString()}`); const data = await res.json(); setAggregatePlayers(data.players ?? []); setAggregateDates(data.totalMatchingDates ?? 0); setAggregateTruncated(!!data.truncatedPlayers); } catch { setAggregatePlayers([]); setAggregateDates(0); } finally { setLoadingAggregate(false); } })(); await Promise.all([summaryPromise, aggregatePromise]); } async function loadPlayersFor(point: RecurringPoint) { setSelected(point); setLoadingPlayers(true); try { const params = new URLSearchParams({ date: point.date, startTime, endTime }); const res = await fetch(`/api/population/recurring/players?${params.toString()}`); const data = await res.json(); setSelectedPlayers(data.players ?? []); setWindowMinutes(data.windowMinutes ?? 0); } catch { setSelectedPlayers([]); setWindowMinutes(0); } finally { setLoadingPlayers(false); } } const maxValue = Math.max(1, ...points.map((p) => p.count)); const axisMax = Math.ceil((maxValue * 1.15) / 5) * 5 || 5; const yTicks = [0, Math.round(axisMax / 2), axisMax]; return (
Recurring window

Population during a repeating time slot

e.g. "who was online 6:00–15:00 every Tuesday" — pick the days, the time window, and the date range to search within.

Days
{[0, 1, 2, 3, 4, 5, 6].map((d) => ( ))}
{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' })} )} ); }); })()}
)} {hasSearched && !loading && points.length > 0 && (
{/* Overall — combined across every matching day */}
Overall — combined across all {aggregateDates} matching day{aggregateDates === 1 ? '' : 's'}, {startTime}–{endTime}

Each player's time spent inside the window, summed across every matching date.

{loadingAggregate ? (
loading…
) : aggregatePlayers.length === 0 ? (
No players recorded across these dates.
) : ( <>
{aggregatePlayers.map((p, i) => (
{i + 1} {/* eslint-disable-next-line @next/next/no-img-element */} {p.country !== undefined && ( {countryCodeToFlag(p.country)} )} {p.name}
{p.daysPresent} of {aggregateDates} day{aggregateDates === 1 ? '' : 's'} {formatMinutes(p.totalMinutes)}
))}
{aggregateTruncated && (
Showing the top {aggregatePlayers.length} players by time — narrow the search for a complete list.
)} )}
{/* One specific day — populated once a bar is clicked, visible (empty) before that */}
{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
) : (
Click any green bar above to see who was online — and exactly when — on that specific day.
)}
)}
); }