409 lines
16 KiB
TypeScript
409 lines
16 KiB
TypeScript
'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<number[]>([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<RecurringPoint[]>([]);
|
||
const [loading, setLoading] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [truncated, setTruncated] = useState(false);
|
||
const [hasSearched, setHasSearched] = useState(false);
|
||
|
||
const [selected, setSelected] = useState<RecurringPoint | null>(null);
|
||
const [selectedPlayers, setSelectedPlayers] = useState<TimelinePlayer[]>([]);
|
||
const [windowMinutes, setWindowMinutes] = useState(0);
|
||
const [loadingPlayers, setLoadingPlayers] = useState(false);
|
||
|
||
const [aggregatePlayers, setAggregatePlayers] = useState<AggregatePlayer[]>([]);
|
||
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 (
|
||
<div className="panel p-6">
|
||
<div className="mb-4">
|
||
<div className="stat-label mb-1">Recurring window</div>
|
||
<h2 className="text-lg text-ink">Population during a repeating time slot</h2>
|
||
<p className="text-sm text-ink-muted mt-1">
|
||
e.g. "who was online 6:00–15:00 every Tuesday" — pick the days, the time window, and the date range to search within.
|
||
</p>
|
||
</div>
|
||
|
||
<div className="flex flex-wrap items-end gap-4 mb-4">
|
||
<div>
|
||
<div className="text-xs text-ink-muted mb-1">Days</div>
|
||
<div className="flex flex-wrap gap-1">
|
||
{[0, 1, 2, 3, 4, 5, 6].map((d) => (
|
||
<button
|
||
key={d}
|
||
type="button"
|
||
onClick={() => toggleDay(d)}
|
||
className={`px-2.5 py-1.5 rounded text-xs border transition-colors ${
|
||
selectedDays.includes(d)
|
||
? 'border-accent/50 text-accent bg-accent-dim/20'
|
||
: 'border-base-border text-ink-muted hover:text-ink'
|
||
}`}
|
||
>
|
||
{DAY_LABELS[d]}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<label className="flex flex-col text-xs text-ink-muted gap-1">
|
||
From
|
||
<input
|
||
type="time"
|
||
value={startTime}
|
||
onChange={(e) => setStartTime(e.target.value)}
|
||
className="bg-base border border-base-border rounded px-2 py-1 text-ink text-sm font-mono"
|
||
/>
|
||
</label>
|
||
<label className="flex flex-col text-xs text-ink-muted gap-1">
|
||
Until
|
||
<input
|
||
type="time"
|
||
value={endTime}
|
||
onChange={(e) => setEndTime(e.target.value)}
|
||
className="bg-base border border-base-border rounded px-2 py-1 text-ink text-sm font-mono"
|
||
/>
|
||
</label>
|
||
<label className="flex flex-col text-xs text-ink-muted gap-1">
|
||
Since
|
||
<input
|
||
type="date"
|
||
value={rangeStart}
|
||
onChange={(e) => setRangeStart(e.target.value)}
|
||
className="bg-base border border-base-border rounded px-2 py-1 text-ink text-sm font-mono"
|
||
/>
|
||
</label>
|
||
<label className="flex flex-col text-xs text-ink-muted gap-1">
|
||
Until
|
||
<input
|
||
type="date"
|
||
value={rangeEnd}
|
||
onChange={(e) => setRangeEnd(e.target.value)}
|
||
className="bg-base border border-base-border rounded px-2 py-1 text-ink text-sm font-mono"
|
||
/>
|
||
</label>
|
||
|
||
<button
|
||
onClick={search}
|
||
className="bg-accent-dim hover:bg-accent hover:text-base text-accent border border-accent/40 rounded px-4 py-2 text-sm font-medium transition-colors"
|
||
>
|
||
Search
|
||
</button>
|
||
</div>
|
||
|
||
{error && <div className="text-sm text-red-400 mb-4">{error}</div>}
|
||
{truncated && (
|
||
<div className="text-xs text-ink-faint mb-4">
|
||
Showing the first 120 matching dates — narrow the date range for a complete picture.
|
||
</div>
|
||
)}
|
||
|
||
{!hasSearched ? (
|
||
<div className="text-sm text-ink-muted py-8 text-center">Pick your days and time window, then hit Search.</div>
|
||
) : loading ? (
|
||
<div className="text-sm text-ink-faint font-mono py-8 text-center">loading…</div>
|
||
) : points.length === 0 ? (
|
||
<div className="text-sm text-ink-muted py-8 text-center">No matching dates in that range.</div>
|
||
) : (
|
||
<>
|
||
<div className="flex items-center gap-1.5 text-xs text-accent mb-2">
|
||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||
<path d="M9 9l6 6M9 15l6-6" strokeLinecap="round" />
|
||
<circle cx="12" cy="12" r="9" />
|
||
</svg>
|
||
Click any bar to see each player's time within that window
|
||
</div>
|
||
<div className="relative" style={{ height: HEIGHT }}>
|
||
<svg width="100%" height={HEIGHT} viewBox={`0 0 800 ${HEIGHT}`} preserveAspectRatio="none">
|
||
<defs>
|
||
<linearGradient id="recurringBarGradient" x1="0" y1="0" x2="0" y2="1">
|
||
<stop offset="0%" stopColor="#5EE596" />
|
||
<stop offset="100%" stopColor="#2A9C5C" />
|
||
</linearGradient>
|
||
</defs>
|
||
{yTicks.map((v) => {
|
||
const plotHeight = HEIGHT - PAD_TOP - PAD_BOTTOM;
|
||
const y = PAD_TOP + (1 - v / axisMax) * plotHeight;
|
||
return (
|
||
<g key={v}>
|
||
<line x1={PAD_LEFT} y1={y} x2={800 - PAD_RIGHT} y2={y} stroke="#2A332E" />
|
||
<text x={PAD_LEFT - 6} y={y + 3} textAnchor="end" fontSize={11} fill="#8A9691">
|
||
{v}
|
||
</text>
|
||
</g>
|
||
);
|
||
})}
|
||
{(() => {
|
||
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 (
|
||
<g key={p.date}>
|
||
<rect
|
||
x={x}
|
||
y={y}
|
||
width={barWidth}
|
||
height={barH}
|
||
rx={3}
|
||
fill="url(#recurringBarGradient)"
|
||
fillOpacity={selected?.date === p.date ? 1 : 0.8}
|
||
style={{
|
||
cursor: 'pointer',
|
||
...(selected?.date === p.date
|
||
? { filter: 'drop-shadow(0 0 6px rgba(63, 211, 122, 0.55))' }
|
||
: {}),
|
||
}}
|
||
onClick={() => loadPlayersFor(p)}
|
||
/>
|
||
{(i === 0 || i === points.length - 1 || i % showEvery === 0) && (
|
||
<text x={x + barWidth / 2} y={HEIGHT - PAD_BOTTOM + 14} textAnchor="middle" fontSize={9} fill="#8A9691">
|
||
{new Date(`${p.date}T00:00:00Z`).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}
|
||
</text>
|
||
)}
|
||
</g>
|
||
);
|
||
});
|
||
})()}
|
||
<line
|
||
x1={PAD_LEFT}
|
||
y1={PAD_TOP + (HEIGHT - PAD_TOP - PAD_BOTTOM)}
|
||
x2={800 - PAD_RIGHT}
|
||
y2={PAD_TOP + (HEIGHT - PAD_TOP - PAD_BOTTOM)}
|
||
stroke="#4C5652"
|
||
/>
|
||
</svg>
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{hasSearched && !loading && points.length > 0 && (
|
||
<div className="mt-6 pt-6 border-t-2 border-base-divider grid md:grid-cols-2 gap-6">
|
||
{/* Overall — combined across every matching day */}
|
||
<div>
|
||
<div className="stat-label mb-1">
|
||
Overall — combined across all {aggregateDates} matching day{aggregateDates === 1 ? '' : 's'}, {startTime}–{endTime}
|
||
</div>
|
||
<p className="text-xs text-ink-muted mb-3">
|
||
Each player's time spent inside the window, summed across every matching date.
|
||
</p>
|
||
|
||
{loadingAggregate ? (
|
||
<div className="text-sm text-ink-faint font-mono py-4">loading…</div>
|
||
) : aggregatePlayers.length === 0 ? (
|
||
<div className="text-sm text-ink-muted py-4">No players recorded across these dates.</div>
|
||
) : (
|
||
<>
|
||
<div className="row-divide">
|
||
{aggregatePlayers.map((p, i) => (
|
||
<Link
|
||
key={p.steamid}
|
||
href={`/players/${encodeURIComponent(p.steamid)}`}
|
||
className="flex items-center justify-between py-2.5 text-sm hover:bg-base transition-colors px-2 -mx-2 rounded"
|
||
>
|
||
<div className="flex items-center gap-3 min-w-0">
|
||
<span className="text-ink-faint text-xs w-5 text-right shrink-0">{i + 1}</span>
|
||
{/* 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"
|
||
/>
|
||
{p.country !== undefined && (
|
||
<span className="text-xs shrink-0" aria-hidden>
|
||
{countryCodeToFlag(p.country)}
|
||
</span>
|
||
)}
|
||
<span className="text-ink truncate">{p.name}</span>
|
||
</div>
|
||
<div className="flex items-center gap-4 shrink-0">
|
||
<span className="text-ink-faint text-xs mono">
|
||
{p.daysPresent} of {aggregateDates} day{aggregateDates === 1 ? '' : 's'}
|
||
</span>
|
||
<span className="text-ink mono text-xs w-16 text-right">{formatMinutes(p.totalMinutes)}</span>
|
||
</div>
|
||
</Link>
|
||
))}
|
||
</div>
|
||
{aggregateTruncated && (
|
||
<div className="text-xs text-ink-faint mt-2">
|
||
Showing the top {aggregatePlayers.length} players by time — narrow the search for a complete list.
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
{/* One specific day — populated once a bar is clicked, visible (empty) before that */}
|
||
<div>
|
||
{selected ? (
|
||
<>
|
||
<div className="stat-label mb-3">
|
||
{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
|
||
</div>
|
||
<PlayerTimelineChart
|
||
players={selectedPlayers}
|
||
windowMinutes={windowMinutes || 1}
|
||
windowStartLabel={startTime}
|
||
windowEndLabel={endTime}
|
||
loading={loadingPlayers}
|
||
/>
|
||
</>
|
||
) : (
|
||
<div className="min-h-[200px] border border-dashed border-base-border rounded-lg p-4">
|
||
<div className="flex items-center gap-1.5 text-sm text-accent">
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" className="shrink-0">
|
||
<path d="M9 9l6 6M9 15l6-6" strokeLinecap="round" />
|
||
<circle cx="12" cy="12" r="9" />
|
||
</svg>
|
||
<span>Click any green bar above to see who was online — and exactly when — on that specific day.</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|