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)}

+
+ + + + + {points.map((point) => ( + + + {`${shortDateFormat.format(new Date(point.date))}: ${formatNumber(point.value)} ${label.toLowerCase()}, ${formatNumber(point.visitors || 0)} visitors`} + + + ))} + +
+ ) +} + function ViewersPage({ viewers }) { const data = viewers.data || {} const active = data.active || [] + const activePages = data.active_pages || [] const topPages = data.top_pages || [] const clients = data.clients || [] const clients30d = data.clients_30d || [] @@ -2129,7 +2186,6 @@ function ViewersPage({ viewers }) { const locations = data.locations || [] const dataTypes = data.data_types || [] const totals = data.totals || {} - const maxDailyEvents = Math.max(1, ...activity30d.map((day) => Number(day.events || 0))) const generatedAt = data.generated_at ? dateFormat.format(new Date(data.generated_at)) : 'Waiting for data' return ( @@ -2182,28 +2238,98 @@ function ViewersPage({ viewers }) { -
- {activity30d.map((day) => { - const height = `${Math.max(8, (Number(day.events || 0) / maxDailyEvents) * 100)}%` - - return ( -
- ) - })} +
+ + + + +
-

Currently viewing

+
+
+

Currently viewing

+

+ Pages with active heartbeats within {formatNumber(data.active_window_seconds || 75)} seconds +

+
+

+ {formatNumber(active.length)} active sessions +

+
+
+ {activePages.map((page) => ( +
+
+

{page.page_title || page.page_path}

+

{page.page_path}

+
+ {(page.clients || []).map((client) => ( + + {client.label} ({formatNumber(client.count)}) + + ))} + {(page.countries || []).map((country) => ( + + {country} + + ))} +
+
+
+

{formatNumber(page.viewers)} viewing

+

{formatNumber(page.visitors)} visitors

+
+

Seen {relativeSeconds(page.last_seen_at)}

+
+ ))} + {!activePages.length ? ( +

+ {viewers.error || 'No pages are actively being viewed right now'} +

+ ) : null} +
+ +
+
+

Active sessions

- Heartbeats within {formatNumber(data.active_window_seconds || 75)} seconds + Individual sessions currently contributing to the active page counts

{active.map((viewer) => ( @@ -2332,6 +2458,8 @@ function ViewersPage({ viewers }) { } function LocationSignalMap({ countries, locations }) { + const mapRef = useRef(null) + const markersRef = useRef(null) const maxCountryVisitors = Math.max(1, ...countries.map((country) => Number(country.visitors || 0))) const maxLocationVisitors = Math.max(1, ...locations.map((location) => Number(location.visitors || 0))) const fallbackLocations = locations.filter((location) => !location.country) @@ -2339,11 +2467,10 @@ function LocationSignalMap({ countries, locations }) { .map((country) => { const coordinates = countryMapPoints[country.country] if (!coordinates) return null - const [left, top] = geoPoint(coordinates[0], coordinates[1]) return { ...country, - left, - top, + lat: coordinates[1], + lon: coordinates[0], label: countryNames[country.country] || country.country, } }) @@ -2364,62 +2491,81 @@ function LocationSignalMap({ countries, locations }) { events: location.events, })) + useEffect(() => { + if (!mapRef.current || markersRef.current) return undefined + + const map = L.map(mapRef.current, { + center: [25, 0], + maxBounds: [[-85, -220], [85, 220]], + minZoom: 1, + scrollWheelZoom: true, + worldCopyJump: true, + zoom: 1, + }) + + L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { + attribution: '© OpenStreetMap contributors', + maxZoom: 8, + }).addTo(map) + + markersRef.current = { + layer: L.layerGroup().addTo(map), + map, + } + + return () => { + markersRef.current = null + map.remove() + } + }, []) + + useEffect(() => { + if (!markersRef.current) return + + const { layer } = markersRef.current + layer.clearLayers() + + countryMarkers.forEach((country) => { + const radius = 10 + (Number(country.visitors || 0) / maxCountryVisitors) * 22 + L.circleMarker([country.lat, country.lon], { + color: '#e82517', + fillColor: '#e82517', + fillOpacity: 0.4, + opacity: 0, + radius, + stroke: false, + }) + .bindTooltip(`${country.label}: ${formatNumber(country.visitors)} visitors`, { + direction: 'top', + opacity: 0.95, + }) + .addTo(layer) + }) + + fallbackLocations.forEach((location) => { + const [x, y] = timezonePoint(location.timezone) + const [lat, lon] = mapPointFromPercent(x, y) + const radius = 7 + (Number(location.visitors || 0) / maxLocationVisitors) * 16 + L.circleMarker([lat, lon], { + color: '#000000', + fillColor: '#000000', + fillOpacity: 0.4, + opacity: 0, + radius, + stroke: false, + }) + .bindTooltip(`${location.timezone}: ${formatNumber(location.visitors)} visitors`, { + direction: 'top', + opacity: 0.95, + }) + .addTo(layer) + }) + }, [countryMarkers, fallbackLocations, maxCountryVisitors, maxLocationVisitors]) + return (
-