refactor: remove realtime visitors detail page

Remove the individual session journey page and make the live visitor
count a static indicator. Prepares for the new aggregated User Journeys
feature (v0.17).
This commit is contained in:
Usman Baig
2026-03-12 20:45:58 +01:00
parent bae492e8d9
commit 6964be9610
10 changed files with 11 additions and 445 deletions

View File

@@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
## [Unreleased]
### Removed
- **Realtime visitors detail page.** The page that showed individual active visitors and their page-by-page session journey has been removed. The live visitor count on your dashboard still works — it just no longer links to a separate page.
### Added
- **Rage click detection.** Pulse now detects when visitors rapidly click the same element 3 or more times — a strong signal of UI frustration. Rage clicks are tracked automatically (no setup required) and surfaced in the new Behavior tab with the element, page, click count, and number of affected sessions.

View File

@@ -451,9 +451,8 @@ export default function SiteDashboardPage() {
</div>
{/* Realtime Indicator */}
<button
onClick={() => router.push(`/sites/${siteId}/realtime`)}
className="flex items-center gap-2 px-3 py-1 bg-green-500/10 rounded-full border border-green-500/20 hover:bg-green-500/20 transition-colors cursor-pointer"
<div
className="flex items-center gap-2 px-3 py-1 bg-green-500/10 rounded-full border border-green-500/20"
>
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-500 opacity-75"></span>
@@ -462,7 +461,7 @@ export default function SiteDashboardPage() {
<span className="text-sm font-medium text-green-700 dark:text-green-400">
{realtime} current visitors
</span>
</button>
</div>
</div>
<div className="flex items-center gap-2">

View File

@@ -1,13 +0,0 @@
'use client'
import ErrorDisplay from '@/components/ErrorDisplay'
export default function RealtimeError({ reset }: { error: Error; reset: () => void }) {
return (
<ErrorDisplay
title="Realtime view failed to load"
message="We couldn't connect to the realtime data stream. Please try again."
onRetry={reset}
/>
)
}

View File

@@ -1,15 +0,0 @@
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: 'Realtime | Pulse',
description: 'See who is on your site right now.',
robots: { index: false, follow: false },
}
export default function RealtimeLayout({
children,
}: {
children: React.ReactNode
}) {
return children
}

View File

@@ -1,234 +0,0 @@
'use client'
import { useEffect, useState } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { getSite, type Site } from '@/lib/api/sites'
import { getSessionDetails, type Visitor, type SessionEvent } from '@/lib/api/realtime'
import { useRealtimeSSE } from '@/lib/hooks/useRealtimeSSE'
import { toast } from '@ciphera-net/ui'
import { getAuthErrorMessage } from '@ciphera-net/ui'
import { UserIcon } from '@ciphera-net/ui'
import { RealtimeSkeleton, SessionEventsSkeleton, useMinimumLoading } from '@/components/skeletons'
import { motion, AnimatePresence } from 'framer-motion'
function formatTimeAgo(dateString: string) {
const date = new Date(dateString)
const now = new Date()
const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000)
if (diffInSeconds < 60) return 'just now'
if (diffInSeconds < 3600) return `${Math.floor(diffInSeconds / 60)}m ago`
if (diffInSeconds < 86400) return `${Math.floor(diffInSeconds / 3600)}h ago`
return `${Math.floor(diffInSeconds / 86400)}d ago`
}
export default function RealtimePage() {
const params = useParams()
const router = useRouter()
const siteId = params.id as string
const [site, setSite] = useState<Site | null>(null)
const { visitors } = useRealtimeSSE(siteId)
const [selectedVisitor, setSelectedVisitor] = useState<Visitor | null>(null)
const [sessionEvents, setSessionEvents] = useState<SessionEvent[]>([])
const [loading, setLoading] = useState(true)
const [loadingEvents, setLoadingEvents] = useState(false)
// Load site info
useEffect(() => {
const init = async () => {
try {
const siteData = await getSite(siteId)
setSite(siteData)
} catch (error: unknown) {
toast.error(getAuthErrorMessage(error) || 'Failed to load site')
} finally {
setLoading(false)
}
}
init()
}, [siteId])
// Auto-select the first visitor when the list populates and nothing is selected
useEffect(() => {
if (visitors.length > 0 && !selectedVisitor) {
handleSelectVisitor(visitors[0])
}
}, [visitors]) // eslint-disable-line react-hooks/exhaustive-deps
const handleSelectVisitor = async (visitor: Visitor) => {
setSelectedVisitor(visitor)
setLoadingEvents(true)
try {
const events = await getSessionDetails(siteId, visitor.session_id)
setSessionEvents(events || [])
} catch (error: unknown) {
toast.error(getAuthErrorMessage(error) || 'Failed to load session events')
} finally {
setLoadingEvents(false)
}
}
useEffect(() => {
if (site?.domain) document.title = `Realtime · ${site.domain} | Pulse`
}, [site?.domain])
const showSkeleton = useMinimumLoading(loading)
if (showSkeleton) return <RealtimeSkeleton />
if (!site) return <div className="p-8">Site not found</div>
return (
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8 h-[calc(100vh-64px)] flex flex-col">
<div className="mb-6 flex items-center justify-between">
<div>
<div className="flex items-center gap-2 mb-1">
<button onClick={() => router.push(`/sites/${siteId}`)} className="text-sm text-neutral-500 hover:text-neutral-900 dark:hover:text-white transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus-visible:rounded">
&larr; Back to Dashboard
</button>
</div>
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white flex items-center gap-3">
Realtime Visitors
<span className="relative flex h-3 w-3">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-3 w-3 bg-green-500"></span>
</span>
<span className="text-lg font-normal text-neutral-500" aria-live="polite" aria-atomic="true">
{visitors.length} active now
</span>
</h1>
</div>
</div>
<div className="flex flex-col md:flex-row flex-1 gap-6 min-h-0">
{/* Visitors List */}
<div className="w-full md:w-1/3 border border-neutral-200 dark:border-neutral-800 rounded-2xl overflow-hidden flex flex-col bg-white dark:bg-neutral-900">
<div className="p-4 border-b border-neutral-200 dark:border-neutral-800 bg-neutral-50 dark:bg-neutral-800/50">
<h2 className="text-xl font-semibold text-neutral-900 dark:text-white">Active Sessions</h2>
</div>
<div className="overflow-y-auto flex-1">
{visitors.length === 0 ? (
<div className="p-8 flex flex-col items-center justify-center text-center gap-3">
<div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-3">
<UserIcon className="w-6 h-6 text-neutral-500 dark:text-neutral-400" />
</div>
<p className="text-sm font-medium text-neutral-900 dark:text-white">
No active visitors right now
</p>
<p className="text-xs text-neutral-500 dark:text-neutral-400">
New visitors will appear here in real-time
</p>
</div>
) : (
<div className="divide-y divide-neutral-100 dark:divide-neutral-800">
<AnimatePresence mode="popLayout">
{visitors.map((visitor) => (
<motion.button
key={visitor.session_id}
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -10 }}
transition={{ duration: 0.2 }}
onClick={() => handleSelectVisitor(visitor)}
className={`w-full text-left p-4 hover:bg-neutral-50 dark:hover:bg-neutral-800/50 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus-visible:ring-inset ${
selectedVisitor?.session_id === visitor.session_id ? 'bg-neutral-50 dark:bg-neutral-800/50 ring-1 ring-inset ring-neutral-200 dark:ring-neutral-700' : ''
}`}
>
<div className="flex justify-between items-start mb-1">
<div className="font-medium text-neutral-900 dark:text-white truncate pr-2">
{visitor.country ? `${getFlagEmoji(visitor.country)} ${visitor.city || 'Unknown City'}` : 'Unknown Location'}
</div>
<span className="text-xs text-neutral-500 whitespace-nowrap">
{formatTimeAgo(visitor.last_seen)}
</span>
</div>
<div className="text-sm text-neutral-600 dark:text-neutral-400 truncate mb-1" title={visitor.current_path}>
{visitor.current_path}
</div>
<div className="flex items-center gap-2 text-xs text-neutral-400">
<span>{visitor.device_type}</span>
<span></span>
<span>{visitor.browser}</span>
<span></span>
<span>{visitor.os}</span>
<span className="ml-auto bg-neutral-100 dark:bg-neutral-800 px-1.5 py-0.5 rounded text-neutral-600 dark:text-neutral-400">
{visitor.pageviews} views
</span>
</div>
</motion.button>
))}
</AnimatePresence>
</div>
)}
</div>
</div>
{/* Session Details */}
<div className="flex-1 border border-neutral-200 dark:border-neutral-800 rounded-2xl overflow-hidden flex flex-col bg-white dark:bg-neutral-900">
<div className="p-4 border-b border-neutral-200 dark:border-neutral-800 bg-neutral-50 dark:bg-neutral-800/50 flex justify-between items-center">
<h2 className="text-xl font-semibold text-neutral-900 dark:text-white">
{selectedVisitor ? 'Session Journey' : 'Select a visitor'}
</h2>
{selectedVisitor && (
<span className="text-xs font-mono text-neutral-400">
ID: {selectedVisitor.session_id.substring(0, 8)}...
</span>
)}
</div>
<div className="flex-1 overflow-y-auto p-6">
{!selectedVisitor ? (
<div className="h-full flex items-center justify-center text-neutral-500">
Select a visitor on the left to see their activity.
</div>
) : loadingEvents ? (
<SessionEventsSkeleton />
) : (
<div className="relative pl-6 border-l-2 border-neutral-100 dark:border-neutral-800 space-y-8">
{sessionEvents.map((event, idx) => (
<div key={event.id} className="relative">
<span className={`absolute -left-[29px] top-1 h-3 w-3 rounded-full border-2 border-white dark:border-neutral-900 ${
idx === 0 ? 'bg-green-500 ring-4 ring-green-100 dark:ring-green-900/30' : 'bg-neutral-300 dark:bg-neutral-700'
}`}></span>
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-neutral-900 dark:text-white">
Visited {event.path}
</span>
<span className="text-xs text-neutral-500">
{new Date(event.timestamp).toLocaleTimeString()}
</span>
</div>
{event.referrer && (
<div className="text-xs text-neutral-500">
Referrer: <span className="text-neutral-700 dark:text-neutral-300">{event.referrer}</span>
</div>
)}
</div>
</div>
))}
<div className="relative">
<span className="absolute -left-[29px] top-1 h-3 w-3 rounded-full border-2 border-white dark:border-neutral-900 bg-neutral-300 dark:bg-neutral-700"></span>
<div className="text-sm text-neutral-500">
Session started {formatTimeAgo(sessionEvents[sessionEvents.length - 1]?.timestamp || new Date().toISOString())}
</div>
</div>
</div>
)}
</div>
</div>
</div>
</div>
)
}
function getFlagEmoji(countryCode: string) {
if (!countryCode || countryCode.length !== 2) return '🌍'
const codePoints = countryCode
.toUpperCase()
.split('')
.map(char => 127397 + char.charCodeAt(0))
return String.fromCodePoint(...codePoints)
}

