'use client'; import { useState } from 'react'; import PlayerTimelineChart from './PlayerTimelineChart'; interface RecurringPoint { date: string; // "2026-07-01" count: number; } interface Segment { startMin: number; endMin: number; } interface TimelinePlayer { steamid: string; name: string; avatarUrl: string; totalMinutes: number; segments: Segment[]; } 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); 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); setError(null); setSelected(null); setHasSearched(true); try { const params = new URLSearchParams({ days: selectedDays.join(','), startTime, endTime, start: rangeStart, end: rangeEnd, }); 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); } } 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' })} )} ); }); })()}
)} {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
)}
); }