From 34c705549b16d5403942c2896d9e78eff38eac13 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Sat, 14 Mar 2026 15:36:37 +0100 Subject: [PATCH 01/86] feat: add Google Search Console integration UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Search Console page with overview cards, top queries/pages tables, and query↔page drill-down. Integrations tab in Settings for connect/disconnect flow. New Search tab in site navigation. --- CHANGELOG.md | 5 + app/sites/[id]/search/page.tsx | 652 +++++++++++++++++++++++++++++++ app/sites/[id]/settings/page.tsx | 209 +++++++++- components/dashboard/SiteNav.tsx | 1 + lib/api/gsc.ts | 79 ++++ lib/swr/dashboard.ts | 46 +++ 6 files changed, 987 insertions(+), 5 deletions(-) create mode 100644 app/sites/[id]/search/page.tsx create mode 100644 lib/api/gsc.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index aff8479..74b6bbb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [Unreleased] +### Added + +- **Google Search Console integration.** Connect your Google Search Console account in Settings > Integrations to see which search queries bring visitors to your site. A new "Search" tab on your dashboard shows total clicks, impressions, average CTR, and average ranking position — with percentage changes compared to the previous period. Browse your top search queries and top pages in sortable, paginated tables. Click any query to see which pages rank for it, or click any page to see which queries drive traffic to it. Pulse only requests read-only access to your Search Console data, encrypts your Google credentials, and lets you disconnect and fully remove all search data at any time. +- **Integrations tab in Settings.** A new "Integrations" section in your site settings is where you connect and manage external services. Google Search Console is the first integration available — more will follow. + ### Improved - **Visit duration now works for single-page sessions.** Previously, if a visitor viewed only one page and left, the visit duration showed as "0s" because there was no second pageview to measure against. Pulse now tracks how long you actually spent on the page and reports real durations — even for single-page visits. This makes the Visit Duration metric, Journeys, and Top Paths much more accurate. diff --git a/app/sites/[id]/search/page.tsx b/app/sites/[id]/search/page.tsx new file mode 100644 index 0000000..01c55f5 --- /dev/null +++ b/app/sites/[id]/search/page.tsx @@ -0,0 +1,652 @@ +'use client' + +import { useEffect, useState } from 'react' +import { useParams } from 'next/navigation' +import Link from 'next/link' +import { getDateRange, formatDate, Select, DatePicker } from '@ciphera-net/ui' +import { CaretDown, CaretUp, MagnifyingGlass, ArrowSquareOut } from '@phosphor-icons/react' +import { useDashboard, useGSCStatus, useGSCOverview, useGSCTopQueries, useGSCTopPages } from '@/lib/swr/dashboard' +import { getGSCQueryPages, getGSCPageQueries } from '@/lib/api/gsc' +import type { GSCDataRow } from '@/lib/api/gsc' +import { SkeletonLine, StatCardSkeleton, useMinimumLoading, useSkeletonFade } from '@/components/skeletons' + +// ─── Helpers ──────────────────────────────────────────────────── + +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) } +} + +const formatPosition = (pos: number) => pos.toFixed(1) +const formatCTR = (ctr: number) => (ctr * 100).toFixed(1) + '%' + +function formatChange(current: number, previous: number) { + if (previous === 0) return null + const change = ((current - previous) / previous) * 100 + return { value: change, label: (change >= 0 ? '+' : '') + change.toFixed(1) + '%' } +} + +function formatNumber(n: number) { + if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M' + if (n >= 1_000) return (n / 1_000).toFixed(1) + 'K' + return n.toLocaleString() +} + +// ─── Page ─────────────────────────────────────────────────────── + +const PAGE_SIZE = 50 + +export default function SearchConsolePage() { + const params = useParams() + const siteId = params.id as string + + // Date range + const [period, setPeriod] = useState('28') + const [dateRange, setDateRange] = useState(() => getDateRange(28)) + const [isDatePickerOpen, setIsDatePickerOpen] = useState(false) + + // View toggle + const [activeView, setActiveView] = useState<'queries' | 'pages'>('queries') + + // Pagination + const [queryPage, setQueryPage] = useState(0) + const [pagePage, setPagePage] = useState(0) + + // Drill-down expansion + const [expandedQuery, setExpandedQuery] = useState(null) + const [expandedPage, setExpandedPage] = useState(null) + const [expandedData, setExpandedData] = useState([]) + const [expandedLoading, setExpandedLoading] = useState(false) + + // Data fetching + const { data: gscStatus } = useGSCStatus(siteId) + const { data: dashboard } = useDashboard(siteId, dateRange.start, dateRange.end) + const { data: overview } = useGSCOverview(siteId, dateRange.start, dateRange.end) + const { data: topQueries, isLoading: queriesLoading } = useGSCTopQueries(siteId, dateRange.start, dateRange.end, PAGE_SIZE, queryPage * PAGE_SIZE) + const { data: topPages, isLoading: pagesLoading } = useGSCTopPages(siteId, dateRange.start, dateRange.end, PAGE_SIZE, pagePage * PAGE_SIZE) + + const showSkeleton = useMinimumLoading(!gscStatus) + const fadeClass = useSkeletonFade(showSkeleton) + + // Document title + useEffect(() => { + const domain = dashboard?.site?.domain + document.title = domain ? `Search Console \u00b7 ${domain} | Pulse` : 'Search Console | Pulse' + }, [dashboard?.site?.domain]) + + // Reset pagination when date range changes + useEffect(() => { + setQueryPage(0) + setPagePage(0) + setExpandedQuery(null) + setExpandedPage(null) + setExpandedData([]) + }, [dateRange.start, dateRange.end]) + + // ─── Expand handlers ─────────────────────────────────────── + + async function handleExpandQuery(query: string) { + if (expandedQuery === query) { + setExpandedQuery(null) + setExpandedData([]) + return + } + setExpandedQuery(query) + setExpandedPage(null) + setExpandedLoading(true) + try { + const res = await getGSCQueryPages(siteId, query, dateRange.start, dateRange.end) + setExpandedData(res.pages) + } catch { + setExpandedData([]) + } finally { + setExpandedLoading(false) + } + } + + async function handleExpandPage(page: string) { + if (expandedPage === page) { + setExpandedPage(null) + setExpandedData([]) + return + } + setExpandedPage(page) + setExpandedQuery(null) + setExpandedLoading(true) + try { + const res = await getGSCPageQueries(siteId, page, dateRange.start, dateRange.end) + setExpandedData(res.queries) + } catch { + setExpandedData([]) + } finally { + setExpandedLoading(false) + } + } + + // ─── Loading skeleton ───────────────────────────────────── + + if (showSkeleton) { + return ( +
+
+
+ + +
+ +
+
+ + + + +
+
+ + {Array.from({ length: 10 }).map((_, i) => ( +
+ +
+ + + + +
+
+ ))} +
+
+ ) + } + + // ─── Not connected state ────────────────────────────────── + + if (gscStatus && !gscStatus.connected) { + return ( +
+
+
+ +
+

+ Connect Google Search Console +

+

+ See how your site performs in Google Search. View top queries, pages, click-through rates, and average position data. +

+ + Connect in Settings + + +
+
+ ) + } + + // ─── Connected — main view ──────────────────────────────── + + const clicksChange = overview ? formatChange(overview.total_clicks, overview.prev_clicks) : null + const impressionsChange = overview ? formatChange(overview.total_impressions, overview.prev_impressions) : null + const ctrChange = overview ? formatChange(overview.avg_ctr, overview.prev_avg_ctr) : null + // For position, lower is better — invert the direction + const positionChange = overview ? formatChange(overview.avg_position, overview.prev_avg_position) : null + + const queries = topQueries?.queries ?? [] + const queriesTotal = topQueries?.total ?? 0 + const pages = topPages?.pages ?? [] + const pagesTotal = topPages?.total ?? 0 + + return ( +
+ {/* Header */} +
+
+

+ Search Console +

+

+ Google Search performance, queries, and page rankings +

+
+ { + 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 === '28') { + setDateRange(getDateRange(28)) + setPeriod('28') + } else if (value === '30') { + setDateRange(getDateRange(30)) + setPeriod('30') + } + }} + options={[ + { value: 'today', label: 'Today' }, + { value: '7', label: 'Last 7 days' }, + { value: '28', label: 'Last 28 days' }, + { value: '30', label: 'Last 30 days' }, + ]} + /> +
+ + {/* Overview cards */} +
+ + + + + +
+ + {/* Bandwidth chart */} +
+

Bandwidth

+ {daily.length > 0 ? ( + + + + + + + + + + + + + + + formatBytes(v)} + tick={{ fontSize: 12, fill: 'currentColor' }} + className="text-neutral-400 dark:text-neutral-500" + axisLine={false} + tickLine={false} + width={60} + /> + { + if (!active || !payload?.length) return null + return ( +
+

{formatDateShort(label)}

+

+ Total: {formatBytes(payload[0]?.value as number)} +

+ {payload[1] && ( +

+ Cached: {formatBytes(payload[1]?.value as number)} +

+ )} +
+ ) + }} + /> + + +
+
+ ) : ( +
+ No bandwidth data for this period. +
+ )} +
+ + {/* Requests + Errors charts side by side */} +
+ {/* Requests chart */} +
+

Requests

+ {daily.length > 0 ? ( + + + + + formatNumber(v)} + tick={{ fontSize: 11, fill: 'currentColor' }} + className="text-neutral-400 dark:text-neutral-500" + axisLine={false} + tickLine={false} + width={50} + /> + { + if (!active || !payload?.length) return null + return ( +
+

{formatDateShort(label)}

+

+ {formatNumber(payload[0]?.value as number)} requests +

+
+ ) + }} + /> + +
+
+ ) : ( +
+ No request data for this period. +
+ )} +
+ + {/* Errors chart */} +
+

Errors

+ {daily.length > 0 ? ( + + ({ + date: d.date, + '3xx': d.error_3xx, + '4xx': d.error_4xx, + '5xx': d.error_5xx, + }))} + margin={{ top: 4, right: 4, bottom: 0, left: 0 }} + > + + + formatNumber(v)} + tick={{ fontSize: 11, fill: 'currentColor' }} + className="text-neutral-400 dark:text-neutral-500" + axisLine={false} + tickLine={false} + width={50} + /> + { + if (!active || !payload?.length) return null + return ( +
+

{formatDateShort(label)}

+ {payload.map((entry) => ( +

+ {entry.name}: {formatNumber(entry.value as number)} +

+ ))} +
+ ) + }} + /> + + + +
+
+ ) : ( +
+ No error data for this period. +
+ )} +
+
+ + {/* Bandwidth by Country */} +
+

Bandwidth by Country

+ {countries.length > 0 ? ( +
+ {countries.map((row) => ( +
+ + {row.country_code} + +
+
+
+ + {formatBytes(row.bandwidth)} + + + {formatNumber(row.requests)} req + +
+ ))} +
+ ) : ( +

+ No geographic data for this period. +

+ )} +
+
+ ) +} + +// ─── Sub-components ───────────────────────────────────────────── + +function OverviewCard({ + label, + value, + change, + invertColor = false, +}: { + label: string + value: string + change: { value: number; positive: boolean } | null + invertColor?: boolean +}) { + // For Origin Response and Errors, a decrease is good (green), an increase is bad (red) + const isGood = change ? (invertColor ? !change.positive : change.positive) : false + const isBad = change ? (invertColor ? change.positive : !change.positive) : false + const changeLabel = change ? (change.positive ? '+' : '') + change.value.toFixed(1) + '%' : null + + return ( +
+

{label}

+

{value}

