From 244fc3ab7b5d7f82a6875d4b130e123ce6a5c7f1 Mon Sep 17 00:00:00 2001 From: Konsti Wohlwend Date: Tue, 30 Jun 2026 23:04:22 -0700 Subject: [PATCH] feat: add navigation funnel graph page (#1701) --- apps/dashboard/package.json | 12 +- .../analytics/funnel-graph/force-layout.ts | 314 +++++++++++++++ .../funnel-graph/funnel-graph-canvas.tsx | 369 ++++++++++++++++++ .../analytics/funnel-graph/normalize-url.ts | 60 +++ .../analytics/funnel-graph/page-client.tsx | 260 ++++++++++++ .../analytics/funnel-graph/page.tsx | 5 + 6 files changed, 1014 insertions(+), 6 deletions(-) create mode 100644 apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/funnel-graph/force-layout.ts create mode 100644 apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/funnel-graph/funnel-graph-canvas.tsx create mode 100644 apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/funnel-graph/normalize-url.ts create mode 100644 apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/funnel-graph/page-client.tsx create mode 100644 apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/funnel-graph/page.tsx diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index ba765d16f..f86897541 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -22,16 +22,21 @@ }, "dependencies": { "@ai-sdk/react": "^3.0.72", - "@pierre/diffs": "^1.2.11", "@assistant-ui/react": "^0.10.24", "@assistant-ui/react-markdown": "^0.10.5", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", + "@hexclave/dashboard-ui-components": "workspace:*", + "@hexclave/next": "workspace:*", + "@hexclave/shared": "workspace:*", + "@hexclave/shared-backend": "workspace:*", + "@hexclave/ui": "workspace:*", "@hookform/resolvers": "^3.3.4", "@monaco-editor/react": "4.7.0", "@oslojs/otp": "^1.1.0", "@phosphor-icons/react": "^2.1.10", + "@pierre/diffs": "^1.2.11", "@radix-ui/react-accordion": "^1.2.1", "@radix-ui/react-avatar": "^1.1.1", "@radix-ui/react-checkbox": "^1.1.2", @@ -53,11 +58,6 @@ "@radix-ui/react-tooltip": "^1.1.3", "@react-hook/resize-observer": "^2.0.2", "@sentry/nextjs": "^10.11.0", - "@hexclave/dashboard-ui-components": "workspace:*", - "@hexclave/shared-backend": "workspace:*", - "@hexclave/next": "workspace:*", - "@hexclave/shared": "workspace:*", - "@hexclave/ui": "workspace:*", "@stripe/connect-js": "^3.3.27", "@stripe/react-connect-js": "^3.3.24", "@stripe/react-stripe-js": "^3.8.1", diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/funnel-graph/force-layout.ts b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/funnel-graph/force-layout.ts new file mode 100644 index 000000000..7cd3896ae --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/funnel-graph/force-layout.ts @@ -0,0 +1,314 @@ +/** + * Graph layout using ForceAtlas2-style simulation with: + * 1. Landing page detection → average distance for soft x-position + * 2. ForceAtlas2 forces with collision avoidance + * 3. Spring strength proportional to log(clicks) + * 4. Edge bundling for parallel edges + */ + +export type GraphNode = { + id: string, + label: string, + domain: string, + pageViews: number, + width: number, + x: number, + y: number, +}; + +export type GraphEdge = { + from: string, + to: string, + count: number, + /** Weight (linear): raw transition count */ + weight: number, +}; + +// Layout constants +const CARD_HEIGHT = 60; +const ITERATIONS = 500; +const GRAVITY = 0.005; +const REPULSION_SCALE = 8000; +const X_CONSTRAINT_STRENGTH = 0.25; +const DAMPING = 0.85; +const MIN_DIST = 50; + +/** + * Detect landing pages: pages with high outbound relative to inbound, + * or common root paths like "/", "/home", etc. + */ +function findLandingPages(nodes: GraphNode[], edges: GraphEdge[]): Set { + const inbound = new Map(); + const outbound = new Map(); + for (const n of nodes) { + inbound.set(n.id, 0); + outbound.set(n.id, 0); + } + for (const e of edges) { + outbound.set(e.from, (outbound.get(e.from) ?? 0) + e.count); + inbound.set(e.to, (inbound.get(e.to) ?? 0) + e.count); + } + + const rootPatterns = ["/", "/index", "/home", "/landing"]; + const landings = new Set(); + + for (const n of nodes) { + const out = outbound.get(n.id) ?? 0; + const inn = inbound.get(n.id) ?? 0; + if (rootPatterns.some((p) => n.id === p || (n.id.endsWith("/") && n.id.slice(0, -1) === p))) { + landings.add(n.id); + continue; + } + if (out > inn * 1.3 && out > 30) { + landings.add(n.id); + } + } + + if (landings.size === 0) { + const sorted = [...nodes].sort((a, b) => (outbound.get(b.id) ?? 0) - (outbound.get(a.id) ?? 0)); + for (let i = 0; i < Math.min(3, sorted.length); i++) { + landings.add(sorted[i]!.id); + } + } + + return landings; +} + +/** + * Compute average shortest-path distance from landing pages using BFS. + */ +function computeDistanceFromLandings( + nodes: GraphNode[], + edges: GraphEdge[], + landings: Set, +): Map { + const adj = new Map(); + for (const n of nodes) { + adj.set(n.id, []); + } + for (const e of edges) { + adj.get(e.from)?.push({ to: e.to }); + } + + const distances = new Map(); + for (const n of nodes) { + distances.set(n.id, []); + } + + for (const landing of landings) { + const dist = new Map(); + const queue: string[] = [landing]; + dist.set(landing, 0); + + while (queue.length > 0) { + const current = queue.shift()!; + const currentDist = dist.get(current)!; + const neighbors = adj.get(current) ?? []; + for (const { to } of neighbors) { + if (!dist.has(to)) { + dist.set(to, currentDist + 1); + queue.push(to); + } + } + } + + for (const [nodeId, d] of dist) { + distances.get(nodeId)?.push(d); + } + } + + const avgDist = new Map(); + let maxDist = 0; + for (const [nodeId, dists] of distances) { + if (dists.length > 0) { + const avg = dists.reduce((a, b) => a + b, 0) / dists.length; + avgDist.set(nodeId, avg); + maxDist = Math.max(maxDist, avg); + } else { + avgDist.set(nodeId, maxDist + 1); + } + } + + // Normalize unreachable nodes + for (const [nodeId, d] of avgDist) { + if (d > maxDist) { + avgDist.set(nodeId, maxDist + 1); + } + } + + return avgDist; +} + +type SimNode = { + id: string, + label: string, + width: number, + x: number, + y: number, + vx: number, + vy: number, + targetX: number, +}; + +/** + * ForceAtlas2-style layout with: + * - Soft x-constraint from landing page distance + * - Repulsion inversely proportional to distance + * - Spring attraction proportional to log(clicks) + * - Collision avoidance based on card dimensions + */ +export function computeLayout(nodes: GraphNode[], edges: GraphEdge[]): GraphNode[] { + if (nodes.length === 0) return []; + + // Find landing pages and compute distances + const landings = findLandingPages(nodes, edges); + const distFromLanding = computeDistanceFromLandings(nodes, edges, landings); + + // Normalize distances to x-range + const maxDist = Math.max(...distFromLanding.values(), 1); + const xSpread = nodes.length * 50; + + // Initialize simulation nodes + const simNodes: SimNode[] = nodes.map((n, i) => { + const dist = distFromLanding.get(n.id) ?? 0; + const targetX = (dist / maxDist) * xSpread; + const ySpread = nodes.length * 60; + return { + id: n.id, + label: n.label, + width: n.width, + x: targetX + (Math.random() - 0.5) * 80, + y: (i / nodes.length - 0.5) * ySpread, + vx: 0, + vy: 0, + targetX, + }; + }); + + const nodeIndex = new Map(); + for (let i = 0; i < simNodes.length; i++) { + nodeIndex.set(simNodes[i]!.id, i); + } + + const maxWeight = edges.reduce((m, e) => Math.max(m, e.weight), 1); + + // Collision padding + const collisionPadX = 30; + const collisionPadY = 25; + const collisionH = CARD_HEIGHT / 2 + collisionPadY; + + for (let iter = 0; iter < ITERATIONS; iter++) { + const alpha = 1 - iter / ITERATIONS; + const cool = 0.1 + 0.9 * alpha; + + // Repulsion between all pairs + collision avoidance + for (let i = 0; i < simNodes.length; i++) { + for (let j = i + 1; j < simNodes.length; j++) { + const a = simNodes[i]!; + const b = simNodes[j]!; + const dx = b.x - a.x; + const dy = b.y - a.y; + let dist = Math.sqrt(dx * dx + dy * dy); + if (dist < MIN_DIST) dist = MIN_DIST; + + // Repulsion + const repForce = REPULSION_SCALE * cool / (dist * dist); + const fx = (dx / dist) * repForce; + const fy = (dy / dist) * repForce; + + a.vx -= fx; + a.vy -= fy; + b.vx += fx; + b.vy += fy; + + // Collision avoidance: push apart if rectangular bounds overlap + // Use per-node width for accurate collision detection + const collisionW = (a.width + b.width) / 2 + collisionPadX; + const overlapX = collisionW - Math.abs(dx); + const overlapY = collisionH * 2 - Math.abs(dy); + if (overlapX > 0 && overlapY > 0) { + const pushX = (overlapX / 2) * Math.sign(dx || 1) * 0.8; + const pushY = (overlapY / 2) * Math.sign(dy || 1) * 0.8; + a.x -= pushX; + b.x += pushX; + a.y -= pushY; + b.y += pushY; + } + } + } + + // Spring attraction along edges, strength ∝ log(clicks) + // Reverse x-force when arrow points left to bias left-to-right flow + for (const edge of edges) { + const ai = nodeIndex.get(edge.from); + const bi = nodeIndex.get(edge.to); + if (ai == null || bi == null) continue; + + const a = simNodes[ai]!; + const b = simNodes[bi]!; + const dx = b.x - a.x; + const dy = b.y - a.y; + const dist = Math.sqrt(dx * dx + dy * dy); + if (dist < 1) continue; + + const strength = 0.008 * (edge.weight / maxWeight) * cool; + const fy = dy * strength; + // Reverse x-component when edge points left (to.x < from.x) + // This makes backward edges repulsive on x, reinforcing left-to-right flow + const fx = dx < 0 ? -(dx * strength) : dx * strength; + + a.vx += fx; + a.vy += fy; + b.vx -= fx; + b.vy -= fy; + } + + // Horizontal alignment bias: strong edges pull nodes toward same y-level + // Weak edges don't care about vertical alignment + for (const edge of edges) { + const ai = nodeIndex.get(edge.from); + const bi = nodeIndex.get(edge.to); + if (ai == null || bi == null) continue; + + const a = simNodes[ai]!; + const b = simNodes[bi]!; + const dy = b.y - a.y; + + // Strength proportional to normalized edge weight (squared for emphasis) + const relWeight = edge.weight / maxWeight; + const alignStrength = 0.012 * relWeight * relWeight * cool; + const fy = dy * alignStrength; + + // Pull both nodes toward their shared y-midpoint + a.vy += fy; + b.vy -= fy; + } + + // Soft x-constraint: pull toward target x based on distance from landings + for (const node of simNodes) { + const xDiff = node.targetX - node.x; + node.vx += xDiff * X_CONSTRAINT_STRENGTH * cool; + } + + // Gravity toward center (y-axis only) + for (const node of simNodes) { + node.vy -= node.y * GRAVITY * cool; + } + + // Apply velocity with damping + for (const node of simNodes) { + node.vx *= DAMPING; + node.vy *= DAMPING; + node.x += node.vx; + node.y += node.vy; + } + } + + // Restore full node data with updated positions + const nodeById = new Map(nodes.map((n) => [n.id, n])); + return simNodes.map((n) => ({ + ...nodeById.get(n.id)!, + x: n.x, + y: n.y, + })); +} diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/funnel-graph/funnel-graph-canvas.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/funnel-graph/funnel-graph-canvas.tsx new file mode 100644 index 000000000..723edb1cb --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/funnel-graph/funnel-graph-canvas.tsx @@ -0,0 +1,369 @@ +"use client"; + +import type React from "react"; +import { cn } from "@/lib/utils"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { GraphEdge, GraphNode } from "./force-layout"; + +const CARD_HEIGHT = 60; + +function edgeOpacity(count: number, maxCount: number): number { + if (maxCount === 0) return 0.1; + return 0.1 + 0.7 * (count / maxCount); +} + +function edgeWidth(count: number, maxCount: number): number { + if (maxCount === 0) return 0.5; + return 0.5 + 1.5 * (count / maxCount); +} + +/** + * Compute edge path with bundling offset for parallel edges. + * Uses rectangular node bounds for connection points. + */ +function getEdgePath(from: GraphNode, to: GraphNode, bundleOffset: number) { + const dx = to.x - from.x; + const dy = to.y - from.y; + const dist = Math.sqrt(dx * dx + dy * dy); + if (dist < 1) return null; + + const ndx = dx / dist; + const ndy = dy / dist; + + // Ray-rectangle intersection for exit/entry points (per-node width) + const hwFrom = from.width / 2; + const hwTo = to.width / 2; + const hh = CARD_HEIGHT / 2; + + const tFromX = ndx !== 0 ? hwFrom / Math.abs(ndx) : Infinity; + const tFromY = ndy !== 0 ? hh / Math.abs(ndy) : Infinity; + const tFrom = Math.min(tFromX, tFromY); + const fromX = from.x + ndx * tFrom; + const fromY = from.y + ndy * tFrom; + + const tToX = ndx !== 0 ? hwTo / Math.abs(ndx) : Infinity; + const tToY = ndy !== 0 ? hh / Math.abs(ndy) : Infinity; + const tTo = Math.min(tToX, tToY); + const toX = to.x - ndx * tTo; + const toY = to.y - ndy * tTo; + + // Perpendicular offset for bundling + subtle curve + const nx = -ndy; + const ny = ndx; + const curvature = Math.min(dist * 0.08, 15) + bundleOffset * 8; + const cx = (fromX + toX) / 2 + nx * curvature; + const cy = (fromY + toY) / 2 + ny * curvature; + + return { fromX, fromY, toX, toY, cx, cy }; +} + +export function FunnelGraphCanvas({ + nodes, + edges, + weakEdges, +}: { + nodes: GraphNode[], + edges: GraphEdge[], + weakEdges: GraphEdge[], +}) { + const containerRef = useRef(null); + const [hoveredNode, setHoveredNode] = useState(null); + const [transform, setTransform] = useState({ x: 0, y: 0, scale: 1 }); + const [isPanning, setIsPanning] = useState(false); + const panStart = useRef({ x: 0, y: 0, tx: 0, ty: 0 }); + const [containerSize, setContainerSize] = useState({ w: 0, h: 0 }); + + useEffect(() => { + const el = containerRef.current; + if (el == null) return; + const obs = new ResizeObserver((entries) => { + const entry = entries[0]; + setContainerSize({ w: entry.contentRect.width, h: entry.contentRect.height }); + }); + obs.observe(el); + return () => obs.disconnect(); + }, []); + + const maxCount = useMemo(() => edges.reduce((m, e) => Math.max(m, e.count), 0), [edges]); + + const nodeStats = useMemo(() => { + const stats = new Map(); + for (const n of nodes) { + stats.set(n.id, { inbound: 0, outbound: 0 }); + } + for (const e of edges) { + const from = stats.get(e.from); + const to = stats.get(e.to); + if (from != null) from.outbound += e.count; + if (to != null) to.inbound += e.count; + } + return stats; + }, [nodes, edges]); + + const highlightedEdges = useMemo(() => { + if (hoveredNode == null) return null; + const set = new Set(); + for (const e of edges) { + if (e.from === hoveredNode || e.to === hoveredNode) { + set.add(`${e.from}\0${e.to}`); + } + } + // Also include weak edges connected to the hovered node + for (const e of weakEdges) { + if (e.from === hoveredNode || e.to === hoveredNode) { + set.add(`${e.from}\0${e.to}`); + } + } + return set; + }, [hoveredNode, edges, weakEdges]); + + // Weak edges visible only on hover + const visibleWeakEdges = useMemo(() => { + if (hoveredNode == null) return []; + return weakEdges.filter((e) => e.from === hoveredNode || e.to === hoveredNode); + }, [hoveredNode, weakEdges]); + + // Compute bundle offsets for parallel edges + const edgeBundleOffsets = useMemo(() => { + const pairCounts = new Map(); + const offsets = new Map(); + for (const e of edges) { + const [a, b] = [e.from, e.to].sort(); + const pairKey = `${a}\0${b}`; + const count = pairCounts.get(pairKey) ?? 0; + offsets.set(`${e.from}\0${e.to}`, count); + pairCounts.set(pairKey, count + 1); + } + return offsets; + }, [edges]); + + const handleMouseDown = useCallback((e: React.MouseEvent) => { + if (e.button !== 0) return; + setIsPanning(true); + panStart.current = { x: e.clientX, y: e.clientY, tx: transform.x, ty: transform.y }; + }, [transform.x, transform.y]); + + const handleMouseMove = useCallback((e: React.MouseEvent) => { + if (!isPanning) return; + const dx = e.clientX - panStart.current.x; + const dy = e.clientY - panStart.current.y; + setTransform((t) => ({ + ...t, + x: panStart.current.tx + dx, + y: panStart.current.ty + dy, + })); + }, [isPanning]); + + const handleMouseUp = useCallback(() => { + setIsPanning(false); + }, []); + + useEffect(() => { + const handleGlobalUp = () => setIsPanning(false); + window.addEventListener("mouseup", handleGlobalUp); + return () => window.removeEventListener("mouseup", handleGlobalUp); + }, []); + + const handleWheel = useCallback((e: React.WheelEvent) => { + e.preventDefault(); + const scaleFactor = e.deltaY > 0 ? 0.9 : 1.1; + setTransform((t) => ({ + ...t, + scale: Math.max(0.1, Math.min(5, t.scale * scaleFactor)), + })); + }, []); + + const resetView = useCallback(() => { + setTransform({ x: 0, y: 0, scale: 1 }); + }, []); + + const nodeMap = useMemo(() => { + const map = new Map(); + for (const n of nodes) map.set(n.id, n); + return map; + }, [nodes]); + + const graphCenter = useMemo(() => { + if (nodes.length === 0) return { x: 0, y: 0 }; + let cx = 0, cy = 0; + for (const n of nodes) { + cx += n.x; + cy += n.y; + } + return { x: cx / nodes.length, y: cy / nodes.length }; + }, [nodes]); + + const offsetX = containerSize.w / 2 - graphCenter.x; + const offsetY = containerSize.h / 2 - graphCenter.y; + + return ( +
+ {/* Controls */} +
+ +
+ + {/* Legend */} +
+
Edge thickness = transitions
+
Scroll to zoom, drag to pan
+
+ + {/* SVG layer for edges */} + + + + + + + + {edges.map((edge) => { + const fromNode = nodeMap.get(edge.from); + const toNode = nodeMap.get(edge.to); + if (fromNode == null || toNode == null) return null; + + const bundleOffset = edgeBundleOffsets.get(`${edge.from}\0${edge.to}`) ?? 0; + const path = getEdgePath(fromNode, toNode, bundleOffset); + if (path == null) return null; + + const isHighlighted = highlightedEdges == null || highlightedEdges.has(`${edge.from}\0${edge.to}`); + const opacity = isHighlighted + ? edgeOpacity(edge.count, maxCount) + : (hoveredNode != null ? 0.03 : edgeOpacity(edge.count, maxCount)); + + return ( + + ); + })} + + {/* Weak edges shown on hover */} + {visibleWeakEdges.map((edge) => { + const fromNode = nodeMap.get(edge.from); + const toNode = nodeMap.get(edge.to); + if (fromNode == null || toNode == null) return null; + + const path = getEdgePath(fromNode, toNode, 0); + if (path == null) return null; + + return ( + + ); + })} + + {/* Edge labels on hover */} + {hoveredNode != null && [...edges, ...visibleWeakEdges] + .filter((e) => e.from === hoveredNode || e.to === hoveredNode) + .map((edge) => { + const fromNode = nodeMap.get(edge.from); + const toNode = nodeMap.get(edge.to); + if (fromNode == null || toNode == null) return null; + const mx = (fromNode.x + toNode.x) / 2; + const my = (fromNode.y + toNode.y) / 2; + return ( + + {edge.count.toLocaleString()} + + ); + }) + } + + + + {/* HTML layer for node cards */} +
+ {nodes.map((node) => { + const stats = nodeStats.get(node.id); + const isHovered = hoveredNode === node.id; + return ( +
setHoveredNode(node.id)} + onMouseLeave={() => setHoveredNode(null)} + > + {node.domain !== "" && ( +
+ {node.domain} +
+ )} +
+ {node.label} +
+
+ {node.pageViews.toLocaleString()} views + · + →{stats?.inbound.toLocaleString() ?? 0} + {stats?.outbound.toLocaleString() ?? 0}→ +
+
+ ); + })} +
+
+ ); +} diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/funnel-graph/normalize-url.ts b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/funnel-graph/normalize-url.ts new file mode 100644 index 000000000..32a9f6406 --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/funnel-graph/normalize-url.ts @@ -0,0 +1,60 @@ +/** + * Normalize a URL path by replacing dynamic segments (UUIDs, numeric IDs, + * hashes, base64 tokens, etc.) with placeholder tokens. This groups + * similar pages (e.g. /users/abc123 and /users/def456 → /users/:id). + */ + +// UUID pattern: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx +const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +// Purely numeric segments (e.g. /posts/12345) +const NUMERIC_REGEX = /^\d+$/; + +// Long hex strings (8+ chars) — likely IDs or hashes +const HEX_ID_REGEX = /^[0-9a-f]{8,}$/i; + +// Base64-like tokens (16+ chars of alphanumeric + padding chars) +const BASE64_TOKEN_REGEX = /^[A-Za-z0-9_-]{16,}[=]{0,2}$/; + +// MongoDB ObjectIDs (24 hex chars) +const OBJECTID_REGEX = /^[0-9a-f]{24}$/i; + +// Short numeric-heavy mixed IDs (e.g. "a1b2c3d4", "usr_abc123") +// Require at least one digit in the suffix to avoid matching static words like "sign_in" +const PREFIXED_ID_REGEX = /^[a-z]{1,10}_[a-z0-9]*\d[a-z0-9]*$/i; + +function isLikelyDynamicSegment(segment: string): boolean { + if (segment.length === 0) return false; + + // Check each pattern from most specific to least + if (UUID_REGEX.test(segment)) return true; + if (OBJECTID_REGEX.test(segment)) return true; + if (NUMERIC_REGEX.test(segment)) return true; + if (HEX_ID_REGEX.test(segment)) return true; + if (PREFIXED_ID_REGEX.test(segment)) return true; + + // Base64 tokens (only for longer segments to avoid false positives on + // short path segments like "api" or "auth") + if (segment.length >= 20 && BASE64_TOKEN_REGEX.test(segment)) return true; + + return false; +} + +export function normalizeUrlPath(path: string): string { + // Strip query string and hash + const cleanPath = path.split("?")[0]!.split("#")[0]!; + + const segments = cleanPath.split("/"); + const normalized = segments.map((seg) => + isLikelyDynamicSegment(seg) ? ":id" : seg + ); + + // Collapse consecutive :id segments (e.g. /a/:id/:id → /a/:id) + const collapsed: string[] = []; + for (const seg of normalized) { + if (seg === ":id" && collapsed[collapsed.length - 1] === ":id") continue; + collapsed.push(seg); + } + + return collapsed.join("/") || "/"; +} diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/funnel-graph/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/funnel-graph/page-client.tsx new file mode 100644 index 000000000..059b51c5b --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/funnel-graph/page-client.tsx @@ -0,0 +1,260 @@ +"use client"; + +import { AppEnabledGuard } from "../../app-enabled-guard"; +import { PageLayout } from "../../page-layout"; +import { useAdminApp } from "../../use-admin-app"; +import { Button, Typography } from "@/components/ui"; +import { SpinnerGapIcon, ArrowClockwiseIcon } from "@phosphor-icons/react"; +import { runAsynchronouslyWithAlert } from "@hexclave/shared/dist/utils/promises"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { normalizeUrlPath } from "./normalize-url"; +import { computeLayout, type GraphNode, type GraphEdge } from "./force-layout"; +import { FunnelGraphCanvas } from "./funnel-graph-canvas"; + +type TransitionRow = { + from_path: string, + to_path: string, + cnt: string, +}; + +type FunnelData = { + nodes: GraphNode[], + edges: GraphEdge[], + weakEdges: GraphEdge[], +}; + +const NAVIGATION_QUERY = ` +SELECT + prev_path as from_path, + path as to_path, + count() as cnt +FROM ( + SELECT + user_id, + JSONExtractString(toString(data), 'path') as path, + lagInFrame(JSONExtractString(toString(data), 'path')) OVER ( + PARTITION BY user_id + ORDER BY event_at ASC + ) as prev_path + FROM default.events + WHERE event_type = '$page-view' + AND JSONExtractString(toString(data), 'path') != '' + AND user_id != '' +) sub +WHERE prev_path != '' AND prev_path != path +GROUP BY from_path, to_path +ORDER BY cnt DESC +LIMIT 500 +`; + +const PAGE_VIEWS_QUERY = ` +SELECT + JSONExtractString(toString(data), 'path') as path, + any(domain(JSONExtractString(toString(data), 'url'))) as page_domain, + count() as views +FROM default.events +WHERE event_type = '$page-view' + AND JSONExtractString(toString(data), 'path') != '' +GROUP BY path +ORDER BY views DESC +LIMIT 200 +`; + +const MIN_CARD_WIDTH = 100; +const MAX_CARD_WIDTH = 220; + +function computeCardWidth(label: string): number { + // ~6.5px per character in 11px monospace font, plus padding (20px) + const textWidth = label.length * 6.5 + 20; + return Math.max(MIN_CARD_WIDTH, Math.min(MAX_CARD_WIDTH, textWidth)); +} + +export default function PageClient() { + const adminApp = useAdminApp(); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const loadedRef = useRef(false); + + const loadData = useCallback(async () => { + setLoading(true); + setError(null); + try { + const response = await adminApp.queryAnalytics({ + query: NAVIGATION_QUERY, + include_all_branches: false, + timeout_ms: 30000, + }); + + const rows = response.result.map((r) => { + const from_path = r.from_path; + const to_path = r.to_path; + const cnt = r.cnt; + if (typeof from_path !== "string" || typeof to_path !== "string") { + throw new Error("Unexpected navigation query result shape: from_path/to_path must be strings"); + } + if (typeof cnt !== "string" && typeof cnt !== "number") { + throw new Error("Unexpected navigation query result shape: cnt must be string or number"); + } + return { from_path, to_path, cnt: String(cnt) } satisfies TransitionRow; + }); + + // Normalize paths and aggregate + const edgeMap = new Map(); + const nodeSet = new Set(); + + for (const row of rows) { + const fromNorm = normalizeUrlPath(row.from_path); + const toNorm = normalizeUrlPath(row.to_path); + if (fromNorm === toNorm) continue; + + const key = `${fromNorm}\0${toNorm}`; + const count = Number(row.cnt); + if (!Number.isFinite(count)) { + throw new Error(`Invalid count value: ${row.cnt}`); + } + edgeMap.set(key, (edgeMap.get(key) ?? 0) + count); + nodeSet.add(fromNorm); + nodeSet.add(toNorm); + } + + // Query page views and domain info + const pvResponse = await adminApp.queryAnalytics({ + query: PAGE_VIEWS_QUERY, + include_all_branches: false, + timeout_ms: 30000, + }); + + const pageViewsMap = new Map(); + for (const row of pvResponse.result) { + const path = row.path; + const domain = row.page_domain; + const views = row.views; + if (typeof path !== "string") continue; + const normPath = normalizeUrlPath(path); + const existing = pageViewsMap.get(normPath); + const viewCount = Number(views) || 0; + if (existing == null) { + pageViewsMap.set(normPath, { views: viewCount, domain: typeof domain === "string" ? domain : "" }); + } else { + existing.views += viewCount; + if (existing.domain === "" && typeof domain === "string") { + existing.domain = domain; + } + } + } + + // Build nodes + const nodeArray: GraphNode[] = Array.from(nodeSet).map((path) => { + const pvInfo = pageViewsMap.get(path); + return { + id: path, + label: path, + domain: pvInfo?.domain ?? "", + pageViews: pvInfo?.views ?? 0, + width: computeCardWidth(path), + x: 0, + y: 0, + }; + }); + + // Build edges + const allEdges: { from: string, to: string, count: number, weight: number }[] = []; + for (const [key, count] of edgeMap) { + const [from, to] = key.split("\0") as [string, string]; + allEdges.push({ + from, + to, + count, + weight: count, + }); + } + + // For each source node, find the max outgoing edge count + const maxOutgoing = new Map(); + for (const e of allEdges) { + const current = maxOutgoing.get(e.from) ?? 0; + if (e.count > current) { + maxOutgoing.set(e.from, e.count); + } + } + + // Filter: keep only edges with >= 10% of the strongest edge from the same source + const edges: GraphEdge[] = []; + const weakEdges: GraphEdge[] = []; + for (const e of allEdges) { + const maxFromSource = maxOutgoing.get(e.from) ?? 1; + if (e.count >= maxFromSource * 0.1) { + edges.push(e); + } else { + weakEdges.push(e); + } + } + + // Only include nodes that have at least one visible edge + const visibleNodes = new Set(); + for (const e of edges) { + visibleNodes.add(e.from); + visibleNodes.add(e.to); + } + const filteredNodeArray = nodeArray.filter((n) => visibleNodes.has(n.id)); + + // Compute ForceAtlas2 layout with landing page distance for x-position + // Only strong edges are used for force calculations + const laidOutNodes = computeLayout(filteredNodeArray, edges); + + setData({ nodes: laidOutNodes, edges, weakEdges }); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setLoading(false); + } + }, [adminApp]); + + useEffect(() => { + if (loadedRef.current) return; + loadedRef.current = true; + runAsynchronouslyWithAlert(loadData); + }, [loadData]); + + return ( + + runAsynchronouslyWithAlert(loadData)} + > + + Refresh + + } + > +
+ {loading && ( +
+ +
+ )} + {error != null && !loading && ( +
+ {error} + +
+ )} + {data != null && !loading && error == null && ( + + )} +
+
+
+ ); +} diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/funnel-graph/page.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/funnel-graph/page.tsx new file mode 100644 index 000000000..84cdebde1 --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/funnel-graph/page.tsx @@ -0,0 +1,5 @@ +import PageClient from "./page-client"; + +export default function Page() { + return ; +}