diff --git a/server.cjs b/server.cjs index 99dd8cc..0d6c6bd 100644 --- a/server.cjs +++ b/server.cjs @@ -173,6 +173,10 @@ function ensureAnalyticsDb() { language text not null default '', timezone text not null default '', country text not null default '', + region text not null default '', + city text not null default '', + latitude real, + longitude real, consent text not null default 'analytics', metadata text not null default '{}' ); @@ -193,7 +197,11 @@ function ensureAnalyticsDb() { screen text not null default '', language text not null default '', timezone text not null default '', - country text not null default '' + country text not null default '', + region text not null default '', + city text not null default '', + latitude real, + longitude real ); create index if not exists viewer_events_occurred_at_idx @@ -209,6 +217,14 @@ function ensureAnalyticsDb() { for (const statement of [ `alter table viewer_events add column country text not null default ''`, `alter table active_viewers add column country text not null default ''`, + `alter table viewer_events add column region text not null default ''`, + `alter table active_viewers add column region text not null default ''`, + `alter table viewer_events add column city text not null default ''`, + `alter table active_viewers add column city text not null default ''`, + `alter table viewer_events add column latitude real`, + `alter table active_viewers add column latitude real`, + `alter table viewer_events add column longitude real`, + `alter table active_viewers add column longitude real`, ]) { try { analyticsDb.exec(statement) @@ -417,6 +433,22 @@ function countryFromHeaders(req) { return country } +function numberHeader(req, name, min, max) { + const value = Number(headerValue(req, name, 40)) + if (!Number.isFinite(value) || value < min || value > max) return null + return value +} + +function locationFromHeaders(req) { + return { + country: countryFromHeaders(req), + region: headerValue(req, 'cf-region', 120), + city: headerValue(req, 'cf-ipcity', 120), + latitude: numberHeader(req, 'cf-iplatitude', -90, 90), + longitude: numberHeader(req, 'cf-iplongitude', -180, 180), + } +} + function analyticsMetadata(req, payload) { const metadata = payload.metadata && typeof payload.metadata === 'object' ? payload.metadata : {} const preferences = metadata.preferences && typeof metadata.preferences === 'object' @@ -519,6 +551,7 @@ function recordViewerEvent(req, payload) { purgeOldAnalytics(db) const serverClient = parseClient(req.headers['user-agent'] || '') + const location = locationFromHeaders(req) const shareUserAgent = payload.user_agent !== 'Not shared' const event = { visitor_id: sanitizeText(payload.visitor_id, 80) || crypto.randomUUID(), @@ -540,7 +573,11 @@ function recordViewerEvent(req, payload) { screen: sanitizeText(payload.screen, 40), language: sanitizeText(payload.language, 40), timezone: sanitizeText(payload.timezone, 80), - country: countryFromHeaders(req), + country: location.country, + region: location.region, + city: location.city, + latitude: location.latitude, + longitude: location.longitude, consent: payload.consent === 'analytics' ? 'analytics' : '', metadata: JSON.stringify(analyticsMetadata(req, payload)), } @@ -553,19 +590,23 @@ function recordViewerEvent(req, payload) { 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, country, consent, metadata) + referrer, user_agent, browser, os, device, screen, language, timezone, + country, region, city, latitude, longitude, 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, @country, @consent, @metadata) + @referrer, @user_agent, @browser, @os, @device, @screen, @language, @timezone, + @country, @region, @city, @latitude, @longitude, @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, country) + referrer, user_agent, browser, os, device, screen, language, timezone, + country, region, city, latitude, longitude) values (@session_id, @visitor_id, @ip_hash, @now, @now, @page_path, @page_title, - @referrer, @user_agent, @browser, @os, @device, @screen, @language, @timezone, @country) + @referrer, @user_agent, @browser, @os, @device, @screen, @language, @timezone, + @country, @region, @city, @latitude, @longitude) on conflict(session_id) do update set last_seen_at = excluded.last_seen_at, page_path = excluded.page_path, @@ -578,7 +619,11 @@ function recordViewerEvent(req, payload) { screen = excluded.screen, language = excluded.language, timezone = excluded.timezone, - country = excluded.country + country = excluded.country, + region = excluded.region, + city = excluded.city, + latitude = excluded.latitude, + longitude = excluded.longitude `).run({ ...event, now }) } @@ -620,7 +665,8 @@ function viewerDashboard() { 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 + referrer, browser, os, device, screen, language, timezone, + country, region, city, latitude, longitude from active_viewers where last_seen_at >= ? order by last_seen_at desc @@ -640,6 +686,10 @@ function viewerDashboard() { language: row.language, timezone: row.timezone, country: row.country, + region: row.region, + city: row.city, + latitude: row.latitude, + longitude: row.longitude, })) const activePageMap = new Map() @@ -728,21 +778,38 @@ function viewerDashboard() { `).all(thirtyDaysSince) const countries = db.prepare(` - select country, count(*) as events, count(distinct visitor_id) as visitors + select + country, + avg(latitude) as latitude, + avg(longitude) as longitude, + count(*) as events, + count(distinct visitor_id) as visitors from viewer_events where occurred_at >= ? and country != '' + and latitude is not null + and longitude is not null group by country order by visitors desc, events desc limit 80 `).all(thirtyDaysSince) const locations = db.prepare(` - select country, timezone, language, count(*) as events, count(distinct visitor_id) as visitors + select + country, + region, + city, + latitude, + longitude, + timezone, + language, + count(*) as events, + count(distinct visitor_id) as visitors from viewer_events where occurred_at >= ? - and (country != '' or (timezone != '' and timezone != 'Not shared')) - group by country, timezone, language + and latitude is not null + and longitude is not null + group by country, region, city, latitude, longitude, timezone, language order by visitors desc, events desc limit 32 `).all(thirtyDaysSince) @@ -791,7 +858,7 @@ function viewerDashboard() { { key: 'page', label: 'Page activity', detail: 'Page path, title, page views, and heartbeat state' }, { key: 'browser', label: 'Browser and device', detail: 'Browser, operating system, broad device type, and user-agent only when allowed' }, { key: 'display', label: 'Display', detail: 'Screen size, viewport size, pixel ratio, and colour depth when allowed' }, - { key: 'locale', label: 'Language and coarse location', detail: 'Browser language and timezone when allowed, plus country code when supplied by the hosting edge' }, + { key: 'locale', label: 'Language and coarse location', detail: 'Browser language and timezone when allowed, plus Cloudflare country/city coordinates when supplied by the edge' }, { key: 'referrer', label: 'Referrer', detail: 'The referring page when allowed and provided by the browser' }, { key: 'diagnostics', label: 'Diagnostics', detail: 'Privacy signals, network hints, request headers, and browser capability details when allowed' }, ], diff --git a/src/App.jsx b/src/App.jsx index 57e5ea0..1254b6d 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -2011,36 +2011,6 @@ const shortDateFormat = new Intl.DateTimeFormat('en-GB', { month: 'short', }) -const timezoneMapPoints = { - 'America/Los_Angeles': [34.05, -118.24], - 'America/Denver': [39.74, -104.99], - 'America/Chicago': [41.88, -87.63], - 'America/New_York': [40.71, -74.01], - 'America/Toronto': [43.65, -79.38], - 'America/Phoenix': [33.45, -112.07], - 'America/Sao_Paulo': [-23.55, -46.63], - 'America/Mexico_City': [19.43, -99.13], - 'Europe/London': [51.51, -0.13], - 'Europe/Paris': [48.86, 2.35], - 'Europe/Berlin': [52.52, 13.41], - 'Europe/Madrid': [40.42, -3.7], - 'Europe/Rome': [41.9, 12.5], - 'Europe/Amsterdam': [52.37, 4.9], - 'Europe/Stockholm': [59.33, 18.07], - 'Europe/Warsaw': [52.23, 21.01], - 'Europe/Moscow': [55.76, 37.62], - 'Africa/Cairo': [30.04, 31.24], - 'Africa/Johannesburg': [-26.2, 28.04], - 'Asia/Dubai': [25.2, 55.27], - 'Asia/Kolkata': [22.57, 88.36], - 'Asia/Singapore': [1.35, 103.82], - 'Asia/Tokyo': [35.68, 139.65], - 'Asia/Seoul': [37.57, 126.98], - 'Asia/Shanghai': [31.23, 121.47], - 'Australia/Sydney': [-33.87, 151.21], - 'Pacific/Auckland': [-36.85, 174.76], -} - const countryNames = { AR: 'Argentina', AU: 'Australia', @@ -2093,17 +2063,6 @@ const countryMapPoints = { ZA: [24, -29], } -function timezonePoint(timezone = '') { - if (timezoneMapPoints[timezone]) return timezoneMapPoints[timezone] - if (timezone.startsWith('America/')) return [39, -96] - if (timezone.startsWith('Europe/')) return [50, 10] - if (timezone.startsWith('Africa/')) return [0, 20] - if (timezone.startsWith('Asia/')) return [34, 100] - if (timezone.startsWith('Australia/')) return [-25, 134] - if (timezone.startsWith('Pacific/')) return [-15, 170] - return [20, 0] -} - function filledLast30Days(rows) { const byDate = new Map(rows.map((row) => [row.date, row])) return Array.from({ length: 30 }, (_, index) => { @@ -2432,7 +2391,7 @@ function ViewersPage({ viewers }) {

Location signals

- Country traffic when available, with timezone fallback from the last 30 days + Cloudflare edge geolocation from the last 30 days