diff --git a/server.cjs b/server.cjs index 9479497..bd76da2 100644 --- a/server.cjs +++ b/server.cjs @@ -172,6 +172,7 @@ function ensureAnalyticsDb() { screen text not null default '', language text not null default '', timezone text not null default '', + country text not null default '', consent text not null default 'analytics', metadata text not null default '{}' ); @@ -191,7 +192,8 @@ function ensureAnalyticsDb() { device text not null default 'Desktop', screen text not null default '', language text not null default '', - timezone text not null default '' + timezone text not null default '', + country text not null default '' ); create index if not exists viewer_events_occurred_at_idx @@ -204,6 +206,17 @@ function ensureAnalyticsDb() { on active_viewers (last_seen_at desc); `) + 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 ''`, + ]) { + try { + analyticsDb.exec(statement) + } catch (error) { + if (!String(error.message || '').includes('duplicate column name')) throw error + } + } + return analyticsDb } @@ -393,6 +406,17 @@ function headerValue(req, name, maxLength = 200) { return sanitizeText(value, maxLength) } +function countryFromHeaders(req) { + const raw = + headerValue(req, 'cf-ipcountry', 12) || + headerValue(req, 'x-vercel-ip-country', 12) || + headerValue(req, 'x-appengine-country', 12) || + headerValue(req, 'cloudfront-viewer-country', 12) + const country = raw.toUpperCase() + if (!/^[A-Z]{2}$/.test(country) || country === 'XX') return '' + return country +} + function analyticsMetadata(req, payload) { const metadata = payload.metadata && typeof payload.metadata === 'object' ? payload.metadata : {} const preferences = metadata.preferences && typeof metadata.preferences === 'object' @@ -513,6 +537,7 @@ function recordViewerEvent(req, payload) { screen: sanitizeText(payload.screen, 40), language: sanitizeText(payload.language, 40), timezone: sanitizeText(payload.timezone, 80), + country: countryFromHeaders(req), consent: payload.consent === 'analytics' ? 'analytics' : '', metadata: JSON.stringify(analyticsMetadata(req, payload)), } @@ -525,19 +550,19 @@ 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, consent, metadata) + referrer, user_agent, browser, os, device, screen, language, timezone, country, 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) + @referrer, @user_agent, @browser, @os, @device, @screen, @language, @timezone, @country, @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) + referrer, user_agent, browser, os, device, screen, language, timezone, country) values (@session_id, @visitor_id, @ip_hash, @now, @now, @page_path, @page_title, - @referrer, @user_agent, @browser, @os, @device, @screen, @language, @timezone) + @referrer, @user_agent, @browser, @os, @device, @screen, @language, @timezone, @country) on conflict(session_id) do update set last_seen_at = excluded.last_seen_at, page_path = excluded.page_path, @@ -549,7 +574,8 @@ function recordViewerEvent(req, payload) { device = excluded.device, screen = excluded.screen, language = excluded.language, - timezone = excluded.timezone + timezone = excluded.timezone, + country = excluded.country `).run({ ...event, now }) } @@ -589,7 +615,7 @@ function viewerDashboard() { 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 + referrer, browser, os, device, screen, language, timezone, country from active_viewers where last_seen_at >= datetime('now', ?) order by last_seen_at desc @@ -608,6 +634,7 @@ function viewerDashboard() { screen: row.screen, language: row.language, timezone: row.timezone, + country: row.country, })) const topPages = db.prepare(` @@ -629,6 +656,47 @@ function viewerDashboard() { limit 12 `).all() + 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') + group by browser, os, device + order by events desc + limit 12 + `).all() + + 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 + from viewer_events + where occurred_at >= datetime('now', '-30 days') + group by date(occurred_at) + order by date asc + `).all() + + 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') + and country != '' + group by country + order by visitors desc, events desc + limit 80 + `).all() + + 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') + and (country != '' or (timezone != '' and timezone != 'Not shared')) + group by country, timezone, language + order by visitors desc, events desc + limit 32 + `).all() + const totals = db.prepare(` select count(*) as events_24h, @@ -638,22 +706,49 @@ function viewerDashboard() { where occurred_at >= datetime('now', '-24 hours') `).get() + const totals30d = db.prepare(` + select + count(*) as events_30d, + count(distinct visitor_id) as visitors_30d, + 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() + return { active_window_seconds: ANALYTICS_ACTIVE_WINDOW_SECONDS, generated_at: new Date().toISOString(), active, top_pages: topPages, clients, + clients_30d: clients30d, + activity_30d: activity30d, + countries, + locations, 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, + events_30d: totals30d?.events_30d || 0, + visitors_30d: totals30d?.visitors_30d || 0, + sessions_30d: totals30d?.sessions_30d || 0, + page_views_30d: totals30d?.page_views_30d || 0, }, + data_types: [ + { 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: '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' }, + ], privacy: { retention_days: ANALYTICS_RETENTION_DAYS, stores_ip_hashes: false, exposes_raw_ip: false, + exposes_precise_location: false, }, } } diff --git a/src/App.jsx b/src/App.jsx index ad4d002..abe32c0 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -2004,12 +2004,132 @@ function relativeSeconds(timestamp) { return `${Math.round(seconds / 60)}m ago` } +const shortDateFormat = new Intl.DateTimeFormat('en-GB', { + day: '2-digit', + month: 'short', +}) + +const timezoneMapPoints = { + 'America/Los_Angeles': [18, 42], + 'America/Denver': [24, 43], + 'America/Chicago': [30, 45], + 'America/New_York': [37, 43], + 'America/Toronto': [38, 40], + 'America/Sao_Paulo': [43, 68], + 'America/Mexico_City': [27, 54], + 'Europe/London': [48, 36], + 'Europe/Paris': [51, 39], + 'Europe/Berlin': [53, 37], + 'Europe/Madrid': [49, 42], + 'Europe/Rome': [53, 43], + 'Europe/Amsterdam': [51, 37], + 'Europe/Stockholm': [54, 31], + 'Europe/Warsaw': [56, 37], + 'Europe/Moscow': [63, 34], + 'Africa/Cairo': [58, 49], + 'Africa/Johannesburg': [58, 75], + 'Asia/Dubai': [66, 52], + 'Asia/Kolkata': [72, 56], + 'Asia/Singapore': [79, 66], + 'Asia/Tokyo': [88, 45], + 'Asia/Seoul': [85, 44], + 'Asia/Shanghai': [81, 48], + 'Australia/Sydney': [90, 78], + 'Pacific/Auckland': [95, 84], +} + +const countryNames = { + AR: 'Argentina', + AU: 'Australia', + BR: 'Brazil', + CA: 'Canada', + CL: 'Chile', + CN: 'China', + DE: 'Germany', + ES: 'Spain', + FR: 'France', + GB: 'United Kingdom', + ID: 'Indonesia', + IN: 'India', + IT: 'Italy', + JP: 'Japan', + KR: 'South Korea', + MX: 'Mexico', + NL: 'Netherlands', + PL: 'Poland', + RU: 'Russia', + SE: 'Sweden', + SG: 'Singapore', + US: 'United States', + ZA: 'South Africa', +} + +const countryMapPoints = { + AR: [-64, -34], + AU: [134, -25], + BR: [-51, -10], + CA: [-106, 56], + CL: [-71, -30], + CN: [104, 35], + DE: [10, 51], + ES: [-4, 40], + FR: [2, 46], + GB: [-2, 54], + ID: [118, -2], + IN: [78, 22], + IT: [12, 43], + JP: [138, 37], + KR: [128, 36], + MX: [-102, 23], + NL: [5, 52], + PL: [19, 52], + RU: [90, 60], + SE: [15, 62], + SG: [104, 1], + US: [-98, 39], + ZA: [24, -29], +} + +function geoPoint(lon, lat) { + return [ + ((lon + 180) / 360) * 100, + ((85 - lat) / 145) * 100, + ] +} + +function timezonePoint(timezone = '') { + if (timezoneMapPoints[timezone]) return timezoneMapPoints[timezone] + if (timezone.startsWith('America/')) return [30, 48] + if (timezone.startsWith('Europe/')) return [53, 38] + if (timezone.startsWith('Africa/')) return [56, 60] + if (timezone.startsWith('Asia/')) return [75, 52] + if (timezone.startsWith('Australia/')) return [88, 76] + if (timezone.startsWith('Pacific/')) return [92, 70] + return [50, 50] +} + +function filledLast30Days(rows) { + const byDate = new Map(rows.map((row) => [row.date, row])) + return Array.from({ length: 30 }, (_, index) => { + 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 } + }) +} + function ViewersPage({ viewers }) { const data = viewers.data || {} const active = data.active || [] const topPages = data.top_pages || [] const clients = data.clients || [] + const clients30d = data.clients_30d || [] + const activity30d = filledLast30Days(data.activity_30d || []) + const countries = data.countries || [] + 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 ( @@ -2038,6 +2158,47 @@ function ViewersPage({ viewers }) { +
+
+
+