+ {changeLabel && ( +

+ {changeLabel} vs previous period +

+ )} +
+ ) +} diff --git a/app/sites/[id]/settings/page.tsx b/app/sites/[id]/settings/page.tsx index 7c842ab..d136265 100644 --- a/app/sites/[id]/settings/page.tsx +++ b/app/sites/[id]/settings/page.tsx @@ -6,6 +6,8 @@ import { updateSite, resetSiteData, deleteSite, type Site, type GeoDataLevel } f import { createGoal, updateGoal, deleteGoal, type Goal } from '@/lib/api/goals' import { createReportSchedule, updateReportSchedule, deleteReportSchedule, testReportSchedule, type ReportSchedule, type CreateReportScheduleRequest, type EmailConfig, type WebhookConfig } from '@/lib/api/report-schedules' import { getGSCAuthURL, disconnectGSC } from '@/lib/api/gsc' +import { getBunnyPullZones, connectBunny, disconnectBunny } from '@/lib/api/bunny' +import type { BunnyPullZone } from '@/lib/api/bunny' import { toast } from '@ciphera-net/ui' import { getAuthErrorMessage } from '@ciphera-net/ui' import { formatDateTime } from '@/lib/utils/formatDate' @@ -17,7 +19,7 @@ import { Select, Modal, Button } from '@ciphera-net/ui' import { APP_URL } from '@/lib/api/client' import { generatePrivacySnippet } from '@/lib/utils/privacySnippet' import { useUnsavedChanges } from '@/lib/hooks/useUnsavedChanges' -import { useSite, useGoals, useReportSchedules, useSubscription, useGSCStatus } from '@/lib/swr/dashboard' +import { useSite, useGoals, useReportSchedules, useSubscription, useGSCStatus, useBunnyStatus } from '@/lib/swr/dashboard' import { getRetentionOptionsForPlan, formatRetentionMonths } from '@/lib/plans' import { motion, AnimatePresence } from 'framer-motion' import { useAuth } from '@/lib/auth/context' @@ -98,6 +100,13 @@ export default function SiteSettingsPage() { const { data: gscStatus, mutate: mutateGSCStatus } = useGSCStatus(siteId) const [gscConnecting, setGscConnecting] = useState(false) const [gscDisconnecting, setGscDisconnecting] = useState(false) + const { data: bunnyStatus, mutate: mutateBunnyStatus } = useBunnyStatus(siteId) + const [bunnyApiKey, setBunnyApiKey] = useState('') + const [bunnyPullZones, setBunnyPullZones] = useState([]) + const [bunnySelectedZone, setBunnySelectedZone] = useState(null) + const [bunnyLoadingZones, setBunnyLoadingZones] = useState(false) + const [bunnyConnecting, setBunnyConnecting] = useState(false) + const [bunnyDisconnecting, setBunnyDisconnecting] = useState(false) const [reportModalOpen, setReportModalOpen] = useState(false) const [editingSchedule, setEditingSchedule] = useState(null) const [reportSaving, setReportSaving] = useState(false) @@ -1600,6 +1609,219 @@ export default function SiteSettingsPage() {
)} + + {/* BunnyCDN */} +
+ {!bunnyStatus?.connected ? ( +
+
+
+ + + + + + +
+
+

BunnyCDN

+

+ Monitor CDN performance with bandwidth usage, cache hit rates, response times, and geographic distribution. +

+
+
+
+ + + +

+ Your API key is encrypted at rest and only used to fetch read-only statistics. You can disconnect at any time. +

+
+ {canEdit && ( +
+
+ { + setBunnyApiKey(e.target.value) + setBunnyPullZones([]) + setBunnySelectedZone(null) + }} + placeholder="BunnyCDN API key" + className="flex-1 px-4 py-2.5 border border-neutral-200 dark:border-neutral-800 rounded-xl bg-white dark:bg-neutral-900 text-neutral-900 dark:text-white text-sm placeholder:text-neutral-400" + /> + +
+ + {bunnyPullZones.length > 0 && ( +
+
+ + +
+ +
+ )} +
+ )} +
+ ) : ( +
+
+
+
+ + + + + + +
+
+

BunnyCDN

+
+ + + {bunnyStatus.status === 'active' ? 'Connected' : bunnyStatus.status === 'syncing' ? 'Syncing...' : 'Error'} + +
+
+
+
+ +
+ {bunnyStatus.pull_zone_name && ( +
+

Pull Zone

+

{bunnyStatus.pull_zone_name}

+
+ )} + {bunnyStatus.last_synced_at && ( +
+

Last Synced

+

+ {new Date(bunnyStatus.last_synced_at).toLocaleString('en-GB')} +

+
+ )} + {bunnyStatus.created_at && ( +
+

Connected Since

+

+ {new Date(bunnyStatus.created_at).toLocaleString('en-GB')} +

+
+ )} +
+ + {bunnyStatus.status === 'error' && bunnyStatus.error_message && ( +
+

{bunnyStatus.error_message}

+
+ )} + + {canEdit && ( +
+ +
+ )} +
+ )} +
)} diff --git a/components/dashboard/SiteNav.tsx b/components/dashboard/SiteNav.tsx index 0c81118..fd7c185 100644 --- a/components/dashboard/SiteNav.tsx +++ b/components/dashboard/SiteNav.tsx @@ -22,6 +22,7 @@ export default function SiteNav({ siteId }: SiteNavProps) { { label: 'Funnels', href: `/sites/${siteId}/funnels` }, { label: 'Behavior', href: `/sites/${siteId}/behavior` }, { label: 'Search', href: `/sites/${siteId}/search` }, + { label: 'CDN', href: `/sites/${siteId}/cdn` }, { label: 'Uptime', href: `/sites/${siteId}/uptime` }, ...(canEdit ? [{ label: 'Settings', href: `/sites/${siteId}/settings` }] : []), ] diff --git a/lib/api/bunny.ts b/lib/api/bunny.ts new file mode 100644 index 0000000..da83227 --- /dev/null +++ b/lib/api/bunny.ts @@ -0,0 +1,84 @@ +import apiRequest from './client' + +// ─── Types ────────────────────────────────────────────────────────── + +export interface BunnyStatus { + connected: boolean + pull_zone_id?: number + pull_zone_name?: string + status?: 'active' | 'syncing' | 'error' + error_message?: string | null + last_synced_at?: string | null + created_at?: string +} + +export interface BunnyOverview { + total_bandwidth: number + total_requests: number + cache_hit_rate: number + avg_origin_response: number + total_errors: number + prev_total_bandwidth: number + prev_total_requests: number + prev_cache_hit_rate: number + prev_avg_origin_response: number + prev_total_errors: number +} + +export interface BunnyDailyRow { + date: string + bandwidth_used: number + bandwidth_cached: number + requests_served: number + requests_cached: number + error_3xx: number + error_4xx: number + error_5xx: number + origin_response_time_avg: number +} + +export interface BunnyPullZone { + id: number + name: string +} + +export interface BunnyGeoRow { + country_code: string + bandwidth: number + requests: number +} + +// ─── API Functions ────────────────────────────────────────────────── + +export async function getBunnyPullZones(siteId: string, apiKey: string): Promise<{ pull_zones: BunnyPullZone[], message?: string }> { + return apiRequest<{ pull_zones: BunnyPullZone[], message?: string }>( + `/sites/${siteId}/integrations/bunny/pull-zones?api_key=${encodeURIComponent(apiKey)}` + ) +} + +export async function connectBunny(siteId: string, apiKey: string, pullZoneId: number, pullZoneName: string): Promise { + await apiRequest(`/sites/${siteId}/integrations/bunny`, { + method: 'POST', + body: JSON.stringify({ api_key: apiKey, pull_zone_id: pullZoneId, pull_zone_name: pullZoneName }), + }) +} + +export async function getBunnyStatus(siteId: string): Promise { + return apiRequest(`/sites/${siteId}/integrations/bunny/status`) +} + +export async function disconnectBunny(siteId: string): Promise { + await apiRequest(`/sites/${siteId}/integrations/bunny`, { method: 'DELETE' }) +} + +export async function getBunnyOverview(siteId: string, startDate: string, endDate: string): Promise { + return apiRequest(`/sites/${siteId}/bunny/overview?start_date=${startDate}&end_date=${endDate}`) +} + +export async function getBunnyDailyStats(siteId: string, startDate: string, endDate: string): Promise<{ daily_stats: BunnyDailyRow[] }> { + return apiRequest<{ daily_stats: BunnyDailyRow[] }>(`/sites/${siteId}/bunny/daily-stats?start_date=${startDate}&end_date=${endDate}`) +} + +export async function getBunnyTopCountries(siteId: string, startDate: string, endDate: string, limit = 20): Promise<{ countries: BunnyGeoRow[] }> { + return apiRequest<{ countries: BunnyGeoRow[] }>(`/sites/${siteId}/bunny/top-countries?start_date=${startDate}&end_date=${endDate}&limit=${limit}`) +} diff --git a/lib/swr/dashboard.ts b/lib/swr/dashboard.ts index 1241f2d..2a6b7ac 100644 --- a/lib/swr/dashboard.ts +++ b/lib/swr/dashboard.ts @@ -35,6 +35,8 @@ import { listGoals, type Goal } from '@/lib/api/goals' import { listReportSchedules, type ReportSchedule } from '@/lib/api/report-schedules' import { getGSCStatus, getGSCOverview, getGSCTopQueries, getGSCTopPages, getGSCDailyTotals, getGSCNewQueries } from '@/lib/api/gsc' import type { GSCStatus, GSCOverview, GSCQueryResponse, GSCPageResponse, GSCDailyTotal, GSCNewQueries } from '@/lib/api/gsc' +import { getBunnyStatus, getBunnyOverview, getBunnyDailyStats, getBunnyTopCountries } from '@/lib/api/bunny' +import type { BunnyStatus, BunnyOverview, BunnyDailyRow, BunnyGeoRow } from '@/lib/api/bunny' import { getSubscription, type SubscriptionDetails } from '@/lib/api/billing' import type { Stats, @@ -86,6 +88,10 @@ const fetchers = { gscTopPages: (siteId: string, start: string, end: string, limit: number, offset: number) => getGSCTopPages(siteId, start, end, limit, offset), gscDailyTotals: (siteId: string, start: string, end: string) => getGSCDailyTotals(siteId, start, end), gscNewQueries: (siteId: string, start: string, end: string) => getGSCNewQueries(siteId, start, end), + bunnyStatus: (siteId: string) => getBunnyStatus(siteId), + bunnyOverview: (siteId: string, start: string, end: string) => getBunnyOverview(siteId, start, end), + bunnyDailyStats: (siteId: string, start: string, end: string) => getBunnyDailyStats(siteId, start, end), + bunnyTopCountries: (siteId: string, start: string, end: string) => getBunnyTopCountries(siteId, start, end), subscription: () => getSubscription(), } @@ -469,6 +475,42 @@ export function useGSCNewQueries(siteId: string, start: string, end: string) { ) } +// * Hook for BunnyCDN connection status +export function useBunnyStatus(siteId: string) { + return useSWR( + siteId ? ['bunnyStatus', siteId] : null, + () => fetchers.bunnyStatus(siteId), + { ...dashboardSWRConfig, refreshInterval: 60 * 1000, dedupingInterval: 30 * 1000 } + ) +} + +// * Hook for BunnyCDN overview metrics (bandwidth, requests, cache hit rate) +export function useBunnyOverview(siteId: string, startDate: string, endDate: string) { + return useSWR( + siteId && startDate && endDate ? ['bunnyOverview', siteId, startDate, endDate] : null, + () => fetchers.bunnyOverview(siteId, startDate, endDate), + dashboardSWRConfig + ) +} + +// * Hook for BunnyCDN daily stats (bandwidth & requests per day) +export function useBunnyDailyStats(siteId: string, startDate: string, endDate: string) { + return useSWR<{ daily_stats: BunnyDailyRow[] }>( + siteId && startDate && endDate ? ['bunnyDailyStats', siteId, startDate, endDate] : null, + () => fetchers.bunnyDailyStats(siteId, startDate, endDate), + dashboardSWRConfig + ) +} + +// * Hook for BunnyCDN top countries by bandwidth +export function useBunnyTopCountries(siteId: string, startDate: string, endDate: string) { + return useSWR<{ countries: BunnyGeoRow[] }>( + siteId && startDate && endDate ? ['bunnyTopCountries', siteId, startDate, endDate] : null, + () => fetchers.bunnyTopCountries(siteId, startDate, endDate), + dashboardSWRConfig + ) +} + // * Hook for subscription details (changes rarely) export function useSubscription() { return useSWR( From 2512be0d57b9bac6c451b72f95b7142590a41780 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Sat, 14 Mar 2026 21:08:42 +0100 Subject: [PATCH 09/86] fix: bunnycdn logo and api key security --- CHANGELOG.md | 3 +++ app/sites/[id]/settings/page.tsx | 8 ++++---- lib/api/bunny.ts | 3 ++- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7232e22..2548d26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Fixed +- **BunnyCDN logo now displays correctly.** The BunnyCDN integration card in Settings previously showed a generic globe icon. It now shows the proper BunnyCDN bunny logo. +- **Your BunnyCDN API key is no longer visible in network URLs.** When loading pull zones, the API key was previously sent as a URL parameter. It's now sent securely in the request body, just like when connecting. + - **No more "Site not found" when switching back to Pulse.** If you left Pulse in the background and came back, you could see a wall of errors and a blank page. This happened because the browser fired several requests at once when the tab regained focus, and if any failed, they all retried repeatedly — flooding the connection and making it worse. Failed requests now back off gracefully instead of retrying in a loop. ### Improved diff --git a/app/sites/[id]/settings/page.tsx b/app/sites/[id]/settings/page.tsx index d136265..f45423c 100644 --- a/app/sites/[id]/settings/page.tsx +++ b/app/sites/[id]/settings/page.tsx @@ -1617,10 +1617,10 @@ export default function SiteSettingsPage() {
- - - - + + + +
diff --git a/lib/api/bunny.ts b/lib/api/bunny.ts index da83227..5bf5057 100644 --- a/lib/api/bunny.ts +++ b/lib/api/bunny.ts @@ -52,7 +52,8 @@ export interface BunnyGeoRow { export async function getBunnyPullZones(siteId: string, apiKey: string): Promise<{ pull_zones: BunnyPullZone[], message?: string }> { return apiRequest<{ pull_zones: BunnyPullZone[], message?: string }>( - `/sites/${siteId}/integrations/bunny/pull-zones?api_key=${encodeURIComponent(apiKey)}` + `/sites/${siteId}/integrations/bunny/pull-zones`, + { method: 'POST', body: JSON.stringify({ api_key: apiKey }) } ) } From d9c01b9b06e63296362b6f737021448ec0cd6a02 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Sat, 14 Mar 2026 21:12:07 +0100 Subject: [PATCH 10/86] feat: add traffic distribution dotted map to CDN tab --- app/sites/[id]/cdn/page.tsx | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/app/sites/[id]/cdn/page.tsx b/app/sites/[id]/cdn/page.tsx index 86caf90..dbfa207 100644 --- a/app/sites/[id]/cdn/page.tsx +++ b/app/sites/[id]/cdn/page.tsx @@ -1,8 +1,11 @@ 'use client' import { useEffect, useState } from 'react' +import dynamic from 'next/dynamic' import { useParams } from 'next/navigation' import Link from 'next/link' + +const DottedMap = dynamic(() => import('@/components/dashboard/DottedMap'), { ssr: false }) import { getDateRange, formatDate, Select } from '@ciphera-net/ui' import { ArrowSquareOut, CloudArrowUp } from '@phosphor-icons/react' import { @@ -406,6 +409,25 @@ export default function CDNPage() {
+ {/* Traffic Distribution Map */} +
+

Traffic Distribution

+ {countries.length > 0 ? ( +
+ ({ + country: row.country_code, + pageviews: row.bandwidth, + }))} + /> +
+ ) : ( +
+ No geographic data for this period. +
+ )} +
+ {/* Bandwidth by Country */}

Bandwidth by Country

From 77b280341bb9e5688b6173198b4db35401e210dc Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Sat, 14 Mar 2026 21:21:53 +0100 Subject: [PATCH 11/86] fix: use official bunnycdn logo, redesign traffic distribution with map and country grid --- app/sites/[id]/cdn/page.tsx | 91 +++++++++++++++++--------------- app/sites/[id]/settings/page.tsx | 48 +++++++++++++---- 2 files changed, 86 insertions(+), 53 deletions(-) diff --git a/app/sites/[id]/cdn/page.tsx b/app/sites/[id]/cdn/page.tsx index dbfa207..d44f9ab 100644 --- a/app/sites/[id]/cdn/page.tsx +++ b/app/sites/[id]/cdn/page.tsx @@ -24,6 +24,15 @@ import { SkeletonLine, StatCardSkeleton, useMinimumLoading, useSkeletonFade } fr // ─── Helpers ──────────────────────────────────────────────────── +function getCountryName(code: string): string { + try { + const regionNames = new Intl.DisplayNames(['en'], { type: 'region' }) + return regionNames.of(code) || code + } catch { + return code + } +} + function formatBytes(bytes: number): string { if (bytes === 0) return '0 B' const units = ['B', 'KB', 'MB', 'GB', 'TB'] @@ -152,7 +161,7 @@ export default function CDNPage() { const daily = dailyStats?.daily_stats ?? [] const countries = topCountries?.countries ?? [] - const maxCountryBandwidth = countries.length > 0 ? countries[0].bandwidth : 1 + const totalBandwidth = countries.reduce((sum, row) => sum + row.bandwidth, 0) return (
@@ -409,56 +418,52 @@ export default function CDNPage() {
- {/* Traffic Distribution Map */} -
+ {/* Traffic Distribution */} +

Traffic Distribution

{countries.length > 0 ? ( -
- ({ - country: row.country_code, - pageviews: row.bandwidth, - }))} - /> -
+ <> +
+ ({ + country: row.country_code, + pageviews: row.bandwidth, + }))} + /> +
+
+ {countries.map((row) => { + const pct = totalBandwidth > 0 ? (row.bandwidth / totalBandwidth) * 100 : 0 + return ( +
+
+ + {getCountryName(row.country_code)} + + + {formatBytes(row.bandwidth)} + +
+
+
+
+
+ {pct.toFixed(1)}% of total traffic +
+
+ ) + })} +
+ ) : (
No geographic data for this period.
)}
- - {/* Bandwidth by Country */} -
-

