update viewers page

This commit is contained in:
Heidi
2026-05-16 07:36:49 +01:00
parent 712acd2348
commit eec70b39aa
2 changed files with 426 additions and 9 deletions
+324 -2
View File
@@ -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