)}
diff --git a/public/script.js b/public/script.js
index be787dd..4ead55b 100644
--- a/public/script.js
+++ b/public/script.js
@@ -631,8 +631,9 @@
});
var mutOpts = { childList: true, attributes: true, characterData: true, subtree: true };
mutationObs.observe(target, mutOpts);
- if (target.parentElement) {
- mutationObs.observe(target.parentElement, mutOpts);
+ var parent = target.parentElement;
+ if (parent && parent.tagName !== 'HTML' && parent.tagName !== 'BODY') {
+ mutationObs.observe(parent, { childList: true });
}
} catch (ex) {
mutationObs = null;
From 585f37f444d75f4ef31536bdb564ee499ca4040b Mon Sep 17 00:00:00 2001
From: Usman Baig
Date: Thu, 12 Mar 2026 17:06:36 +0100
Subject: [PATCH 21/61] docs: add rage click and dead click detection to
changelog
---
CHANGELOG.md | 8 ++++++++
1 file changed, 8 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 78b2f07..feae0b6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,9 +8,17 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
### 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.
+- **Dead click detection.** Clicks on buttons, links, and other interactive elements that produce no visible result (no navigation, no DOM change, no network request) are now detected and reported. This helps you find broken buttons, disabled links, and unresponsive UI elements your visitors are struggling with.
+- **Behavior tab.** A new tab in your site dashboard — alongside Dashboard, Uptime, and Funnels — dedicated to user behavior signals. Houses rage clicks, dead clicks, a by-page frustration breakdown, and scroll depth (moved from the main dashboard for a cleaner layout).
+- **Frustration summary cards.** The Behavior tab opens with three at-a-glance cards: total rage clicks, total dead clicks, and total frustration signals with the most affected page — each with a percentage change compared to the previous period.
- **Scheduled Reports.** You can now get your analytics delivered automatically — set up daily, weekly, or monthly reports sent straight to your email, Slack, Discord, or any webhook. Each report includes your key stats (visitors, pageviews, bounce rate), top pages, and traffic sources, all in a clean branded format. Set them up in your site settings under the new "Reports" tab, and hit "Test" to preview before going live. You can create up to 10 schedules per site.
- **Time-of-day report scheduling.** Choose when your reports arrive — pick the hour, day of week (for weekly), or day of month (for monthly). Schedule cards show a human-readable description like "Every Monday at 9:00 AM (UTC)."
+### Changed
+
+- **Scroll depth moved to Behavior tab.** The scroll depth radar chart has been relocated from the main dashboard to the new Behavior tab, where it fits more naturally alongside other user behavior metrics.
+
### Fixed
- **Region names now display correctly.** Some regions were showing as cryptic codes like "14" (Poland), "KKC" (Thailand), or "IDF" (France) instead of their actual names. The Locations panel now shows proper region names like "Masovian", "Khon Kaen", and "Île-de-France."
From 2f01be1c67b657941e21671b3c7402d637b1f377 Mon Sep 17 00:00:00 2001
From: Usman Baig
Date: Thu, 12 Mar 2026 18:03:22 +0100
Subject: [PATCH 22/61] feat: polish behavior page UI with 8 improvements
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Add column headers to rage/dead click tables
- Rich empty states with icons matching dashboard pattern
- Add frustration trend comparison chart (current vs previous period)
- Show "New" badge instead of misleading "+100%" when previous period is 0
- Click-to-copy on CSS selectors with toast feedback
- Normalize min-height to 270px for consistent card sizing
- Fix page title to include site domain (Behavior · domain | Pulse)
- Add "last seen" column with relative timestamps
---
app/sites/[id]/behavior/page.tsx | 9 +-
.../behavior/FrustrationByPageTable.tsx | 13 +-
.../behavior/FrustrationSummaryCards.tsx | 21 ++-
components/behavior/FrustrationTable.tsx | 84 ++++++++--
components/behavior/FrustrationTrend.tsx | 158 ++++++++++++++++++
5 files changed, 256 insertions(+), 29 deletions(-)
create mode 100644 components/behavior/FrustrationTrend.tsx
diff --git a/app/sites/[id]/behavior/page.tsx b/app/sites/[id]/behavior/page.tsx
index f96b3cd..6de0d44 100644
--- a/app/sites/[id]/behavior/page.tsx
+++ b/app/sites/[id]/behavior/page.tsx
@@ -18,6 +18,7 @@ import {
import FrustrationSummaryCards from '@/components/behavior/FrustrationSummaryCards'
import FrustrationTable from '@/components/behavior/FrustrationTable'
import FrustrationByPageTable from '@/components/behavior/FrustrationByPageTable'
+import FrustrationTrend from '@/components/behavior/FrustrationTrend'
import { useDashboard } from '@/lib/swr/dashboard'
const ScrollDepth = dynamic(() => import('@/components/dashboard/ScrollDepth'))
@@ -91,8 +92,9 @@ export default function BehaviorPage() {
}, [fetchData])
useEffect(() => {
- document.title = 'Behavior | Pulse'
- }, [])
+ const domain = dashboard?.site?.domain
+ document.title = domain ? `Behavior · ${domain} | Pulse` : 'Behavior | Pulse'
+ }, [dashboard?.site?.domain])
const fetchAllRage = useCallback(
() => getRageClicks(siteId, dateRange.start, dateRange.end, 100),
@@ -181,12 +183,13 @@ export default function BehaviorPage() {
{/* By page breakdown */}
- {/* Scroll depth */}
+ {/* Scroll depth + Frustration trend */}
+
) : (
-
-
- No frustration signals detected in this period
+
+
+
+
+
+ No frustration signals detected
+
+
+ Page-level frustration data will appear here once rage clicks or dead clicks are detected on your site.
@@ -152,7 +101,53 @@ export default function FrustrationTrend({ summary, loading }: FrustrationTrendP
Frustration trend data will appear here once rage clicks or dead clicks are detected across periods.
- Current vs. previous period comparison
+ Rage vs. dead click breakdown
@@ -97,7 +108,7 @@ export default function FrustrationTrend({ summary, loading }: FrustrationTrendP
No trend data yet
- Frustration trend data will appear here once rage clicks or dead clicks are detected across periods.
+ Frustration trend data will appear here once rage clicks or dead clicks are detected on your site.
- Rage and dead clicks split across current and previous period
+ {hasPrevious
+ ? 'Rage and dead clicks split across current and previous period'
+ : 'Rage vs. dead click breakdown'}
@@ -121,9 +134,9 @@ export default function FrustrationTrend({ summary, loading }: FrustrationTrendP
className="mx-auto aspect-square max-h-[250px]"
>
- }
+ content={}
/>
Date: Thu, 12 Mar 2026 18:35:36 +0100
Subject: [PATCH 30/61] fix: hide scroll depth and trend chart when
rate-limited
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Frustration APIs and dashboard API are separate — when frustration
calls fail, Scroll Depth still rendered from cached SWR data,
creating a broken mixed state. Now tracks error state and hides
the bottom section entirely on failure.
---
app/sites/[id]/behavior/page.tsx | 21 +++++++++++++--------
1 file changed, 13 insertions(+), 8 deletions(-)
diff --git a/app/sites/[id]/behavior/page.tsx b/app/sites/[id]/behavior/page.tsx
index 8c93ffa..a7e5794 100644
--- a/app/sites/[id]/behavior/page.tsx
+++ b/app/sites/[id]/behavior/page.tsx
@@ -53,6 +53,7 @@ export default function BehaviorPage() {
const [deadClicks, setDeadClicks] = useState<{ items: FrustrationElement[]; total: number }>({ items: [], total: 0 })
const [byPage, setByPage] = useState([])
const [loading, setLoading] = useState(true)
+ const [error, setError] = useState(false)
const refreshRef = useRef | null>(null)
// Fetch dashboard data for scroll depth (goal_counts + stats)
@@ -70,7 +71,9 @@ export default function BehaviorPage() {
setRageClicks(rageData)
setDeadClicks(deadData)
setByPage(pageData)
+ setError(false)
} catch {
+ setError(true)
toast.error('Failed to load behavior data')
} finally {
setLoading(false)
@@ -185,14 +188,16 @@ export default function BehaviorPage() {
{/* By page breakdown */}
- {/* Scroll depth + Frustration trend */}
-
-
-
-
+ {/* Scroll depth + Frustration trend — hide when data failed to load */}
+ {!error && (
+
{/* Header */}
@@ -189,7 +143,7 @@ export default function BehaviorPage() {
{/* Scroll depth + Frustration trend — hide when data failed to load */}
- {!error && (
+ {!behaviorError && (
{
+ return apiRequest(`/sites/${siteId}/behavior${buildQuery({ startDate, endDate, limit })}`)
+ .then(r => r ?? emptyBehavior)
+}
+
export function getFrustrationSummary(siteId: string, startDate?: string, endDate?: string): Promise {
return apiRequest(`/sites/${siteId}/frustration/summary${buildQuery({ startDate, endDate })}`)
.then(r => r ?? { rage_clicks: 0, rage_unique_elements: 0, rage_top_page: '', dead_clicks: 0, dead_unique_elements: 0, dead_top_page: '', prev_rage_clicks: 0, prev_dead_clicks: 0 })
diff --git a/lib/swr/dashboard.ts b/lib/swr/dashboard.ts
index e6e9813..724c868 100644
--- a/lib/swr/dashboard.ts
+++ b/lib/swr/dashboard.ts
@@ -15,6 +15,7 @@ import {
getRealtime,
getStats,
getDailyStats,
+ getBehavior,
} from '@/lib/api/stats'
import { listAnnotations } from '@/lib/api/annotations'
import type { Annotation } from '@/lib/api/annotations'
@@ -32,6 +33,7 @@ import type {
DashboardReferrersData,
DashboardPerformanceData,
DashboardGoalsData,
+ BehaviorData,
} from '@/lib/api/stats'
// * SWR fetcher functions
@@ -52,6 +54,7 @@ const fetchers = {
campaigns: (siteId: string, start: string, end: string, limit: number) =>
getCampaigns(siteId, start, end, limit),
annotations: (siteId: string, start: string, end: string) => listAnnotations(siteId, start, end),
+ behavior: (siteId: string, start: string, end: string) => getBehavior(siteId, start, end),
}
// * Standard SWR config for dashboard data
@@ -265,5 +268,18 @@ export function useAnnotations(siteId: string, startDate: string, endDate: strin
)
}
+// * Hook for bundled behavior data (all frustration signals in one request)
+export function useBehavior(siteId: string, start: string, end: string) {
+ return useSWR(
+ siteId && start && end ? ['behavior', siteId, start, end] : null,
+ () => fetchers.behavior(siteId, start, end),
+ {
+ ...dashboardSWRConfig,
+ refreshInterval: 60 * 1000,
+ dedupingInterval: 10 * 1000,
+ }
+ )
+}
+
// * Re-export for convenience
export { fetchers }
From bae492e8d9cd6e3a54bfaf90c6e5e7c7e77209bf Mon Sep 17 00:00:00 2001
From: Usman Baig
Date: Thu, 12 Mar 2026 20:31:21 +0100
Subject: [PATCH 32/61] style: show only percentage badge on hover in
frustration tables
---
components/behavior/FrustrationTable.tsx | 6 ------
1 file changed, 6 deletions(-)
diff --git a/components/behavior/FrustrationTable.tsx b/components/behavior/FrustrationTable.tsx
index 5fedd0f..d16ee4f 100644
--- a/components/behavior/FrustrationTable.tsx
+++ b/components/behavior/FrustrationTable.tsx
@@ -5,7 +5,6 @@ import { formatNumber, Modal } from '@ciphera-net/ui'
import { FrameCornersIcon, Copy, Check, CursorClick } from '@phosphor-icons/react'
import { toast } from '@ciphera-net/ui'
import type { FrustrationElement } from '@/lib/api/stats'
-import { formatRelativeTime } from '@/lib/utils/formatDate'
import { ListSkeleton } from '@/components/skeletons'
const DISPLAY_LIMIT = 7
@@ -93,11 +92,6 @@ function Row({
- {/* Secondary info: visible on hover */}
-
- {showAvgClicks && item.avg_click_count != null ? `avg ${item.avg_click_count.toFixed(1)} · ` : ''}
- {item.sessions} {item.sessions === 1 ? 'sess' : 'sess'} · {formatRelativeTime(item.last_seen)}
-
{/* Percentage badge: slides in on hover */}
{pct}
From 6964be9610cb0cc672a737ccf5e8aa5098e3e35a Mon Sep 17 00:00:00 2001
From: Usman Baig
Date: Thu, 12 Mar 2026 20:45:58 +0100
Subject: [PATCH 33/61] 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).
---
CHANGELOG.md | 4 +
app/sites/[id]/page.tsx | 7 +-
app/sites/[id]/realtime/error.tsx | 13 --
app/sites/[id]/realtime/layout.tsx | 15 --
app/sites/[id]/realtime/page.tsx | 234 ----------------------
components/dashboard/RealtimeVisitors.tsx | 14 +-
components/dashboard/SiteNav.tsx | 2 +-
components/skeletons.tsx | 72 -------
lib/api/realtime.ts | 42 ----
lib/hooks/useRealtimeSSE.ts | 53 -----
10 files changed, 11 insertions(+), 445 deletions(-)
delete mode 100644 app/sites/[id]/realtime/error.tsx
delete mode 100644 app/sites/[id]/realtime/layout.tsx
delete mode 100644 app/sites/[id]/realtime/page.tsx
delete mode 100644 lib/api/realtime.ts
delete mode 100644 lib/hooks/useRealtimeSSE.ts
diff --git a/CHANGELOG.md b/CHANGELOG.md
index feae0b6..d5a123c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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.
diff --git a/app/sites/[id]/page.tsx b/app/sites/[id]/page.tsx
index 51a8a89..50d3072 100644
--- a/app/sites/[id]/page.tsx
+++ b/app/sites/[id]/page.tsx
@@ -451,9 +451,8 @@ export default function SiteDashboardPage() {
{/* Realtime Indicator */}
-
diff --git a/app/sites/[id]/realtime/error.tsx b/app/sites/[id]/realtime/error.tsx
deleted file mode 100644
index 77bb93a..0000000
--- a/app/sites/[id]/realtime/error.tsx
+++ /dev/null
@@ -1,13 +0,0 @@
-'use client'
-
-import ErrorDisplay from '@/components/ErrorDisplay'
-
-export default function RealtimeError({ reset }: { error: Error; reset: () => void }) {
- return (
-
- )
-}
diff --git a/app/sites/[id]/realtime/layout.tsx b/app/sites/[id]/realtime/layout.tsx
deleted file mode 100644
index 64b256b..0000000
--- a/app/sites/[id]/realtime/layout.tsx
+++ /dev/null
@@ -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
-}
diff --git a/app/sites/[id]/realtime/page.tsx b/app/sites/[id]/realtime/page.tsx
deleted file mode 100644
index 0ef2c04..0000000
--- a/app/sites/[id]/realtime/page.tsx
+++ /dev/null
@@ -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(null)
- const { visitors } = useRealtimeSSE(siteId)
- const [selectedVisitor, setSelectedVisitor] = useState(null)
- const [sessionEvents, setSessionEvents] = useState([])
- 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
- if (!site) return
Site not found
-
- return (
-
-
-
-
-
-
-
- Realtime Visitors
-
-
-
-
-
- {visitors.length} active now
-
-
+ {/* Sankey area */}
+
+ {/* Top paths table */}
+
+
+
+
+
+ )
+}
+
// ─── Uptime page skeleton ────────────────────────────────────
export function UptimeSkeleton() {
From 49aa8aae60dab1747b516f5f889684807f2dab0a Mon Sep 17 00:00:00 2001
From: Usman Baig
Date: Thu, 12 Mar 2026 21:37:07 +0100
Subject: [PATCH 40/61] docs: update frontend changelog for user journeys
---
CHANGELOG.md | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d5a123c..711fc98 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
## [Unreleased]
+### Added
+
+- **User Journeys tab.** A new "Journeys" tab on your site dashboard visualizes how visitors navigate through your site. A Sankey flow diagram shows the most common paths users take — from landing page through to exit — so you can see where traffic flows and where it drops off. Filter by entry page, adjust the depth (2-10 steps), and click any page in the diagram to drill into paths through it. Below the diagram, a "Top Paths" table ranks the most common full navigation sequences with session counts and average duration.
+
### 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.
From 4cd95446725ee120d4431ea5ad751bbdafe453f4 Mon Sep 17 00:00:00 2001
From: Usman Baig
Date: Thu, 12 Mar 2026 21:39:31 +0100
Subject: [PATCH 41/61] fix(journeys): use correct session_count property in
entry point dropdown
---
app/sites/[id]/journeys/page.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/app/sites/[id]/journeys/page.tsx b/app/sites/[id]/journeys/page.tsx
index e2b16a1..9738726 100644
--- a/app/sites/[id]/journeys/page.tsx
+++ b/app/sites/[id]/journeys/page.tsx
@@ -56,7 +56,7 @@ export default function JourneysPage() {
{ value: '', label: 'All entry points' },
...(entryPoints ?? []).map((ep) => ({
value: ep.path,
- label: `${ep.path} (${ep.sessions.toLocaleString()})`,
+ label: `${ep.path} (${ep.session_count.toLocaleString()})`,
})),
]
From 908606ade24a5f15ebd8270cdc8afa9daafc2daa Mon Sep 17 00:00:00 2001
From: Usman Baig
Date: Thu, 12 Mar 2026 21:49:31 +0100
Subject: [PATCH 42/61] fix: make journey empty states consistent with
dashboard blocks
---
components/journeys/SankeyDiagram.tsx | 13 +++++++++++--
components/journeys/TopPathsTable.tsx | 13 ++++++++++---
2 files changed, 21 insertions(+), 5 deletions(-)
diff --git a/components/journeys/SankeyDiagram.tsx b/components/journeys/SankeyDiagram.tsx
index 4baa053..4ac3a62 100644
--- a/components/journeys/SankeyDiagram.tsx
+++ b/components/journeys/SankeyDiagram.tsx
@@ -2,6 +2,7 @@
import { useMemo, useState } from 'react'
import { useTheme } from '@ciphera-net/ui'
+import { TreeStructure } from '@phosphor-icons/react'
import type { PathTransition } from '@/lib/api/journeys'
// ─── Types ──────────────────────────────────────────────────────────
@@ -277,8 +278,16 @@ export default function SankeyDiagram({
if (!transitions.length || !links.length) {
return (
-
- No journey data available
+
+
+
+
+
+ No journey data yet
+
+
+ Navigation flows will appear here as visitors browse through your site.
+
)
}
diff --git a/components/journeys/TopPathsTable.tsx b/components/journeys/TopPathsTable.tsx
index ae04ebf..d92ee70 100644
--- a/components/journeys/TopPathsTable.tsx
+++ b/components/journeys/TopPathsTable.tsx
@@ -2,6 +2,7 @@
import type { TopPath } from '@/lib/api/journeys'
import { TableSkeleton } from '@/components/skeletons'
+import { Path } from '@phosphor-icons/react'
interface TopPathsTableProps {
paths: TopPath[]
@@ -69,9 +70,15 @@ export default function TopPathsTable({ paths, loading }: TopPathsTableProps) {
) : (
-
-
- No path data available
+
+
+
+
+
+ No path data yet
+
+
+ Common navigation paths will appear here as visitors browse your site.