update viewers page
This commit is contained in:
+82
@@ -776,6 +776,24 @@ function viewerDashboard() {
|
|||||||
limit 12
|
limit 12
|
||||||
`).all(thirtyDaysSince)
|
`).all(thirtyDaysSince)
|
||||||
|
|
||||||
|
const activity24h = db.prepare(`
|
||||||
|
select
|
||||||
|
substr(occurred_at, 1, 13) || ':00:00.000Z' 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,
|
||||||
|
count(distinct browser || '|' || os || '|' || device) as clients,
|
||||||
|
count(distinct case
|
||||||
|
when country != '' then country
|
||||||
|
when timezone != '' and timezone != 'Not shared' then timezone
|
||||||
|
else null
|
||||||
|
end) as locations
|
||||||
|
from viewer_events
|
||||||
|
where occurred_at >= ?
|
||||||
|
group by substr(occurred_at, 1, 13)
|
||||||
|
order by date asc
|
||||||
|
`).all(daySince)
|
||||||
|
|
||||||
const activity30d = db.prepare(`
|
const activity30d = db.prepare(`
|
||||||
select
|
select
|
||||||
date(occurred_at) as date,
|
date(occurred_at) as date,
|
||||||
@@ -794,6 +812,26 @@ function viewerDashboard() {
|
|||||||
order by date asc
|
order by date asc
|
||||||
`).all(thirtyDaysSince)
|
`).all(thirtyDaysSince)
|
||||||
|
|
||||||
|
const activityLocationRows24h = db.prepare(`
|
||||||
|
select
|
||||||
|
substr(occurred_at, 1, 13) || ':00:00.000Z' as date,
|
||||||
|
coalesce(nullif(country, ''), '') as country,
|
||||||
|
coalesce(nullif(city, ''), '') as city,
|
||||||
|
coalesce(nullif(region, ''), '') as region,
|
||||||
|
coalesce(nullif(timezone, ''), '') as timezone,
|
||||||
|
count(distinct visitor_id) as visitors
|
||||||
|
from viewer_events
|
||||||
|
where occurred_at >= ?
|
||||||
|
and (
|
||||||
|
country != ''
|
||||||
|
or city != ''
|
||||||
|
or region != ''
|
||||||
|
or (timezone != '' and timezone != 'Not shared')
|
||||||
|
)
|
||||||
|
group by substr(occurred_at, 1, 13), coalesce(nullif(country, ''), ''), coalesce(nullif(city, ''), ''), coalesce(nullif(region, ''), ''), coalesce(nullif(timezone, ''), '')
|
||||||
|
order by date asc, visitors desc
|
||||||
|
`).all(daySince)
|
||||||
|
|
||||||
const activityLocationRows = db.prepare(`
|
const activityLocationRows = db.prepare(`
|
||||||
select
|
select
|
||||||
date(occurred_at) as date,
|
date(occurred_at) as date,
|
||||||
@@ -815,6 +853,18 @@ function viewerDashboard() {
|
|||||||
`).all(thirtyDaysSince)
|
`).all(thirtyDaysSince)
|
||||||
|
|
||||||
const locationsByDate = new Map()
|
const locationsByDate = new Map()
|
||||||
|
const locations24hByDate = new Map()
|
||||||
|
|
||||||
|
for (const row of activityLocationRows24h) {
|
||||||
|
const label = [row.city, row.region, row.country || row.timezone]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(', ')
|
||||||
|
if (!label) continue
|
||||||
|
const current = locations24hByDate.get(row.date) || []
|
||||||
|
current.push({ label, visitors: row.visitors || 0 })
|
||||||
|
locations24hByDate.set(row.date, current)
|
||||||
|
}
|
||||||
|
|
||||||
for (const row of activityLocationRows) {
|
for (const row of activityLocationRows) {
|
||||||
const label = [row.city, row.region, row.country || row.timezone]
|
const label = [row.city, row.region, row.country || row.timezone]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
@@ -825,6 +875,20 @@ function viewerDashboard() {
|
|||||||
locationsByDate.set(row.date, current)
|
locationsByDate.set(row.date, current)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const activityClientRows24h = db.prepare(`
|
||||||
|
select
|
||||||
|
substr(occurred_at, 1, 13) || ':00:00.000Z' as date,
|
||||||
|
browser,
|
||||||
|
os,
|
||||||
|
device,
|
||||||
|
count(distinct visitor_id) as visitors,
|
||||||
|
count(*) as events
|
||||||
|
from viewer_events
|
||||||
|
where occurred_at >= ?
|
||||||
|
group by substr(occurred_at, 1, 13), browser, os, device
|
||||||
|
order by date asc, visitors desc, events desc
|
||||||
|
`).all(daySince)
|
||||||
|
|
||||||
const activityClientRows = db.prepare(`
|
const activityClientRows = db.prepare(`
|
||||||
select
|
select
|
||||||
date(occurred_at) as date,
|
date(occurred_at) as date,
|
||||||
@@ -840,6 +904,15 @@ function viewerDashboard() {
|
|||||||
`).all(thirtyDaysSince)
|
`).all(thirtyDaysSince)
|
||||||
|
|
||||||
const clientsByDate = new Map()
|
const clientsByDate = new Map()
|
||||||
|
const clients24hByDate = new Map()
|
||||||
|
|
||||||
|
for (const row of activityClientRows24h) {
|
||||||
|
const label = `${row.browser} on ${row.os}${row.device ? ` (${row.device})` : ''}`
|
||||||
|
const current = clients24hByDate.get(row.date) || []
|
||||||
|
if (current.length < 4) current.push({ label, visitors: row.visitors || 0, events: row.events || 0 })
|
||||||
|
clients24hByDate.set(row.date, current)
|
||||||
|
}
|
||||||
|
|
||||||
for (const row of activityClientRows) {
|
for (const row of activityClientRows) {
|
||||||
const label = `${row.browser} on ${row.os}${row.device ? ` (${row.device})` : ''}`
|
const label = `${row.browser} on ${row.os}${row.device ? ` (${row.device})` : ''}`
|
||||||
const current = clientsByDate.get(row.date) || []
|
const current = clientsByDate.get(row.date) || []
|
||||||
@@ -847,6 +920,12 @@ function viewerDashboard() {
|
|||||||
clientsByDate.set(row.date, current)
|
clientsByDate.set(row.date, current)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const activity24hWithLabels = activity24h.map((row) => ({
|
||||||
|
...row,
|
||||||
|
client_labels: clients24hByDate.get(row.date) || [],
|
||||||
|
location_labels: locations24hByDate.get(row.date) || [],
|
||||||
|
}))
|
||||||
|
|
||||||
const activityWithLocations = activity30d.map((row) => ({
|
const activityWithLocations = activity30d.map((row) => ({
|
||||||
...row,
|
...row,
|
||||||
client_labels: clientsByDate.get(row.date) || [],
|
client_labels: clientsByDate.get(row.date) || [],
|
||||||
@@ -894,6 +973,7 @@ function viewerDashboard() {
|
|||||||
select
|
select
|
||||||
count(*) as events_24h,
|
count(*) as events_24h,
|
||||||
count(distinct visitor_id) as visitors_24h,
|
count(distinct visitor_id) as visitors_24h,
|
||||||
|
count(distinct session_id) as sessions_24h,
|
||||||
sum(case when event_type = 'page_view' then 1 else 0 end) as page_views_24h
|
sum(case when event_type = 'page_view' then 1 else 0 end) as page_views_24h
|
||||||
from viewer_events
|
from viewer_events
|
||||||
where occurred_at >= ?
|
where occurred_at >= ?
|
||||||
@@ -917,6 +997,7 @@ function viewerDashboard() {
|
|||||||
top_pages: topPages,
|
top_pages: topPages,
|
||||||
clients,
|
clients,
|
||||||
clients_30d: clients30d,
|
clients_30d: clients30d,
|
||||||
|
activity_24h: activity24hWithLabels,
|
||||||
activity_30d: activityWithLocations,
|
activity_30d: activityWithLocations,
|
||||||
countries,
|
countries,
|
||||||
locations,
|
locations,
|
||||||
@@ -924,6 +1005,7 @@ function viewerDashboard() {
|
|||||||
active_now: active.length,
|
active_now: active.length,
|
||||||
events_24h: totals?.events_24h || 0,
|
events_24h: totals?.events_24h || 0,
|
||||||
visitors_24h: totals?.visitors_24h || 0,
|
visitors_24h: totals?.visitors_24h || 0,
|
||||||
|
sessions_24h: totals?.sessions_24h || 0,
|
||||||
page_views_24h: totals?.page_views_24h || 0,
|
page_views_24h: totals?.page_views_24h || 0,
|
||||||
events_30d: totals30d?.events_30d || 0,
|
events_30d: totals30d?.events_30d || 0,
|
||||||
visitors_30d: totals30d?.visitors_30d || 0,
|
visitors_30d: totals30d?.visitors_30d || 0,
|
||||||
|
|||||||
+144
-63
@@ -2011,6 +2011,11 @@ const shortDateFormat = new Intl.DateTimeFormat('en-GB', {
|
|||||||
month: 'short',
|
month: 'short',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const shortTimeFormat = new Intl.DateTimeFormat('en-GB', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})
|
||||||
|
|
||||||
const countryNames = {
|
const countryNames = {
|
||||||
AR: 'Argentina',
|
AR: 'Argentina',
|
||||||
AU: 'Australia',
|
AU: 'Australia',
|
||||||
@@ -2054,7 +2059,35 @@ function filledLast30Days(rows) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function MiniLineChart({ accent = 'text-fury-cyan', data, label, metric, stroke = '#e82517' }) {
|
function filledLast24Hours(rows) {
|
||||||
|
const byDate = new Map(rows.map((row) => [row.date, row]))
|
||||||
|
return Array.from({ length: 24 }, (_, index) => {
|
||||||
|
const date = new Date()
|
||||||
|
date.setMinutes(0, 0, 0)
|
||||||
|
date.setHours(date.getHours() - (23 - index))
|
||||||
|
const key = date.toISOString().slice(0, 13) + ':00:00.000Z'
|
||||||
|
return byDate.get(key) || {
|
||||||
|
date: key,
|
||||||
|
events: 0,
|
||||||
|
visitors: 0,
|
||||||
|
page_views: 0,
|
||||||
|
clients: 0,
|
||||||
|
locations: 0,
|
||||||
|
client_labels: [],
|
||||||
|
location_labels: [],
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function MiniLineChart({
|
||||||
|
accent = 'text-fury-cyan',
|
||||||
|
bucketLabel = 'Daily count',
|
||||||
|
data,
|
||||||
|
label,
|
||||||
|
metric,
|
||||||
|
pointFormat = shortDateFormat,
|
||||||
|
stroke = '#e82517',
|
||||||
|
}) {
|
||||||
const [hoveredPoint, setHoveredPoint] = useState(null)
|
const [hoveredPoint, setHoveredPoint] = useState(null)
|
||||||
const width = 320
|
const width = 320
|
||||||
const height = 112
|
const height = 112
|
||||||
@@ -2076,7 +2109,7 @@ function MiniLineChart({ accent = 'text-fury-cyan', data, label, metric, stroke
|
|||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-semibold">{label}</p>
|
<p className="text-sm font-semibold">{label}</p>
|
||||||
<p className="mt-1 text-xs text-text-soft">Daily count</p>
|
<p className="mt-1 text-xs text-text-soft">{bucketLabel}</p>
|
||||||
</div>
|
</div>
|
||||||
<p className={`text-sm font-semibold ${accent}`}>{formatNumber(latest)}</p>
|
<p className={`text-sm font-semibold ${accent}`}>{formatNumber(latest)}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -2118,7 +2151,7 @@ function MiniLineChart({ accent = 'text-fury-cyan', data, label, metric, stroke
|
|||||||
transform: 'translate(-50%, -115%)',
|
transform: 'translate(-50%, -115%)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{shortDateFormat.format(new Date(hoveredPoint.date))}: {formatNumber(hoveredPoint.value)} {label.toLowerCase()}
|
{pointFormat.format(new Date(hoveredPoint.date))}: {formatNumber(hoveredPoint.value)} {label.toLowerCase()}
|
||||||
<span className="block font-normal text-bg/80">
|
<span className="block font-normal text-bg/80">
|
||||||
{formatNumber(hoveredPoint.visitors || 0)} visitors
|
{formatNumber(hoveredPoint.visitors || 0)} visitors
|
||||||
</span>
|
</span>
|
||||||
@@ -2144,6 +2177,87 @@ function MiniLineChart({ accent = 'text-fury-cyan', data, label, metric, stroke
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function AnalyticsPeriodSection({
|
||||||
|
activity,
|
||||||
|
bucketLabel,
|
||||||
|
description,
|
||||||
|
pointFormat,
|
||||||
|
title,
|
||||||
|
totals,
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<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">{title}</h2>
|
||||||
|
<p className="mt-1 text-sm text-text-soft">{description}</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)} visitors
|
||||||
|
</span>
|
||||||
|
<span className="rounded-md bg-surface px-3 py-2 font-semibold">
|
||||||
|
{formatNumber(totals.sessions)} sessions
|
||||||
|
</span>
|
||||||
|
<span className="rounded-md bg-surface px-3 py-2 font-semibold">
|
||||||
|
{formatNumber(totals.pageViews)} page views
|
||||||
|
</span>
|
||||||
|
<span className="rounded-md bg-surface px-3 py-2 font-semibold">
|
||||||
|
{formatNumber(totals.events)} events
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5 grid gap-4 lg:grid-cols-2 xl:grid-cols-5">
|
||||||
|
<MiniLineChart
|
||||||
|
bucketLabel={bucketLabel}
|
||||||
|
data={activity}
|
||||||
|
label="Events"
|
||||||
|
metric="events"
|
||||||
|
pointFormat={pointFormat}
|
||||||
|
stroke="#e82517"
|
||||||
|
/>
|
||||||
|
<MiniLineChart
|
||||||
|
accent="text-fury-violet"
|
||||||
|
bucketLabel={bucketLabel}
|
||||||
|
data={activity}
|
||||||
|
label="Visitors"
|
||||||
|
metric="visitors"
|
||||||
|
pointFormat={pointFormat}
|
||||||
|
stroke="#fb7b04"
|
||||||
|
/>
|
||||||
|
<MiniLineChart
|
||||||
|
accent="text-fury-cyan"
|
||||||
|
bucketLabel={bucketLabel}
|
||||||
|
data={activity}
|
||||||
|
label="Page views"
|
||||||
|
metric="page_views"
|
||||||
|
pointFormat={pointFormat}
|
||||||
|
stroke="#ed5145"
|
||||||
|
/>
|
||||||
|
<MiniLineChart
|
||||||
|
accent="text-text"
|
||||||
|
bucketLabel={bucketLabel}
|
||||||
|
data={activity}
|
||||||
|
label="Clients"
|
||||||
|
metric="clients"
|
||||||
|
pointFormat={pointFormat}
|
||||||
|
stroke="#000000"
|
||||||
|
/>
|
||||||
|
<MiniLineChart
|
||||||
|
accent="text-fury-cyan"
|
||||||
|
bucketLabel={bucketLabel}
|
||||||
|
data={activity}
|
||||||
|
label="Locations"
|
||||||
|
metric="locations"
|
||||||
|
pointFormat={pointFormat}
|
||||||
|
stroke="#009ccc"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function ViewersPage({ viewers }) {
|
function ViewersPage({ viewers }) {
|
||||||
const data = viewers.data || {}
|
const data = viewers.data || {}
|
||||||
const active = data.active || []
|
const active = data.active || []
|
||||||
@@ -2151,6 +2265,7 @@ function ViewersPage({ viewers }) {
|
|||||||
const topPages = data.top_pages || []
|
const topPages = data.top_pages || []
|
||||||
const clients = data.clients || []
|
const clients = data.clients || []
|
||||||
const clients30d = data.clients_30d || []
|
const clients30d = data.clients_30d || []
|
||||||
|
const activity24h = filledLast24Hours(data.activity_24h || [])
|
||||||
const activity30d = filledLast30Days(data.activity_30d || [])
|
const activity30d = filledLast30Days(data.activity_30d || [])
|
||||||
const countries = data.countries || []
|
const countries = data.countries || []
|
||||||
const locations = data.locations || []
|
const locations = data.locations || []
|
||||||
@@ -2184,67 +2299,33 @@ function ViewersPage({ viewers }) {
|
|||||||
<Stat label="Events 24h" value={formatNumber(totals.events_24h)} />
|
<Stat label="Events 24h" value={formatNumber(totals.events_24h)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-lg border border-border bg-fury-white p-5 shadow-sm">
|
<AnalyticsPeriodSection
|
||||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
activity={activity24h}
|
||||||
<div>
|
bucketLabel="Hourly count"
|
||||||
<h2 className="text-lg font-semibold">Last 30 days</h2>
|
description="Consented analytics grouped by hour"
|
||||||
<p className="mt-1 text-sm text-text-soft">
|
pointFormat={shortTimeFormat}
|
||||||
Retained consented analytics over the current privacy window
|
title="Last 24 hours"
|
||||||
</p>
|
totals={{
|
||||||
</div>
|
events: totals.events_24h,
|
||||||
<div className="grid gap-3 text-sm sm:grid-cols-4">
|
visitors: totals.visitors_24h,
|
||||||
<span className="rounded-md bg-surface px-3 py-2 font-semibold">
|
sessions: totals.sessions_24h,
|
||||||
{formatNumber(totals.visitors_30d)} visitors
|
pageViews: totals.page_views_24h,
|
||||||
</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 grid gap-4 lg:grid-cols-2 xl:grid-cols-5">
|
<AnalyticsPeriodSection
|
||||||
<MiniLineChart
|
activity={activity30d}
|
||||||
data={activity30d}
|
bucketLabel="Daily count"
|
||||||
label="Events"
|
description="Retained consented analytics over the current privacy window"
|
||||||
metric="events"
|
pointFormat={shortDateFormat}
|
||||||
stroke="#e82517"
|
title="Last 30 days"
|
||||||
/>
|
totals={{
|
||||||
<MiniLineChart
|
events: totals.events_30d,
|
||||||
accent="text-fury-violet"
|
visitors: totals.visitors_30d,
|
||||||
data={activity30d}
|
sessions: totals.sessions_30d,
|
||||||
label="Visitors"
|
pageViews: totals.page_views_30d,
|
||||||
metric="visitors"
|
}}
|
||||||
stroke="#fb7b04"
|
/>
|
||||||
/>
|
|
||||||
<MiniLineChart
|
|
||||||
accent="text-fury-cyan"
|
|
||||||
data={activity30d}
|
|
||||||
label="Page views"
|
|
||||||
metric="page_views"
|
|
||||||
stroke="#ed5145"
|
|
||||||
/>
|
|
||||||
<MiniLineChart
|
|
||||||
accent="text-text"
|
|
||||||
data={activity30d}
|
|
||||||
label="Clients"
|
|
||||||
metric="clients"
|
|
||||||
stroke="#000000"
|
|
||||||
/>
|
|
||||||
<MiniLineChart
|
|
||||||
accent="text-fury-cyan"
|
|
||||||
data={activity30d}
|
|
||||||
label="Locations"
|
|
||||||
metric="locations"
|
|
||||||
stroke="#009ccc"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="overflow-hidden rounded-lg border border-border bg-fury-white shadow-sm">
|
<div className="overflow-hidden rounded-lg border border-border bg-fury-white shadow-sm">
|
||||||
<div className="border-b border-surface px-5 py-4">
|
<div className="border-b border-surface px-5 py-4">
|
||||||
|
|||||||
Reference in New Issue
Block a user