Last 30 days

+

+ Retained consented analytics over the current privacy window +

+
+
+ + {formatNumber(totals.visitors_30d)} visitors + + + {formatNumber(totals.sessions_30d)} sessions + + + {formatNumber(totals.page_views_30d)} page views + + + {formatNumber(totals.events_30d)} events + +
+
+ +
+ {activity30d.map((day) => { + const height = `${Math.max(8, (Number(day.events || 0) / maxDailyEvents) * 100)}%` + + return ( +
+ ) + })} +
+
+

Currently viewing

@@ -2059,7 +2220,7 @@ function ViewersPage({ viewers }) { {viewer.browser} on {viewer.os}

- {viewer.device} · {viewer.screen || 'unknown screen'} · {viewer.timezone || 'unknown timezone'} + {viewer.device} · {viewer.screen || 'unknown screen'} · {viewer.country || viewer.timezone || 'unknown location'}

{viewer.language || 'unknown language'}

@@ -2113,14 +2274,175 @@ function ViewersPage({ viewers }) {
+
+
+
+

Collected data types

+

+ Optional fields only appear for visitors who explicitly allow them +

+
+ {dataTypes.map((item) => ( +
+

{item.label}

+

{item.detail}

+
+ ))} + {!dataTypes.length ?

No collection manifest returned

: null} +
+ +
+
+

Location signals

+

+ Country traffic when available, with timezone fallback from the last 30 days +

+
+ +
+
+ +
+
+

Clients over 30 days

+

Browsers, devices, and operating systems across retained data

+
+ {clients30d.map((client) => ( +
+
+

{client.browser} on {client.os}

+

{client.device}

+
+

{formatNumber(client.visitors)} visitors

+

{formatNumber(client.events)} events

+
+ ))} + {!clients30d.length ?

No 30-day 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. + and public output excludes raw IP addresses and precise location.

) } +function LocationSignalMap({ countries, locations }) { + 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) + const countryMarkers = countries + .map((country) => { + const coordinates = countryMapPoints[country.country] + if (!coordinates) return null + const [left, top] = geoPoint(coordinates[0], coordinates[1]) + return { + ...country, + left, + top, + label: countryNames[country.country] || country.country, + } + }) + .filter(Boolean) + const topLocations = countries.length + ? countries.slice(0, 8).map((country) => ({ + key: country.country, + label: countryNames[country.country] || country.country, + detail: 'country signal', + visitors: country.visitors, + events: country.events, + })) + : locations.slice(0, 8).map((location) => ({ + key: `${location.timezone}-${location.language}`, + label: location.timezone, + detail: location.language || 'unknown language', + visitors: location.visitors, + events: location.events, + })) + + return ( +
+
+