furhter bug fixes and updates

This commit is contained in:
jenz
2026-08-22 17:10:27 +02:00
parent 9e8e8378a2
commit 16bb9e14a5
18 changed files with 1070 additions and 256 deletions
@@ -0,0 +1,298 @@
'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 border-base-border">
<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>
);
}