feat: add custom date range picker, fix country click highlight on map

- Analytics default interval changed to 1h
- Add 'Custom' toggle option with datetime-local pickers (pre-filled to last 24h)
- Refactor analytics-db: buildWhere now takes from/to unix timestamps instead of Interval
- Export INTERVAL_SECONDS from analytics-db for route reuse
- All 6 API routes accept from/to params (fallback to interval if absent)
- Timeline bucket size computed from duration rather than hardcoded per interval
- Fix map country click highlight: bake isSelected into GeoJSON features (data-driven)
  instead of relying on Layer filter prop updates (unreliable in react-map-gl v8)
- Split highlight into countries-selected (data-driven) and countries-hover (filter-driven)
- Show tooltip at country centroid when selected via table, hover takes precedence

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
fuomag9
2026-02-27 10:38:02 +01:00
parent 608fb9c6fe
commit 9e2007eb0c
10 changed files with 311 additions and 100 deletions

View File

@@ -1,6 +1,6 @@
'use client';
import React, { useState, useEffect, useCallback } from 'react';
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import dynamic from 'next/dynamic';
import Link from 'next/link';
import {
@@ -44,6 +44,17 @@ const WorldMap = dynamic(() => import('./WorldMapInner'), {
// ── Types (mirrored from analytics-db — can't import server-only code) ────────
type Interval = '1h' | '12h' | '24h' | '7d' | '30d';
type DisplayInterval = Interval | 'custom';
const INTERVAL_SECONDS_CLIENT: Record<Interval, number> = {
'1h': 3600, '12h': 43200, '24h': 86400, '7d': 7 * 86400, '30d': 30 * 86400,
};
/** Format a datetime-local string for <input type="datetime-local"> (local time, no seconds) */
function toDatetimeLocal(date: Date): string {
const pad = (n: number) => String(n).padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
}
interface AnalyticsSummary {
totalRequests: number;
@@ -97,12 +108,10 @@ function formatBytes(bytes: number): string {
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
}
function formatTs(ts: number, interval: Interval): string {
function formatTs(ts: number, rangeSeconds: number): string {
const d = new Date(ts * 1000);
if (interval === '1h') return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
if (interval === '12h') return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
if (interval === '24h') return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
if (interval === '7d') return d.toLocaleDateString([], { weekday: 'short' }) + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
if (rangeSeconds <= 86400) return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
if (rangeSeconds <= 7 * 86400) return d.toLocaleDateString([], { weekday: 'short' }) + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
return d.toLocaleDateString([], { month: 'short', day: 'numeric' });
}
@@ -134,10 +143,14 @@ function StatCard({ label, value, sub, color }: { label: string; value: string;
// ── Main component ────────────────────────────────────────────────────────────
export default function AnalyticsClient() {
const [interval, setIntervalVal] = useState<Interval>('24h');
const [interval, setIntervalVal] = useState<DisplayInterval>('1h');
const [host, setHost] = useState<string>('all');
const [hosts, setHosts] = useState<string[]>([]);
// Custom range (datetime-local strings: "YYYY-MM-DDTHH:MM")
const [customFrom, setCustomFrom] = useState<string>('');
const [customTo, setCustomTo] = useState<string>('');
const [summary, setSummary] = useState<AnalyticsSummary | null>(null);
const [timeline, setTimeline] = useState<TimelineBucket[]>([]);
const [countries, setCountries] = useState<CountryStats[]>([]);
@@ -147,15 +160,42 @@ export default function AnalyticsClient() {
const [loading, setLoading] = useState(true);
const [selectedCountry, setSelectedCountry] = useState<string | null>(null);
/** How many seconds the current selection spans — used for chart axis labels */
const rangeSeconds = useMemo(() => {
if (interval === 'custom' && customFrom && customTo) {
const diff = Math.floor(new Date(customTo).getTime() / 1000) - Math.floor(new Date(customFrom).getTime() / 1000);
return diff > 0 ? diff : 3600;
}
return INTERVAL_SECONDS_CLIENT[interval as Interval] ?? 3600;
}, [interval, customFrom, customTo]);
/** Build the query string for all analytics endpoints */
const buildParams = useCallback((extra = '') => {
const h = `host=${encodeURIComponent(host)}`;
if (interval === 'custom' && customFrom && customTo) {
const from = Math.floor(new Date(customFrom).getTime() / 1000);
const to = Math.floor(new Date(customTo).getTime() / 1000);
return `?from=${from}&to=${to}&${h}${extra}`;
}
return `?interval=${interval}&${h}${extra}`;
}, [interval, host, customFrom, customTo]);
// Fetch hosts once
useEffect(() => {
fetch('/api/analytics/hosts').then(r => r.json()).then(setHosts).catch(() => {});
}, []);
// Fetch all analytics data when interval or host changes
// Fetch all analytics data when range/host changes
// For custom: only fetch when both dates are set and from < to
useEffect(() => {
if (interval === 'custom') {
if (!customFrom || !customTo) return;
const from = new Date(customFrom).getTime();
const to = new Date(customTo).getTime();
if (from >= to) return;
}
setLoading(true);
const params = `?interval=${interval}&host=${encodeURIComponent(host)}`;
const params = buildParams();
Promise.all([
fetch(`/api/analytics/summary${params}`).then(r => r.json()),
fetch(`/api/analytics/timeline${params}`).then(r => r.json()),
@@ -171,16 +211,15 @@ export default function AnalyticsClient() {
setUserAgents(u);
setBlocked(b);
}).catch(() => {}).finally(() => setLoading(false));
}, [interval, host]);
}, [buildParams, interval, customFrom, customTo]);
const fetchBlockedPage = useCallback((page: number) => {
const params = `?interval=${interval}&host=${encodeURIComponent(host)}&page=${page}`;
fetch(`/api/analytics/blocked${params}`).then(r => r.json()).then(setBlocked).catch(() => {});
}, [interval, host]);
fetch(`/api/analytics/blocked${buildParams(`&page=${page}`)}`).then(r => r.json()).then(setBlocked).catch(() => {});
}, [buildParams]);
// ── Chart configs ─────────────────────────────────────────────────────────
const timelineLabels = timeline.map(b => formatTs(b.ts, interval));
const timelineLabels = timeline.map(b => formatTs(b.ts, rangeSeconds));
const timelineOptions: ApexOptions = {
...DARK_CHART,
chart: { ...DARK_CHART.chart, type: 'area', stacked: true, id: 'timeline' },
@@ -235,19 +274,69 @@ export default function AnalyticsClient() {
Analytics
</Typography>
</Box>
<Stack direction="row" spacing={2} alignItems="center">
<Stack direction="row" spacing={2} alignItems="center" flexWrap="wrap" useFlexGap>
<ToggleButtonGroup
value={interval}
exclusive
size="small"
onChange={(_e, v) => { if (v) setIntervalVal(v); }}
onChange={(_e, v) => {
if (!v) return;
if (v === 'custom' && !customFrom) {
// Pre-fill custom range with last 24h
const now = new Date();
const ago = new Date(now.getTime() - 86400 * 1000);
setCustomFrom(toDatetimeLocal(ago));
setCustomTo(toDatetimeLocal(now));
}
setIntervalVal(v);
}}
>
<ToggleButton value="1h">1h</ToggleButton>
<ToggleButton value="12h">12h</ToggleButton>
<ToggleButton value="24h">24h</ToggleButton>
<ToggleButton value="7d">7d</ToggleButton>
<ToggleButton value="30d">30d</ToggleButton>
<ToggleButton value="custom">Custom</ToggleButton>
</ToggleButtonGroup>
{interval === 'custom' && (
<Stack direction="row" spacing={1} alignItems="center">
<input
type="datetime-local"
value={customFrom}
max={customTo || undefined}
onChange={e => setCustomFrom(e.target.value)}
style={{
background: 'rgba(30,41,59,0.8)',
border: '1px solid rgba(148,163,184,0.2)',
borderRadius: 6,
color: '#f1f5f9',
padding: '5px 8px',
fontSize: 13,
colorScheme: 'dark',
outline: 'none',
}}
/>
<Typography variant="caption" color="text.disabled"></Typography>
<input
type="datetime-local"
value={customTo}
min={customFrom || undefined}
onChange={e => setCustomTo(e.target.value)}
style={{
background: 'rgba(30,41,59,0.8)',
border: '1px solid rgba(148,163,184,0.2)',
borderRadius: 6,
color: '#f1f5f9',
padding: '5px 8px',
fontSize: 13,
colorScheme: 'dark',
outline: 'none',
}}
/>
</Stack>
)}
<FormControl size="small" sx={{ minWidth: 160 }}>
<Select
value={host}