View File

@@ -1,19 +1,11 @@
'use client'
import { useRouter } from 'next/navigation'
interface RealtimeVisitorsProps {
count: number
siteId?: string
}
export default function RealtimeVisitors({ count, siteId }: RealtimeVisitorsProps) {
const router = useRouter()
export default function RealtimeVisitors({ count }: RealtimeVisitorsProps) {
return (
<div
onClick={() => siteId && router.push(`/sites/${siteId}/realtime`)}
className={`bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 ${siteId ? 'cursor-pointer hover:border-neutral-300 dark:hover:border-neutral-700 transition-colors' : ''}`}
<div
className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6"
>
<div className="flex items-center justify-between mb-2">
<div className="text-sm text-neutral-600 dark:text-neutral-400">

View File

@@ -26,7 +26,7 @@ export default function SiteNav({ siteId }: SiteNavProps) {
const isActive = (href: string) => {
if (href === `/sites/${siteId}`) {
return pathname === href || pathname === `${href}/realtime`
return pathname === href
}
return pathname.startsWith(href)
}

View File

@@ -166,78 +166,6 @@ export function DashboardSkeleton() {
)
}
// ─── Realtime page skeleton ──────────────────────────────────
export function RealtimeSkeleton() {
return (
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8 h-[calc(100vh-64px)] flex flex-col">
<div className="mb-6">
<SkeletonLine className="h-4 w-32 mb-2" />
<SkeletonLine className="h-8 w-64" />
</div>
<div className="flex flex-col md:flex-row flex-1 gap-6 min-h-0">
{/* Visitors list */}
<div className="w-full md:w-1/3 border border-neutral-200 dark:border-neutral-800 rounded-2xl overflow-hidden flex flex-col bg-white dark:bg-neutral-900">
<div className="p-4 border-b border-neutral-200 dark:border-neutral-800">
<SkeletonLine className="h-6 w-32" />
</div>
<div className="p-2 space-y-1">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="p-4 space-y-2">
<div className="flex justify-between">
<SkeletonLine className="h-4 w-32" />
<SkeletonLine className="h-4 w-16" />
</div>
<SkeletonLine className="h-3 w-48" />
<div className="flex gap-2">
<SkeletonLine className="h-3 w-16" />
<SkeletonLine className="h-3 w-16" />
<SkeletonLine className="h-3 w-16" />
</div>
</div>
))}
</div>
</div>
{/* Session details */}
<div className="flex-1 border border-neutral-200 dark:border-neutral-800 rounded-2xl overflow-hidden bg-white dark:bg-neutral-900">
<div className="p-4 border-b border-neutral-200 dark:border-neutral-800">
<SkeletonLine className="h-6 w-40" />
</div>
<div className="p-6 space-y-6">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex gap-4 pl-6">
<SkeletonCircle className="h-3 w-3 shrink-0 mt-1" />
<div className="space-y-1 flex-1">
<SkeletonLine className="h-4 w-48" />
<SkeletonLine className="h-3 w-32" />
</div>
</div>
))}
</div>
</div>
</div>
</div>
)
}
// ─── Session events skeleton (for loading events panel) ──────
export function SessionEventsSkeleton() {
return (
<div className="relative pl-6 border-l-2 border-neutral-100 dark:border-neutral-800 space-y-8">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="relative">
<span className={`absolute -left-[29px] top-1 h-3 w-3 rounded-full ${SK}`} />
<div className="space-y-1">
<SkeletonLine className="h-4 w-48" />
<SkeletonLine className="h-3 w-32" />
</div>
</div>
))}
</div>
)
}
// ─── Uptime page skeleton ────────────────────────────────────
export function UptimeSkeleton() {

View File

@@ -1,42 +0,0 @@
import apiRequest from './client'
export interface Visitor {
session_id: string
first_seen: string
last_seen: string
pageviews: number
current_path: string
browser: string
os: string
device_type: string
country: string
city: string
}
export interface SessionEvent {
id: string
site_id: string
session_id: string
path: string
referrer: string | null
user_agent: string
country: string | null
city: string | null
region: string | null
device_type: string
screen_resolution: string | null
browser: string | null
os: string | null
timestamp: string
created_at: string
}
export async function getRealtimeVisitors(siteId: string): Promise<Visitor[]> {
const data = await apiRequest<{ visitors: Visitor[] }>(`/sites/${siteId}/realtime/visitors`)
return data.visitors
}
export async function getSessionDetails(siteId: string, sessionId: string): Promise<SessionEvent[]> {
const data = await apiRequest<{ events: SessionEvent[] }>(`/sites/${siteId}/sessions/${sessionId}`)
return data.events
}

View File

@@ -1,53 +0,0 @@
// * SSE hook for real-time visitor streaming.
// * Replaces 5-second polling with a persistent EventSource connection.
// * The backend broadcasts one DB query per site to all connected clients,
// * so 1,000 users on the same site share a single query instead of each
// * triggering their own.
import { useEffect, useRef, useState, useCallback } from 'react'
import { API_URL } from '@/lib/api/client'
import type { Visitor } from '@/lib/api/realtime'
interface UseRealtimeSSEReturn {
visitors: Visitor[]
connected: boolean
}
export function useRealtimeSSE(siteId: string): UseRealtimeSSEReturn {
const [visitors, setVisitors] = useState<Visitor[]>([])
const [connected, setConnected] = useState(false)
const esRef = useRef<EventSource | null>(null)
// Stable callback so we don't recreate EventSource on every render
const handleMessage = useCallback((event: MessageEvent) => {
try {
const data = JSON.parse(event.data)
setVisitors(data.visitors || [])
} catch {
// Ignore malformed messages
}
}, [])
useEffect(() => {
if (!siteId) return
const url = `${API_URL}/api/v1/sites/${siteId}/realtime/stream`
const es = new EventSource(url, { withCredentials: true })
esRef.current = es
es.onopen = () => setConnected(true)
es.onmessage = handleMessage
es.onerror = () => {
setConnected(false)
// EventSource auto-reconnects with exponential backoff
}
return () => {
es.close()
esRef.current = null
setConnected(false)
}
}, [siteId, handleMessage])
return { visitors, connected }
}