diff --git a/.gitignore b/.gitignore
index 4702c83..ede46df 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,3 +5,5 @@ dist
.DS_Store
npm-debug.log*
vite-dev*.log
+server-local*.log
+.local-storage/
diff --git a/README.md b/README.md
index 98bdb22..c132d02 100644
--- a/README.md
+++ b/README.md
@@ -8,6 +8,7 @@ Routes:
- `/teams` TSS team leaderboard
- `/teams/:teamname` generated team profile with roster, summary, rating history, and battle results
- `/battle-logs` Battle Logs
+- `/viewers` public consented viewer analytics dashboard
## Local development
@@ -83,6 +84,30 @@ UPTIME_HISTORY_LIMIT=336
The server creates the storage folder, SQLite database, and `uptime_snapshots`
table automatically.
+## Viewer analytics
+
+The site shows a GDPR-style consent banner before analytics start. If a visitor
+allows analytics, the browser sends page-view and heartbeat events to
+`POST /api/viewers/event`. The public `/viewers` page reads `GET /api/viewers`
+and shows active pages, client/browser information, 24-hour page totals, and
+top pages.
+
+Viewer analytics are stored in SQLite under the same `UPTIME_STORAGE_DIR` by
+default. Raw IP addresses are not stored in the public response; the server
+stores a salted IP hash for deduplication and abuse review. Set a unique salt in
+production:
+
+```sh
+ANALYTICS_DATABASE_FILE=viewers.sqlite
+ANALYTICS_RETENTION_DAYS=30
+ANALYTICS_ACTIVE_WINDOW_SECONDS=75
+ANALYTICS_SALT=replace-with-a-random-secret
+```
+
+This is an implementation aid, not legal advice. For production GDPR compliance,
+publish a privacy notice that matches the configured retention period and data
+fields, and make sure the configured salt is secret.
+
## GitHub webhook
The webhook process listens on port `3011` at `/github`. Configure GitHub to send
diff --git a/example.env b/example.env
index b1a8b03..9d59bd3 100644
--- a/example.env
+++ b/example.env
@@ -7,6 +7,10 @@ UPTIME_STORAGE_DIR=~/tsswebstorage
UPTIME_DATABASE_FILE=uptime.sqlite
UPTIME_SAMPLE_INTERVAL_MS=1800000
UPTIME_HISTORY_LIMIT=336
+ANALYTICS_DATABASE_FILE=viewers.sqlite
+ANALYTICS_RETENTION_DAYS=30
+ANALYTICS_ACTIVE_WINDOW_SECONDS=75
+ANALYTICS_SALT=change-me-viewer-salt
API_CACHE_TTL_MS=15000
API_RATE_LIMIT_WINDOW_MS=60000
API_RATE_LIMIT_MAX=120
diff --git a/server.cjs b/server.cjs
index 100e2d7..4b49ebd 100644
--- a/server.cjs
+++ b/server.cjs
@@ -1,4 +1,5 @@
const fs = require('node:fs')
+const crypto = require('node:crypto')
const http = require('node:http')
const https = require('node:https')
const os = require('node:os')
@@ -42,6 +43,10 @@ const UPTIME_STORAGE_DIR = process.env.UPTIME_STORAGE_DIR || '~/tsswebstorage'
const UPTIME_DATABASE_FILE = process.env.UPTIME_DATABASE_FILE || 'uptime.sqlite'
const UPTIME_SAMPLE_INTERVAL_MS = Number(process.env.UPTIME_SAMPLE_INTERVAL_MS || 30 * 60 * 1000)
const UPTIME_HISTORY_LIMIT = Number(process.env.UPTIME_HISTORY_LIMIT || 336)
+const ANALYTICS_DATABASE_FILE = process.env.ANALYTICS_DATABASE_FILE || 'viewers.sqlite'
+const ANALYTICS_RETENTION_DAYS = Number(process.env.ANALYTICS_RETENTION_DAYS || 30)
+const ANALYTICS_ACTIVE_WINDOW_SECONDS = Number(process.env.ANALYTICS_ACTIVE_WINDOW_SECONDS || 75)
+const ANALYTICS_SALT = process.env.ANALYTICS_SALT || 'change-me-viewer-salt'
const API_CACHE_TTL_MS = Number(process.env.API_CACHE_TTL_MS || 15000)
const API_RATE_LIMIT_WINDOW_MS = Number(process.env.API_RATE_LIMIT_WINDOW_MS || 60000)
const API_RATE_LIMIT_MAX = Number(process.env.API_RATE_LIMIT_MAX || 120)
@@ -49,6 +54,7 @@ const DIST_DIR = path.join(__dirname, 'dist')
const MAX_TEAM_NAME_LENGTH = 80
const MAX_CACHE_ENTRIES = 200
const MAX_RATE_LIMIT_KEYS = 1000
+const MAX_ANALYTICS_BODY_BYTES = 16 * 1024
const mimeTypes = {
'.css': 'text/css; charset=utf-8',
@@ -76,6 +82,7 @@ const jsonHeaders = {
const apiCache = new Map()
const rateLimits = new Map()
let uptimeDb = null
+let analyticsDb = null
let latestUptimeSnapshot = null
function sendJson(res, status, body, headers = {}) {
@@ -138,6 +145,67 @@ function uptimeStoragePath() {
return path.resolve(expandHome(UPTIME_STORAGE_DIR))
}
+function ensureAnalyticsDb() {
+ if (analyticsDb) return analyticsDb
+
+ const storageDir = uptimeStoragePath()
+ fs.mkdirSync(storageDir, { recursive: true })
+
+ analyticsDb = new Database(path.join(storageDir, ANALYTICS_DATABASE_FILE))
+ analyticsDb.pragma('journal_mode = WAL')
+ analyticsDb.exec(`
+ create table if not exists viewer_events (
+ id integer primary key autoincrement,
+ occurred_at text not null default (datetime('now')),
+ visitor_id text not null,
+ session_id text not null,
+ ip_hash text not null,
+ event_type text not null,
+ page_path text not null,
+ page_title text not null,
+ referrer text not null default '',
+ user_agent text not null default '',
+ browser text not null default 'Unknown',
+ os text not null default 'Unknown',
+ device text not null default 'Desktop',
+ screen text not null default '',
+ language text not null default '',
+ timezone text not null default '',
+ consent text not null default 'analytics',
+ metadata text not null default '{}'
+ );
+
+ create table if not exists active_viewers (
+ session_id text primary key,
+ visitor_id text not null,
+ ip_hash text not null,
+ first_seen_at text not null,
+ last_seen_at text not null,
+ page_path text not null,
+ page_title text not null,
+ referrer text not null default '',
+ user_agent text not null default '',
+ browser text not null default 'Unknown',
+ os text not null default 'Unknown',
+ device text not null default 'Desktop',
+ screen text not null default '',
+ language text not null default '',
+ timezone text not null default ''
+ );
+
+ create index if not exists viewer_events_occurred_at_idx
+ on viewer_events (occurred_at desc);
+
+ create index if not exists viewer_events_page_path_idx
+ on viewer_events (page_path, occurred_at desc);
+
+ create index if not exists active_viewers_last_seen_at_idx
+ on active_viewers (last_seen_at desc);
+ `)
+
+ return analyticsDb
+}
+
function ensureUptimeDb() {
if (uptimeDb) return uptimeDb
@@ -308,6 +376,221 @@ function clientIp(req) {
return req.socket.remoteAddress || 'unknown'
}
+function hashIp(ip) {
+ return crypto.createHash('sha256').update(`${ANALYTICS_SALT}:${ip}`).digest('hex')
+}
+
+function sanitizeText(value, maxLength = 200) {
+ return String(value || '').replace(/[\u0000-\u001f\u007f]/g, '').trim().slice(0, maxLength)
+}
+
+function sanitizePath(value) {
+ const raw = sanitizeText(value, 300)
+ if (!raw.startsWith('/')) return '/'
+ return raw
+}
+
+function parseClient(userAgent = '') {
+ const ua = String(userAgent)
+ let browser = 'Unknown'
+ let osName = 'Unknown'
+ let device = 'Desktop'
+
+ if (/Edg\//.test(ua)) browser = 'Microsoft Edge'
+ else if (/OPR\//.test(ua)) browser = 'Opera'
+ else if (/Firefox\//.test(ua)) browser = 'Firefox'
+ else if (/Chrome\//.test(ua) && !/Chromium\//.test(ua)) browser = 'Chrome'
+ else if (/Safari\//.test(ua) && /Version\//.test(ua)) browser = 'Safari'
+
+ if (/Windows NT/.test(ua)) osName = 'Windows'
+ else if (/Android/.test(ua)) osName = 'Android'
+ else if (/(iPhone|iPad|iPod)/.test(ua)) osName = 'iOS'
+ else if (/Mac OS X/.test(ua)) osName = 'macOS'
+ else if (/Linux/.test(ua)) osName = 'Linux'
+
+ if (/Mobi|Android|iPhone|iPod/.test(ua)) device = 'Mobile'
+ else if (/iPad|Tablet/.test(ua)) device = 'Tablet'
+
+ return { browser, os: osName, device }
+}
+
+function readJsonBody(req) {
+ return new Promise((resolve, reject) => {
+ const chunks = []
+ let size = 0
+
+ req.on('data', (chunk) => {
+ size += chunk.length
+ if (size > MAX_ANALYTICS_BODY_BYTES) {
+ reject(new Error('Request body too large'))
+ req.destroy()
+ return
+ }
+ chunks.push(chunk)
+ })
+
+ req.on('end', () => {
+ try {
+ const body = Buffer.concat(chunks).toString('utf8')
+ resolve(body ? JSON.parse(body) : {})
+ } catch {
+ reject(new Error('Invalid JSON body'))
+ }
+ })
+ req.on('error', reject)
+ })
+}
+
+function purgeOldAnalytics(db) {
+ db.prepare(`
+ delete from viewer_events
+ where occurred_at < datetime('now', ?)
+ `).run(`-${ANALYTICS_RETENTION_DAYS} days`)
+
+ db.prepare(`
+ delete from active_viewers
+ where last_seen_at < datetime('now', ?)
+ `).run(`-${ANALYTICS_ACTIVE_WINDOW_SECONDS * 3} seconds`)
+}
+
+function recordViewerEvent(req, payload) {
+ const db = ensureAnalyticsDb()
+ purgeOldAnalytics(db)
+
+ const serverClient = parseClient(req.headers['user-agent'] || '')
+ const event = {
+ visitor_id: sanitizeText(payload.visitor_id, 80) || crypto.randomUUID(),
+ session_id: sanitizeText(payload.session_id, 80) || crypto.randomUUID(),
+ ip_hash: hashIp(clientIp(req)),
+ event_type: ['page_view', 'heartbeat', 'consent'].includes(payload.event_type)
+ ? payload.event_type
+ : 'heartbeat',
+ page_path: sanitizePath(payload.page_path),
+ page_title: sanitizeText(payload.page_title, 160),
+ referrer: sanitizeText(payload.referrer, 300),
+ user_agent: sanitizeText(req.headers['user-agent'] || payload.user_agent, 500),
+ browser: sanitizeText(payload.browser || serverClient.browser, 80),
+ os: sanitizeText(payload.os || serverClient.os, 80),
+ device: sanitizeText(payload.device || serverClient.device, 80),
+ screen: sanitizeText(payload.screen, 40),
+ language: sanitizeText(payload.language, 40),
+ timezone: sanitizeText(payload.timezone, 80),
+ consent: payload.consent === 'analytics' ? 'analytics' : '',
+ metadata: JSON.stringify(payload.metadata && typeof payload.metadata === 'object' ? payload.metadata : {}),
+ }
+
+ if (event.consent !== 'analytics') {
+ throw new Error('Analytics consent is required')
+ }
+
+ const now = new Date().toISOString()
+ db.prepare(`
+ insert into viewer_events
+ (occurred_at, visitor_id, session_id, ip_hash, event_type, page_path, page_title,
+ referrer, user_agent, browser, os, device, screen, language, timezone, consent, metadata)
+ values
+ (@occurred_at, @visitor_id, @session_id, @ip_hash, @event_type, @page_path, @page_title,
+ @referrer, @user_agent, @browser, @os, @device, @screen, @language, @timezone, @consent, @metadata)
+ `).run({ ...event, occurred_at: now })
+
+ db.prepare(`
+ insert into active_viewers
+ (session_id, visitor_id, ip_hash, first_seen_at, last_seen_at, page_path, page_title,
+ referrer, user_agent, browser, os, device, screen, language, timezone)
+ values
+ (@session_id, @visitor_id, @ip_hash, @now, @now, @page_path, @page_title,
+ @referrer, @user_agent, @browser, @os, @device, @screen, @language, @timezone)
+ on conflict(session_id) do update set
+ last_seen_at = excluded.last_seen_at,
+ page_path = excluded.page_path,
+ page_title = excluded.page_title,
+ referrer = excluded.referrer,
+ user_agent = excluded.user_agent,
+ browser = excluded.browser,
+ os = excluded.os,
+ device = excluded.device,
+ screen = excluded.screen,
+ language = excluded.language,
+ timezone = excluded.timezone
+ `).run({ ...event, now })
+}
+
+function viewerDashboard() {
+ const db = ensureAnalyticsDb()
+ purgeOldAnalytics(db)
+
+ const activeSince = `-${ANALYTICS_ACTIVE_WINDOW_SECONDS} seconds`
+ const active = db.prepare(`
+ select session_id, visitor_id, first_seen_at, last_seen_at, page_path, page_title,
+ referrer, browser, os, device, screen, language, timezone
+ from active_viewers
+ where last_seen_at >= datetime('now', ?)
+ order by last_seen_at desc
+ limit 100
+ `).all(activeSince).map((row) => ({
+ session: row.session_id.slice(0, 8),
+ visitor: row.visitor_id.slice(0, 8),
+ first_seen_at: row.first_seen_at,
+ last_seen_at: row.last_seen_at,
+ page_path: row.page_path,
+ page_title: row.page_title,
+ referrer: row.referrer,
+ browser: row.browser,
+ os: row.os,
+ device: row.device,
+ screen: row.screen,
+ language: row.language,
+ timezone: row.timezone,
+ }))
+
+ const topPages = db.prepare(`
+ select page_path, page_title, count(*) as views
+ from viewer_events
+ where event_type = 'page_view'
+ and occurred_at >= datetime('now', '-24 hours')
+ group by page_path, page_title
+ order by views desc, page_path asc
+ limit 12
+ `).all()
+
+ const clients = db.prepare(`
+ select browser, os, device, count(*) as events
+ from viewer_events
+ where occurred_at >= datetime('now', '-24 hours')
+ group by browser, os, device
+ order by events desc
+ limit 12
+ `).all()
+
+ const totals = db.prepare(`
+ select
+ count(*) as events_24h,
+ count(distinct visitor_id) as visitors_24h,
+ sum(case when event_type = 'page_view' then 1 else 0 end) as page_views_24h
+ from viewer_events
+ where occurred_at >= datetime('now', '-24 hours')
+ `).get()
+
+ return {
+ active_window_seconds: ANALYTICS_ACTIVE_WINDOW_SECONDS,
+ generated_at: new Date().toISOString(),
+ active,
+ top_pages: topPages,
+ clients,
+ totals: {
+ active_now: active.length,
+ events_24h: totals?.events_24h || 0,
+ visitors_24h: totals?.visitors_24h || 0,
+ page_views_24h: totals?.page_views_24h || 0,
+ },
+ privacy: {
+ retention_days: ANALYTICS_RETENTION_DAYS,
+ stores_ip_hashes: true,
+ exposes_raw_ip: false,
+ },
+ }
+}
+
function isRateLimited(req) {
const now = Date.now()
const ip = clientIp(req)
@@ -476,36 +759,66 @@ function serveStatic(req, res) {
})
}
-http
- .createServer((req, res) => {
- if (req.url === '/health') {
- sendJson(res, 200, { ok: true })
+const server = http.createServer((req, res) => {
+ if (req.url === '/health') {
+ sendJson(res, 200, { ok: true })
+ return
+ }
+
+ if (req.method === 'GET' && req.url === '/api/uptime') {
+ uptimeHistory()
+ .then((data) => sendJson(res, 200, data))
+ .catch((error) => sendJson(res, 500, { error: 'Uptime history unavailable', detail: error.message }))
+ return
+ }
+
+ if (req.method === 'GET' && req.url === '/api/viewers') {
+ try {
+ sendJson(res, 200, viewerDashboard())
+ } catch (error) {
+ sendJson(res, 500, { error: 'Viewer analytics unavailable', detail: error.message })
+ }
+ return
+ }
+
+ if (req.method === 'POST' && req.url === '/api/viewers/event') {
+ if (!isSameOriginRequest(req)) {
+ sendJson(res, 403, { error: 'Analytics events are restricted to this site' })
return
}
- if (req.method === 'GET' && req.url === '/api/uptime') {
- uptimeHistory()
- .then((data) => sendJson(res, 200, data))
- .catch((error) => sendJson(res, 500, { error: 'Uptime history unavailable', detail: error.message }))
+ if (isRateLimited(req)) {
+ sendJson(res, 429, { error: 'Too many analytics events' })
return
}
- if (req.method === 'OPTIONS' && req.url.startsWith('/api/')) {
- sendJson(res, 403, { error: 'CORS requests are not allowed' })
- return
- }
+ readJsonBody(req)
+ .then((payload) => {
+ recordViewerEvent(req, payload)
+ send(res, 204, '', { 'cache-control': 'no-store' })
+ })
+ .catch((error) => sendJson(res, 400, { error: error.message }))
+ return
+ }
- if (req.url.startsWith('/api/')) {
- proxyRequest(req, res)
- return
- }
+ if (req.method === 'OPTIONS' && req.url.startsWith('/api/')) {
+ sendJson(res, 403, { error: 'CORS requests are not allowed' })
+ return
+ }
- serveStatic(req, res)
- })
- .listen(PORT, '0.0.0.0', () => {
- console.log(`tssbot-web serving http://localhost:${PORT}`)
- console.log(`proxying API requests to ${API_UPSTREAM}`)
- console.log(`sampling uptime every ${Math.round(UPTIME_SAMPLE_INTERVAL_MS / 60000)} minutes`)
- console.log(`storing uptime snapshots in ${path.join(uptimeStoragePath(), UPTIME_DATABASE_FILE)}`)
- startUptimeSampler()
- })
+ if (req.url.startsWith('/api/')) {
+ proxyRequest(req, res)
+ return
+ }
+
+ serveStatic(req, res)
+})
+
+server.listen(PORT, '0.0.0.0', () => {
+ console.log(`tssbot-web serving http://localhost:${PORT}`)
+ console.log(`proxying API requests to ${API_UPSTREAM}`)
+ console.log(`sampling uptime every ${Math.round(UPTIME_SAMPLE_INTERVAL_MS / 60000)} minutes`)
+ console.log(`storing uptime snapshots in ${path.join(uptimeStoragePath(), UPTIME_DATABASE_FILE)}`)
+ console.log(`storing viewer analytics in ${path.join(uptimeStoragePath(), ANALYTICS_DATABASE_FILE)}`)
+ startUptimeSampler()
+})
diff --git a/src/App.jsx b/src/App.jsx
index ec3a1f9..1489e53 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -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' ?
+ 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. +
++ Public analytics +
++ Live consented browser sessions and page activity. Last refreshed {generatedAt}. +
++ Heartbeats within {formatNumber(data.active_window_seconds || 75)} seconds +
+{viewer.page_title || viewer.page_path}
+{viewer.page_path}
++ {viewer.browser} on {viewer.os} +
++ {viewer.device} · {viewer.screen || 'unknown screen'} · {viewer.timezone || 'unknown timezone'} +
+{viewer.language || 'unknown language'}
+Seen {relativeSeconds(viewer.last_seen_at)}
+#{viewer.session}
++ {viewers.error || 'No consented viewers are active right now'} +
+ ) : null} +Page views over the last 24 hours
+{page.page_title || page.page_path}
+{page.page_path}
+{formatNumber(page.views)}
+No page views recorded yet
: null} +Browsers, devices, and operating systems seen in 24 hours
+{client.browser} on {client.os}
+{client.device}
+{formatNumber(client.events)}
+No client data recorded yet
: null} ++ Analytics are opt-in, retained for {formatNumber(data.privacy?.retention_days || 30)} days, + and public output excludes raw IP addresses. +
+