diff --git a/package-lock.json b/package-lock.json index 7d00324..c3fd87d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "@tailwindcss/vite": "^4.1.8", "@vitejs/plugin-react": "^4.5.0", "better-sqlite3": "^12.10.0", + "leaflet": "^1.9.4", "react": "^19.1.0", "react-dom": "^19.1.0", "vite": "^6.3.5" @@ -2715,6 +2716,12 @@ "json-buffer": "3.0.1" } }, + "node_modules/leaflet": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", + "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", + "license": "BSD-2-Clause" + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", diff --git a/package.json b/package.json index 0acf5f6..8a9ff55 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "@tailwindcss/vite": "^4.1.8", "@vitejs/plugin-react": "^4.5.0", "better-sqlite3": "^12.10.0", + "leaflet": "^1.9.4", "react": "^19.1.0", "react-dom": "^19.1.0", "vite": "^6.3.5" diff --git a/server.cjs b/server.cjs index bd76da2..99dd8cc 100644 --- a/server.cjs +++ b/server.cjs @@ -500,15 +500,18 @@ function readJsonBody(req) { } function purgeOldAnalytics(db) { + const eventCutoff = new Date(Date.now() - ANALYTICS_RETENTION_DAYS * 24 * 60 * 60 * 1000).toISOString() + const activeCutoff = new Date(Date.now() - ANALYTICS_ACTIVE_WINDOW_SECONDS * 3 * 1000).toISOString() + db.prepare(` delete from viewer_events - where occurred_at < datetime('now', ?) - `).run(`-${ANALYTICS_RETENTION_DAYS} days`) + where occurred_at < ? + `).run(eventCutoff) db.prepare(` delete from active_viewers - where last_seen_at < datetime('now', ?) - `).run(`-${ANALYTICS_ACTIVE_WINDOW_SECONDS * 3} seconds`) + where last_seen_at < ? + `).run(activeCutoff) } function recordViewerEvent(req, payload) { @@ -612,12 +615,14 @@ function viewerDashboard() { const db = ensureAnalyticsDb() purgeOldAnalytics(db) - const activeSince = `-${ANALYTICS_ACTIVE_WINDOW_SECONDS} seconds` + const activeSince = new Date(Date.now() - ANALYTICS_ACTIVE_WINDOW_SECONDS * 1000).toISOString() + const daySince = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString() + const thirtyDaysSince = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString() 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, country from active_viewers - where last_seen_at >= datetime('now', ?) + where last_seen_at >= ? order by last_seen_at desc limit 100 `).all(activeSince).map((row) => ({ @@ -637,65 +642,110 @@ function viewerDashboard() { country: row.country, })) + const activePageMap = new Map() + for (const viewer of active) { + const key = `${viewer.page_path}|${viewer.page_title}` + const existing = activePageMap.get(key) || { + page_path: viewer.page_path, + page_title: viewer.page_title, + viewers: 0, + visitors: new Set(), + clients: new Map(), + countries: new Set(), + last_seen_at: viewer.last_seen_at, + } + + existing.viewers += 1 + existing.visitors.add(viewer.visitor) + if (viewer.country) existing.countries.add(viewer.country) + const clientKey = `${viewer.browser} on ${viewer.os}` + existing.clients.set(clientKey, (existing.clients.get(clientKey) || 0) + 1) + if (new Date(viewer.last_seen_at).getTime() > new Date(existing.last_seen_at).getTime()) { + existing.last_seen_at = viewer.last_seen_at + } + activePageMap.set(key, existing) + } + + const activePages = Array.from(activePageMap.values()) + .map((page) => ({ + page_path: page.page_path, + page_title: page.page_title, + viewers: page.viewers, + visitors: page.visitors.size, + countries: Array.from(page.countries).sort(), + clients: Array.from(page.clients.entries()) + .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) + .slice(0, 4) + .map(([label, count]) => ({ label, count })), + last_seen_at: page.last_seen_at, + })) + .sort((a, b) => b.viewers - a.viewers || new Date(b.last_seen_at) - new Date(a.last_seen_at)) + 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') + and occurred_at >= ? group by page_path, page_title order by views desc, page_path asc limit 12 - `).all() + `).all(daySince) const clients = db.prepare(` select browser, os, device, count(*) as events from viewer_events - where occurred_at >= datetime('now', '-24 hours') + where occurred_at >= ? group by browser, os, device order by events desc limit 12 - `).all() + `).all(daySince) const clients30d = db.prepare(` select browser, os, device, count(*) as events, count(distinct visitor_id) as visitors from viewer_events - where occurred_at >= datetime('now', '-30 days') + where occurred_at >= ? group by browser, os, device order by events desc limit 12 - `).all() + `).all(thirtyDaysSince) const activity30d = db.prepare(` select date(occurred_at) as date, count(*) as events, count(distinct visitor_id) as visitors, - sum(case when event_type = 'page_view' then 1 else 0 end) as page_views + sum(case when event_type = 'page_view' then 1 else 0 end) as page_views, + count(distinct browser || '|' || os || '|' || device) as clients, + count(distinct case + when country != '' then country + when timezone != '' and timezone != 'Not shared' then timezone + else null + end) as locations from viewer_events - where occurred_at >= datetime('now', '-30 days') + where occurred_at >= ? group by date(occurred_at) order by date asc - `).all() + `).all(thirtyDaysSince) const countries = db.prepare(` select country, count(*) as events, count(distinct visitor_id) as visitors from viewer_events - where occurred_at >= datetime('now', '-30 days') + where occurred_at >= ? and country != '' group by country order by visitors desc, events desc limit 80 - `).all() + `).all(thirtyDaysSince) const locations = db.prepare(` select country, timezone, language, count(*) as events, count(distinct visitor_id) as visitors from viewer_events - where occurred_at >= datetime('now', '-30 days') + where occurred_at >= ? and (country != '' or (timezone != '' and timezone != 'Not shared')) group by country, timezone, language order by visitors desc, events desc limit 32 - `).all() + `).all(thirtyDaysSince) const totals = db.prepare(` select @@ -703,8 +753,8 @@ function viewerDashboard() { 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() + where occurred_at >= ? + `).get(daySince) const totals30d = db.prepare(` select @@ -713,13 +763,14 @@ function viewerDashboard() { count(distinct session_id) as sessions_30d, sum(case when event_type = 'page_view' then 1 else 0 end) as page_views_30d from viewer_events - where occurred_at >= datetime('now', '-30 days') - `).get() + where occurred_at >= ? + `).get(thirtyDaysSince) return { active_window_seconds: ANALYTICS_ACTIVE_WINDOW_SECONDS, generated_at: new Date().toISOString(), active, + active_pages: activePages, top_pages: topPages, clients, clients_30d: clients30d, diff --git a/src/App.jsx b/src/App.jsx index abe32c0..6d4ee97 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,4 +1,6 @@ import { useEffect, useMemo, useRef, useState } from 'react' +import L from 'leaflet' +import 'leaflet/dist/leaflet.css' import Tree from '../Tree/Tree' import FallingLeaves from '../Tree/FallingLeaves' @@ -2090,10 +2092,10 @@ const countryMapPoints = { ZA: [24, -29], } -function geoPoint(lon, lat) { +function mapPointFromPercent(x, y) { return [ - ((lon + 180) / 360) * 100, - ((85 - lat) / 145) * 100, + 85 - (y / 100) * 145, + (x / 100) * 360 - 180, ] } @@ -2114,13 +2116,68 @@ function filledLast30Days(rows) { const date = new Date() date.setDate(date.getDate() - (29 - index)) const key = date.toISOString().slice(0, 10) - return byDate.get(key) || { date: key, events: 0, visitors: 0, page_views: 0 } + return byDate.get(key) || { + date: key, + events: 0, + visitors: 0, + page_views: 0, + clients: 0, + locations: 0, + } }) } +function MiniLineChart({ accent = 'text-fury-cyan', data, label, metric, stroke = '#e82517' }) { + const width = 320 + const height = 112 + const padding = 12 + const maxValue = Math.max(1, ...data.map((item) => Number(item[metric] || 0))) + const points = data.map((item, index) => { + const x = padding + (index / Math.max(1, data.length - 1)) * (width - padding * 2) + const y = height - padding - (Number(item[metric] || 0) / maxValue) * (height - padding * 2) + return { ...item, x, y, value: Number(item[metric] || 0) } + }) + const pathData = points + .map((point, index) => `${index === 0 ? 'M' : 'L'} ${point.x.toFixed(2)} ${point.y.toFixed(2)}`) + .join(' ') + const latest = points.at(-1)?.value || 0 + + return ( +
{label}
+Daily count
+{formatNumber(latest)}
++ Pages with active heartbeats within {formatNumber(data.active_window_seconds || 75)} seconds +
++ {formatNumber(active.length)} active sessions +
+{page.page_title || page.page_path}
+{page.page_path}
+{formatNumber(page.viewers)} viewing
+{formatNumber(page.visitors)} visitors
+Seen {relativeSeconds(page.last_seen_at)}
++ {viewers.error || 'No pages are actively being viewed right now'} +
+ ) : null} +- Heartbeats within {formatNumber(data.active_window_seconds || 75)} seconds + Individual sessions currently contributing to the active page counts