aggressive data collection :PP

This commit is contained in:
Heidi
2026-05-14 22:52:33 +01:00
parent 98f374a300
commit ef10da8b0b
6 changed files with 735 additions and 34 deletions
+361 -7
View File
@@ -11,6 +11,8 @@ const dateFormat = new Intl.DateTimeFormat('en-GB', {
const apiEndpoints = {
health: '/health',
uptime: '/api/uptime',
viewers: '/api/viewers',
viewerEvent: '/api/viewers/event',
teams: '/api/tss/leaderboard/teams?limit=100',
teamsHealth: '/api/tss/leaderboard/teams?limit=1',
resolve: (name) => `/api/tss/teams/resolve?name=${encodeURIComponent(name)}`,
@@ -23,8 +25,12 @@ const navItems = [
{ path: '/', label: 'Home' },
{ path: '/teams', label: 'Team leaderboard' },
{ path: '/battle-logs', label: 'Battle Logs' },
{ path: '/viewers', label: 'Viewers' },
]
const analyticsConsentKey = 'tssbot.analyticsConsent'
const analyticsVisitorKey = 'tssbot.analyticsVisitor'
async function fetchJson(path, signal) {
const response = await fetch(path, {
signal,
@@ -43,6 +49,7 @@ function parseRoute(pathname = window.location.pathname) {
if (pathname === '/') return { page: 'home', teamName: '' }
if (pathname === '/teams') return { page: 'teams', teamName: '' }
if (pathname === '/uptime') return { page: 'uptime', teamName: '' }
if (pathname === '/viewers') return { page: 'viewers', teamName: '' }
if (pathname.startsWith('/teams/')) {
const teamName = decodeURIComponent(pathname.slice('/teams/'.length))
return { page: 'team', teamName }
@@ -68,6 +75,69 @@ function bestTeamName(team) {
return team?.tag_name || team?.short_name || team?.long_name || ''
}
function storedConsent() {
try {
return window.localStorage.getItem(analyticsConsentKey) || ''
} catch {
return ''
}
}
function stableId(storageKey) {
try {
const existing = window.localStorage.getItem(storageKey)
if (existing) return existing
const id =
window.crypto?.randomUUID?.() ||
`${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`
window.localStorage.setItem(storageKey, id)
return id
} catch {
return `${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`
}
}
const analyticsSessionId =
window.crypto?.randomUUID?.() ||
`${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`
function browserName() {
const ua = navigator.userAgent
if (ua.includes('Edg/')) return 'Microsoft Edge'
if (ua.includes('OPR/')) return 'Opera'
if (ua.includes('Firefox/')) return 'Firefox'
if (ua.includes('Chrome/') && !ua.includes('Chromium/')) return 'Chrome'
if (ua.includes('Safari/') && ua.includes('Version/')) return 'Safari'
return 'Unknown'
}
function operatingSystem() {
const ua = navigator.userAgent
if (ua.includes('Windows NT')) return 'Windows'
if (ua.includes('Android')) return 'Android'
if (/iPhone|iPad|iPod/.test(ua)) return 'iOS'
if (ua.includes('Mac OS X')) return 'macOS'
if (ua.includes('Linux')) return 'Linux'
return 'Unknown'
}
function deviceType() {
const ua = navigator.userAgent
if (/Mobi|Android|iPhone|iPod/.test(ua)) return 'Mobile'
if (/iPad|Tablet/.test(ua)) return 'Tablet'
return 'Desktop'
}
function routeLabel(route) {
if (route.page === 'team' && route.teamName) return `Team: ${route.teamName}`
if (route.page === 'teams') return 'Team leaderboard'
if (route.page === 'battle-logs') return 'Battle Logs'
if (route.page === 'uptime') return 'Uptime'
if (route.page === 'viewers') return 'viewers'
return 'Home'
}
async function fetchRecentTssGames(teams, signal) {
const teamNames = teams.map(bestTeamName).filter(Boolean).slice(0, 12)
@@ -122,6 +192,8 @@ function App() {
const [leaderboard, setLeaderboard] = useState({ status: 'idle', data: null, error: null })
const [live, setLive] = useState({ status: 'idle', data: null, error: null })
const [uptime, setUptime] = useState({ status: 'idle', checks: [], history: [], updatedAt: null })
const [viewers, setViewers] = useState({ status: 'idle', data: null, error: null, updatedAt: null })
const [analyticsConsent, setAnalyticsConsent] = useState(() => storedConsent())
const [teamQuery, setTeamQuery] = useState('')
const [searchHint, setSearchHint] = useState({ status: 'idle', name: '' })
const [profile, setProfile] = useState({
@@ -157,11 +229,64 @@ function App() {
? "Battle Logs | Toothless' TSS Bot"
: route.page === 'uptime'
? "Uptime | Toothless' TSS Bot"
: route.page === 'viewers'
? "viewers | Toothless' TSS Bot"
: "Toothless' TSS Bot"
document.title = title
}, [route.page, route.teamName])
useEffect(() => {
if (analyticsConsent !== 'analytics') return
const visitorId = stableId(analyticsVisitorKey)
let stopped = false
function sendViewerEvent(eventType) {
if (stopped) return
const body = {
consent: 'analytics',
event_type: eventType,
visitor_id: visitorId,
session_id: analyticsSessionId,
page_path: window.location.pathname,
page_title: routeLabel(route),
referrer: document.referrer,
browser: browserName(),
os: operatingSystem(),
device: deviceType(),
screen: `${window.screen.width}x${window.screen.height}`,
language: navigator.language,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
metadata: {
color_depth: window.screen.colorDepth,
viewport: `${window.innerWidth}x${window.innerHeight}`,
},
}
fetch(apiEndpoints.viewerEvent, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
keepalive: true,
}).catch(() => {})
}
sendViewerEvent('page_view')
const timer = window.setInterval(() => sendViewerEvent('heartbeat'), 30000)
const onVisibilityChange = () => {
if (document.visibilityState === 'visible') sendViewerEvent('heartbeat')
}
document.addEventListener('visibilitychange', onVisibilityChange)
return () => {
stopped = true
window.clearInterval(timer)
document.removeEventListener('visibilitychange', onVisibilityChange)
}
}, [analyticsConsent, route])
useEffect(() => {
const onKeyDown = (event) => {
if (event.key === 'Escape') {
@@ -417,6 +542,44 @@ function App() {
}
}, [route.page])
useEffect(() => {
if (route.page !== 'viewers') return
const controller = new AbortController()
function loadViewers() {
setViewers((current) => ({
status: current.status === 'ready' ? 'refreshing' : 'loading',
data: current.data,
error: null,
updatedAt: current.updatedAt,
}))
fetchJson(apiEndpoints.viewers, controller.signal)
.then((data) => {
setViewers({
status: 'ready',
data,
error: null,
updatedAt: Date.now(),
})
})
.catch((error) => {
if (!controller.signal.aborted) {
setViewers((current) => ({ ...current, status: 'error', error: error.message }))
}
})
}
loadViewers()
const timer = window.setInterval(loadViewers, 5000)
return () => {
window.clearInterval(timer)
controller.abort()
}
}, [route.page])
useEffect(() => {
if (route.page !== 'team' || !route.teamName) return
@@ -470,11 +633,22 @@ function App() {
}
}
function chooseAnalyticsConsent(value) {
try {
window.localStorage.setItem(analyticsConsentKey, value)
} catch {
// Local storage can be blocked; the in-memory choice still controls this session.
}
setAnalyticsConsent(value)
}
const activeNavPath =
route.page === 'team'
? '/teams'
: route.page === 'battle-logs'
? '/battle-logs'
: route.page === 'viewers'
? '/viewers'
: window.location.pathname
return (
@@ -541,8 +715,10 @@ function App() {
) : null}
{route.page === 'battle-logs' ? <BattleLogsPage live={live} matches={matches} /> : null}
{route.page === 'uptime' ? <UptimePage uptime={uptime} /> : null}
{route.page === 'viewers' ? <ViewersPage viewers={viewers} /> : null}
</section>
<Footer navigate={navigate} />
<ConsentBanner consent={analyticsConsent} onChoose={chooseAnalyticsConsent} />
</main>
)
}
@@ -552,18 +728,72 @@ function Footer({ navigate }) {
<footer className="mt-14 border-t border-border bg-fury-white">
<div className="mx-auto flex w-full max-w-7xl flex-col gap-3 px-5 py-6 text-sm text-text-soft sm:flex-row sm:items-center sm:justify-between sm:px-8">
<p>Toothless&apos; TSS Bot</p>
<button
className="w-fit font-semibold text-fury-cyan transition hover:text-text"
onClick={() => navigate('/uptime')}
type="button"
>
Uptime
</button>
<div className="flex flex-wrap gap-4">
<button
className="w-fit font-semibold text-fury-cyan transition hover:text-text"
onClick={() => navigate('/uptime')}
type="button"
>
Uptime
</button>
<button
className="w-fit font-semibold text-fury-cyan transition hover:text-text"
onClick={() => navigate('/viewers')}
type="button"
>
viewers
</button>
</div>
</div>
</footer>
)
}
function ConsentBanner({ consent, onChoose }) {
if (consent) {
return (
<button
className="fixed right-4 bottom-4 z-50 rounded-md border border-border bg-fury-white px-3 py-2 text-xs font-semibold text-text-soft shadow-sm transition hover:text-text"
onClick={() => onChoose('')}
type="button"
>
Privacy settings
</button>
)
}
return (
<div className="fixed inset-x-0 bottom-0 z-50 border-t border-border bg-fury-white shadow-[0_-8px_24px_rgba(0,0,0,0.08)]">
<div className="mx-auto flex w-full max-w-7xl flex-col gap-4 px-5 py-4 sm:px-8 lg:flex-row lg:items-center lg:justify-between">
<div className="max-w-3xl">
<h2 className="text-base font-semibold">Analytics consent</h2>
<p className="mt-1 text-sm text-text-soft">
We can track page views, live viewing state, browser, device, screen size,
language, timezone, referrer, and pseudonymous identifiers so the public
viewers page works. Raw IP addresses are not shown publicly.
</p>
</div>
<div className="flex shrink-0 flex-wrap gap-2">
<button
className="rounded-md border border-border px-4 py-2 text-sm font-semibold text-text-soft transition hover:bg-surface hover:text-text"
onClick={() => onChoose('declined')}
type="button"
>
Decline
</button>
<button
className="rounded-md bg-fury-cyan px-4 py-2 text-sm font-semibold text-bg transition hover:bg-fury-aqua"
onClick={() => onChoose('analytics')}
type="button"
>
Allow analytics
</button>
</div>
</div>
</div>
)
}
function Landing({ live, matches, navigate }) {
const treeRef = useRef(null)
@@ -1093,6 +1323,130 @@ function BattleLogsPage({ live, matches }) {
)
}
function relativeSeconds(timestamp) {
if (!timestamp) return 'unknown'
const seconds = Math.max(0, Math.round((Date.now() - new Date(timestamp).getTime()) / 1000))
if (seconds < 60) return `${seconds}s ago`
return `${Math.round(seconds / 60)}m ago`
}
function ViewersPage({ viewers }) {
const data = viewers.data || {}
const active = data.active || []
const topPages = data.top_pages || []
const clients = data.clients || []
const totals = data.totals || {}
const generatedAt = data.generated_at ? dateFormat.format(new Date(data.generated_at)) : 'Waiting for data'
return (
<section className="space-y-6">
<div className="rounded-lg border border-border bg-fury-white p-6 shadow-sm">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div>
<p className="text-sm font-semibold uppercase tracking-wide text-fury-cyan">
Public analytics
</p>
<h1 className="mt-1 text-4xl font-bold">viewers</h1>
<p className="mt-2 text-sm text-text-soft">
Live consented browser sessions and page activity. Last refreshed {generatedAt}.
</p>
</div>
<span className="w-fit rounded-md bg-surface px-3 py-2 text-sm font-semibold text-fury-cyan">
{viewers.status === 'loading' ? 'Loading' : `${formatNumber(totals.active_now)} active now`}
</span>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<Stat label="Active now" value={formatNumber(totals.active_now)} />
<Stat label="Visitors 24h" value={formatNumber(totals.visitors_24h)} />
<Stat label="Page views 24h" value={formatNumber(totals.page_views_24h)} />
<Stat label="Events 24h" value={formatNumber(totals.events_24h)} />
</div>
<div className="overflow-hidden rounded-lg border border-border bg-fury-white shadow-sm">
<div className="border-b border-surface px-5 py-4">
<h2 className="text-lg font-semibold">Currently viewing</h2>
<p className="mt-1 text-sm text-text-soft">
Heartbeats within {formatNumber(data.active_window_seconds || 75)} seconds
</p>
</div>
{active.map((viewer) => (
<div
className="grid gap-3 border-b border-surface px-5 py-4 text-sm lg:grid-cols-[1fr_1fr_auto_auto_auto]"
key={viewer.session}
>
<div className="min-w-0">
<p className="truncate font-semibold">{viewer.page_title || viewer.page_path}</p>
<p className="truncate text-xs text-text-soft">{viewer.page_path}</p>
</div>
<div className="min-w-0">
<p className="truncate font-semibold">
{viewer.browser} on {viewer.os}
</p>
<p className="truncate text-xs text-text-soft">
{viewer.device} · {viewer.screen || 'unknown screen'} · {viewer.timezone || 'unknown timezone'}
</p>
</div>
<p className="text-text-soft">{viewer.language || 'unknown language'}</p>
<p className="text-text-soft">Seen {relativeSeconds(viewer.last_seen_at)}</p>
<p className="font-mono text-xs text-text-muted">#{viewer.session}</p>
</div>
))}
{!active.length ? (
<p className="px-5 py-10 text-sm text-text-soft">
{viewers.error || 'No consented viewers are active right now'}
</p>
) : null}
</div>
<div className="grid gap-6 xl:grid-cols-2">
<div className="rounded-lg border border-border bg-fury-white shadow-sm">
<div className="border-b border-surface px-5 py-4">
<h2 className="text-lg font-semibold">Top pages</h2>
<p className="mt-1 text-sm text-text-soft">Page views over the last 24 hours</p>
</div>
{topPages.map((page) => (
<div className="grid grid-cols-[1fr_auto] gap-3 border-b border-surface px-5 py-3 text-sm" key={page.page_path}>
<div className="min-w-0">
<p className="truncate font-semibold">{page.page_title || page.page_path}</p>
<p className="truncate text-xs text-text-soft">{page.page_path}</p>
</div>
<p className="font-semibold text-fury-cyan">{formatNumber(page.views)}</p>
</div>
))}
{!topPages.length ? <p className="px-5 py-10 text-sm text-text-soft">No page views recorded yet</p> : null}
</div>
<div className="rounded-lg border border-border bg-fury-white shadow-sm">
<div className="border-b border-surface px-5 py-4">
<h2 className="text-lg font-semibold">Clients</h2>
<p className="mt-1 text-sm text-text-soft">Browsers, devices, and operating systems seen in 24 hours</p>
</div>
{clients.map((client) => (
<div
className="grid grid-cols-[1fr_auto] gap-3 border-b border-surface px-5 py-3 text-sm"
key={`${client.browser}-${client.os}-${client.device}`}
>
<div className="min-w-0">
<p className="truncate font-semibold">{client.browser} on {client.os}</p>
<p className="truncate text-xs text-text-soft">{client.device}</p>
</div>
<p className="font-semibold text-fury-cyan">{formatNumber(client.events)}</p>
</div>
))}
{!clients.length ? <p className="px-5 py-10 text-sm text-text-soft">No client data recorded yet</p> : null}
</div>
</div>
<p className="text-xs text-text-soft">
Analytics are opt-in, retained for {formatNumber(data.privacy?.retention_days || 30)} days,
and public output excludes raw IP addresses.
</p>
</section>
)
}
function UptimePage({ uptime }) {
const checks = uptime.checks
const history = uptime.history