update viewers page
This commit is contained in:
+324
-2
@@ -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 }) {
|
||||
<Stat label="Events 24h" value={formatNumber(totals.events_24h)} />
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border bg-fury-white p-5 shadow-sm">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">Last 30 days</h2>
|
||||
<p className="mt-1 text-sm text-text-soft">
|
||||
Retained consented analytics over the current privacy window
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-3 text-sm sm:grid-cols-4">
|
||||
<span className="rounded-md bg-surface px-3 py-2 font-semibold">
|
||||
{formatNumber(totals.visitors_30d)} visitors
|
||||
</span>
|
||||
<span className="rounded-md bg-surface px-3 py-2 font-semibold">
|
||||
{formatNumber(totals.sessions_30d)} sessions
|
||||
</span>
|
||||
<span className="rounded-md bg-surface px-3 py-2 font-semibold">
|
||||
{formatNumber(totals.page_views_30d)} page views
|
||||
</span>
|
||||
<span className="rounded-md bg-surface px-3 py-2 font-semibold">
|
||||
{formatNumber(totals.events_30d)} events
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex h-36 items-end gap-1 rounded-md border border-border bg-bg p-3">
|
||||
{activity30d.map((day) => {
|
||||
const height = `${Math.max(8, (Number(day.events || 0) / maxDailyEvents) * 100)}%`
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-label={`${formatNumber(day.events)} events on ${day.date}`}
|
||||
className="min-w-1 flex-1 rounded-sm bg-fury-cyan transition hover:bg-fury-aqua"
|
||||
key={day.date}
|
||||
style={{ height }}
|
||||
title={`${shortDateFormat.format(new Date(day.date))}: ${formatNumber(day.events)} events, ${formatNumber(day.visitors)} visitors`}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-lg border border-border bg-fury-white shadow-sm">
|
||||
<div className="border-b border-surface px-5 py-4">
|
||||
<h2 className="text-lg font-semibold">Currently viewing</h2>
|
||||
@@ -2059,7 +2220,7 @@ function ViewersPage({ viewers }) {
|
||||
{viewer.browser} on {viewer.os}
|
||||
</p>
|
||||
<p className="truncate text-xs text-text-soft">
|
||||
{viewer.device} · {viewer.screen || 'unknown screen'} · {viewer.timezone || 'unknown timezone'}
|
||||
{viewer.device} · {viewer.screen || 'unknown screen'} · {viewer.country || viewer.timezone || 'unknown location'}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-text-soft">{viewer.language || 'unknown language'}</p>
|
||||
@@ -2113,14 +2274,175 @@ function ViewersPage({ viewers }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[0.9fr_1.1fr]">
|
||||
<div className="rounded-lg border border-border bg-fury-white shadow-sm">
|
||||
<div className="border-b border-surface px-5 py-4">
|
||||
<h2 className="text-lg font-semibold">Collected data types</h2>
|
||||
<p className="mt-1 text-sm text-text-soft">
|
||||
Optional fields only appear for visitors who explicitly allow them
|
||||
</p>
|
||||
</div>
|
||||
{dataTypes.map((item) => (
|
||||
<div className="border-b border-surface px-5 py-3 text-sm" key={item.key}>
|
||||
<p className="font-semibold">{item.label}</p>
|
||||
<p className="mt-1 text-text-soft">{item.detail}</p>
|
||||
</div>
|
||||
))}
|
||||
{!dataTypes.length ? <p className="px-5 py-10 text-sm text-text-soft">No collection manifest returned</p> : null}
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border bg-fury-white shadow-sm">
|
||||
<div className="border-b border-surface px-5 py-4">
|
||||
<h2 className="text-lg font-semibold">Location signals</h2>
|
||||
<p className="mt-1 text-sm text-text-soft">
|
||||
Country traffic when available, with timezone fallback from the last 30 days
|
||||
</p>
|
||||
</div>
|
||||
<LocationSignalMap countries={countries} locations={locations} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border bg-fury-white shadow-sm">
|
||||
<div className="border-b border-surface px-5 py-4">
|
||||
<h2 className="text-lg font-semibold">Clients over 30 days</h2>
|
||||
<p className="mt-1 text-sm text-text-soft">Browsers, devices, and operating systems across retained data</p>
|
||||
</div>
|
||||
{clients30d.map((client) => (
|
||||
<div
|
||||
className="grid grid-cols-[1fr_auto_auto] gap-3 border-b border-surface px-5 py-3 text-sm"
|
||||
key={`${client.browser}-${client.os}-${client.device}-30d`}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-semibold">{client.browser} on {client.os}</p>
|
||||
<p className="truncate text-xs text-text-soft">{client.device}</p>
|
||||
</div>
|
||||
<p className="text-text-soft">{formatNumber(client.visitors)} visitors</p>
|
||||
<p className="font-semibold text-fury-cyan">{formatNumber(client.events)} events</p>
|
||||
</div>
|
||||
))}
|
||||
{!clients30d.length ? <p className="px-5 py-10 text-sm text-text-soft">No 30-day client data recorded yet</p> : null}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-text-soft">
|
||||
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.
|
||||
</p>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="p-5">
|
||||
<div className="relative h-[340px] overflow-hidden rounded-md border border-border bg-surface">
|
||||
<iframe
|
||||
className="absolute inset-0 h-full w-full grayscale-[20%] saturate-[0.85]"
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer"
|
||||
src="https://www.openstreetmap.org/export/embed.html?bbox=-180%2C-58%2C180%2C84&layer=mapnik"
|
||||
title="OpenStreetMap traffic overview"
|
||||
/>
|
||||
<div className="pointer-events-none absolute inset-0 bg-fury-white/10" />
|
||||
|
||||
{countryMarkers.map((country) => {
|
||||
const size = 16 + (Number(country.visitors || 0) / maxCountryVisitors) * 28
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group pointer-events-auto absolute -translate-x-1/2 -translate-y-1/2"
|
||||
key={country.country}
|
||||
style={{ left: `${country.left}%`, top: `${country.top}%` }}
|
||||
>
|
||||
<span
|
||||
className="block rounded-full border-2 border-fury-white bg-fury-cyan shadow-[0_4px_18px_rgba(232,37,23,0.35)]"
|
||||
style={{ height: size, width: size }}
|
||||
/>
|
||||
<span className="absolute left-1/2 top-full z-10 mt-2 hidden w-max max-w-44 -translate-x-1/2 rounded-md bg-text px-2 py-1 text-xs font-semibold text-bg shadow-lg group-hover:block">
|
||||
{country.label}: {formatNumber(country.visitors)} visitors
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{fallbackLocations.map((location) => {
|
||||
const [x, y] = timezonePoint(location.timezone)
|
||||
const radius = 5 + (Number(location.visitors || 0) / maxLocationVisitors) * 12
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group pointer-events-auto absolute -translate-x-1/2 -translate-y-1/2"
|
||||
key={`${location.timezone}-${location.language}`}
|
||||
style={{ left: `${x}%`, top: `${y}%` }}
|
||||
>
|
||||
<span
|
||||
className="block rounded-full border-2 border-fury-white bg-text shadow-[0_4px_18px_rgba(0,0,0,0.2)]"
|
||||
style={{ height: radius * 2, width: radius * 2 }}
|
||||
/>
|
||||
<span className="absolute left-1/2 top-full z-10 mt-2 hidden w-max max-w-52 -translate-x-1/2 rounded-md bg-text px-2 py-1 text-xs font-semibold text-bg shadow-lg group-hover:block">
|
||||
{location.timezone}: {formatNumber(location.visitors)} visitors
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
<div className="pointer-events-none absolute right-3 bottom-3 rounded-sm bg-fury-white/95 px-2 py-1 text-[10px] font-semibold text-text-soft shadow-sm">
|
||||
© OpenStreetMap contributors
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-2 sm:grid-cols-2">
|
||||
{topLocations.map((location) => (
|
||||
<div className="grid grid-cols-[1fr_auto] gap-3 rounded-md bg-surface px-3 py-2 text-sm" key={`${location.key}-row`}>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-semibold">{location.label}</p>
|
||||
<p className="truncate text-xs text-text-soft">{location.detail}</p>
|
||||
</div>
|
||||
<p className="font-semibold text-fury-cyan">{formatNumber(location.visitors)}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!countries.length && !locations.length ? (
|
||||
<p className="mt-4 text-sm text-text-soft">
|
||||
No country or timezone location signals have been shared yet.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function UptimePage({ uptime }) {
|
||||
const checks = uptime.checks
|
||||
const history = uptime.history
|
||||
|
||||
Reference in New Issue
Block a user