diff --git a/CHANGELOG.md b/CHANGELOG.md index a9d8034..c4e0994 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,32 @@ All notable changes to Pulse (frontend and product) are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and Pulse uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html) with a **0.x.y** version scheme while in initial development. The leading `0` indicates that the public API and behaviour may change until we release **1.0.0**. -## [Unreleased] +## [0.15.0-alpha] - 2026-03-13 + +### 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. + +### 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." ## [0.14.0-alpha] - 2026-03-12 diff --git a/app/sites/[id]/behavior/page.tsx b/app/sites/[id]/behavior/page.tsx new file mode 100644 index 0000000..a172a39 --- /dev/null +++ b/app/sites/[id]/behavior/page.tsx @@ -0,0 +1,168 @@ +'use client' + +import { useCallback, useEffect, useState } from 'react' +import { useParams } from 'next/navigation' +import { getDateRange, formatDate } from '@ciphera-net/ui' +import { Select, DatePicker } from '@ciphera-net/ui' +import dynamic from 'next/dynamic' +import { getRageClicks, getDeadClicks } from '@/lib/api/stats' +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, useBehavior } from '@/lib/swr/dashboard' + +const ScrollDepth = dynamic(() => import('@/components/dashboard/ScrollDepth')) + +function getThisWeekRange(): { start: string; end: string } { + const today = new Date() + const dayOfWeek = today.getDay() + const monday = new Date(today) + monday.setDate(today.getDate() - (dayOfWeek === 0 ? 6 : dayOfWeek - 1)) + return { start: formatDate(monday), end: formatDate(today) } +} + +function getThisMonthRange(): { start: string; end: string } { + const today = new Date() + const firstOfMonth = new Date(today.getFullYear(), today.getMonth(), 1) + return { start: formatDate(firstOfMonth), end: formatDate(today) } +} + +export default function BehaviorPage() { + const params = useParams() + const siteId = params.id as string + + const [period, setPeriod] = useState('30') + const [dateRange, setDateRange] = useState(() => getDateRange(30)) + const [isDatePickerOpen, setIsDatePickerOpen] = useState(false) + + // Single request for all frustration data + const { data: behavior, isLoading: loading, error: behaviorError } = useBehavior(siteId, dateRange.start, dateRange.end) + + // Fetch dashboard data for scroll depth (goal_counts + stats) + const { data: dashboard } = useDashboard(siteId, dateRange.start, dateRange.end) + + useEffect(() => { + const domain = dashboard?.site?.domain + document.title = domain ? `Behavior · ${domain} | Pulse` : 'Behavior | Pulse' + }, [dashboard?.site?.domain]) + + // On-demand fetchers for modal "view all" + const fetchAllRage = useCallback( + () => getRageClicks(siteId, dateRange.start, dateRange.end, 100), + [siteId, dateRange.start, dateRange.end] + ) + + const fetchAllDead = useCallback( + () => getDeadClicks(siteId, dateRange.start, dateRange.end, 100), + [siteId, dateRange.start, dateRange.end] + ) + + const summary = behavior?.summary ?? null + const rageClicks = behavior?.rage_clicks ?? { items: [], total: 0 } + const deadClicks = behavior?.dead_clicks ?? { items: [], total: 0 } + const byPage = behavior?.by_page ?? [] + + return ( +
+ {/* Header */} +
+
+

+ Behavior +

+

+ Frustration signals and user engagement patterns +

