Files
projects-jenz/discord_verificiation/playtime-frontend/components/RecurringWindowChart.tsx
T

299 lines
11 KiB
TypeScript
Raw 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 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<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);
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 (
<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>
</>
)}
{selected && (
<div className="mt-4 pt-4 border-t-2 border-base-divider">
<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>
)}
</div>
);
}