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 }) {
+ Retained consented analytics over the current privacy window +
+- {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 }) {+ Optional fields only appear for visitors who explicitly allow them +
+{item.label}
+{item.detail}
+No collection manifest returned
: null} ++ Country traffic when available, with timezone fallback from the last 30 days +
+Browsers, devices, and operating systems across retained data
+{client.browser} on {client.os}
+{client.device}
+{formatNumber(client.visitors)} visitors
+{formatNumber(client.events)} events
+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 ( +{location.label}
+{location.detail}
+{formatNumber(location.visitors)}
++ No country or timezone location signals have been shared yet. +
+ ) : null} +