+
+ { + if (value === 'today') { + const today = formatDate(new Date()) + setDateRange({ start: today, end: today }) + setPeriod('today') + } else if (value === '7') { + setDateRange(getDateRange(7)) + setPeriod('7') + } else if (value === 'week') { + setDateRange(getThisWeekRange()) + setPeriod('week') + } else if (value === '30') { + setDateRange(getDateRange(30)) + setPeriod('30') + } else if (value === 'month') { + setDateRange(getThisMonthRange()) + setPeriod('month') + } else if (value === 'custom') { + setIsDatePickerOpen(true) + } + }} + options={[ + { value: 'today', label: 'Today' }, + { value: '7', label: 'Last 7 days' }, + { value: '30', label: 'Last 30 days' }, + { value: 'divider-1', label: '', divider: true }, + { value: 'week', label: 'This week' }, + { value: 'month', label: 'This month' }, + { value: 'divider-2', label: '', divider: true }, + { value: 'custom', label: 'Custom' }, + ]} + /> +
+ + {/* Controls */} +
+
+ + setDepth(Number(e.target.value))} + className="w-32 accent-brand-orange" + /> + {depth} +
+ + handleReportToggle(schedule)} + className="sr-only peer" + /> +
+ + +
+ )} +
+ + ))} + + )} + + )} @@ -1177,6 +1523,165 @@ export default function SiteSettingsPage() { + setReportModalOpen(false)} + title={editingSchedule ? 'Edit report schedule' : 'Add report schedule'} + > +
+
+ +
+ {(['email', 'slack', 'discord', 'webhook'] as const).map((ch) => ( + + ))} +
+
+ + {reportForm.channel === 'email' ? ( +
+ + setReportForm({ ...reportForm, recipients: e.target.value })} + placeholder="email1@example.com, email2@example.com" + className="w-full px-4 py-2 border border-neutral-200 dark:border-neutral-800 rounded-lg bg-white dark:bg-neutral-900 text-neutral-900 dark:text-white" + required + /> +

Comma-separated email addresses.

+
+ ) : ( +
+ + setReportForm({ ...reportForm, webhookUrl: e.target.value })} + placeholder="https://hooks.example.com/..." + className="w-full px-4 py-2 border border-neutral-200 dark:border-neutral-800 rounded-lg bg-white dark:bg-neutral-900 text-neutral-900 dark:text-white" + required + /> +
+ )} + +
+ + setReportForm({ ...reportForm, sendDay: parseInt(v) })} + options={WEEKDAY_NAMES.map((name, i) => ({ value: String(i), label: name }))} + variant="input" + fullWidth + align="left" + /> +
+ )} + + {reportForm.frequency === 'monthly' && ( +
+ + setReportForm({ ...reportForm, sendHour: parseInt(v) })} + options={Array.from({ length: 24 }, (_, i) => ({ + value: String(i), + label: formatHour(i), + }))} + variant="input" + fullWidth + align="left" + /> +
+ +
+ + setReportForm({ ...reportForm, reportType: v })} + options={[ + { value: 'summary', label: 'Summary' }, + { value: 'pages', label: 'Pages' }, + { value: 'sources', label: 'Sources' }, + { value: 'goals', label: 'Goals' }, + ]} + variant="input" + fullWidth + align="left" + /> +
+ +
+ + +
+
+
+ setShowVerificationModal(false)} diff --git a/components/behavior/FrustrationByPageTable.tsx b/components/behavior/FrustrationByPageTable.tsx new file mode 100644 index 0000000..eb1e3ea --- /dev/null +++ b/components/behavior/FrustrationByPageTable.tsx @@ -0,0 +1,114 @@ +'use client' + +import { formatNumber } from '@ciphera-net/ui' +import { Files } from '@phosphor-icons/react' +import type { FrustrationByPage } from '@/lib/api/stats' + +interface FrustrationByPageTableProps { + pages: FrustrationByPage[] + loading: boolean +} + +function SkeletonRows() { + return ( +
+ {Array.from({ length: 5 }).map((_, i) => ( +
+
+
+
+
+
+
+
+
+ ))} +
+ ) +} + +export default function FrustrationByPageTable({ pages, loading }: FrustrationByPageTableProps) { + const hasData = pages.length > 0 + const maxTotal = Math.max(...pages.map(p => p.total), 1) + + return ( +
+
+

+ Frustration by Page +

+
+

+ Pages with the most frustration signals +

+ + {loading ? ( + + ) : hasData ? ( +
+ {/* Header */} +
+ Page +
+ Rage + Dead + Total + Elements +
+
+ + {/* Rows */} +
+ {pages.map((page) => { + const barWidth = (page.total / maxTotal) * 100 + return ( +
+ {/* Background bar */} +
+ + {page.page_path} + +
+ + {formatNumber(page.rage_clicks)} + + + {formatNumber(page.dead_clicks)} + + + {formatNumber(page.total)} + + + {page.unique_elements} + +
+
+ ) + })} +
+
+ ) : ( +
+
+ +
+

+ No frustration signals detected +

+

+ Page-level frustration data will appear here once rage clicks or dead clicks are detected on your site. +

+
+ )} +
+ ) +} diff --git a/components/behavior/FrustrationSummaryCards.tsx b/components/behavior/FrustrationSummaryCards.tsx new file mode 100644 index 0000000..304c974 --- /dev/null +++ b/components/behavior/FrustrationSummaryCards.tsx @@ -0,0 +1,124 @@ +'use client' + +import type { FrustrationSummary } from '@/lib/api/stats' + +interface FrustrationSummaryCardsProps { + data: FrustrationSummary | null + loading: boolean +} + +function pctChange(current: number, previous: number): { type: 'pct'; value: number } | { type: 'new' } | null { + if (previous === 0 && current === 0) return null + if (previous === 0) return { type: 'new' } + return { type: 'pct', value: Math.round(((current - previous) / previous) * 100) } +} + +function ChangeIndicator({ change }: { change: ReturnType }) { + if (change === null) return null + if (change.type === 'new') { + return ( + + New + + ) + } + const isUp = change.value > 0 + const isDown = change.value < 0 + return ( + + {isUp ? '+' : ''}{change.value}% + + ) +} + +function SkeletonCard() { + return ( +
+
+
+
+
+
+
+ ) +} + +export default function FrustrationSummaryCards({ data, loading }: FrustrationSummaryCardsProps) { + if (loading || !data) { + return ( +
+ + + +
+ ) + } + + const rageChange = pctChange(data.rage_clicks, data.prev_rage_clicks) + const deadChange = pctChange(data.dead_clicks, data.prev_dead_clicks) + const topPage = data.rage_top_page || data.dead_top_page + const totalSignals = data.rage_clicks + data.dead_clicks + + return ( +
+ {/* Rage Clicks */} +
+

+ Rage Clicks +

+
+ + {data.rage_clicks.toLocaleString()} + + +
+

+ {data.rage_unique_elements} unique elements +

+
+ + {/* Dead Clicks */} +
+

+ Dead Clicks +

+
+ + {data.dead_clicks.toLocaleString()} + + +
+

+ {data.dead_unique_elements} unique elements +

+
+ + {/* Total Frustration Signals */} +
+

+ Total Signals +

+ + {totalSignals.toLocaleString()} + + {topPage ? ( +

+ Top page: {topPage} +

+ ) : ( +

+ No data in this period +

+ )} +
+
+ ) +} diff --git a/components/behavior/FrustrationTable.tsx b/components/behavior/FrustrationTable.tsx new file mode 100644 index 0000000..d16ee4f --- /dev/null +++ b/components/behavior/FrustrationTable.tsx @@ -0,0 +1,220 @@ +'use client' + +import { useState, useEffect } from 'react' +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 { ListSkeleton } from '@/components/skeletons' + +const DISPLAY_LIMIT = 7 + +interface FrustrationTableProps { + title: string + description: string + items: FrustrationElement[] + total: number + totalSignals: number + showAvgClicks?: boolean + loading: boolean + fetchAll?: () => Promise<{ items: FrustrationElement[]; total: number }> +} + +function SkeletonRows() { + return ( +
+ {Array.from({ length: DISPLAY_LIMIT }).map((_, i) => ( +
+
+
+
+
+
+
+ ))} +
+ ) +} + +function SelectorCell({ selector }: { selector: string }) { + const [copied, setCopied] = useState(false) + + const handleCopy = (e: React.MouseEvent) => { + e.stopPropagation() + navigator.clipboard.writeText(selector) + setCopied(true) + toast.success('Selector copied') + setTimeout(() => setCopied(false), 2000) + } + + return ( + + ) +} + +function Row({ + item, + showAvgClicks, + totalSignals, +}: { + item: FrustrationElement + showAvgClicks?: boolean + totalSignals: number +}) { + const pct = totalSignals > 0 ? `${Math.round((item.count / totalSignals) * 100)}%` : '' + + return ( +
+
+
+ + + {item.page_path} + +
+
+
+ {/* Percentage badge: slides in on hover */} + + {pct} + + + {formatNumber(item.count)} + +
+
+ ) +} + +export default function FrustrationTable({ + title, + description, + items, + total, + totalSignals, + showAvgClicks, + loading, + fetchAll, +}: FrustrationTableProps) { + const [isModalOpen, setIsModalOpen] = useState(false) + const [fullData, setFullData] = useState([]) + const [isLoadingFull, setIsLoadingFull] = useState(false) + const hasData = items.length > 0 + const showViewAll = hasData && total > items.length + const emptySlots = Math.max(0, DISPLAY_LIMIT - items.length) + + useEffect(() => { + if (isModalOpen && fetchAll) { + const load = async () => { + setIsLoadingFull(true) + try { + const result = await fetchAll() + setFullData(result.items) + } catch { + // silent + } finally { + setIsLoadingFull(false) + } + } + load() + } else { + setFullData([]) + } + }, [isModalOpen, fetchAll]) + + return ( + <> +
+
+
+

+ {title} +

+ {showViewAll && ( + + )} +
+
+

+ {description} +

+ +
+ {loading ? ( + + ) : hasData ? ( + <> + {items.map((item, i) => ( + + ))} + {Array.from({ length: emptySlots }).map((_, i) => ( + +
+ + setIsModalOpen(false)} + title={title} + className="max-w-2xl" + > +
+ {isLoadingFull ? ( +
+ +
+ ) : fullData.length > 0 ? ( +
+ {fullData.map((item, i) => ( + + ))} +
+ ) : ( +

+ No data available +

+ )} +
+
+ + ) +} diff --git a/components/behavior/FrustrationTrend.tsx b/components/behavior/FrustrationTrend.tsx new file mode 100644 index 0000000..9247113 --- /dev/null +++ b/components/behavior/FrustrationTrend.tsx @@ -0,0 +1,166 @@ +'use client' + +import { TrendUp } from '@phosphor-icons/react' +import { Pie, PieChart, Tooltip } from 'recharts' + +import { + ChartContainer, + type ChartConfig, +} from '@/components/charts' +import type { FrustrationSummary } from '@/lib/api/stats' + +interface FrustrationTrendProps { + summary: FrustrationSummary | null + loading: boolean +} + +function SkeletonCard() { + return ( +
+
+
+
+
+
+
+
+
+ ) +} + +const LABELS: Record = { + rage_clicks: 'Rage Clicks', + dead_clicks: 'Dead Clicks', + prev_rage_clicks: 'Prev Rage Clicks', + prev_dead_clicks: 'Prev Dead Clicks', +} + +const COLORS = { + rage_clicks: 'rgba(253, 94, 15, 0.7)', + dead_clicks: 'rgba(180, 83, 9, 0.7)', + prev_rage_clicks: 'rgba(253, 94, 15, 0.35)', + prev_dead_clicks: 'rgba(180, 83, 9, 0.35)', +} as const + +const chartConfig = { + count: { label: 'Count' }, + rage_clicks: { label: 'Rage Clicks', color: COLORS.rage_clicks }, + dead_clicks: { label: 'Dead Clicks', color: COLORS.dead_clicks }, + prev_rage_clicks: { label: 'Prev Rage Clicks', color: COLORS.prev_rage_clicks }, + prev_dead_clicks: { label: 'Prev Dead Clicks', color: COLORS.prev_dead_clicks }, +} satisfies ChartConfig + +function CustomTooltip({ active, payload }: { active?: boolean; payload?: Array<{ payload: { type: string; count: number; fill: string } }> }) { + if (!active || !payload?.length) return null + const item = payload[0].payload + return ( +
+
+ + {LABELS[item.type] ?? item.type} + + + {item.count.toLocaleString()} + +
+ ) +} + +export default function FrustrationTrend({ summary, loading }: FrustrationTrendProps) { + if (loading || !summary) return + + const hasData = summary.rage_clicks > 0 || summary.dead_clicks > 0 || + summary.prev_rage_clicks > 0 || summary.prev_dead_clicks > 0 + + const totalCurrent = summary.rage_clicks + summary.dead_clicks + const totalPrevious = summary.prev_rage_clicks + summary.prev_dead_clicks + const totalChange = totalPrevious > 0 + ? Math.round(((totalCurrent - totalPrevious) / totalPrevious) * 100) + : null + const hasPrevious = totalPrevious > 0 + + const chartData = [ + { type: 'rage_clicks', count: summary.rage_clicks, fill: COLORS.rage_clicks }, + { type: 'dead_clicks', count: summary.dead_clicks, fill: COLORS.dead_clicks }, + { type: 'prev_rage_clicks', count: summary.prev_rage_clicks, fill: COLORS.prev_rage_clicks }, + { type: 'prev_dead_clicks', count: summary.prev_dead_clicks, fill: COLORS.prev_dead_clicks }, + ].filter(d => d.count > 0) + + if (!hasData) { + return ( +
+
+

+ Frustration Trend +

+
+

+ Rage vs. dead click breakdown +

+
+
+ +
+

+ No trend data yet +

+

+ Frustration trend data will appear here once rage clicks or dead clicks are detected on your site. +

+
+
+ ) + } + + return ( +
+
+

+ Frustration Trend +

+
+

+ {hasPrevious + ? 'Rage and dead clicks split across current and previous period' + : 'Rage vs. dead click breakdown'} +

+ +
+ + + } + /> + + + +
+ +
+ {totalChange !== null ? ( + <> + {totalChange > 0 ? 'Up' : totalChange < 0 ? 'Down' : 'No change'} by {Math.abs(totalChange)}% vs previous period + + ) : totalCurrent > 0 ? ( + <> + {totalCurrent.toLocaleString()} new signals this period + + ) : ( + 'No frustration signals detected' + )} +
+
+ ) +} diff --git a/components/charts/chart.tsx b/components/charts/chart.tsx index a4249f5..2424041 100644 --- a/components/charts/chart.tsx +++ b/components/charts/chart.tsx @@ -174,7 +174,7 @@ const ChartTooltipContent = React.forwardRef< !hideIndicator && (
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' : ''}`} +
diff --git a/components/dashboard/SiteNav.tsx b/components/dashboard/SiteNav.tsx index 8703540..90544c2 100644 --- a/components/dashboard/SiteNav.tsx +++ b/components/dashboard/SiteNav.tsx @@ -18,14 +18,16 @@ export default function SiteNav({ siteId }: SiteNavProps) { const tabs = [ { label: 'Dashboard', href: `/sites/${siteId}` }, - { label: 'Uptime', href: `/sites/${siteId}/uptime` }, + { label: 'Journeys', href: `/sites/${siteId}/journeys` }, { label: 'Funnels', href: `/sites/${siteId}/funnels` }, + { label: 'Behavior', href: `/sites/${siteId}/behavior` }, + { label: 'Uptime', href: `/sites/${siteId}/uptime` }, ...(canEdit ? [{ label: 'Settings', href: `/sites/${siteId}/settings` }] : []), ] const isActive = (href: string) => { if (href === `/sites/${siteId}`) { - return pathname === href || pathname === `${href}/realtime` + return pathname === href } return pathname.startsWith(href) } diff --git a/components/journeys/SankeyDiagram.tsx b/components/journeys/SankeyDiagram.tsx new file mode 100644 index 0000000..771b4b6 --- /dev/null +++ b/components/journeys/SankeyDiagram.tsx @@ -0,0 +1,457 @@ +'use client' + +import { useCallback, useMemo, useRef, useState } from 'react' +import { useTheme } from '@ciphera-net/ui' +import { TreeStructure } from '@phosphor-icons/react' +import { sankey, sankeyJustify } from 'd3-sankey' +import type { + SankeyNode as D3SankeyNode, + SankeyLink as D3SankeyLink, + SankeyExtraProperties, +} from 'd3-sankey' +import type { PathTransition } from '@/lib/api/journeys' + +// ─── Types ────────────────────────────────────────────────────────── + +interface SankeyDiagramProps { + transitions: PathTransition[] + totalSessions: number + depth: number + onNodeClick?: (path: string) => void +} + +interface NodeExtra extends SankeyExtraProperties { + id: string + label: string + color: string +} + +interface LinkExtra extends SankeyExtraProperties { + value: number +} + +type LayoutNode = D3SankeyNode +type LayoutLink = D3SankeyLink + +// ─── Constants ────────────────────────────────────────────────────── + +const COLUMN_COLORS = [ + '#FD5E0F', // brand orange (entry) + '#3B82F6', // blue + '#10B981', // emerald + '#F59E0B', // amber + '#8B5CF6', // violet + '#EC4899', // pink + '#06B6D4', // cyan + '#EF4444', // red + '#84CC16', // lime + '#F97316', // orange again + '#6366F1', // indigo +] +const EXIT_GREY = '#52525b' +const SVG_W = 1100 +const MARGIN = { top: 24, right: 140, bottom: 24, left: 10 } +const MAX_NODES_PER_COLUMN = 5 + +function colorForColumn(col: number): string { + return COLUMN_COLORS[col % COLUMN_COLORS.length] +} + +// ─── Smart label: show last meaningful path segment ───────────────── + +function smartLabel(path: string): string { + if (path === '/' || path === '(exit)') return path + // Remove trailing slash, split, take last 2 segments + const segments = path.replace(/\/$/, '').split('/') + if (segments.length <= 2) return path + // Show /last-segment for short paths, or …/last-segment for deep ones + const last = segments[segments.length - 1] + return `…/${last}` +} + +function truncateLabel(s: string, max: number) { + return s.length > max ? s.slice(0, max - 1) + '\u2026' : s +} + +function estimateTextWidth(s: string) { + return s.length * 7 +} + +// ─── Data transformation ──────────────────────────────────────────── + +function buildSankeyData(transitions: PathTransition[], depth: number) { + const numCols = depth + 1 + const nodeMap = new Map() + const links: Array<{ source: string; target: string; value: number }> = [] + const flowOut = new Map() + const flowIn = new Map() + + for (const t of transitions) { + if (t.step_index >= numCols || t.step_index + 1 >= numCols) continue + + const fromId = `${t.step_index}:${t.from_path}` + const toId = `${t.step_index + 1}:${t.to_path}` + + if (!nodeMap.has(fromId)) { + nodeMap.set(fromId, { id: fromId, label: t.from_path, color: colorForColumn(t.step_index) }) + } + if (!nodeMap.has(toId)) { + nodeMap.set(toId, { id: toId, label: t.to_path, color: colorForColumn(t.step_index + 1) }) + } + + links.push({ source: fromId, target: toId, value: t.session_count }) + flowOut.set(fromId, (flowOut.get(fromId) ?? 0) + t.session_count) + flowIn.set(toId, (flowIn.get(toId) ?? 0) + t.session_count) + } + + // ─── Cap nodes per column: keep top N by flow, merge rest into (other) ── + const columns = new Map() + for (const [nodeId] of nodeMap) { + if (nodeId === 'exit') continue + const col = parseInt(nodeId.split(':')[0], 10) + if (!columns.has(col)) columns.set(col, []) + columns.get(col)!.push(nodeId) + } + + for (const [col, nodeIds] of columns) { + if (nodeIds.length <= MAX_NODES_PER_COLUMN) continue + + // Sort by total flow (max of in/out) descending + nodeIds.sort((a, b) => { + const flowA = Math.max(flowIn.get(a) ?? 0, flowOut.get(a) ?? 0) + const flowB = Math.max(flowIn.get(b) ?? 0, flowOut.get(b) ?? 0) + return flowB - flowA + }) + + const keep = new Set(nodeIds.slice(0, MAX_NODES_PER_COLUMN)) + const otherId = `${col}:(other)` + nodeMap.set(otherId, { id: otherId, label: '(other)', color: colorForColumn(col) }) + + // Redirect links from/to pruned nodes to (other) + for (let i = 0; i < links.length; i++) { + const l = links[i] + if (!keep.has(l.source) && nodeIds.includes(l.source)) { + links[i] = { ...l, source: otherId } + } + if (!keep.has(l.target) && nodeIds.includes(l.target)) { + links[i] = { ...l, target: otherId } + } + } + + // Remove pruned nodes + for (const id of nodeIds) { + if (!keep.has(id)) nodeMap.delete(id) + } + } + + // Deduplicate links after merging (same source→target pairs) + const linkMap = new Map() + for (const l of links) { + const key = `${l.source}->${l.target}` + const existing = linkMap.get(key) + if (existing) { + existing.value += l.value + } else { + linkMap.set(key, { ...l }) + } + } + + // Recalculate flowOut/flowIn after merge + flowOut.clear() + flowIn.clear() + for (const l of linkMap.values()) { + flowOut.set(l.source, (flowOut.get(l.source) ?? 0) + l.value) + flowIn.set(l.target, (flowIn.get(l.target) ?? 0) + l.value) + } + + // Add exit nodes for flows that don't continue + for (const [nodeId] of nodeMap) { + if (nodeId === 'exit') continue + const col = parseInt(nodeId.split(':')[0], 10) + if (col >= numCols - 1) continue + + const totalIn = flowIn.get(nodeId) ?? 0 + const totalOut = flowOut.get(nodeId) ?? 0 + const flow = Math.max(totalIn, totalOut) + const exitCount = flow - totalOut + + if (exitCount > 0) { + const exitId = 'exit' + if (!nodeMap.has(exitId)) { + nodeMap.set(exitId, { id: exitId, label: '(exit)', color: EXIT_GREY }) + } + const key = `${nodeId}->exit` + const existing = linkMap.get(key) + if (existing) { + existing.value += exitCount + } else { + linkMap.set(key, { source: nodeId, target: exitId, value: exitCount }) + } + } + } + + return { + nodes: Array.from(nodeMap.values()), + links: Array.from(linkMap.values()), + } +} + +// ─── SVG path for a link ribbon ───────────────────────────────────── + +function ribbonPath(link: LayoutLink): string { + const src = link.source as LayoutNode + const tgt = link.target as LayoutNode + const sx = src.x1! + const tx = tgt.x0! + const w = link.width! + // d3-sankey y0/y1 are the CENTER of the link band, not the top + const sy = link.y0! - w / 2 + const ty = link.y1! - w / 2 + const mx = (sx + tx) / 2 + + return [ + `M${sx},${sy}`, + `C${mx},${sy} ${mx},${ty} ${tx},${ty}`, + `L${tx},${ty + w}`, + `C${mx},${ty + w} ${mx},${sy + w} ${sx},${sy + w}`, + 'Z', + ].join(' ') +} + +// ─── Component ────────────────────────────────────────────────────── + +export default function SankeyDiagram({ + transitions, + totalSessions, + depth, + onNodeClick, +}: SankeyDiagramProps) { + const { resolvedTheme } = useTheme() + const isDark = resolvedTheme === 'dark' + const [hovered, setHovered] = useState<{ type: 'link' | 'node'; id: string } | null>(null) + const svgRef = useRef(null) + + const data = useMemo( + () => buildSankeyData(transitions, depth), + [transitions, depth], + ) + + // Dynamic SVG height based on max nodes in any column + const svgH = useMemo(() => { + const columns = new Map() + for (const node of data.nodes) { + if (node.id === 'exit') continue + const col = parseInt(node.id.split(':')[0], 10) + columns.set(col, (columns.get(col) ?? 0) + 1) + } + const maxNodes = Math.max(1, ...columns.values()) + // Base 400 + 50px per node beyond 4 + return Math.max(400, Math.min(800, 400 + Math.max(0, maxNodes - 4) * 50)) + }, [data]) + + const layout = useMemo(() => { + if (!data.links.length) return null + + const generator = sankey() + .nodeId((d) => d.id) + .nodeWidth(18) + .nodePadding(16) + .nodeAlign(sankeyJustify) + .extent([ + [MARGIN.left, MARGIN.top], + [SVG_W - MARGIN.right, svgH - MARGIN.bottom], + ]) + + return generator({ + nodes: data.nodes.map((d) => ({ ...d })), + links: data.links.map((d) => ({ ...d })), + }) + }, [data, svgH]) + + // Single event handler on SVG — reads data-* attrs from e.target + const handleMouseOver = useCallback((e: React.MouseEvent) => { + const target = e.target as SVGElement + const el = target.closest('[data-node-id], [data-link-id]') as SVGElement | null + if (!el) return + const nodeId = el.getAttribute('data-node-id') + const linkId = el.getAttribute('data-link-id') + if (nodeId) { + setHovered((prev) => (prev?.type === 'node' && prev.id === nodeId) ? prev : { type: 'node', id: nodeId }) + } else if (linkId) { + setHovered((prev) => (prev?.type === 'link' && prev.id === linkId) ? prev : { type: 'link', id: linkId }) + } + }, []) + + const handleMouseLeave = useCallback(() => { + setHovered(null) + }, []) + + // ─── Empty state ──────────────────────────────────────────────── + if (!transitions.length || !layout) { + return ( +
+
+ +
+

+ No journey data yet +

+

+ Navigation flows will appear here as visitors browse through your site. +

+
+ ) + } + + // ─── Colors ───────────────────────────────────────────────────── + const labelColor = isDark ? '#e5e5e5' : '#404040' + const labelBg = isDark ? 'rgba(23, 23, 23, 0.9)' : 'rgba(255, 255, 255, 0.9)' + const nodeStroke = isDark ? 'rgba(255,255,255,0.15)' : 'rgba(0,0,0,0.1)' + + return ( + + {/* Links */} + + {layout.links.map((link, i) => { + const src = link.source as LayoutNode + const tgt = link.target as LayoutNode + const srcId = String(src.id) + const tgtId = String(tgt.id) + const linkId = `${srcId}->${tgtId}` + + let isHighlighted = false + if (hovered?.type === 'link') { + isHighlighted = hovered.id === linkId + } else if (hovered?.type === 'node') { + isHighlighted = srcId === hovered.id || tgtId === hovered.id + } + + let opacity = isDark ? 0.45 : 0.5 + if (hovered) { + opacity = isHighlighted ? 0.75 : 0.08 + } + + return ( + + + {src.label} → {tgt.label}:{' '} + {(link.value as number).toLocaleString()} sessions + + + ) + })} + + + {/* Nodes */} + + {layout.nodes.map((node) => { + const nodeId = String(node.id) + const isExit = nodeId === 'exit' + const w = isExit ? 8 : (node.x1 ?? 0) - (node.x0 ?? 0) + const h = (node.y1 ?? 0) - (node.y0 ?? 0) + const x = isExit ? (node.x0 ?? 0) + 5 : (node.x0 ?? 0) + + return ( + { + if (onNodeClick && !isExit) onNodeClick(node.label) + }} + > + + {node.label} — {(node.value ?? 0).toLocaleString()} sessions + + + ) + })} + + + {/* Labels — only for nodes tall enough to avoid overlap */} + + {layout.nodes.map((node) => { + const x0 = node.x0 ?? 0 + const x1 = node.x1 ?? 0 + const y0 = node.y0 ?? 0 + const y1 = node.y1 ?? 0 + const nodeH = y1 - y0 + if (nodeH < 36) return null // hide labels for small nodes — hover for details + + const rawLabel = smartLabel(node.label) + const label = truncateLabel(rawLabel, 24) + const textW = estimateTextWidth(label) + const padX = 6 + const rectW = textW + padX * 2 + const rectH = 20 + + const isRight = x1 > SVG_W - MARGIN.right - 60 + const textX = isRight ? x0 - 6 : x1 + 6 + const textY = y0 + nodeH / 2 + const anchor = isRight ? 'end' : 'start' + const bgX = isRight ? textX - textW - padX : textX - padX + const bgY = textY - rectH / 2 + + const nodeId = String(node.id) + const isExit = nodeId === 'exit' + + return ( + + + { + if (onNodeClick && !isExit) onNodeClick(node.label) + }} + > + {label} + + + ) + })} + + + ) +} diff --git a/components/journeys/TopPathsTable.tsx b/components/journeys/TopPathsTable.tsx new file mode 100644 index 0000000..d92ee70 --- /dev/null +++ b/components/journeys/TopPathsTable.tsx @@ -0,0 +1,87 @@ +'use client' + +import type { TopPath } from '@/lib/api/journeys' +import { TableSkeleton } from '@/components/skeletons' +import { Path } from '@phosphor-icons/react' + +interface TopPathsTableProps { + paths: TopPath[] + loading: boolean +} + +function formatDuration(seconds: number): string { + if (seconds <= 0) return '0s' + const m = Math.floor(seconds / 60) + const s = Math.round(seconds % 60) + if (m === 0) return `${s}s` + return `${m}m ${s}s` +} + +export default function TopPathsTable({ paths, loading }: TopPathsTableProps) { + const hasData = paths.length > 0 + + return ( +
+
+

+ Top Paths +

+
+

+ Most common navigation paths across sessions +

+ + {loading ? ( + + ) : hasData ? ( +
+ {/* Header */} +
+ # + Path + Sessions + Dur. +
+ + {/* Rows */} +
+ {paths.map((path, i) => ( +
+ + {i + 1} + + + {path.page_sequence.join(' → ')} + + + {path.session_count.toLocaleString()} + + + {formatDuration(path.avg_duration)} + +
+ ))} +
+
+ ) : ( +
+
+ +
+

+ No path data yet +

+

+ Common navigation paths will appear here as visitors browse your site. +

+
+ )} +
+ ) +} diff --git a/components/skeletons.tsx b/components/skeletons.tsx index 89ee488..b21ac54 100644 --- a/components/skeletons.tsx +++ b/components/skeletons.tsx @@ -166,74 +166,31 @@ export function DashboardSkeleton() { ) } -// ─── Realtime page skeleton ────────────────────────────────── +// ─── Journeys page skeleton ───────────────────────────────── -export function RealtimeSkeleton() { +export function JourneysSkeleton() { return ( -
-
- - +
+ {/* Header */} +
+
+ + +
+
-
- {/* Visitors list */} -
-
- -
-
- {Array.from({ length: 6 }).map((_, i) => ( -
-
- - -
- -
- - - -
-
- ))} -
-
- {/* Session details */} -
-
- -
-
- {Array.from({ length: 5 }).map((_, i) => ( -
- -
- - -
-
- ))} -
-
+ {/* Controls */} +
+ + +
+ {/* Sankey area */} + + {/* Top paths table */} +
+ +
-
- ) -} - -// ─── Session events skeleton (for loading events panel) ────── - -export function SessionEventsSkeleton() { - return ( -
- {Array.from({ length: 5 }).map((_, i) => ( -
- -
- - -
-
- ))}
) } diff --git a/lib/api/journeys.ts b/lib/api/journeys.ts new file mode 100644 index 0000000..82c2491 --- /dev/null +++ b/lib/api/journeys.ts @@ -0,0 +1,93 @@ +import apiRequest from './client' + +// ─── Types ────────────────────────────────────────────────────────── + +export interface PathTransition { + from_path: string + to_path: string + step_index: number + session_count: number +} + +export interface TransitionsResponse { + transitions: PathTransition[] + total_sessions: number +} + +export interface TopPath { + page_sequence: string[] + session_count: number + avg_duration: number +} + +export interface EntryPoint { + path: string + session_count: number +} + +// ─── Helpers ──────────────────────────────────────────────────────── + +function buildQuery(opts: { + startDate?: string + endDate?: string + depth?: number + limit?: number + min_sessions?: number + entry_path?: string +}): string { + const params = new URLSearchParams() + if (opts.startDate) params.append('start_date', opts.startDate) + if (opts.endDate) params.append('end_date', opts.endDate) + if (opts.depth != null) params.append('depth', opts.depth.toString()) + if (opts.limit != null) params.append('limit', opts.limit.toString()) + if (opts.min_sessions != null) params.append('min_sessions', opts.min_sessions.toString()) + if (opts.entry_path) params.append('entry_path', opts.entry_path) + const query = params.toString() + return query ? `?${query}` : '' +} + +// ─── API Functions ────────────────────────────────────────────────── + +export function getJourneyTransitions( + siteId: string, + startDate?: string, + endDate?: string, + opts?: { depth?: number; minSessions?: number; entryPath?: string } +): Promise { + return apiRequest( + `/sites/${siteId}/journeys/transitions${buildQuery({ + startDate, + endDate, + depth: opts?.depth, + min_sessions: opts?.minSessions, + entry_path: opts?.entryPath, + })}` + ).then(r => r ?? { transitions: [], total_sessions: 0 }) +} + +export function getJourneyTopPaths( + siteId: string, + startDate?: string, + endDate?: string, + opts?: { limit?: number; minSessions?: number; entryPath?: string } +): Promise { + return apiRequest<{ paths: TopPath[] }>( + `/sites/${siteId}/journeys/top-paths${buildQuery({ + startDate, + endDate, + limit: opts?.limit, + min_sessions: opts?.minSessions, + entry_path: opts?.entryPath, + })}` + ).then(r => r?.paths ?? []) +} + +export function getJourneyEntryPoints( + siteId: string, + startDate?: string, + endDate?: string +): Promise { + return apiRequest<{ entry_points: EntryPoint[] }>( + `/sites/${siteId}/journeys/entry-points${buildQuery({ startDate, endDate })}` + ).then(r => r?.entry_points ?? []) +} diff --git a/lib/api/realtime.ts b/lib/api/realtime.ts deleted file mode 100644 index 3bbc89d..0000000 --- a/lib/api/realtime.ts +++ /dev/null @@ -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 { - const data = await apiRequest<{ visitors: Visitor[] }>(`/sites/${siteId}/realtime/visitors`) - return data.visitors -} - -export async function getSessionDetails(siteId: string, sessionId: string): Promise { - const data = await apiRequest<{ events: SessionEvent[] }>(`/sites/${siteId}/sessions/${sessionId}`) - return data.events -} diff --git a/lib/api/report-schedules.ts b/lib/api/report-schedules.ts new file mode 100644 index 0000000..685b06f --- /dev/null +++ b/lib/api/report-schedules.ts @@ -0,0 +1,80 @@ +import apiRequest from './client' + +export interface ReportSchedule { + id: string + site_id: string + organization_id: string + channel: 'email' | 'slack' | 'discord' | 'webhook' + channel_config: EmailConfig | WebhookConfig + frequency: 'daily' | 'weekly' | 'monthly' + timezone: string + enabled: boolean + report_type: 'summary' | 'pages' | 'sources' | 'goals' + send_hour: number + send_day: number | null + next_send_at: string | null + last_sent_at: string | null + last_error: string | null + created_at: string + updated_at: string +} + +export interface EmailConfig { + recipients: string[] +} + +export interface WebhookConfig { + url: string +} + +export interface CreateReportScheduleRequest { + channel: string + channel_config: EmailConfig | WebhookConfig + frequency: string + timezone?: string + report_type?: string + send_hour?: number + send_day?: number +} + +export interface UpdateReportScheduleRequest { + channel?: string + channel_config?: EmailConfig | WebhookConfig + frequency?: string + timezone?: string + report_type?: string + enabled?: boolean + send_hour?: number + send_day?: number +} + +export async function listReportSchedules(siteId: string): Promise { + const res = await apiRequest<{ report_schedules: ReportSchedule[] }>(`/sites/${siteId}/report-schedules`) + return res?.report_schedules ?? [] +} + +export async function createReportSchedule(siteId: string, data: CreateReportScheduleRequest): Promise { + return apiRequest(`/sites/${siteId}/report-schedules`, { + method: 'POST', + body: JSON.stringify(data), + }) +} + +export async function updateReportSchedule(siteId: string, scheduleId: string, data: UpdateReportScheduleRequest): Promise { + return apiRequest(`/sites/${siteId}/report-schedules/${scheduleId}`, { + method: 'PUT', + body: JSON.stringify(data), + }) +} + +export async function deleteReportSchedule(siteId: string, scheduleId: string): Promise { + await apiRequest(`/sites/${siteId}/report-schedules/${scheduleId}`, { + method: 'DELETE', + }) +} + +export async function testReportSchedule(siteId: string, scheduleId: string): Promise { + await apiRequest(`/sites/${siteId}/report-schedules/${scheduleId}/test`, { + method: 'POST', + }) +} diff --git a/lib/api/stats.ts b/lib/api/stats.ts index 883e87f..f40a948 100644 --- a/lib/api/stats.ts +++ b/lib/api/stats.ts @@ -103,6 +103,34 @@ export interface AuthParams { captcha?: { captcha_id?: string, captcha_solution?: string, captcha_token?: string } } +export interface FrustrationSummary { + rage_clicks: number + rage_unique_elements: number + rage_top_page: string + dead_clicks: number + dead_unique_elements: number + dead_top_page: string + prev_rage_clicks: number + prev_dead_clicks: number +} + +export interface FrustrationElement { + selector: string + page_path: string + count: number + avg_click_count?: number + sessions: number + last_seen: string +} + +export interface FrustrationByPage { + page_path: string + rage_clicks: number + dead_clicks: number + total: number + unique_elements: number +} + // ─── Helpers ──────────────────────────────────────────────────────── function appendAuthParams(params: URLSearchParams, auth?: AuthParams) { @@ -402,3 +430,48 @@ export function getEventPropertyValues(siteId: string, eventName: string, propNa return apiRequest<{ values: EventPropertyValue[] }>(`/sites/${siteId}/goals/${encodeURIComponent(eventName)}/properties/${encodeURIComponent(propName)}${buildQuery({ startDate, endDate, limit })}`) .then(r => r?.values || []) } + +// ─── Frustration Signals ──────────────────────────────────────────── + +export interface BehaviorData { + summary: FrustrationSummary + rage_clicks: { items: FrustrationElement[]; total: number } + dead_clicks: { items: FrustrationElement[]; total: number } + by_page: FrustrationByPage[] +} + +const emptyBehavior: BehaviorData = { + summary: { 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 }, + rage_clicks: { items: [], total: 0 }, + dead_clicks: { items: [], total: 0 }, + by_page: [], +} + +export function getBehavior(siteId: string, startDate?: string, endDate?: string, limit = 7): Promise { + 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 }) +} + +export function getRageClicks(siteId: string, startDate?: string, endDate?: string, limit = 10, pagePath?: string): Promise<{ items: FrustrationElement[], total: number }> { + const params = buildQuery({ startDate, endDate, limit }) + const pageFilter = pagePath ? `&page_path=${encodeURIComponent(pagePath)}` : '' + return apiRequest<{ items: FrustrationElement[], total: number }>(`/sites/${siteId}/frustration/rage-clicks${params}${pageFilter}`) + .then(r => r ?? { items: [], total: 0 }) +} + +export function getDeadClicks(siteId: string, startDate?: string, endDate?: string, limit = 10, pagePath?: string): Promise<{ items: FrustrationElement[], total: number }> { + const params = buildQuery({ startDate, endDate, limit }) + const pageFilter = pagePath ? `&page_path=${encodeURIComponent(pagePath)}` : '' + return apiRequest<{ items: FrustrationElement[], total: number }>(`/sites/${siteId}/frustration/dead-clicks${params}${pageFilter}`) + .then(r => r ?? { items: [], total: 0 }) +} + +export function getFrustrationByPage(siteId: string, startDate?: string, endDate?: string, limit = 20): Promise { + return apiRequest<{ pages: FrustrationByPage[] }>(`/sites/${siteId}/frustration/by-page${buildQuery({ startDate, endDate, limit })}`) + .then(r => r?.pages ?? []) +} diff --git a/lib/hooks/useRealtimeSSE.ts b/lib/hooks/useRealtimeSSE.ts deleted file mode 100644 index 63e3b63..0000000 --- a/lib/hooks/useRealtimeSSE.ts +++ /dev/null @@ -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([]) - const [connected, setConnected] = useState(false) - const esRef = useRef(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 } -} diff --git a/lib/swr/dashboard.ts b/lib/swr/dashboard.ts index e6e9813..c43faa2 100644 --- a/lib/swr/dashboard.ts +++ b/lib/swr/dashboard.ts @@ -15,7 +15,16 @@ import { getRealtime, getStats, getDailyStats, + getBehavior, } from '@/lib/api/stats' +import { + getJourneyTransitions, + getJourneyTopPaths, + getJourneyEntryPoints, + type TransitionsResponse, + type TopPath as JourneyTopPath, + type EntryPoint, +} from '@/lib/api/journeys' import { listAnnotations } from '@/lib/api/annotations' import type { Annotation } from '@/lib/api/annotations' import { getSite } from '@/lib/api/sites' @@ -32,6 +41,7 @@ import type { DashboardReferrersData, DashboardPerformanceData, DashboardGoalsData, + BehaviorData, } from '@/lib/api/stats' // * SWR fetcher functions @@ -52,6 +62,13 @@ 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), + journeyTransitions: (siteId: string, start: string, end: string, depth?: number, minSessions?: number, entryPath?: string) => + getJourneyTransitions(siteId, start, end, { depth, minSessions, entryPath }), + journeyTopPaths: (siteId: string, start: string, end: string, limit?: number, minSessions?: number, entryPath?: string) => + getJourneyTopPaths(siteId, start, end, { limit, minSessions, entryPath }), + journeyEntryPoints: (siteId: string, start: string, end: string) => + getJourneyEntryPoints(siteId, start, end), } // * Standard SWR config for dashboard data @@ -265,5 +282,57 @@ 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, + } + ) +} + +// * Hook for journey flow transitions (Sankey diagram data) +export function useJourneyTransitions(siteId: string, start: string, end: string, depth?: number, minSessions?: number, entryPath?: string) { + return useSWR( + siteId && start && end ? ['journeyTransitions', siteId, start, end, depth, minSessions, entryPath] : null, + () => fetchers.journeyTransitions(siteId, start, end, depth, minSessions, entryPath), + { + ...dashboardSWRConfig, + refreshInterval: 60 * 1000, + dedupingInterval: 10 * 1000, + } + ) +} + +// * Hook for top journey paths +export function useJourneyTopPaths(siteId: string, start: string, end: string, limit?: number, minSessions?: number, entryPath?: string) { + return useSWR( + siteId && start && end ? ['journeyTopPaths', siteId, start, end, limit, minSessions, entryPath] : null, + () => fetchers.journeyTopPaths(siteId, start, end, limit, minSessions, entryPath), + { + ...dashboardSWRConfig, + refreshInterval: 60 * 1000, + dedupingInterval: 10 * 1000, + } + ) +} + +// * Hook for journey entry points (refreshes less frequently) +export function useJourneyEntryPoints(siteId: string, start: string, end: string) { + return useSWR( + siteId && start && end ? ['journeyEntryPoints', siteId, start, end] : null, + () => fetchers.journeyEntryPoints(siteId, start, end), + { + ...dashboardSWRConfig, + refreshInterval: 5 * 60 * 1000, + dedupingInterval: 30 * 1000, + } + ) +} + // * Re-export for convenience export { fetchers } diff --git a/package-lock.json b/package-lock.json index 9bd3697..0e6b7c4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,14 @@ { "name": "pulse-frontend", - "version": "0.13.0-alpha", + "version": "0.14.0-alpha", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pulse-frontend", - "version": "0.13.0-alpha", + "version": "0.14.0-alpha", "dependencies": { - "@ciphera-net/ui": "^0.2.4", + "@ciphera-net/ui": "^0.2.5", "@ducanh2912/next-pwa": "^10.2.9", "@phosphor-icons/react": "^2.1.10", "@simplewebauthn/browser": "^13.2.2", @@ -19,6 +19,7 @@ "class-variance-authority": "^0.7.1", "cobe": "^0.6.5", "country-flag-icons": "^1.6.4", + "d3-sankey": "^0.12.3", "d3-scale": "^4.0.2", "framer-motion": "^12.23.26", "html-to-image": "^1.11.13", @@ -41,6 +42,7 @@ "@tailwindcss/typography": "^0.5.19", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", + "@types/d3-sankey": "^0.12.5", "@types/d3-scale": "^4.0.9", "@types/node": "^20.14.12", "@types/react": "^19.2.14", @@ -1667,9 +1669,9 @@ } }, "node_modules/@ciphera-net/ui": { - "version": "0.2.4", - "resolved": "https://npm.pkg.github.com/download/@ciphera-net/ui/0.2.4/e2e049be27e465b91cb347295c3854c1cd9927d7", - "integrity": "sha512-qqVYM4umgEQf/rQ/F2SdEpEi8D99eyMgO54As8l4O8B0z5kL+9CRtB5MT2ZcrhjsvGLj5tzL18gEzbaqrlXMmA==", + "version": "0.2.5", + "resolved": "https://npm.pkg.github.com/download/@ciphera-net/ui/0.2.5/01371025a6706621b7a2c353cb1b07a239961fc3", + "integrity": "sha512-Ybd3zZLqpdv/dktNylT/jOm9OTMVST35+19QY+DTvDeluF3B4bN2YA7S85V7PpXGmZBmnPQX3U8qP4t2HwyyMw==", "dependencies": { "@phosphor-icons/react": "^2.1.10", "class-variance-authority": "^0.7.1", @@ -5600,9 +5602,9 @@ "license": "MIT" }, "node_modules/@types/d3-color": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-2.0.6.tgz", - "integrity": "sha512-tbaFGDmJWHqnenvk3QGSvD3RVwr631BjKRD7Sc7VLRgrdX5mk5hTyoeBL6rXZaeoXzmZwIl1D2HPogEdt1rHBg==", + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", "license": "MIT" }, "node_modules/@types/d3-ease": { @@ -5611,12 +5613,48 @@ "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", "license": "MIT" }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, "node_modules/@types/d3-path": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", "license": "MIT" }, + "node_modules/@types/d3-sankey": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/@types/d3-sankey/-/d3-sankey-0.12.5.tgz", + "integrity": "sha512-/3RZSew0cLAtzGQ+C89hq/Rp3H20QJuVRSqFy6RKLe7E0B8kd2iOS1oBsodrgds4PcNVpqWhdUEng/SHvBcJ6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-shape": "^1" + } + }, + "node_modules/@types/d3-sankey/node_modules/@types/d3-path": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-1.0.11.tgz", + "integrity": "sha512-4pQMp8ldf7UaB/gR8Fvvy69psNHkTpD/pVw3vmEi8iZAB9EPMBruB1JvHO4BIq9QkUUd2lV1F5YXpMNj7JPBpw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-sankey/node_modules/@types/d3-shape": { + "version": "1.3.12", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-1.3.12.tgz", + "integrity": "sha512-8oMzcd4+poSLGgV0R1Q1rOlx/xdmozS4Xab7np0eamFFUYq71AU9pOCJEFnkXW2aI/oXdVYJzw6pssbSut7Z9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-path": "^1" + } + }, "node_modules/@types/d3-scale": { "version": "4.0.9", "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", @@ -7879,6 +7917,46 @@ "node": ">=12" } }, + "node_modules/d3-sankey": { + "version": "0.12.3", + "resolved": "https://registry.npmjs.org/d3-sankey/-/d3-sankey-0.12.3.tgz", + "integrity": "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-array": "1 - 2", + "d3-shape": "^1.2.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-array": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz", + "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==", + "license": "BSD-3-Clause", + "dependencies": { + "internmap": "^1.0.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-path": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz", + "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-sankey/node_modules/d3-shape": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz", + "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-path": "1" + } + }, + "node_modules/d3-sankey/node_modules/internmap": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz", + "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==", + "license": "ISC" + }, "node_modules/d3-scale": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", @@ -7931,6 +8009,15 @@ "node": ">=12" } }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", @@ -14804,15 +14891,6 @@ "d3-timer": "^3.0.1" } }, - "node_modules/victory-vendor/node_modules/@types/d3-interpolate": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", - "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", - "license": "MIT", - "dependencies": { - "@types/d3-color": "*" - } - }, "node_modules/victory-vendor/node_modules/d3-ease": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", @@ -14822,15 +14900,6 @@ "node": ">=12" } }, - "node_modules/victory-vendor/node_modules/d3-timer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", - "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, "node_modules/vite": { "version": "7.3.1", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", diff --git a/package.json b/package.json index 2251172..e94b947 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pulse-frontend", - "version": "0.14.0-alpha", + "version": "0.15.0-alpha", "private": true, "scripts": { "dev": "next dev", @@ -12,7 +12,7 @@ "test:watch": "vitest" }, "dependencies": { - "@ciphera-net/ui": "^0.2.4", + "@ciphera-net/ui": "^0.2.5", "@ducanh2912/next-pwa": "^10.2.9", "@phosphor-icons/react": "^2.1.10", "@simplewebauthn/browser": "^13.2.2", @@ -23,6 +23,7 @@ "class-variance-authority": "^0.7.1", "cobe": "^0.6.5", "country-flag-icons": "^1.6.4", + "d3-sankey": "^0.12.3", "d3-scale": "^4.0.2", "framer-motion": "^12.23.26", "html-to-image": "^1.11.13", @@ -51,6 +52,7 @@ "@tailwindcss/typography": "^0.5.19", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", + "@types/d3-sankey": "^0.12.5", "@types/d3-scale": "^4.0.9", "@types/node": "^20.14.12", "@types/react": "^19.2.14", diff --git a/public/script.js b/public/script.js index 8c9dd01..4ead55b 100644 --- a/public/script.js +++ b/public/script.js @@ -424,6 +424,251 @@ }, { passive: true }); } + // * Strip HTML tags from a string (used for sanitizing attribute values) + function stripHtml(str) { + if (typeof str !== 'string') return ''; + return str.replace(/<[^>]*>/g, '').trim(); + } + + // * Build a compact element identifier string for frustration tracking + // * Format: tag#id.class1.class2[href="/path"] + function getElementIdentifier(el) { + if (!el || !el.tagName) return ''; + var result = el.tagName.toLowerCase(); + + // * Add #id if present + if (el.id) { + result += '#' + stripHtml(el.id); + } + + // * Add classes (handle SVG elements where className is SVGAnimatedString) + var rawClassName = el.className; + if (rawClassName && typeof rawClassName !== 'string' && rawClassName.baseVal !== undefined) { + rawClassName = rawClassName.baseVal; + } + if (typeof rawClassName === 'string' && rawClassName.trim()) { + var classes = rawClassName.trim().split(/\s+/); + var filtered = []; + for (var ci = 0; ci < classes.length && filtered.length < 5; ci++) { + var cls = classes[ci]; + if (cls.length > 50) continue; + if (/^(ng-|js-|is-|has-|animate)/.test(cls)) continue; + filtered.push(cls); + } + if (filtered.length > 0) { + result += '.' + filtered.join('.'); + } + } + + // * Add key attributes + var attrs = ['href', 'role', 'type', 'name', 'data-action']; + for (var ai = 0; ai < attrs.length; ai++) { + var attrName = attrs[ai]; + var attrVal = el.getAttribute(attrName); + if (attrVal !== null && attrVal !== '') { + var sanitized = stripHtml(attrVal); + if (sanitized.length > 50) sanitized = sanitized.substring(0, 50); + result += '[' + attrName + '="' + sanitized + '"]'; + } + } + + // * Truncate to max 200 chars + if (result.length > 200) { + result = result.substring(0, 200); + } + + return result; + } + + // * Auto-track rage clicks (rapid repeated clicks on the same element) + // * Fires rage_click when same element is clicked 3+ times within 800ms + // * Opt-out: add data-no-rage to the script tag + if (!script.hasAttribute('data-no-rage')) { + var rageClickHistory = {}; // * selector -> { times: [timestamps], lastFired: 0 } + var RAGE_CLICK_THRESHOLD = 3; + var RAGE_CLICK_WINDOW = 800; + var RAGE_CLICK_DEBOUNCE = 5000; + var RAGE_CLEANUP_INTERVAL = 10000; + + // * Cleanup stale rage click entries every 10 seconds + setInterval(function() { + var now = Date.now(); + for (var key in rageClickHistory) { + if (!rageClickHistory.hasOwnProperty(key)) continue; + var entry = rageClickHistory[key]; + // * Remove if last click was more than 10 seconds ago + if (entry.times.length === 0 || now - entry.times[entry.times.length - 1] > RAGE_CLEANUP_INTERVAL) { + delete rageClickHistory[key]; + } + } + }, RAGE_CLEANUP_INTERVAL); + + document.addEventListener('click', function(e) { + var el = e.target; + if (!el || !el.tagName) return; + + var selector = getElementIdentifier(el); + if (!selector) return; + + var now = Date.now(); + var currentPath = window.location.pathname + window.location.search; + + if (!rageClickHistory[selector]) { + rageClickHistory[selector] = { times: [], lastFired: 0 }; + } + + var entry = rageClickHistory[selector]; + + // * Add current click timestamp + entry.times.push(now); + + // * Remove clicks outside the time window + while (entry.times.length > 0 && now - entry.times[0] > RAGE_CLICK_WINDOW) { + entry.times.shift(); + } + + // * Check if rage click threshold is met + if (entry.times.length >= RAGE_CLICK_THRESHOLD) { + // * Debounce: max one rage_click per element per 5 seconds + if (now - entry.lastFired >= RAGE_CLICK_DEBOUNCE) { + var clickCount = entry.times.length; + trackCustomEvent('rage_click', { + selector: selector, + click_count: String(clickCount), + page_path: currentPath, + x: String(Math.round(e.clientX)), + y: String(Math.round(e.clientY)) + }); + entry.lastFired = now; + } + // * Reset tracker after firing or debounce skip + entry.times = []; + } + }, true); // * Capture phase + } + + // * Auto-track dead clicks (clicks on interactive elements that produce no effect) + // * Fires dead_click when an interactive element is clicked but no DOM change, navigation, + // * or network request occurs within 1 second + // * Opt-out: add data-no-dead to the script tag + if (!script.hasAttribute('data-no-dead')) { + var INTERACTIVE_SELECTOR = 'a,button,input,select,textarea,[role="button"],[role="link"],[role="tab"],[role="menuitem"],[onclick],[tabindex]'; + var DEAD_CLICK_DEBOUNCE = 10000; + var DEAD_CLEANUP_INTERVAL = 30000; + var deadClickDebounce = {}; // * selector -> lastFiredTimestamp + + // * Cleanup stale dead click debounce entries every 30 seconds + setInterval(function() { + var now = Date.now(); + for (var key in deadClickDebounce) { + if (!deadClickDebounce.hasOwnProperty(key)) continue; + if (now - deadClickDebounce[key] > DEAD_CLEANUP_INTERVAL) { + delete deadClickDebounce[key]; + } + } + }, DEAD_CLEANUP_INTERVAL); + + // * Polyfill check for Element.matches + var matchesFn = (function() { + var ep = Element.prototype; + return ep.matches || ep.msMatchesSelector || ep.webkitMatchesSelector || null; + })(); + + // * Find the nearest interactive element by walking up max 3 levels + function findInteractiveElement(el) { + if (!matchesFn) return null; + var depth = 0; + var current = el; + while (current && depth <= 3) { + if (current.nodeType === 1 && matchesFn.call(current, INTERACTIVE_SELECTOR)) { + return current; + } + current = current.parentElement; + depth++; + } + return null; + } + + document.addEventListener('click', function(e) { + var target = findInteractiveElement(e.target); + if (!target) return; + + var selector = getElementIdentifier(target); + if (!selector) return; + + var now = Date.now(); + + // * Debounce: max one dead_click per element per 10 seconds + if (deadClickDebounce[selector] && now - deadClickDebounce[selector] < DEAD_CLICK_DEBOUNCE) { + return; + } + + var currentPath = window.location.pathname + window.location.search; + var clickX = String(Math.round(e.clientX)); + var clickY = String(Math.round(e.clientY)); + var effectDetected = false; + var hrefBefore = location.href; + var mutationObs = null; + var perfObs = null; + var cleanupTimer = null; + + function cleanup() { + if (mutationObs) { try { mutationObs.disconnect(); } catch (ex) {} mutationObs = null; } + if (perfObs) { try { perfObs.disconnect(); } catch (ex) {} perfObs = null; } + if (cleanupTimer) { clearTimeout(cleanupTimer); cleanupTimer = null; } + } + + function onEffect() { + effectDetected = true; + cleanup(); + } + + // * Set up MutationObserver to detect DOM changes on the element and its parent + if (typeof MutationObserver !== 'undefined') { + try { + mutationObs = new MutationObserver(function() { + onEffect(); + }); + var mutOpts = { childList: true, attributes: true, characterData: true, subtree: true }; + mutationObs.observe(target, mutOpts); + var parent = target.parentElement; + if (parent && parent.tagName !== 'HTML' && parent.tagName !== 'BODY') { + mutationObs.observe(parent, { childList: true }); + } + } catch (ex) { + mutationObs = null; + } + } + + // * Set up PerformanceObserver to detect network requests + if (typeof PerformanceObserver !== 'undefined') { + try { + perfObs = new PerformanceObserver(function() { + onEffect(); + }); + perfObs.observe({ type: 'resource' }); + } catch (ex) { + perfObs = null; + } + } + + // * After 1 second, check if any effect was detected + cleanupTimer = setTimeout(function() { + cleanup(); + // * Also check if navigation occurred + if (effectDetected || location.href !== hrefBefore) return; + + deadClickDebounce[selector] = Date.now(); + trackCustomEvent('dead_click', { + selector: selector, + page_path: currentPath, + x: clickX, + y: clickY + }); + }, 1000); + }, true); // * Capture phase + } + // * Auto-track outbound link clicks and file downloads (on by default) // * Opt-out: add data-no-outbound or data-no-downloads to the script tag var trackOutbound = !script.hasAttribute('data-no-outbound');