Files

409 lines
16 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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:0015: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>
);
}