Bandwidth by Country

- {countries.length > 0 ? ( -
- {countries.map((row) => ( -
- - {row.country_code} - -
-
-
- - {formatBytes(row.bandwidth)} - - - {formatNumber(row.requests)} req - -
- ))} -
- ) : ( -

- No geographic data for this period. -

- )} -
) } diff --git a/app/sites/[id]/settings/page.tsx b/app/sites/[id]/settings/page.tsx index f45423c..7c3fb16 100644 --- a/app/sites/[id]/settings/page.tsx +++ b/app/sites/[id]/settings/page.tsx @@ -1616,11 +1616,25 @@ export default function SiteSettingsPage() {
- - - - - + + + + + + + + + + + + + + + + + + +
@@ -1733,11 +1747,25 @@ export default function SiteSettingsPage() {
- - - - - + + + + + + + + + + + + + + + + + + +
From 1e61926bc680c3ab480097d71967045fbb279e9d Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Sat, 14 Mar 2026 21:26:51 +0100 Subject: [PATCH 12/86] fix: parse bunnycdn datacenter codes to ISO country codes for map dots and flags --- app/sites/[id]/cdn/page.tsx | 86 ++++++++++++++++++++++++++++--------- 1 file changed, 66 insertions(+), 20 deletions(-) diff --git a/app/sites/[id]/cdn/page.tsx b/app/sites/[id]/cdn/page.tsx index d44f9ab..3ace75a 100644 --- a/app/sites/[id]/cdn/page.tsx +++ b/app/sites/[id]/cdn/page.tsx @@ -24,15 +24,63 @@ import { SkeletonLine, StatCardSkeleton, useMinimumLoading, useSkeletonFade } fr // ─── Helpers ──────────────────────────────────────────────────── -function getCountryName(code: string): string { +// US state codes → map to "US" for the dotted map +const US_STATES = new Set([ + 'AL','AK','AZ','AR','CO','CT','DC','DE','FL','GA','HI','ID','IL','IN','IA', + 'KS','KY','LA','ME','MD','MA','MI','MN','MS','MO','MT','NE','NV','NH','NJ', + 'NM','NY','NC','ND','OH','OK','OR','PA','RI','SC','SD','TN','TX','UT','VT', + 'VA','WA','WV','WI','WY', +]) +// Canadian province codes → map to "CA" +const CA_PROVINCES = new Set(['AB','BC','MB','NB','NL','NS','NT','NU','ON','PE','QC','SK','YT']) + +/** + * Extract ISO country code from BunnyCDN datacenter string. + * e.g. "EU: Zurich, CH" → "CH", "NA: Chicago, IL" → "US", "NA: Toronto, CA" → "CA" + */ +function extractCountryCode(datacenter: string): string { + const parts = datacenter.split(', ') + const code = parts[parts.length - 1]?.trim().toUpperCase() + if (!code || code.length !== 2) return '' + if (US_STATES.has(code)) return 'US' + if (CA_PROVINCES.has(code)) return 'CA' + return code +} + +/** + * Extract the city name from a BunnyCDN datacenter string. + * e.g. "EU: Zurich, CH" → "Zurich" + */ +function extractCity(datacenter: string): string { + const afterColon = datacenter.split(': ')[1] || datacenter + return afterColon.split(',')[0]?.trim() || datacenter +} + +/** Convert ISO country code to flag emoji */ +function countryFlag(code: string): string { try { - const regionNames = new Intl.DisplayNames(['en'], { type: 'region' }) - return regionNames.of(code) || code - } catch { return code + .toUpperCase() + .split('') + .map(c => String.fromCodePoint(0x1F1E6 + c.charCodeAt(0) - 65)) + .join('') + } catch { + return '' } } +/** Aggregate bandwidth by ISO country code for the map */ +function aggregateByCountry(data: Array<{ country_code: string; bandwidth: number }>): Array<{ country: string; pageviews: number }> { + const byCountry = new Map() + for (const row of data) { + const cc = extractCountryCode(row.country_code) + if (cc) { + byCountry.set(cc, (byCountry.get(cc) || 0) + row.bandwidth) + } + } + return Array.from(byCountry, ([country, pageviews]) => ({ country, pageviews })) +} + function formatBytes(bytes: number): string { if (bytes === 0) return '0 B' const units = ['B', 'KB', 'MB', 'GB', 'TB'] @@ -424,34 +472,32 @@ export default function CDNPage() { {countries.length > 0 ? ( <>
- ({ - country: row.country_code, - pageviews: row.bandwidth, - }))} - /> +
-
+
{countries.map((row) => { const pct = totalBandwidth > 0 ? (row.bandwidth / totalBandwidth) * 100 : 0 + const cc = extractCountryCode(row.country_code) + const city = extractCity(row.country_code) return ( -
-
- - {getCountryName(row.country_code)} - - +
+
+ {cc && {countryFlag(cc)}} +
+ {city} +
+ {formatBytes(row.bandwidth)}
-
- {pct.toFixed(1)}% of total traffic +
+ {pct.toFixed(1)}% of total traffic
) From f278aada7ae2d57fdb5d32bf83c88f9be8cf17cb Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Sat, 14 Mar 2026 21:35:26 +0100 Subject: [PATCH 13/86] fix: use flag icons, show per-datacenter dots on map, format tooltip as bytes --- app/sites/[id]/cdn/page.tsx | 42 ++++++++++++++---------------- components/dashboard/DottedMap.tsx | 6 +++-- 2 files changed, 23 insertions(+), 25 deletions(-) diff --git a/app/sites/[id]/cdn/page.tsx b/app/sites/[id]/cdn/page.tsx index 3ace75a..267b6c7 100644 --- a/app/sites/[id]/cdn/page.tsx +++ b/app/sites/[id]/cdn/page.tsx @@ -4,6 +4,7 @@ import { useEffect, useState } from 'react' import dynamic from 'next/dynamic' import { useParams } from 'next/navigation' import Link from 'next/link' +import * as Flags from 'country-flag-icons/react/3x2' const DottedMap = dynamic(() => import('@/components/dashboard/DottedMap'), { ssr: false }) import { getDateRange, formatDate, Select } from '@ciphera-net/ui' @@ -56,29 +57,24 @@ function extractCity(datacenter: string): string { return afterColon.split(',')[0]?.trim() || datacenter } -/** Convert ISO country code to flag emoji */ -function countryFlag(code: string): string { - try { - return code - .toUpperCase() - .split('') - .map(c => String.fromCodePoint(0x1F1E6 + c.charCodeAt(0) - 65)) - .join('') - } catch { - return '' - } +/** Get flag icon component for a country code */ +function getFlagIcon(code: string) { + if (!code) return null + const FlagComponent = (Flags as Record>)[code] + return FlagComponent ? : null } -/** Aggregate bandwidth by ISO country code for the map */ -function aggregateByCountry(data: Array<{ country_code: string; bandwidth: number }>): Array<{ country: string; pageviews: number }> { - const byCountry = new Map() - for (const row of data) { - const cc = extractCountryCode(row.country_code) - if (cc) { - byCountry.set(cc, (byCountry.get(cc) || 0) + row.bandwidth) - } - } - return Array.from(byCountry, ([country, pageviews]) => ({ country, pageviews })) +/** + * Map each datacenter entry to its country's centroid for the dotted map. + * Each datacenter gets its own dot (sized by bandwidth) at the country's position. + */ +function mapToCountryCentroids(data: Array<{ country_code: string; bandwidth: number }>): Array<{ country: string; pageviews: number }> { + return data + .map((row) => ({ + country: extractCountryCode(row.country_code), + pageviews: row.bandwidth, + })) + .filter((d) => d.country !== '') } function formatBytes(bytes: number): string { @@ -472,7 +468,7 @@ export default function CDNPage() { {countries.length > 0 ? ( <>
- +
{countries.map((row) => { @@ -482,7 +478,7 @@ export default function CDNPage() { return (
- {cc && {countryFlag(cc)}} + {cc && getFlagIcon(cc)}
{city}
diff --git a/components/dashboard/DottedMap.tsx b/components/dashboard/DottedMap.tsx index 8d6ecf6..2c7055b 100644 --- a/components/dashboard/DottedMap.tsx +++ b/components/dashboard/DottedMap.tsx @@ -57,6 +57,8 @@ const BASE_DOTS_PATH = (() => { interface DottedMapProps { data: Array<{ country: string; pageviews: number }> className?: string + /** Custom formatter for tooltip values. Defaults to formatNumber. */ + formatValue?: (value: number) => string } function getCountryName(code: string): string { @@ -68,7 +70,7 @@ function getCountryName(code: string): string { } } -export default function DottedMap({ data, className }: DottedMapProps) { +export default function DottedMap({ data, className, formatValue = formatNumber }: DottedMapProps) { const [tooltip, setTooltip] = useState<{ x: number; y: number; country: string; pageviews: number } | null>(null) const markerData = useMemo(() => { @@ -152,7 +154,7 @@ export default function DottedMap({ data, className }: DottedMapProps) { style={{ left: tooltip.x, top: tooltip.y }} > {getCountryName(tooltip.country)} - {formatNumber(tooltip.pageviews)} + {formatValue(tooltip.pageviews)}
)}
From 7247281ce20190f47256b6b227ca2dbf715d6376 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Sat, 14 Mar 2026 22:01:44 +0100 Subject: [PATCH 14/86] feat: move performance to dedicated tab, fix 0/99999 metrics bug Performance metrics moved from dashboard into a new Performance tab. Fixed null handling so "No data" shows instead of misleading zeros. Script no longer sends INP=0 when no interaction occurred. --- CHANGELOG.md | 5 + app/sites/[id]/page.tsx | 16 - app/sites/[id]/performance/page.tsx | 156 ++++++++++ components/dashboard/PerformanceStats.tsx | 363 +++++++++++----------- components/dashboard/SiteNav.tsx | 1 + lib/api/stats.ts | 7 +- public/script.js | 9 +- 7 files changed, 346 insertions(+), 211 deletions(-) create mode 100644 app/sites/[id]/performance/page.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 2548d26..eb0eafb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Added +- **Dedicated Performance tab.** Core Web Vitals (LCP, CLS, INP) have moved from the main dashboard into their own "Performance" tab. This gives you a full-page view with your overall performance score, individual metric cards, and a "Slowest pages by metric" table you can sort by LCP, CLS, or INP. The tab includes its own date range picker so you can analyze performance trends independently. + - **BunnyCDN integration.** Connect your BunnyCDN account in Settings > Integrations to monitor your CDN performance right alongside your analytics. A new "CDN" tab on your dashboard shows total bandwidth served, request volume, cache hit rate, origin response time, and error counts — each with percentage changes compared to the previous period. Charts show bandwidth trends (total vs cached), daily request volume, and error breakdowns over time. A geographic breakdown shows which countries consume the most bandwidth. Pulse only stores your API key encrypted and only reads statistics — it never modifies anything in your BunnyCDN account. You can disconnect and fully remove all CDN data at any time. - **Smart pull zone matching.** When connecting BunnyCDN, Pulse automatically filters your pull zones to only show the ones that match your tracked site's domain — so you can't accidentally connect the wrong pull zone. @@ -20,6 +22,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Fixed +- **Performance metrics no longer show "0 0 0" when no data exists.** Previously, if no visitors had reported Web Vitals data, the Performance section showed "LCP 0 ms, CLS 0, INP 0 ms" and rated everything as "Good" — which was misleading. It now clearly says "No data" when no metrics have been collected, and shows a helpful message explaining when data will appear. +- **Performance metrics no longer show inflated numbers from slow outliers.** A single very slow page load could skew the entire site's LCP or INP average to unrealistically high values. Pulse now uses the 75th percentile (p75) — the same methodology Google uses — so a handful of extreme outliers don't distort your scores. + - **BunnyCDN logo now displays correctly.** The BunnyCDN integration card in Settings previously showed a generic globe icon. It now shows the proper BunnyCDN bunny logo. - **Your BunnyCDN API key is no longer visible in network URLs.** When loading pull zones, the API key was previously sent as a URL parameter. It's now sent securely in the request body, just like when connecting. diff --git a/app/sites/[id]/page.tsx b/app/sites/[id]/page.tsx index bb3eae8..be5b210 100644 --- a/app/sites/[id]/page.tsx +++ b/app/sites/[id]/page.tsx @@ -5,7 +5,6 @@ import { logger } from '@/lib/utils/logger' import { useCallback, useEffect, useRef, useState, useMemo } from 'react' import { useParams, useRouter, useSearchParams } from 'next/navigation' import { - getPerformanceByPage, getTopPages, getTopReferrers, getCountries, @@ -32,7 +31,6 @@ import TopReferrers from '@/components/dashboard/TopReferrers' import Locations from '@/components/dashboard/Locations' import TechSpecs from '@/components/dashboard/TechSpecs' -const PerformanceStats = dynamic(() => import('@/components/dashboard/PerformanceStats')) const GoalStats = dynamic(() => import('@/components/dashboard/GoalStats')) const Campaigns = dynamic(() => import('@/components/dashboard/Campaigns')) const PeakHours = dynamic(() => import('@/components/dashboard/PeakHours')) @@ -556,20 +554,6 @@ export default function SiteDashboardPage() { />
- {/* Performance Stats - Only show if enabled */} - {site.enable_performance_insights && ( -
- -
- )} -
import('@/components/dashboard/PerformanceStats')) + +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) } +} + +function PerformanceSkeleton() { + return ( +
+
+
+
+
+
+
+
+
+
+ {[1, 2, 3].map(i => ( +
+ ))} +
+
+
+ ) +} + +export default function PerformancePage() { + 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) + + const { data: dashboard, isLoading: loading } = useDashboard(siteId, dateRange.start, dateRange.end) + + const site = dashboard?.site ?? null + const showSkeleton = useMinimumLoading(loading && !dashboard) + const fadeClass = useSkeletonFade(showSkeleton) + + useEffect(() => { + const domain = site?.domain + document.title = domain ? `Performance \u00b7 ${domain} | Pulse` : 'Performance | Pulse' + }, [site?.domain]) + + if (showSkeleton) return + + if (site && !site.enable_performance_insights) { + return ( +
+
+

+ Performance insights are disabled +

+

+ Enable performance insights in your site settings to start collecting Core Web Vitals data. +

+
+
+ ) + } + + return ( +
+ {/* Header */} +
+
+

+ Performance +

+

+ Core Web Vitals from real user sessions +

+
+ - )} -
- - {loadingTable ? ( -
- ) : rows.length === 0 ? ( -
- No per-page metrics yet. Data appears as visitors are tracked with performance insights enabled. -
- ) : ( -
- - - - - - - - - - - - {rows.map((r) => ( - - - - - - - - ))} - -
PathSamplesLCPCLSINP
- {r.path || '/'} - {r.samples} - {formatMetric('lcp', r.lcp)} - - {formatMetric('cls', r.cls)} - - {formatMetric('inp', r.inp)} -
-
- )} -
-
+ {!hasData && ( +
+ No performance data collected yet. Core Web Vitals data will appear here once visitors browse your site with performance insights enabled.
- + )} + + {hasData && ( +
+ * 75th percentile (p75) calculated from real user sessions. Lower is better. +
+ )} + + {/* Worst pages by metric */} +
+
+

+ Slowest pages by metric +

+ {canRefetch && ( + { - 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' }, - ]} - /> -
- - - - setIsDatePickerOpen(false)} - onApply={(range) => { - setDateRange(range) - setPeriod('custom') - setIsDatePickerOpen(false) - }} - initialRange={dateRange} - /> -
- ) -} diff --git a/app/sites/[id]/settings/page.tsx b/app/sites/[id]/settings/page.tsx index 7c3fb16..b5dc03c 100644 --- a/app/sites/[id]/settings/page.tsx +++ b/app/sites/[id]/settings/page.tsx @@ -74,8 +74,6 @@ export default function SiteSettingsPage() { collect_device_info: true, collect_geo_data: 'full' as GeoDataLevel, collect_screen_resolution: true, - // Performance insights setting - enable_performance_insights: false, // Bot and noise filtering filter_bots: true, // Hide unknown locations @@ -135,7 +133,6 @@ export default function SiteSettingsPage() { collect_device_info: site.collect_device_info ?? true, collect_geo_data: site.collect_geo_data || 'full', collect_screen_resolution: site.collect_screen_resolution ?? true, - enable_performance_insights: site.enable_performance_insights ?? false, filter_bots: site.filter_bots ?? true, hide_unknown_locations: site.hide_unknown_locations ?? false, data_retention_months: site.data_retention_months ?? 6 @@ -150,7 +147,6 @@ export default function SiteSettingsPage() { collect_device_info: site.collect_device_info ?? true, collect_geo_data: site.collect_geo_data || 'full', collect_screen_resolution: site.collect_screen_resolution ?? true, - enable_performance_insights: site.enable_performance_insights ?? false, filter_bots: site.filter_bots ?? true, hide_unknown_locations: site.hide_unknown_locations ?? false, data_retention_months: site.data_retention_months ?? 6 @@ -423,8 +419,6 @@ export default function SiteSettingsPage() { collect_device_info: formData.collect_device_info, collect_geo_data: formData.collect_geo_data, collect_screen_resolution: formData.collect_screen_resolution, - // Performance insights setting - enable_performance_insights: formData.enable_performance_insights, // Bot and noise filtering filter_bots: formData.filter_bots, // Hide unknown locations @@ -443,7 +437,6 @@ export default function SiteSettingsPage() { collect_device_info: formData.collect_device_info, collect_geo_data: formData.collect_geo_data, collect_screen_resolution: formData.collect_screen_resolution, - enable_performance_insights: formData.enable_performance_insights, filter_bots: formData.filter_bots, hide_unknown_locations: formData.hide_unknown_locations, data_retention_months: formData.data_retention_months @@ -511,7 +504,6 @@ export default function SiteSettingsPage() { collect_device_info: formData.collect_device_info, collect_geo_data: formData.collect_geo_data, collect_screen_resolution: formData.collect_screen_resolution, - enable_performance_insights: formData.enable_performance_insights, filter_bots: formData.filter_bots, hide_unknown_locations: formData.hide_unknown_locations, data_retention_months: formData.data_retention_months @@ -1113,30 +1105,6 @@ export default function SiteSettingsPage() {
- {/* Performance Insights Toggle */} -
-

Performance Insights

-
-
-
-

Performance Insights (Add-on)

-

- Track Core Web Vitals (LCP, CLS, INP) to monitor site performance -

-
- -
-
-
- {/* Data Retention */}

Data Retention

diff --git a/components/dashboard/PerformanceStats.tsx b/components/dashboard/PerformanceStats.tsx deleted file mode 100644 index bf9ce8a..0000000 --- a/components/dashboard/PerformanceStats.tsx +++ /dev/null @@ -1,243 +0,0 @@ -'use client' - -import { useState, useEffect } from 'react' -import { PerformanceStats as Stats, PerformanceByPageStat, getPerformanceByPage } from '@/lib/api/stats' -import { Select } from '@ciphera-net/ui' -import { TableSkeleton } from '@/components/skeletons' - -interface Props { - stats: Stats | null - performanceByPage?: PerformanceByPageStat[] | null - siteId?: string - startDate?: string - endDate?: string - getPerformanceByPage?: typeof getPerformanceByPage -} - -type Score = 'good' | 'needs-improvement' | 'poor' - -const getScore = (metric: 'lcp' | 'cls' | 'inp', value: number): Score => { - if (metric === 'lcp') return value <= 2500 ? 'good' : value <= 4000 ? 'needs-improvement' : 'poor' - if (metric === 'cls') return value <= 0.1 ? 'good' : value <= 0.25 ? 'needs-improvement' : 'poor' - if (metric === 'inp') return value <= 200 ? 'good' : value <= 500 ? 'needs-improvement' : 'poor' - return 'good' -} - -const scoreColors = { - good: 'text-green-600 bg-green-50 dark:bg-green-900/20 dark:text-green-400 border-green-200 dark:border-green-800', - 'needs-improvement': 'text-yellow-600 bg-yellow-50 dark:bg-yellow-900/20 dark:text-yellow-400 border-yellow-200 dark:border-yellow-800', - poor: 'text-red-600 bg-red-50 dark:bg-red-900/20 dark:text-red-400 border-red-200 dark:border-red-800', -} - -const badgeColors = { - good: 'text-green-700 dark:text-green-400 bg-green-100 dark:bg-green-900/30 border-green-200 dark:border-green-800', - 'needs-improvement': 'text-yellow-700 dark:text-yellow-400 bg-yellow-100 dark:bg-yellow-900/30 border-yellow-200 dark:border-yellow-800', - poor: 'text-red-700 dark:text-red-400 bg-red-100 dark:bg-red-900/30 border-red-200 dark:border-red-800', -} - -function MetricCard({ label, value, unit, score }: { label: string, value: string, unit: string, score: Score | null }) { - const noData = score === null - const colorClass = noData - ? 'text-neutral-500 bg-neutral-50 dark:bg-neutral-800/50 dark:text-neutral-400 border-neutral-200 dark:border-neutral-700' - : scoreColors[score] - - return ( -
-
{label}
-
- {value} - {unit && {unit}} -
-
- ) -} - -function formatMetricValue(metric: 'lcp' | 'cls' | 'inp', val: number | null): string { - if (val == null) return 'No data' - if (metric === 'cls') return val.toFixed(3) - return `${Math.round(val)}` -} - -function formatMetricCell(metric: 'lcp' | 'cls' | 'inp', val: number | null): string { - if (val == null) return '\u2014' - if (metric === 'cls') return val.toFixed(3) - return `${Math.round(val)} ms` -} - -export default function PerformanceStats({ stats, performanceByPage, siteId, startDate, endDate, getPerformanceByPage }: Props) { - const [sortBy, setSortBy] = useState<'lcp' | 'cls' | 'inp'>('lcp') - const [overrideRows, setOverrideRows] = useState(null) - const [loadingTable, setLoadingTable] = useState(false) - - useEffect(() => { - setOverrideRows(null) - }, [performanceByPage]) - - const rows = overrideRows ?? performanceByPage ?? [] - const canRefetch = Boolean(getPerformanceByPage && siteId && startDate && endDate) - - const handleSortChange = (value: string) => { - const v = value as 'lcp' | 'cls' | 'inp' - setSortBy(v) - if (!getPerformanceByPage || !siteId || !startDate || !endDate) return - setLoadingTable(true) - getPerformanceByPage(siteId, startDate, endDate, { sort: v, limit: 20 }) - .then(setOverrideRows) - .finally(() => setLoadingTable(false)) - } - - const hasData = stats && stats.samples > 0 - const lcp = stats?.lcp ?? null - const cls = stats?.cls ?? null - const inp = stats?.inp ?? null - - const lcpScore = lcp != null ? getScore('lcp', lcp) : null - const clsScore = cls != null ? getScore('cls', cls) : null - const inpScore = inp != null ? getScore('inp', inp) : null - - // Overall score: worst of available metrics - let overallScore: Score | null = null - if (hasData) { - const scores = [lcpScore, clsScore, inpScore].filter((s): s is Score => s !== null) - if (scores.length > 0) { - if (scores.includes('poor')) overallScore = 'poor' - else if (scores.includes('needs-improvement')) overallScore = 'needs-improvement' - else overallScore = 'good' - } - } - - const overallLabel = overallScore - ? { good: 'Good', 'needs-improvement': 'Needs improvement', poor: 'Poor' }[overallScore] - : 'No data' - - const overallBadgeClass = overallScore - ? badgeColors[overallScore] - : 'text-neutral-500 dark:text-neutral-400 bg-neutral-100 dark:bg-neutral-800 border-neutral-200 dark:border-neutral-700' - - const getCellScoreClass = (score: Score) => { - const m: Record = { - good: 'text-green-600 dark:text-green-400', - 'needs-improvement': 'text-yellow-600 dark:text-yellow-400', - poor: 'text-red-600 dark:text-red-400', - } - return m[score] ?? '' - } - - const getCellClass = (metric: 'lcp' | 'cls' | 'inp', val: number | null) => { - if (val == null) return 'text-neutral-400 dark:text-neutral-500' - return getCellScoreClass(getScore(metric, val)) - } - - return ( -
- {/* Overall badge + summary */} -
- - {overallLabel} - - {hasData && ( - - Based on {stats.samples.toLocaleString()} session{stats.samples !== 1 ? 's' : ''} (p75 values) - - )} -
- - {/* Metric cards */} -
- - - -
- - {!hasData && ( -
- No performance data collected yet. Core Web Vitals data will appear here once visitors browse your site with performance insights enabled. -
- )} - - {hasData && ( -
- * 75th percentile (p75) calculated from real user sessions. Lower is better. -
- )} - - {/* Worst pages by metric */} -
-
-

- Slowest pages by metric -

- {canRefetch && ( - setDepth(Number(e.target.value))} className="w-32 accent-brand-orange" /> - {depth} + {depth}
setDepth(Number(e.target.value))} - className="w-32 accent-brand-orange" - /> - {depth} + {/* Single card: toolbar + chart */} +
+ {/* Toolbar */} +
+
+ {/* Depth slider */} +
+
+ 2 steps + + {depth} steps deep + + 10 steps +
+ setDepth(DEPTH_STEPS[parseInt(e.target.value)])} + aria-label="Journey depth" + aria-valuetext={`${depth} steps deep`} + className="w-full h-2 bg-neutral-200 rounded-lg appearance-none cursor-pointer dark:bg-neutral-700 accent-brand-orange focus:outline-none focus:ring-2 focus:ring-brand-orange focus:ring-offset-2" + /> +
+ + {/* Entry point + Reset */} +
+ setEntryPath(value)} - options={entryPointOptions} - /> + {/* Sankey Diagram */} +
+ setEntryPath(path)} + /> +
- {(depth !== 3 || entryPath) && ( - + {/* Footer */} + {totalSessions > 0 && ( +
+ {totalSessions.toLocaleString()} sessions tracked +
)}
- {/* Sankey Diagram */} -
- setEntryPath(path)} - /> -
- - {/* Top Paths */} - - {/* Date Picker Modal */} Date: Sun, 15 Mar 2026 11:43:04 +0100 Subject: [PATCH 20/86] fix: remove focus ring from depth slider, debounce API calls until drag ends --- app/sites/[id]/journeys/page.tsx | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/app/sites/[id]/journeys/page.tsx b/app/sites/[id]/journeys/page.tsx index 25ebb3a..6d0af17 100644 --- a/app/sites/[id]/journeys/page.tsx +++ b/app/sites/[id]/journeys/page.tsx @@ -36,9 +36,10 @@ export default function JourneysPage() { const [dateRange, setDateRange] = useState(() => getDateRange(30)) const [isDatePickerOpen, setIsDatePickerOpen] = useState(false) const [depth, setDepth] = useState(3) + const [displayDepth, setDisplayDepth] = useState(3) const [entryPath, setEntryPath] = useState('') - const sliderIndex = DEPTH_STEPS.indexOf(depth) + const sliderIndex = DEPTH_STEPS.indexOf(displayDepth) const { data: transitionsData, isLoading: transitionsLoading } = useJourneyTransitions( siteId, dateRange.start, dateRange.end, depth, 1, entryPath || undefined @@ -126,7 +127,7 @@ export default function JourneysPage() {
2 steps - {depth} steps deep + {displayDepth} steps deep 10 steps
@@ -136,10 +137,12 @@ export default function JourneysPage() { max={DEPTH_STEPS.length - 1} step="1" value={sliderIndex} - onChange={(e) => setDepth(DEPTH_STEPS[parseInt(e.target.value)])} + onChange={(e) => setDisplayDepth(DEPTH_STEPS[parseInt(e.target.value)])} + onMouseUp={(e) => setDepth(DEPTH_STEPS[parseInt((e.target as HTMLInputElement).value)])} + onTouchEnd={(e) => setDepth(DEPTH_STEPS[parseInt((e.target as HTMLInputElement).value)])} aria-label="Journey depth" - aria-valuetext={`${depth} steps deep`} - className="w-full h-2 bg-neutral-200 rounded-lg appearance-none cursor-pointer dark:bg-neutral-700 accent-brand-orange focus:outline-none focus:ring-2 focus:ring-brand-orange focus:ring-offset-2" + aria-valuetext={`${displayDepth} steps deep`} + className="w-full h-2 bg-neutral-200 rounded-lg appearance-none cursor-pointer dark:bg-neutral-700 accent-brand-orange focus:outline-none" />
@@ -154,7 +157,7 @@ export default function JourneysPage() { /> {(depth !== 3 || entryPath) && (
+ {/* Top Paths */} +
+ +
+ {/* Date Picker Modal */} 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)} +
+ {paths.map((path, i) => ( +
+
+ + #{i + 1} +
+ + + {path.session_count.toLocaleString()} + + {path.avg_duration > 0 && ( + + + {formatDuration(path.avg_duration)} + + )} +
- ))} -
+
+ {path.page_sequence.map((page, j) => ( +
+ {j > 0 && ( + + )} + + {smartLabel(page)} + +
+ ))} +
+
+ ))}
) : (
From 1e147c955bccd00b0ceca074310d0b7517b905b0 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Sun, 15 Mar 2026 11:58:33 +0100 Subject: [PATCH 22/86] fix: improve visit duration reliability with pagehide fallback and dedup guard --- CHANGELOG.md | 3 ++- public/script.js | 26 +++++++++++++------------- 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 62db945..3271cd8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Improved -- **Deeper journey exploration.** The depth slider on the Journeys page now goes up to 10 steps (previously capped at 5), so you can follow longer visitor paths through your site and see exactly where people go after many clicks. +- **Redesigned Journeys page.** The depth slider now matches the rest of the UI and goes up to 10 steps. Controls are integrated into the chart card for a cleaner layout, and Top Paths are shown as visual breadcrumb cards instead of a cramped table. +- **More reliable visit duration tracking.** Visit duration was silently dropping to 0s for visitors who only viewed one page — especially on mobile or when closing a tab quickly. The tracking script now captures time-on-page more reliably across all browsers, and sessions where duration couldn't be measured are excluded from the average instead of counting as 0s. - **More accurate rage click detection.** Rage clicks no longer fire when you triple-click to select text on a page. Previously, selecting a paragraph (a normal 3-click action) was being counted as a rage click, which inflated frustration metrics. Only genuinely frustrated rapid clicking is tracked now. - **Fresher CDN data.** BunnyCDN statistics now refresh every 3 hours instead of once a day, so your CDN tab shows much more current bandwidth, request, and cache data. - **More accurate dead click detection.** Dead clicks were being reported on elements that actually worked — like close buttons on cart drawers, modal dismiss buttons, and page content areas. Three fixes make dead clicks much more reliable: diff --git a/public/script.js b/public/script.js index 340590a..246be40 100644 --- a/public/script.js +++ b/public/script.js @@ -37,21 +37,20 @@ // * Time-on-page tracking: records when the current pageview started var pageStartTime = 0; + var metricsSent = false; + function sendMetrics() { - if (!currentEventId) return; + if (!currentEventId || metricsSent) return; // * Calculate time-on-page in seconds var durationSec = pageStartTime > 0 ? Math.round((Date.now() - pageStartTime) / 1000) : 0; - var payload = { event_id: currentEventId }; - - // * Always include duration if we have a valid measurement - if (durationSec > 0) payload.duration = durationSec; - // * Skip if nothing to send (no duration) - if (!payload.duration) return; + if (durationSec <= 0) return; - var data = JSON.stringify(payload); + metricsSent = true; + + var data = JSON.stringify({ event_id: currentEventId, duration: durationSec }); if (navigator.sendBeacon) { navigator.sendBeacon(apiUrl + '/api/v1/metrics', new Blob([data], {type: 'application/json'})); @@ -66,12 +65,12 @@ } // * Send metrics when user leaves or hides the page - document.addEventListener('visibilitychange', () => { - if (document.visibilityState === 'hidden') { - // * Delay slightly so duration measurement captures final moment - setTimeout(sendMetrics, 150); - } + // * visibilitychange is the primary signal, pagehide is the fallback + // * for browsers/scenarios where visibilitychange doesn't fire (tab close, mobile app kill) + document.addEventListener('visibilitychange', function() { + if (document.visibilityState === 'hidden') sendMetrics(); }); + window.addEventListener('pagehide', sendMetrics); // * Memory cache for session ID (fallback if storage is unavailable) let cachedSessionId = null; @@ -312,6 +311,7 @@ if (data && data.id) { currentEventId = data.id; pageStartTime = Date.now(); + metricsSent = false; } }).catch(() => { // * Silently fail - don't interrupt user experience From e8f00e06ecb2585dcb8516613679468d0c9ca133 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Sun, 15 Mar 2026 12:17:48 +0100 Subject: [PATCH 23/86] feat: replace sankey chart with column-based journey visualization --- app/sites/[id]/journeys/page.tsx | 6 +- components/journeys/ColumnJourney.tsx | 479 ++++++++++++++++++++++++++ components/journeys/SankeyDiagram.tsx | 457 ------------------------ package-lock.json | 73 +--- package.json | 2 - 5 files changed, 484 insertions(+), 533 deletions(-) create mode 100644 components/journeys/ColumnJourney.tsx delete mode 100644 components/journeys/SankeyDiagram.tsx diff --git a/app/sites/[id]/journeys/page.tsx b/app/sites/[id]/journeys/page.tsx index 30e5660..61ae39b 100644 --- a/app/sites/[id]/journeys/page.tsx +++ b/app/sites/[id]/journeys/page.tsx @@ -4,7 +4,7 @@ import { useEffect, useState } from 'react' import { useParams } from 'next/navigation' import { getDateRange, formatDate } from '@ciphera-net/ui' import { Select, DatePicker } from '@ciphera-net/ui' -import SankeyDiagram from '@/components/journeys/SankeyDiagram' +import ColumnJourney from '@/components/journeys/ColumnJourney' import TopPathsTable from '@/components/journeys/TopPathsTable' import { JourneysSkeleton, useMinimumLoading, useSkeletonFade } from '@/components/skeletons' import { @@ -172,9 +172,9 @@ export default function JourneysPage() {
- {/* Sankey Diagram */} + {/* Journey Columns */}
- void +} + +interface ColumnPage { + path: string + sessionCount: number +} + +interface Column { + index: number + totalSessions: number + dropOffPercent: number + pages: ColumnPage[] +} + +interface LineDef { + sourceY: number + destY: number + sourceX: number + destX: number + weight: number +} + +// ─── Constants ────────────────────────────────────────────────────── + +const COLUMN_COLORS = [ + '#FD5E0F', '#3B82F6', '#10B981', '#F59E0B', '#8B5CF6', + '#EC4899', '#06B6D4', '#EF4444', '#84CC16', '#F97316', '#6366F1', +] +const MAX_NODES_PER_COLUMN = 10 + +function colorForColumn(col: number): string { + return COLUMN_COLORS[col % COLUMN_COLORS.length] +} + +// ─── Helpers ──────────────────────────────────────────────────────── + +function smartLabel(path: string): string { + if (path === '/' || path === '(other)') return path + const segments = path.replace(/\/$/, '').split('/') + if (segments.length <= 2) return path + return `…/${segments[segments.length - 1]}` +} + +// ─── Data transformation ──────────────────────────────────────────── + +function buildColumns( + transitions: PathTransition[], + depth: number, + selections: Map, +): Column[] { + const numCols = depth + 1 + const columns: Column[] = [] + + // Build a filtered transitions set based on selections + // For each column N with a selection, only keep transitions at step_index=N + // where from_path matches the selection + let filteredTransitions = transitions + + for (let col = 0; col < numCols - 1; col++) { + const selected = selections.get(col) + if (selected) { + filteredTransitions = filteredTransitions.filter( + (t) => t.step_index !== col || t.from_path === selected + ) + } + } + + for (let col = 0; col < numCols; col++) { + const pageMap = new Map() + + if (col === 0) { + // Column 0: aggregate from_path across step_index=0 + for (const t of filteredTransitions) { + if (t.step_index === 0) { + pageMap.set(t.from_path, (pageMap.get(t.from_path) ?? 0) + t.session_count) + } + } + } else { + // Column N: aggregate to_path across step_index=N-1 + for (const t of filteredTransitions) { + if (t.step_index === col - 1) { + pageMap.set(t.to_path, (pageMap.get(t.to_path) ?? 0) + t.session_count) + } + } + } + + // Sort descending by count + let pages = Array.from(pageMap.entries()) + .map(([path, sessionCount]) => ({ path, sessionCount })) + .sort((a, b) => b.sessionCount - a.sessionCount) + + // Cap and merge into (other) + if (pages.length > MAX_NODES_PER_COLUMN) { + const kept = pages.slice(0, MAX_NODES_PER_COLUMN) + const otherCount = pages + .slice(MAX_NODES_PER_COLUMN) + .reduce((sum, p) => sum + p.sessionCount, 0) + kept.push({ path: '(other)', sessionCount: otherCount }) + pages = kept + } + + const totalSessions = pages.reduce((sum, p) => sum + p.sessionCount, 0) + const prevTotal = col > 0 ? columns[col - 1].totalSessions : totalSessions + const dropOffPercent = + col === 0 || prevTotal === 0 + ? 0 + : Math.round(((totalSessions - prevTotal) / prevTotal) * 100) + + columns.push({ index: col, totalSessions, dropOffPercent, pages }) + } + + return columns +} + +// ─── Sub-components ───────────────────────────────────────────────── + +function ColumnHeader({ + column, + color, +}: { + column: Column + color: string +}) { + return ( +
+ + {column.index + 1} + +
+ + {column.totalSessions.toLocaleString()} + + + visitors + + {column.dropOffPercent !== 0 && ( + + {column.dropOffPercent > 0 ? '+' : ''} + {column.dropOffPercent}% + + )} +
+
+ ) +} + +function PageRow({ + page, + colIndex, + columnTotal, + isSelected, + isOther, + onClick, +}: { + page: ColumnPage + colIndex: number + columnTotal: number + isSelected: boolean + isOther: boolean + onClick: () => void +}) { + const pct = columnTotal > 0 ? Math.round((page.sessionCount / columnTotal) * 100) : 0 + + return ( + + ) +} + +function JourneyColumn({ + column, + color, + selectedPath, + onSelect, +}: { + column: Column + color: string + selectedPath: string | undefined + onSelect: (path: string) => void +}) { + if (column.pages.length === 0) { + return ( +
+ +
+ + No onward traffic + +
+
+ ) + } + + return ( +
+ +
+ {column.pages.map((page) => { + const isOther = page.path === '(other)' + return ( + { + if (!isOther) onSelect(page.path) + }} + /> + ) + })} +
+
+ ) +} + +// ─── Connection Lines ─────────────────────────────────────────────── + +function ConnectionLines({ + containerRef, + selections, + columns, + transitions, +}: { + containerRef: React.RefObject + selections: Map + columns: Column[] + transitions: PathTransition[] +}) { + const [lines, setLines] = useState<(LineDef & { color: string })[]>([]) + const [dimensions, setDimensions] = useState({ width: 0, height: 0 }) + + useLayoutEffect(() => { + const container = containerRef.current + if (!container || selections.size === 0) { + setLines([]) + return + } + + const containerRect = container.getBoundingClientRect() + setDimensions({ + width: container.scrollWidth, + height: container.scrollHeight, + }) + + const newLines: (LineDef & { color: string })[] = [] + + for (const [colIdx, selectedPath] of selections) { + const nextCol = columns[colIdx + 1] + if (!nextCol) continue + + // Find the source row element + const sourceEl = container.querySelector( + `[data-col="${colIdx}"][data-path="${CSS.escape(selectedPath)}"]` + ) as HTMLElement | null + if (!sourceEl) continue + + const sourceRect = sourceEl.getBoundingClientRect() + const sourceY = + sourceRect.top + sourceRect.height / 2 - containerRect.top + container.scrollTop + const sourceX = sourceRect.right - containerRect.left + container.scrollLeft + + // Find matching transitions + const relevantTransitions = transitions.filter( + (t) => t.step_index === colIdx && t.from_path === selectedPath + ) + + const color = colorForColumn(colIdx) + + for (const t of relevantTransitions) { + const destEl = container.querySelector( + `[data-col="${colIdx + 1}"][data-path="${CSS.escape(t.to_path)}"]` + ) as HTMLElement | null + if (!destEl) continue + + const destRect = destEl.getBoundingClientRect() + const destY = + destRect.top + destRect.height / 2 - containerRect.top + container.scrollTop + const destX = destRect.left - containerRect.left + container.scrollLeft + + const maxCount = Math.max(...relevantTransitions.map((rt) => rt.session_count)) + const weight = Math.max(1, Math.min(4, (t.session_count / maxCount) * 4)) + + newLines.push({ sourceY, destY, sourceX, destX, weight, color }) + } + } + + setLines(newLines) + }, [selections, columns, transitions, containerRef]) + + if (lines.length === 0) return null + + return ( + + {lines.map((line, i) => { + const midX = (line.sourceX + line.destX) / 2 + return ( + + ) + })} + + ) +} + +// ─── Main Component ───────────────────────────────────────────────── + +export default function ColumnJourney({ + transitions, + totalSessions, + depth, + onNodeClick, +}: ColumnJourneyProps) { + const [selections, setSelections] = useState>(new Map()) + const containerRef = useRef(null) + + // Clear selections when data changes + const transitionsKey = useMemo( + () => transitions.length + '-' + depth, + [transitions.length, depth] + ) + const prevKeyRef = useRef(transitionsKey) + if (prevKeyRef.current !== transitionsKey) { + prevKeyRef.current = transitionsKey + if (selections.size > 0) setSelections(new Map()) + } + + const columns = useMemo( + () => buildColumns(transitions, depth, selections), + [transitions, depth, selections] + ) + + const handleSelect = useCallback( + (colIndex: number, path: string) => { + // Column 0 click → set entry path filter (API-level) + if (colIndex === 0 && onNodeClick) { + onNodeClick(path) + return + } + + setSelections((prev) => { + const next = new Map(prev) + // Toggle: click same page deselects + if (next.get(colIndex) === path) { + next.delete(colIndex) + } else { + next.set(colIndex, path) + } + // Clear all selections after this column + for (const key of Array.from(next.keys())) { + if (key > colIndex) next.delete(key) + } + return next + }) + }, + [onNodeClick] + ) + + // ─── Empty state ──────────────────────────────────────────────── + if (!transitions.length) { + return ( +
+
+ +
+

+ No journey data yet +

+

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

+
+ ) + } + + return ( +
+
+
+ {columns.map((col) => ( + handleSelect(col.index, path)} + /> + ))} +
+ +
+
+ ) +} diff --git a/components/journeys/SankeyDiagram.tsx b/components/journeys/SankeyDiagram.tsx deleted file mode 100644 index 771b4b6..0000000 --- a/components/journeys/SankeyDiagram.tsx +++ /dev/null @@ -1,457 +0,0 @@ -'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/package-lock.json b/package-lock.json index 0e6b7c4..bcf20b8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "pulse-frontend", - "version": "0.14.0-alpha", + "version": "0.15.0-alpha", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pulse-frontend", - "version": "0.14.0-alpha", + "version": "0.15.0-alpha", "dependencies": { "@ciphera-net/ui": "^0.2.5", "@ducanh2912/next-pwa": "^10.2.9", @@ -19,7 +19,6 @@ "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", @@ -42,7 +41,6 @@ "@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", @@ -5628,33 +5626,6 @@ "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", @@ -7917,46 +7888,6 @@ "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", diff --git a/package.json b/package.json index e94b947..2443198 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,6 @@ "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", @@ -52,7 +51,6 @@ "@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", From 9528eca443ed2b85a22b7c53a9dca8e4961f85ee Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Sun, 15 Mar 2026 12:23:05 +0100 Subject: [PATCH 24/86] fix: handle 204 No Content responses in API client Prevent error toasts on successful delete operations by checking for 204 status before attempting to parse response body as JSON. --- lib/api/client.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/api/client.ts b/lib/api/client.ts index 6362b8c..5014fc5 100644 --- a/lib/api/client.ts +++ b/lib/api/client.ts @@ -339,6 +339,10 @@ async function apiRequest( throw new ApiError(message, response.status, errorBody) } + if (response.status === 204) { + return undefined as T + } + return response.json() })() From 4103014cdb668c922fddf71cbc342fc5a4bb8986 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Sun, 15 Mar 2026 12:28:43 +0100 Subject: [PATCH 25/86] fix: restyle journey columns to match Pirsch card-based design --- components/journeys/ColumnJourney.tsx | 78 +++++++++++---------------- 1 file changed, 30 insertions(+), 48 deletions(-) diff --git a/components/journeys/ColumnJourney.tsx b/components/journeys/ColumnJourney.tsx index 025795d..570fc56 100644 --- a/components/journeys/ColumnJourney.tsx +++ b/components/journeys/ColumnJourney.tsx @@ -64,9 +64,6 @@ function buildColumns( const numCols = depth + 1 const columns: Column[] = [] - // Build a filtered transitions set based on selections - // For each column N with a selection, only keep transitions at step_index=N - // where from_path matches the selection let filteredTransitions = transitions for (let col = 0; col < numCols - 1; col++) { @@ -82,14 +79,12 @@ function buildColumns( const pageMap = new Map() if (col === 0) { - // Column 0: aggregate from_path across step_index=0 for (const t of filteredTransitions) { if (t.step_index === 0) { pageMap.set(t.from_path, (pageMap.get(t.from_path) ?? 0) + t.session_count) } } } else { - // Column N: aggregate to_path across step_index=N-1 for (const t of filteredTransitions) { if (t.step_index === col - 1) { pageMap.set(t.to_path, (pageMap.get(t.to_path) ?? 0) + t.session_count) @@ -97,12 +92,10 @@ function buildColumns( } } - // Sort descending by count let pages = Array.from(pageMap.entries()) .map(([path, sessionCount]) => ({ path, sessionCount })) .sort((a, b) => b.sessionCount - a.sessionCount) - // Cap and merge into (other) if (pages.length > MAX_NODES_PER_COLUMN) { const kept = pages.slice(0, MAX_NODES_PER_COLUMN) const otherCount = pages @@ -135,23 +128,20 @@ function ColumnHeader({ color: string }) { return ( -
+
{column.index + 1} -
- - {column.totalSessions.toLocaleString()} - - - visitors +
+ + {column.totalSessions.toLocaleString()} visitors {column.dropOffPercent !== 0 && ( @@ -191,42 +181,39 @@ function PageRow({ data-path={page.path} className={` group flex items-center justify-between w-full - h-9 px-2 rounded-lg text-left transition-colors + px-3 py-2.5 rounded-lg text-left transition-all ${isOther ? 'cursor-default' : 'cursor-pointer'} ${ isSelected - ? 'bg-brand-orange/10 dark:bg-brand-orange/15 ring-1 ring-brand-orange/30' + ? 'bg-neutral-900 dark:bg-white border border-neutral-900 dark:border-white' : isOther - ? '' - : 'hover:bg-neutral-50 dark:hover:bg-neutral-800/50' + ? 'border border-neutral-100 dark:border-neutral-800' + : 'border border-neutral-200 dark:border-neutral-700 hover:border-neutral-300 dark:hover:border-neutral-600 hover:shadow-sm' } `} > {isOther ? page.path : smartLabel(page.path)} -
- {!isOther && ( - - {pct}% - - )} - - {page.sessionCount.toLocaleString()} - -
+ : 'text-neutral-900 dark:text-white' + }`} + > + {page.sessionCount.toLocaleString()} +
) } @@ -244,7 +231,7 @@ function JourneyColumn({ }) { if (column.pages.length === 0) { return ( -
+
@@ -256,9 +243,9 @@ function JourneyColumn({ } return ( -
+
-
+
{column.pages.map((page) => { const isOther = page.path === '(other)' return ( @@ -315,7 +302,6 @@ function ConnectionLines({ const nextCol = columns[colIdx + 1] if (!nextCol) continue - // Find the source row element const sourceEl = container.querySelector( `[data-col="${colIdx}"][data-path="${CSS.escape(selectedPath)}"]` ) as HTMLElement | null @@ -326,7 +312,6 @@ function ConnectionLines({ sourceRect.top + sourceRect.height / 2 - containerRect.top + container.scrollTop const sourceX = sourceRect.right - containerRect.left + container.scrollLeft - // Find matching transitions const relevantTransitions = transitions.filter( (t) => t.step_index === colIdx && t.from_path === selectedPath ) @@ -371,7 +356,7 @@ function ConnectionLines({ d={`M ${line.sourceX},${line.sourceY} C ${midX},${line.sourceY} ${midX},${line.destY} ${line.destX},${line.destY}`} stroke={line.color} strokeWidth={line.weight} - strokeOpacity={0.3} + strokeOpacity={0.35} fill="none" /> ) @@ -409,7 +394,6 @@ export default function ColumnJourney({ const handleSelect = useCallback( (colIndex: number, path: string) => { - // Column 0 click → set entry path filter (API-level) if (colIndex === 0 && onNodeClick) { onNodeClick(path) return @@ -417,13 +401,11 @@ export default function ColumnJourney({ setSelections((prev) => { const next = new Map(prev) - // Toggle: click same page deselects if (next.get(colIndex) === path) { next.delete(colIndex) } else { next.set(colIndex, path) } - // Clear all selections after this column for (const key of Array.from(next.keys())) { if (key > colIndex) next.delete(key) } @@ -456,7 +438,7 @@ export default function ColumnJourney({ ref={containerRef} className="overflow-x-auto -mx-6 px-6 pb-2 relative" > -
+
{columns.map((col) => ( Date: Sun, 15 Mar 2026 12:42:49 +0100 Subject: [PATCH 26/86] fix: cascade column selection filter downstream, trim empty columns, add scroll fade --- components/journeys/ColumnJourney.tsx | 70 ++++++++++++++++++++++----- 1 file changed, 57 insertions(+), 13 deletions(-) diff --git a/components/journeys/ColumnJourney.tsx b/components/journeys/ColumnJourney.tsx index 570fc56..7558d26 100644 --- a/components/journeys/ColumnJourney.tsx +++ b/components/journeys/ColumnJourney.tsx @@ -1,6 +1,6 @@ 'use client' -import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { TreeStructure } from '@phosphor-icons/react' import type { PathTransition } from '@/lib/api/journeys' @@ -64,29 +64,26 @@ function buildColumns( const numCols = depth + 1 const columns: Column[] = [] - let filteredTransitions = transitions - - for (let col = 0; col < numCols - 1; col++) { - const selected = selections.get(col) - if (selected) { - filteredTransitions = filteredTransitions.filter( - (t) => t.step_index !== col || t.from_path === selected - ) - } - } + // Build columns one at a time, cascading selections forward. + // When a selection exists at column N, we filter transitions at step N + // to only the selected from_path. The resulting to_paths become + // the only allowed from_paths at step N+1, and so on downstream. + let allowedPaths: Set | null = null // null = no filter active for (let col = 0; col < numCols; col++) { const pageMap = new Map() if (col === 0) { - for (const t of filteredTransitions) { + for (const t of transitions) { if (t.step_index === 0) { pageMap.set(t.from_path, (pageMap.get(t.from_path) ?? 0) + t.session_count) } } } else { - for (const t of filteredTransitions) { + for (const t of transitions) { if (t.step_index === col - 1) { + // If there's an active filter, only include transitions from allowed paths + if (allowedPaths && !allowedPaths.has(t.from_path)) continue pageMap.set(t.to_path, (pageMap.get(t.to_path) ?? 0) + t.session_count) } } @@ -113,6 +110,28 @@ function buildColumns( : Math.round(((totalSessions - prevTotal) / prevTotal) * 100) columns.push({ index: col, totalSessions, dropOffPercent, pages }) + + // If this column has a selection, cascade the filter forward + const selected = selections.get(col) + if (selected) { + // The next column's allowed paths are the to_paths from this selection + const nextAllowed = new Set() + for (const t of transitions) { + if (t.step_index === col && t.from_path === selected) { + nextAllowed.add(t.to_path) + } + } + allowedPaths = nextAllowed + } else if (allowedPaths) { + // No selection at this column but filter is active from upstream — + // carry forward all paths in this column as allowed + allowedPaths = new Set(pages.map((p) => p.path).filter((p) => p !== '(other)')) + } + } + + // Trim empty trailing columns + while (columns.length > 1 && columns[columns.length - 1].pages.length === 0) { + columns.pop() } return columns @@ -374,6 +393,7 @@ export default function ColumnJourney({ onNodeClick, }: ColumnJourneyProps) { const [selections, setSelections] = useState>(new Map()) + const [canScrollRight, setCanScrollRight] = useState(false) const containerRef = useRef(null) // Clear selections when data changes @@ -392,6 +412,26 @@ export default function ColumnJourney({ [transitions, depth, selections] ) + // Check if there's scrollable content to the right + useEffect(() => { + const el = containerRef.current + if (!el) return + + function check() { + if (!el) return + setCanScrollRight(el.scrollWidth - el.scrollLeft - el.clientWidth > 1) + } + + check() + el.addEventListener('scroll', check, { passive: true }) + const ro = new ResizeObserver(check) + ro.observe(el) + return () => { + el.removeEventListener('scroll', check) + ro.disconnect() + } + }, [columns]) + const handleSelect = useCallback( (colIndex: number, path: string) => { if (colIndex === 0 && onNodeClick) { @@ -456,6 +496,10 @@ export default function ColumnJourney({ transitions={transitions} />
+ {/* Scroll fade indicator */} + {canScrollRight && ( +
+ )}
) } From 9f9f4286b7ddd2b916d60217620dbd24b2a704cf Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Sun, 15 Mar 2026 12:46:48 +0100 Subject: [PATCH 27/86] fix: selections only show connection lines, no longer filter column data --- components/journeys/ColumnJourney.tsx | 30 ++------------------------- 1 file changed, 2 insertions(+), 28 deletions(-) diff --git a/components/journeys/ColumnJourney.tsx b/components/journeys/ColumnJourney.tsx index 7558d26..77e5771 100644 --- a/components/journeys/ColumnJourney.tsx +++ b/components/journeys/ColumnJourney.tsx @@ -59,17 +59,10 @@ function smartLabel(path: string): string { function buildColumns( transitions: PathTransition[], depth: number, - selections: Map, ): Column[] { const numCols = depth + 1 const columns: Column[] = [] - // Build columns one at a time, cascading selections forward. - // When a selection exists at column N, we filter transitions at step N - // to only the selected from_path. The resulting to_paths become - // the only allowed from_paths at step N+1, and so on downstream. - let allowedPaths: Set | null = null // null = no filter active - for (let col = 0; col < numCols; col++) { const pageMap = new Map() @@ -82,8 +75,6 @@ function buildColumns( } else { for (const t of transitions) { if (t.step_index === col - 1) { - // If there's an active filter, only include transitions from allowed paths - if (allowedPaths && !allowedPaths.has(t.from_path)) continue pageMap.set(t.to_path, (pageMap.get(t.to_path) ?? 0) + t.session_count) } } @@ -110,23 +101,6 @@ function buildColumns( : Math.round(((totalSessions - prevTotal) / prevTotal) * 100) columns.push({ index: col, totalSessions, dropOffPercent, pages }) - - // If this column has a selection, cascade the filter forward - const selected = selections.get(col) - if (selected) { - // The next column's allowed paths are the to_paths from this selection - const nextAllowed = new Set() - for (const t of transitions) { - if (t.step_index === col && t.from_path === selected) { - nextAllowed.add(t.to_path) - } - } - allowedPaths = nextAllowed - } else if (allowedPaths) { - // No selection at this column but filter is active from upstream — - // carry forward all paths in this column as allowed - allowedPaths = new Set(pages.map((p) => p.path).filter((p) => p !== '(other)')) - } } // Trim empty trailing columns @@ -408,8 +382,8 @@ export default function ColumnJourney({ } const columns = useMemo( - () => buildColumns(transitions, depth, selections), - [transitions, depth, selections] + () => buildColumns(transitions, depth), + [transitions, depth] ) // Check if there's scrollable content to the right From b10abd38fc698ecea86384f009dab05591657184 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Sun, 15 Mar 2026 12:56:59 +0100 Subject: [PATCH 28/86] feat: show exit count when selecting a page, fix scroll fade overlay --- components/journeys/ColumnJourney.tsx | 100 ++++++++++++++++++++++---- 1 file changed, 85 insertions(+), 15 deletions(-) diff --git a/components/journeys/ColumnJourney.tsx b/components/journeys/ColumnJourney.tsx index 77e5771..c9c6e22 100644 --- a/components/journeys/ColumnJourney.tsx +++ b/components/journeys/ColumnJourney.tsx @@ -262,6 +262,13 @@ function JourneyColumn({ // ─── Connection Lines ─────────────────────────────────────────────── +interface ExitLabel { + x: number + y: number + count: number + color: string +} + function ConnectionLines({ containerRef, selections, @@ -274,12 +281,14 @@ function ConnectionLines({ transitions: PathTransition[] }) { const [lines, setLines] = useState<(LineDef & { color: string })[]>([]) + const [exits, setExits] = useState([]) const [dimensions, setDimensions] = useState({ width: 0, height: 0 }) useLayoutEffect(() => { const container = containerRef.current if (!container || selections.size === 0) { setLines([]) + setExits([]) return } @@ -290,10 +299,10 @@ function ConnectionLines({ }) const newLines: (LineDef & { color: string })[] = [] + const newExits: ExitLabel[] = [] for (const [colIdx, selectedPath] of selections) { const nextCol = columns[colIdx + 1] - if (!nextCol) continue const sourceEl = container.querySelector( `[data-col="${colIdx}"][data-path="${CSS.escape(selectedPath)}"]` @@ -311,28 +320,76 @@ function ConnectionLines({ const color = colorForColumn(colIdx) - for (const t of relevantTransitions) { - const destEl = container.querySelector( - `[data-col="${colIdx + 1}"][data-path="${CSS.escape(t.to_path)}"]` - ) as HTMLElement | null - if (!destEl) continue + // Find total sessions for this page + const col = columns[colIdx] + const page = col?.pages.find((p) => p.path === selectedPath) + const pageCount = page?.sessionCount ?? 0 + const outboundCount = relevantTransitions.reduce((sum, t) => sum + t.session_count, 0) + const exitCount = pageCount - outboundCount - const destRect = destEl.getBoundingClientRect() - const destY = - destRect.top + destRect.height / 2 - containerRect.top + container.scrollTop - const destX = destRect.left - containerRect.left + container.scrollLeft + if (nextCol) { + const maxCount = Math.max( + ...relevantTransitions.map((rt) => rt.session_count), + exitCount > 0 ? exitCount : 0 + ) - const maxCount = Math.max(...relevantTransitions.map((rt) => rt.session_count)) - const weight = Math.max(1, Math.min(4, (t.session_count / maxCount) * 4)) + for (const t of relevantTransitions) { + const destEl = container.querySelector( + `[data-col="${colIdx + 1}"][data-path="${CSS.escape(t.to_path)}"]` + ) as HTMLElement | null + if (!destEl) continue - newLines.push({ sourceY, destY, sourceX, destX, weight, color }) + const destRect = destEl.getBoundingClientRect() + const destY = + destRect.top + destRect.height / 2 - containerRect.top + container.scrollTop + const destX = destRect.left - containerRect.left + container.scrollLeft + + const weight = maxCount > 0 + ? Math.max(1, Math.min(4, (t.session_count / maxCount) * 4)) + : 1 + + newLines.push({ sourceY, destY, sourceX, destX, weight, color }) + } + } + + // Show exit if any visitors dropped off + if (exitCount > 0) { + // Position the exit label below the last destination or below the source + const lastDestY = newLines.length > 0 + ? Math.max(...newLines.filter((l) => l.sourceX === sourceX).map((l) => l.destY)) + : sourceY + + const exitY = lastDestY + 30 + const exitX = nextCol + ? ((): number => { + // Find the left edge of the next column + const nextColEl = container.querySelector(`[data-col="${colIdx + 1}"]`) as HTMLElement | null + if (nextColEl) { + const nextRect = nextColEl.getBoundingClientRect() + return nextRect.left - containerRect.left + container.scrollLeft + } + return sourceX + 100 + })() + : sourceX + 100 + + newLines.push({ + sourceY, + destY: exitY, + sourceX, + destX: exitX, + weight: 1, + color: '#52525b', // EXIT_GREY + }) + + newExits.push({ x: exitX, y: exitY, count: exitCount, color: '#52525b' }) } } setLines(newLines) + setExits(newExits) }, [selections, columns, transitions, containerRef]) - if (lines.length === 0) return null + if (lines.length === 0 && exits.length === 0) return null return ( ) })} + {exits.map((exit, i) => ( + + + (exit) {exit.count} + + + ))} ) } @@ -472,7 +542,7 @@ export default function ColumnJourney({
{/* Scroll fade indicator */} {canScrollRight && ( -
+
)}
) From ada2c65d8f0cdda6c8fdf9f9eee4d8dc7db1ab0b Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Sun, 15 Mar 2026 13:03:06 +0100 Subject: [PATCH 29/86] fix: show exit as red card in next column instead of SVG text hack --- components/journeys/ColumnJourney.tsx | 170 ++++++++++++-------------- 1 file changed, 78 insertions(+), 92 deletions(-) diff --git a/components/journeys/ColumnJourney.tsx b/components/journeys/ColumnJourney.tsx index c9c6e22..04c77fa 100644 --- a/components/journeys/ColumnJourney.tsx +++ b/components/journeys/ColumnJourney.tsx @@ -215,14 +215,16 @@ function JourneyColumn({ column, color, selectedPath, + exitCount, onSelect, }: { column: Column color: string selectedPath: string | undefined + exitCount: number onSelect: (path: string) => void }) { - if (column.pages.length === 0) { + if (column.pages.length === 0 && exitCount === 0) { return (
@@ -255,6 +257,20 @@ function JourneyColumn({ /> ) })} + {exitCount > 0 && ( +
+ + (exit) + + + {exitCount.toLocaleString()} + +
+ )}
) @@ -262,13 +278,6 @@ function JourneyColumn({ // ─── Connection Lines ─────────────────────────────────────────────── -interface ExitLabel { - x: number - y: number - count: number - color: string -} - function ConnectionLines({ containerRef, selections, @@ -281,14 +290,12 @@ function ConnectionLines({ transitions: PathTransition[] }) { const [lines, setLines] = useState<(LineDef & { color: string })[]>([]) - const [exits, setExits] = useState([]) const [dimensions, setDimensions] = useState({ width: 0, height: 0 }) useLayoutEffect(() => { const container = containerRef.current if (!container || selections.size === 0) { setLines([]) - setExits([]) return } @@ -299,10 +306,10 @@ function ConnectionLines({ }) const newLines: (LineDef & { color: string })[] = [] - const newExits: ExitLabel[] = [] for (const [colIdx, selectedPath] of selections) { const nextCol = columns[colIdx + 1] + if (!nextCol) continue const sourceEl = container.querySelector( `[data-col="${colIdx}"][data-path="${CSS.escape(selectedPath)}"]` @@ -319,77 +326,43 @@ function ConnectionLines({ ) const color = colorForColumn(colIdx) + const maxCount = relevantTransitions.length > 0 + ? Math.max(...relevantTransitions.map((rt) => rt.session_count)) + : 1 - // Find total sessions for this page - const col = columns[colIdx] - const page = col?.pages.find((p) => p.path === selectedPath) - const pageCount = page?.sessionCount ?? 0 - const outboundCount = relevantTransitions.reduce((sum, t) => sum + t.session_count, 0) - const exitCount = pageCount - outboundCount + for (const t of relevantTransitions) { + const destEl = container.querySelector( + `[data-col="${colIdx + 1}"][data-path="${CSS.escape(t.to_path)}"]` + ) as HTMLElement | null + if (!destEl) continue - if (nextCol) { - const maxCount = Math.max( - ...relevantTransitions.map((rt) => rt.session_count), - exitCount > 0 ? exitCount : 0 - ) + const destRect = destEl.getBoundingClientRect() + const destY = + destRect.top + destRect.height / 2 - containerRect.top + container.scrollTop + const destX = destRect.left - containerRect.left + container.scrollLeft - for (const t of relevantTransitions) { - const destEl = container.querySelector( - `[data-col="${colIdx + 1}"][data-path="${CSS.escape(t.to_path)}"]` - ) as HTMLElement | null - if (!destEl) continue + const weight = Math.max(1, Math.min(4, (t.session_count / maxCount) * 4)) - const destRect = destEl.getBoundingClientRect() - const destY = - destRect.top + destRect.height / 2 - containerRect.top + container.scrollTop - const destX = destRect.left - containerRect.left + container.scrollLeft - - const weight = maxCount > 0 - ? Math.max(1, Math.min(4, (t.session_count / maxCount) * 4)) - : 1 - - newLines.push({ sourceY, destY, sourceX, destX, weight, color }) - } + newLines.push({ sourceY, destY, sourceX, destX, weight, color }) } - // Show exit if any visitors dropped off - if (exitCount > 0) { - // Position the exit label below the last destination or below the source - const lastDestY = newLines.length > 0 - ? Math.max(...newLines.filter((l) => l.sourceX === sourceX).map((l) => l.destY)) - : sourceY - - const exitY = lastDestY + 30 - const exitX = nextCol - ? ((): number => { - // Find the left edge of the next column - const nextColEl = container.querySelector(`[data-col="${colIdx + 1}"]`) as HTMLElement | null - if (nextColEl) { - const nextRect = nextColEl.getBoundingClientRect() - return nextRect.left - containerRect.left + container.scrollLeft - } - return sourceX + 100 - })() - : sourceX + 100 - - newLines.push({ - sourceY, - destY: exitY, - sourceX, - destX: exitX, - weight: 1, - color: '#52525b', // EXIT_GREY - }) - - newExits.push({ x: exitX, y: exitY, count: exitCount, color: '#52525b' }) + // Draw line to exit card if it exists + const exitEl = container.querySelector( + `[data-col="${colIdx + 1}"][data-path="(exit)"]` + ) as HTMLElement | null + if (exitEl) { + const exitRect = exitEl.getBoundingClientRect() + const exitY = + exitRect.top + exitRect.height / 2 - containerRect.top + container.scrollTop + const exitX = exitRect.left - containerRect.left + container.scrollLeft + newLines.push({ sourceY, destY: exitY, sourceX, destX: exitX, weight: 1, color: '#ef4444' }) } } setLines(newLines) - setExits(newExits) }, [selections, columns, transitions, containerRef]) - if (lines.length === 0 && exits.length === 0) return null + if (lines.length === 0) return null return ( ) })} - {exits.map((exit, i) => ( - - - (exit) {exit.count} - - - ))} ) } +// ─── Exit count helper ────────────────────────────────────────────── + +function getExitCount( + colIdx: number, + selectedPath: string, + columns: Column[], + transitions: PathTransition[], +): number { + const col = columns[colIdx] + const page = col?.pages.find((p) => p.path === selectedPath) + if (!page) return 0 + const outbound = transitions + .filter((t) => t.step_index === colIdx && t.from_path === selectedPath) + .reduce((sum, t) => sum + t.session_count, 0) + return Math.max(0, page.sessionCount - outbound) +} + // ─── Main Component ───────────────────────────────────────────────── export default function ColumnJourney({ @@ -523,15 +500,24 @@ export default function ColumnJourney({ className="overflow-x-auto -mx-6 px-6 pb-2 relative" >
- {columns.map((col) => ( - handleSelect(col.index, path)} - /> - ))} + {columns.map((col) => { + // Show exit card in this column if the previous column has a selection + const prevSelection = selections.get(col.index - 1) + const exitCount = prevSelection + ? getExitCount(col.index - 1, prevSelection, columns, transitions) + : 0 + + return ( + handleSelect(col.index, path)} + /> + ) + })}
Date: Sun, 15 Mar 2026 13:12:17 +0100 Subject: [PATCH 30/86] feat: polish journey columns with bar charts, count pills, colored selection, dotted connectors --- components/journeys/ColumnJourney.tsx | 100 +++++++++++++++++--------- 1 file changed, 67 insertions(+), 33 deletions(-) diff --git a/components/journeys/ColumnJourney.tsx b/components/journeys/ColumnJourney.tsx index 04c77fa..0e420c1 100644 --- a/components/journeys/ColumnJourney.tsx +++ b/components/journeys/ColumnJourney.tsx @@ -1,6 +1,6 @@ 'use client' -import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { TreeStructure } from '@phosphor-icons/react' import type { PathTransition } from '@/lib/api/journeys' @@ -121,28 +121,35 @@ function ColumnHeader({ color: string }) { return ( -
+
{column.index + 1} -
- - {column.totalSessions.toLocaleString()} visitors +
+ + {column.totalSessions.toLocaleString()} - {column.dropOffPercent !== 0 && ( - - {column.dropOffPercent > 0 ? '+' : ''} - {column.dropOffPercent}% +
+ + visitors - )} + {column.dropOffPercent !== 0 && ( + + {column.dropOffPercent > 0 ? '+' : ''} + {column.dropOffPercent}% + + )} +
+ {/* Colored connector line from header to cards */} +
) } @@ -150,19 +157,23 @@ function ColumnHeader({ function PageRow({ page, colIndex, + colColor, columnTotal, + maxCount, isSelected, isOther, onClick, }: { page: ColumnPage colIndex: number + colColor: string columnTotal: number + maxCount: number isSelected: boolean isOther: boolean onClick: () => void }) { - const pct = columnTotal > 0 ? Math.round((page.sessionCount / columnTotal) * 100) : 0 + const barWidth = maxCount > 0 ? (page.sessionCount / maxCount) * 100 : 0 return ( ) } function JourneyColumn({ column, - color, selectedPath, exitCount, onSelect, }: { column: Column - color: string selectedPath: string | undefined exitCount: number onSelect: (path: string) => void }) { if (column.pages.length === 0 && exitCount === 0) { return ( -
- -
+
+ +
No onward traffic @@ -265,9 +250,9 @@ function JourneyColumn({ const maxCount = Math.max(...column.pages.map((p) => p.sessionCount), 0) return ( -
- -
+
+ +
{column.pages.map((page) => { const isOther = page.path === '(other)' return ( @@ -275,7 +260,6 @@ function JourneyColumn({ key={page.path} page={page} colIndex={column.index} - colColor={color} columnTotal={column.totalSessions} maxCount={maxCount} isSelected={selectedPath === page.path} @@ -290,12 +274,12 @@ function JourneyColumn({
- + (exit) - + {exitCount.toLocaleString()}
@@ -518,11 +502,10 @@ export default function ColumnJourney({ return ( {i > 0 && ( -
+
)} handleSelect(col.index, path)} From de16991bb3fe864de82e4ef1c5cca5e76ca48945 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Sun, 15 Mar 2026 13:30:20 +0100 Subject: [PATCH 34/86] fix: inset bar chart so left rounding is visible --- components/journeys/ColumnJourney.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/journeys/ColumnJourney.tsx b/components/journeys/ColumnJourney.tsx index e6efe61..f759b29 100644 --- a/components/journeys/ColumnJourney.tsx +++ b/components/journeys/ColumnJourney.tsx @@ -185,7 +185,7 @@ function PageRow({ {/* Background bar */} {!isOther && (
Date: Sun, 15 Mar 2026 13:33:10 +0100 Subject: [PATCH 35/86] fix: bar chart overflow by using scaleX instead of width percentage --- components/journeys/ColumnJourney.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/components/journeys/ColumnJourney.tsx b/components/journeys/ColumnJourney.tsx index f759b29..1055950 100644 --- a/components/journeys/ColumnJourney.tsx +++ b/components/journeys/ColumnJourney.tsx @@ -183,11 +183,11 @@ function PageRow({ `} > {/* Background bar */} - {!isOther && ( + {!isOther && barWidth > 0 && (
From e7907d68bf4376566b2729e4fdb0d953cf7a8414 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Sun, 15 Mar 2026 13:39:41 +0100 Subject: [PATCH 36/86] fix: default depth 10, bar rounding, exit row height, connection line reach --- app/sites/[id]/journeys/page.tsx | 8 ++++---- components/journeys/ColumnJourney.tsx | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/app/sites/[id]/journeys/page.tsx b/app/sites/[id]/journeys/page.tsx index 61ae39b..0953d0e 100644 --- a/app/sites/[id]/journeys/page.tsx +++ b/app/sites/[id]/journeys/page.tsx @@ -37,8 +37,8 @@ export default function JourneysPage() { const [period, setPeriod] = useState('30') const [dateRange, setDateRange] = useState(() => getDateRange(30)) const [isDatePickerOpen, setIsDatePickerOpen] = useState(false) - const [depth, setDepth] = useState(3) - const [displayDepth, setDisplayDepth] = useState(3) + const [depth, setDepth] = useState(10) + const [displayDepth, setDisplayDepth] = useState(10) const [entryPath, setEntryPath] = useState('') const sliderIndex = DEPTH_STEPS.indexOf(displayDepth) @@ -160,9 +160,9 @@ export default function JourneysPage() { onChange={(value) => setEntryPath(value)} options={entryPointOptions} /> - {(depth !== 3 || entryPath) && ( + {(depth !== 10 || entryPath) && ( @@ -409,11 +409,11 @@ export default function HomePage() { {/* * Global Overview - min-h ensures no layout shift when Plan & usage loads */}
-
+

Total Sites

{sites.length}

-
+

Total Visitors (24h)

{sites.length === 0 || Object.keys(siteStats).length < sites.length diff --git a/app/sites/[id]/cdn/page.tsx b/app/sites/[id]/cdn/page.tsx index 267b6c7..a244b55 100644 --- a/app/sites/[id]/cdn/page.tsx +++ b/app/sites/[id]/cdn/page.tsx @@ -143,7 +143,7 @@ export default function CDNPage() {

-
+
@@ -249,7 +249,7 @@ export default function CDNPage() {
{/* Overview cards */} -
+
) : hasData ? ( -
+
{/* Header */}
Page @@ -72,7 +72,7 @@ export default function FrustrationByPageTable({ pages, loading }: FrustrationBy style={{ width: `${barWidth}%` }} /> {page.page_path} diff --git a/components/behavior/FrustrationTable.tsx b/components/behavior/FrustrationTable.tsx index d16ee4f..8294b95 100644 --- a/components/behavior/FrustrationTable.tsx +++ b/components/behavior/FrustrationTable.tsx @@ -81,7 +81,7 @@ function Row({ return (
-
+
)}
-
+
{(['top_pages', 'entry_pages', 'exit_pages'] as Tab[]).map((tab) => (
-
+
{(['browsers', 'os', 'devices', 'screens'] as Tab[]).map((tab) => (
@@ -160,9 +165,9 @@ export default function JourneysPage() { onChange={(value) => setEntryPath(value)} options={entryPointOptions} /> - {(displayDepth !== 10 || entryPath) && ( + {(depth !== 10 || entryPath) && (
From d864d951f982706dcd3a0a649e1ebd4f288b6e25 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Sun, 15 Mar 2026 20:08:15 +0100 Subject: [PATCH 50/86] fix: rebuild depth slider from scratch - Use min=2 max=10 directly on range input (no array index mapping) - Debounce via useEffect instead of manual timer refs - Always render Reset button with opacity transition (no layout shift) - Immediate setCommittedDepth on reset for instant response --- app/sites/[id]/journeys/page.tsx | 57 ++++++++++++++++++-------------- 1 file changed, 32 insertions(+), 25 deletions(-) diff --git a/app/sites/[id]/journeys/page.tsx b/app/sites/[id]/journeys/page.tsx index 27a43b7..67f5a41 100644 --- a/app/sites/[id]/journeys/page.tsx +++ b/app/sites/[id]/journeys/page.tsx @@ -1,6 +1,6 @@ 'use client' -import { useEffect, useRef, useState } from 'react' +import { useEffect, useState } from 'react' import { useParams } from 'next/navigation' import { getDateRange, formatDate } from '@ciphera-net/ui' import { Select, DatePicker } from '@ciphera-net/ui' @@ -14,7 +14,7 @@ import { useJourneyEntryPoints, } from '@/lib/swr/dashboard' -const DEPTH_STEPS = [2, 3, 4, 5, 6, 7, 8, 9, 10] +const DEFAULT_DEPTH = 10 function getThisWeekRange(): { start: string; end: string } { const today = new Date() @@ -37,21 +37,25 @@ export default function JourneysPage() { const [period, setPeriod] = useState('30') const [dateRange, setDateRange] = useState(() => getDateRange(30)) const [isDatePickerOpen, setIsDatePickerOpen] = useState(false) - const [depth, setDepth] = useState(10) - const [debouncedDepth, setDebouncedDepth] = useState(10) + const [depth, setDepth] = useState(DEFAULT_DEPTH) + const [committedDepth, setCommittedDepth] = useState(DEFAULT_DEPTH) const [entryPath, setEntryPath] = useState('') - const depthTimer = useRef>(null) - const sliderIndex = DEPTH_STEPS.indexOf(depth) + useEffect(() => { + const t = setTimeout(() => setCommittedDepth(depth), 300) + return () => clearTimeout(t) + }, [depth]) - function handleDepthChange(newDepth: number) { - setDepth(newDepth) - if (depthTimer.current) clearTimeout(depthTimer.current) - depthTimer.current = setTimeout(() => setDebouncedDepth(newDepth), 300) + const isDefault = depth === DEFAULT_DEPTH && !entryPath + + function resetFilters() { + setDepth(DEFAULT_DEPTH) + setCommittedDepth(DEFAULT_DEPTH) + setEntryPath('') } const { data: transitionsData, isLoading: transitionsLoading } = useJourneyTransitions( - siteId, dateRange.start, dateRange.end, debouncedDepth, 1, entryPath || undefined + siteId, dateRange.start, dateRange.end, committedDepth, 1, entryPath || undefined ) const { data: topPaths, isLoading: topPathsLoading } = useJourneyTopPaths( siteId, dateRange.start, dateRange.end, 20, 1, entryPath || undefined @@ -145,11 +149,11 @@ export default function JourneysPage() {
handleDepthChange(DEPTH_STEPS[parseInt(e.target.value)])} + min={2} + max={10} + step={1} + value={depth} + onChange={(e) => setDepth(parseInt(e.target.value))} aria-label="Journey depth" aria-valuetext={`${depth} steps deep`} className="w-full h-2 bg-neutral-200 rounded-lg appearance-none cursor-pointer dark:bg-neutral-700 accent-brand-orange focus:outline-none" @@ -165,14 +169,17 @@ export default function JourneysPage() { onChange={(value) => setEntryPath(value)} options={entryPointOptions} /> - {(depth !== 10 || entryPath) && ( - - )} +
@@ -182,7 +189,7 @@ export default function JourneysPage() { setEntryPath(path)} />
From 4f4f2f4f9ac12b2df69fcc9e4341d74ceaa8f40f Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Sun, 15 Mar 2026 20:31:57 +0100 Subject: [PATCH 51/86] refactor: redesign top paths table to match Pulse patterns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace card-per-row with compact list rows + background bars - Drop rank badges (order already communicates rank) - Inline path sequence + stats into single row - Truncate sequences longer than 7 steps (first 3 + … + last 2) - Duration shows on hover with slide-in animation - Use brand-orange bars proportional to top path count --- components/journeys/TopPathsTable.tsx | 105 ++++++++++++++++---------- 1 file changed, 65 insertions(+), 40 deletions(-) diff --git a/components/journeys/TopPathsTable.tsx b/components/journeys/TopPathsTable.tsx index f85e57c..72bdcd1 100644 --- a/components/journeys/TopPathsTable.tsx +++ b/components/journeys/TopPathsTable.tsx @@ -2,7 +2,7 @@ import type { TopPath } from '@/lib/api/journeys' import { TableSkeleton } from '@/components/skeletons' -import { Path, ArrowRight, Clock, Users } from '@phosphor-icons/react' +import { Path, ArrowRight, Clock } from '@phosphor-icons/react' interface TopPathsTableProps { paths: TopPath[] @@ -24,8 +24,16 @@ function smartLabel(path: string): string { return `…/${segments[segments.length - 1]}` } +function truncateSequence(seq: string[], max: number): (string | null)[] { + if (seq.length <= max) return seq + const head = seq.slice(0, 3) + const tail = seq.slice(-2) + return [...head, null, ...tail] +} + export default function TopPathsTable({ paths, loading }: TopPathsTableProps) { const hasData = paths.length > 0 + const maxCount = hasData ? paths[0].session_count : 0 return (
@@ -41,49 +49,66 @@ export default function TopPathsTable({ paths, loading }: TopPathsTableProps) { {loading ? ( ) : hasData ? ( -
- {paths.map((path, i) => ( -
-
- - #{i + 1} - -
- - - {path.session_count.toLocaleString()} - - {path.avg_duration > 0 && ( - - - {formatDuration(path.avg_duration)} - - )} -
-
-
- {path.page_sequence.map((page, j) => ( -
- {j > 0 && ( - +
+ {paths.map((path, i) => { + const barWidth = maxCount > 0 ? (path.session_count / maxCount) * 100 : 0 + const displaySeq = truncateSequence(path.page_sequence, 7) + + return ( +
+ {/* Background bar */} +
+ + {/* Content */} +
+ {/* Path sequence */} +
+ {displaySeq.map((page, j) => ( +
+ {j > 0 && ( + + )} + {page === null ? ( + + … + + ) : ( + + {smartLabel(page)} + + )} +
+ ))} +
+ + {/* Stats */} +
+ {path.avg_duration > 0 && ( + + + {formatDuration(path.avg_duration)} + )} - - {smartLabel(page)} + + {path.session_count.toLocaleString()}
- ))} +
-
- ))} + ) + })}
) : (
From df2b3cadd729f13ab2edcf8fee3391a4077beff8 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Sun, 15 Mar 2026 20:39:25 +0100 Subject: [PATCH 52/86] feat: add inline bar charts to all dashboard list components Add proportional background bars (brand-orange) to Pages, Referrers, Locations, Technology, and Campaigns tables. Bars scale relative to the top item in each list. --- components/dashboard/Campaigns.tsx | 12 ++++-- components/dashboard/ContentStats.tsx | 62 +++++++++++++++------------ components/dashboard/Locations.tsx | 12 ++++-- components/dashboard/TechSpecs.tsx | 12 ++++-- components/dashboard/TopReferrers.tsx | 46 ++++++++++++-------- 5 files changed, 89 insertions(+), 55 deletions(-) diff --git a/components/dashboard/Campaigns.tsx b/components/dashboard/Campaigns.tsx index 38e5cb0..0d5c745 100644 --- a/components/dashboard/Campaigns.tsx +++ b/components/dashboard/Campaigns.tsx @@ -155,13 +155,19 @@ export default function Campaigns({ siteId, dateRange, filters, onFilter }: Camp ) : hasData ? ( <> {displayedData.map((item) => { + const maxVis = displayedData[0]?.visitors ?? 0 + const barWidth = maxVis > 0 ? (item.visitors / maxVis) * 100 : 0 return (
onFilter?.({ dimension: 'utm_source', operator: 'is', values: [item.source] })} - className={`flex items-center justify-between py-1.5 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors${onFilter ? ' cursor-pointer' : ''}`} + className={`relative flex items-center justify-between py-1.5 group hover:bg-neutral-50/50 dark:hover:bg-neutral-800/50 rounded-lg px-2 -mx-2 transition-colors${onFilter ? ' cursor-pointer' : ''}`} > -
+
+
{renderSourceIcon(item.source)}
@@ -174,7 +180,7 @@ export default function Campaigns({ siteId, dateRange, filters, onFilter }: Camp
-
+
{totalVisitors > 0 ? `${Math.round((item.visitors / totalVisitors) * 100)}%` : ''} diff --git a/components/dashboard/ContentStats.tsx b/components/dashboard/ContentStats.tsx index f56b5df..4b0a460 100644 --- a/components/dashboard/ContentStats.tsx +++ b/components/dashboard/ContentStats.tsx @@ -148,34 +148,42 @@ export default function ContentStats({ topPages, entryPages, exitPages, domain,
) : hasData ? ( <> - {displayedData.map((page) => ( -
onFilter?.({ dimension: 'page', operator: 'is', values: [page.path] })} - className={`flex items-center justify-between h-9 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors${onFilter ? ' cursor-pointer' : ''}`} - > -
- {page.path} - e.stopPropagation()} - className="ml-2 flex-shrink-0" - > - - + {displayedData.map((page, idx) => { + const maxPv = displayedData[0]?.pageviews ?? 0 + const barWidth = maxPv > 0 ? (page.pageviews / maxPv) * 100 : 0 + return ( +
onFilter?.({ dimension: 'page', operator: 'is', values: [page.path] })} + className={`relative flex items-center justify-between h-9 group hover:bg-neutral-50/50 dark:hover:bg-neutral-800/50 rounded-lg px-2 -mx-2 transition-colors${onFilter ? ' cursor-pointer' : ''}`} + > +
+ +
+ + {totalPageviews > 0 ? `${Math.round((page.pageviews / totalPageviews) * 100)}%` : ''} + + + {formatNumber(page.pageviews)} + +
-
- - {totalPageviews > 0 ? `${Math.round((page.pageviews / totalPageviews) * 100)}%` : ''} - - - {formatNumber(page.pageviews)} - -
-
- ))} + ) + })} {Array.from({ length: emptySlots }).map((_, i) => (