diff --git a/apps/start/package.json b/apps/start/package.json index ea949289..a3dbf91c 100644 --- a/apps/start/package.json +++ b/apps/start/package.json @@ -25,6 +25,7 @@ "@faker-js/faker": "^9.6.0", "@hookform/resolvers": "^3.3.4", "@hyperdx/node-opentelemetry": "^0.8.1", + "@nivo/sankey": "^0.99.0", "@number-flow/react": "0.3.5", "@openpanel/common": "workspace:^", "@openpanel/constants": "workspace:^", @@ -172,4 +173,4 @@ "web-vitals": "^4.2.4", "wrangler": "^4.42.2" } -} +} \ No newline at end of file diff --git a/apps/start/src/components/charts/chart-tooltip.tsx b/apps/start/src/components/charts/chart-tooltip.tsx index 9fe73e33..7d49980a 100644 --- a/apps/start/src/components/charts/chart-tooltip.tsx +++ b/apps/start/src/components/charts/chart-tooltip.tsx @@ -5,9 +5,15 @@ import { Tooltip as RechartsTooltip, type TooltipProps } from 'recharts'; export const ChartTooltipContainer = ({ children, -}: { children: React.ReactNode }) => { + className, +}: { children: React.ReactNode; className?: string }) => { return ( -
+
{children}
); diff --git a/apps/start/src/components/overview/overview-top-geo.tsx b/apps/start/src/components/overview/overview-top-geo.tsx index 551c50a6..6c788cb3 100644 --- a/apps/start/src/components/overview/overview-top-geo.tsx +++ b/apps/start/src/components/overview/overview-top-geo.tsx @@ -130,7 +130,7 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) { /> )} - + pushModal('OverviewTopGenericModal', { @@ -140,6 +140,17 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) { } /> {/* */} + + Geo data provided by{' '} + + MaxMind + + diff --git a/apps/start/src/components/overview/overview-user-journey.tsx b/apps/start/src/components/overview/overview-user-journey.tsx new file mode 100644 index 00000000..d3894ae6 --- /dev/null +++ b/apps/start/src/components/overview/overview-user-journey.tsx @@ -0,0 +1,408 @@ +import { + ChartTooltipContainer, + ChartTooltipHeader, + ChartTooltipItem, +} from '@/components/charts/chart-tooltip'; +import { useEventQueryFilters } from '@/hooks/use-event-query-filters'; +import { useNumber } from '@/hooks/use-numer-formatter'; +import { cn } from '@/utils/cn'; +import { round } from '@/utils/math'; +import { ResponsiveSankey } from '@nivo/sankey'; +import { parseAsInteger, useQueryState } from 'nuqs'; +import { + type ReactNode, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { createPortal } from 'react-dom'; + +import { useTRPC } from '@/integrations/trpc/react'; +import { truncate } from '@/utils/truncate'; +import { useQuery } from '@tanstack/react-query'; +import { ArrowRightIcon } from 'lucide-react'; +import { useTheme } from '../theme-provider'; +import { Widget, WidgetBody } from '../widget'; +import { WidgetButtons, WidgetFooter, WidgetHead } from './overview-widget'; +import { useOverviewOptions } from './useOverviewOptions'; + +interface OverviewUserJourneyProps { + projectId: string; +} + +type PortalTooltipPosition = { left: number; top: number; ready: boolean }; + +const showPath = (string: string) => { + try { + const url = new URL(string); + return url.pathname; + } catch { + return string; + } +}; + +const showDomain = (string: string) => { + try { + const url = new URL(string); + return url.hostname; + } catch { + return string; + } +}; + +function SankeyPortalTooltip({ + children, + offset = 12, + padding = 8, +}: { + children: ReactNode; + offset?: number; + padding?: number; +}) { + const anchorRef = useRef(null); + const tooltipRef = useRef(null); + const [anchorRect, setAnchorRect] = useState(null); + const [pos, setPos] = useState({ + left: 0, + top: 0, + ready: false, + }); + const [mounted, setMounted] = useState(false); + + useLayoutEffect(() => { + setMounted(true); + }, []); + + useLayoutEffect(() => { + const el = anchorRef.current; + if (!el) return; + + // Nivo renders the tooltip content inside an absolutely-positioned wrapper
. + // The wrapper is the immediate parent of our rendered content. + const wrapper = el.parentElement; + if (!wrapper) return; + + const update = () => { + setAnchorRect(wrapper.getBoundingClientRect()); + }; + + update(); + + const ro = new ResizeObserver(update); + ro.observe(wrapper); + + window.addEventListener('scroll', update, true); + window.addEventListener('resize', update); + + return () => { + ro.disconnect(); + window.removeEventListener('scroll', update, true); + window.removeEventListener('resize', update); + }; + }, []); + + useLayoutEffect(() => { + if (!mounted) return; + if (!anchorRect) return; + const tooltipEl = tooltipRef.current; + if (!tooltipEl) return; + + const rect = tooltipEl.getBoundingClientRect(); + const vw = window.innerWidth; + const vh = window.innerHeight; + + // Start by following Nivo's tooltip anchor position. + let left = anchorRect.left + offset; + let top = anchorRect.top + offset; + + // Clamp inside viewport with a little padding. + left = Math.min( + Math.max(padding, left), + Math.max(padding, vw - rect.width - padding), + ); + top = Math.min( + Math.max(padding, top), + Math.max(padding, vh - rect.height - padding), + ); + + setPos({ left, top, ready: true }); + }, [mounted, anchorRect, children, offset, padding]); + + // SSR safety: on the server, just render the tooltip normally. + if (typeof document === 'undefined') { + return <>{children}; + } + + return ( + <> + {/* Render a tiny (screen-reader-only) anchor inside Nivo's tooltip wrapper. */} + + {mounted && + createPortal( +
+ {children} +
, + document.body, + )} + + ); +} + +export default function OverviewUserJourney({ + projectId, +}: OverviewUserJourneyProps) { + const { range, startDate, endDate } = useOverviewOptions(); + const [filters] = useEventQueryFilters(); + const [steps, setSteps] = useQueryState( + 'journeySteps', + parseAsInteger.withDefault(5).withOptions({ history: 'push' }), + ); + const containerRef = useRef(null); + const trpc = useTRPC(); + + const query = useQuery( + trpc.overview.userJourney.queryOptions({ + projectId, + filters, + startDate, + endDate, + range, + steps: steps ?? 5, + }), + ); + + const data = query.data; + const number = useNumber(); + + // Process data for Sankey - nodes are already sorted by step then value from backend + const sankeyData = useMemo(() => { + if (!data) return { nodes: [], links: [] }; + + return { + nodes: data.nodes.map((node: any) => ({ + ...node, + // Store label for display in tooltips + label: node.label || node.id, + data: { + percentage: node.percentage, + value: node.value, + step: node.step, + label: node.label || node.id, + }, + })), + links: data.links, + }; + }, [data]); + + const totalSessions = useMemo(() => { + if (!sankeyData.nodes || sankeyData.nodes.length === 0) return 0; + // Total sessions used by backend for percentages is the sum of entry nodes (step 1). + // Fall back to summing all nodes if step is missing for some reason. + const step1 = sankeyData.nodes.filter((n: any) => n.data?.step === 1); + const base = step1.length > 0 ? step1 : sankeyData.nodes; + return base.reduce((sum: number, n: any) => sum + (n.data?.value ?? 0), 0); + }, [sankeyData.nodes]); + + const stepOptions = [3, 5]; + + const { appTheme } = useTheme(); + + return ( + + +
User Journey
+ + {stepOptions.map((option) => ( + + ))} + +
+ + {query.isLoading ? ( +
+
Loading...
+
+ ) : sankeyData.nodes.length === 0 ? ( +
+
+ No journey data available +
+
+ ) : ( +
+ node.nodeColor} + nodeBorderRadius={2} + animate={false} + nodeBorderWidth={0} + nodeOpacity={0.8} + linkContract={1} + linkOpacity={0.3} + linkBlendMode={'normal'} + nodeTooltip={({ node }: any) => { + const label = node?.data?.label ?? node?.label ?? node?.id; + const value = node?.data?.value ?? node?.value ?? 0; + const step = node?.data?.step; + const pct = + typeof node?.data?.percentage === 'number' + ? node.data.percentage + : totalSessions > 0 + ? (value / totalSessions) * 100 + : 0; + const color = + node?.color ?? + node?.data?.nodeColor ?? + node?.data?.color ?? + node?.nodeColor ?? + '#64748b'; + + return ( + + + +
+ + {showDomain(label)} + + {showPath(label)} +
+ {typeof step === 'number' && ( +
+ Step {step} +
+ )} +
+ +
+
Sessions
+
{number.format(value)}
+
+
+
Share
+
{number.format(round(pct, 1))} %
+
+
+
+
+ ); + }} + linkTooltip={({ link }: any) => { + const sourceLabel = + link?.source?.data?.label ?? + link?.source?.label ?? + link?.source?.id; + const targetLabel = + link?.target?.data?.label ?? + link?.target?.label ?? + link?.target?.id; + + const value = link?.value ?? 0; + const sourceValue = + link?.source?.data?.value ?? link?.source?.value ?? 0; + + const pctOfTotal = + totalSessions > 0 ? (value / totalSessions) * 100 : 0; + const pctOfSource = + sourceValue > 0 ? (value / sourceValue) * 100 : 0; + + const sourceStep = link?.source?.data?.step; + const targetStep = link?.target?.data?.step; + + const color = + link?.color ?? + link?.source?.color ?? + link?.source?.data?.nodeColor ?? + '#64748b'; + + const sourceDomain = showDomain(sourceLabel); + const targetDomain = showDomain(targetLabel); + const isSameDomain = sourceDomain === targetDomain; + + return ( + + + +
+ + {showDomain(sourceLabel)} + + {showPath(sourceLabel)} + + {!isSameDomain && ( + + {showDomain(targetLabel)} + + )} + {showPath(targetLabel)} +
+ {typeof sourceStep === 'number' && + typeof targetStep === 'number' && ( +
+ {sourceStep} → {targetStep} +
+ )} +
+ + +
+
Sessions
+
{number.format(value)}
+
+
+
+ % of total +
+
{number.format(round(pctOfTotal, 1))} %
+
+
+
+ % of source +
+
{number.format(round(pctOfSource, 1))} %
+
+
+
+
+ ); + }} + label={(node: any) => { + const label = showPath( + node.data?.label || node.label || node.id, + ); + return truncate(label, 30, 'middle'); + }} + labelTextColor={appTheme === 'dark' ? '#e2e8f0' : '#0f172a'} + nodeSpacing={10} + /> +
+ )} +
+ +
+ Shows the most common paths users take through your application +
+
+
+ ); +} diff --git a/apps/start/src/routes/_app.$organizationId.$projectId.index.tsx b/apps/start/src/routes/_app.$organizationId.$projectId.index.tsx index ed241c53..3ced28c0 100644 --- a/apps/start/src/routes/_app.$organizationId.$projectId.index.tsx +++ b/apps/start/src/routes/_app.$organizationId.$projectId.index.tsx @@ -13,6 +13,7 @@ import OverviewTopEvents from '@/components/overview/overview-top-events'; import OverviewTopGeo from '@/components/overview/overview-top-geo'; import OverviewTopPages from '@/components/overview/overview-top-pages'; import OverviewTopSources from '@/components/overview/overview-top-sources'; +import OverviewUserJourney from '@/components/overview/overview-user-journey'; import { PAGE_TITLES, createProjectTitle } from '@/utils/title'; import { createFileRoute } from '@tanstack/react-router'; @@ -57,6 +58,7 @@ function ProjectDashboard() { +
); diff --git a/apps/start/src/utils/theme.ts b/apps/start/src/utils/theme.ts index 111aa87b..0f210251 100644 --- a/apps/start/src/utils/theme.ts +++ b/apps/start/src/utils/theme.ts @@ -1,27 +1,13 @@ // import resolveConfig from 'tailwindcss/resolveConfig'; +import { chartColors } from '@openpanel/constants'; + // import tailwinConfig from '../../tailwind.config'; // export const resolvedTailwindConfig = resolveConfig(tailwinConfig); // export const theme = resolvedTailwindConfig.theme as Record; -const chartColors = [ - { main: '#2563EB', translucent: 'rgba(37, 99, 235, 0.1)' }, - { main: '#ff7557', translucent: 'rgba(255, 117, 87, 0.1)' }, - { main: '#7fe1d8', translucent: 'rgba(127, 225, 216, 0.1)' }, - { main: '#f8bc3c', translucent: 'rgba(248, 188, 60, 0.1)' }, - { main: '#b3596e', translucent: 'rgba(179, 89, 110, 0.1)' }, - { main: '#72bef4', translucent: 'rgba(114, 190, 244, 0.1)' }, - { main: '#ffb27a', translucent: 'rgba(255, 178, 122, 0.1)' }, - { main: '#0f7ea0', translucent: 'rgba(15, 126, 160, 0.1)' }, - { main: '#3ba974', translucent: 'rgba(59, 169, 116, 0.1)' }, - { main: '#febbb2', translucent: 'rgba(254, 187, 178, 0.1)' }, - { main: '#cb80dc', translucent: 'rgba(203, 128, 220, 0.1)' }, - { main: '#5cb7af', translucent: 'rgba(92, 183, 175, 0.1)' }, - { main: '#7856ff', translucent: 'rgba(120, 86, 255, 0.1)' }, -]; - export function getChartColor(index: number): string { return chartColors[index % chartColors.length]?.main || chartColors[0].main; } diff --git a/apps/start/src/utils/truncate.ts b/apps/start/src/utils/truncate.ts index 5a3e9af3..15978540 100644 --- a/apps/start/src/utils/truncate.ts +++ b/apps/start/src/utils/truncate.ts @@ -1,6 +1,16 @@ -export function truncate(str: string, len: number) { +export function truncate( + str: string, + len: number, + mode: 'start' | 'end' | 'middle' = 'end', +) { if (str.length <= len) { return str; } + if (mode === 'start') { + return `...${str.slice(-len)}`; + } + if (mode === 'middle') { + return `${str.slice(0, len / 2)}...${str.slice(-len / 2)}`; + } return `${str.slice(0, len)}...`; } diff --git a/packages/constants/index.ts b/packages/constants/index.ts index aff1b11d..e644eec7 100644 --- a/packages/constants/index.ts +++ b/packages/constants/index.ts @@ -501,3 +501,19 @@ export const countries = { export function getCountry(code?: string) { return countries[code as keyof typeof countries]; } + +export const chartColors = [ + { main: '#2563EB', translucent: 'rgba(37, 99, 235, 0.1)' }, + { main: '#ff7557', translucent: 'rgba(255, 117, 87, 0.1)' }, + { main: '#7fe1d8', translucent: 'rgba(127, 225, 216, 0.1)' }, + { main: '#f8bc3c', translucent: 'rgba(248, 188, 60, 0.1)' }, + { main: '#b3596e', translucent: 'rgba(179, 89, 110, 0.1)' }, + { main: '#72bef4', translucent: 'rgba(114, 190, 244, 0.1)' }, + { main: '#ffb27a', translucent: 'rgba(255, 178, 122, 0.1)' }, + { main: '#0f7ea0', translucent: 'rgba(15, 126, 160, 0.1)' }, + { main: '#3ba974', translucent: 'rgba(59, 169, 116, 0.1)' }, + { main: '#febbb2', translucent: 'rgba(254, 187, 178, 0.1)' }, + { main: '#cb80dc', translucent: 'rgba(203, 128, 220, 0.1)' }, + { main: '#5cb7af', translucent: 'rgba(92, 183, 175, 0.1)' }, + { main: '#7856ff', translucent: 'rgba(120, 86, 255, 0.1)' }, +]; diff --git a/packages/db/src/services/overview.service.ts b/packages/db/src/services/overview.service.ts index f74895e3..a58c05a2 100644 --- a/packages/db/src/services/overview.service.ts +++ b/packages/db/src/services/overview.service.ts @@ -1,4 +1,5 @@ import { average, sum } from '@openpanel/common'; +import { chartColors } from '@openpanel/constants'; import { getCache } from '@openpanel/redis'; import { type IChartEventFilter, zTimeInterval } from '@openpanel/validation'; import { omit } from 'ramda'; @@ -104,6 +105,18 @@ export type IGetTopGenericInput = z.infer & { timezone: string; }; +export const zGetUserJourneyInput = z.object({ + projectId: z.string(), + filters: z.array(z.any()), + startDate: z.string(), + endDate: z.string(), + steps: z.number().min(2).max(10).default(5), +}); + +export type IGetUserJourneyInput = z.infer & { + timezone: string; +}; + export class OverviewService { constructor(private client: typeof ch) {} @@ -729,6 +742,345 @@ export class OverviewService { return mainQuery.execute(); } + + async getUserJourney({ + projectId, + filters, + startDate, + endDate, + steps = 5, + timezone, + }: IGetUserJourneyInput): Promise<{ + nodes: Array<{ + id: string; + label: string; + nodeColor: string; + percentage?: number; + value?: number; + step?: number; + }>; + links: Array<{ source: string; target: string; value: number }>; + }> { + // Config + const TOP_ENTRIES = 3; // Only show top 3 entry pages + const TOP_DESTINATIONS_PER_NODE = 3; // Top 3 destinations from each node + + // Color palette - each entry page gets a consistent color + const COLORS = chartColors.map((color) => color.main); + + // Step 1: Get session paths (deduped consecutive pages) + const orderedEventsQuery = clix(this.client, timezone) + .select<{ + session_id: string; + path: string; + created_at: string; + }>(['session_id', 'concat(origin, path) as path', 'created_at']) + .from(TABLE_NAMES.events) + .where('project_id', '=', projectId) + .where('name', '=', 'screen_view') + .where('path', '!=', '') + .where('path', 'IS NOT NULL') + .where('created_at', 'BETWEEN', [ + clix.datetime(startDate, 'toDateTime'), + clix.datetime(endDate, 'toDateTime'), + ]) + .rawWhere(this.getRawWhereClause('events', filters)) + .orderBy('session_id', 'ASC') + .orderBy('created_at', 'ASC'); + + // Intermediate CTE to compute deduped paths + const pathsDedupedCTE = clix(this.client, timezone) + .with('ordered_events', orderedEventsQuery) + .select<{ + session_id: string; + paths_deduped: string[]; + }>([ + 'session_id', + `arraySlice( + arrayFilter( + (x, i) -> i = 1 OR x != paths_raw[i - 1], + groupArray(path) as paths_raw, + arrayEnumerate(paths_raw) + ), + 1, ${steps} + ) as paths_deduped`, + ]) + .from('ordered_events') + .groupBy(['session_id']); + + const sessionPathsQuery = clix(this.client, timezone) + .with('paths_deduped_cte', pathsDedupedCTE) + .select<{ + session_id: string; + entry_page: string; + paths: string[]; + }>([ + 'session_id', + // Truncate at first repeat + `if( + arrayFirstIndex(x -> x > 1, arrayEnumerateUniq(paths_deduped)) = 0, + paths_deduped, + arraySlice( + paths_deduped, + 1, + arrayFirstIndex(x -> x > 1, arrayEnumerateUniq(paths_deduped)) - 1 + ) + ) as paths`, + // Entry page is first element + 'paths[1] as entry_page', + ]) + .from('paths_deduped_cte') + .having('length(paths)', '>=', 2); + + // Step 2: Find top 3 entry pages + const topEntriesQuery = clix(this.client, timezone) + .with('session_paths', sessionPathsQuery) + .select<{ entry_page: string; count: number }>([ + 'entry_page', + 'count() as count', + ]) + .from('session_paths') + .groupBy(['entry_page']) + .orderBy('count', 'DESC') + .limit(TOP_ENTRIES); + + const topEntries = await topEntriesQuery.execute(); + + if (topEntries.length === 0) { + return { nodes: [], links: [] }; + } + + const topEntryPages = topEntries.map((e) => e.entry_page); + const totalSessions = topEntries.reduce((sum, e) => sum + e.count, 0); + + // Step 3: Get all transitions, but ONLY for sessions starting with top entries + const transitionsQuery = clix(this.client, timezone) + .with('paths_deduped_cte', pathsDedupedCTE) + .with( + 'session_paths', + clix(this.client, timezone) + .select([ + 'session_id', + // Truncate at first repeat + `if( + arrayFirstIndex(x -> x > 1, arrayEnumerateUniq(paths_deduped)) = 0, + paths_deduped, + arraySlice( + paths_deduped, + 1, + arrayFirstIndex(x -> x > 1, arrayEnumerateUniq(paths_deduped)) - 1 + ) + ) as paths`, + ]) + .from('paths_deduped_cte') + .having('length(paths)', '>=', 2) + // ONLY sessions starting with top entry pages + .having('paths[1]', 'IN', topEntryPages), + ) + .select<{ + source: string; + target: string; + step: number; + value: number; + }>([ + 'pair.1 as source', + 'pair.2 as target', + 'pair.3 as step', + 'count() as value', + ]) + .from( + clix.exp( + '(SELECT arrayJoin(arrayMap(i -> (paths[i], paths[i + 1], i), range(1, length(paths)))) as pair FROM session_paths WHERE length(paths) >= 2)', + ), + ) + .groupBy(['source', 'target', 'step']) + .orderBy('step', 'ASC') + .orderBy('value', 'DESC'); + + const transitions = await transitionsQuery.execute(); + + if (transitions.length === 0) { + return { nodes: [], links: [] }; + } + + // Step 4: Build the sankey progressively step by step + // Start with entry nodes, then follow top destinations at each step + // Use unique node IDs by combining path with step to prevent circular references + const nodes = new Map< + string, + { path: string; value: number; step: number; color: string } + >(); + const links: Array<{ source: string; target: string; value: number }> = []; + + // Helper to create unique node ID + const getNodeId = (path: string, step: number) => `${path}::step${step}`; + + // Group transitions by step + const transitionsByStep = new Map(); + for (const t of transitions) { + if (!transitionsByStep.has(t.step)) { + transitionsByStep.set(t.step, []); + } + transitionsByStep.get(t.step)!.push(t); + } + + // Initialize with entry pages (step 1) + const activeNodes = new Map(); // path -> nodeId + topEntries.forEach((entry, idx) => { + const nodeId = getNodeId(entry.entry_page, 1); + nodes.set(nodeId, { + path: entry.entry_page, + value: entry.count, + step: 1, + color: COLORS[idx % COLORS.length]!, + }); + activeNodes.set(entry.entry_page, nodeId); + }); + + // Process each step: from active nodes, find top destinations + for (let step = 1; step < steps; step++) { + const stepTransitions = transitionsByStep.get(step) || []; + const nextActiveNodes = new Map(); + + // For each currently active node, find its top destinations + for (const [sourcePath, sourceNodeId] of activeNodes) { + // Get transitions FROM this source path + const fromSource = stepTransitions + .filter((t) => t.source === sourcePath) + .sort((a, b) => b.value - a.value) + .slice(0, TOP_DESTINATIONS_PER_NODE); + + for (const t of fromSource) { + // Skip self-loops + if (t.source === t.target) continue; + + const targetNodeId = getNodeId(t.target, step + 1); + + // Add link using unique node IDs + links.push({ + source: sourceNodeId, + target: targetNodeId, + value: t.value, + }); + + // Add/update target node + const existing = nodes.get(targetNodeId); + if (existing) { + existing.value += t.value; + } else { + // Inherit color from source or assign new + const sourceData = nodes.get(sourceNodeId); + nodes.set(targetNodeId, { + path: t.target, + value: t.value, + step: step + 1, + color: sourceData?.color || COLORS[nodes.size % COLORS.length]!, + }); + } + + nextActiveNodes.set(t.target, targetNodeId); + } + } + + // Update active nodes for next iteration + activeNodes.clear(); + for (const [path, nodeId] of nextActiveNodes) { + activeNodes.set(path, nodeId); + } + + // Stop if no more nodes to process + if (activeNodes.size === 0) break; + } + + // Step 5: Filter links by threshold (0.25% of total sessions) + const MIN_LINK_PERCENT = 0.25; + const minLinkValue = Math.ceil((totalSessions * MIN_LINK_PERCENT) / 100); + const filteredLinks = links.filter((link) => link.value >= minLinkValue); + + // Step 6: Find all nodes referenced by remaining links + const referencedNodeIds = new Set(); + filteredLinks.forEach((link) => { + referencedNodeIds.add(link.source); + referencedNodeIds.add(link.target); + }); + + // Step 7: Recompute node values from filtered links (sum of incoming links) + const nodeValuesFromLinks = new Map(); + filteredLinks.forEach((link) => { + // Add to target node value + const current = nodeValuesFromLinks.get(link.target) || 0; + nodeValuesFromLinks.set(link.target, current + link.value); + }); + + // For entry nodes (step 1), only keep them if they have outgoing links after filtering + nodes.forEach((nodeData, nodeId) => { + if (nodeData.step === 1) { + const hasOutgoing = filteredLinks.some((l) => l.source === nodeId); + if (!hasOutgoing) { + // No outgoing links, remove entry node + referencedNodeIds.delete(nodeId); + } + } + }); + + // Step 8: Build final nodes array sorted by step then value + // Only include nodes that are referenced by filtered links + const finalNodes = Array.from(nodes.entries()) + .filter(([id]) => referencedNodeIds.has(id)) + .map(([id, data]) => { + // Use value from links for non-entry nodes, or original value for entry nodes with outgoing links + const value = + data.step === 1 + ? data.value + : nodeValuesFromLinks.get(id) || data.value; + return { + id, + label: data.path, // Add label for display + nodeColor: data.color, + percentage: (value / totalSessions) * 100, + value, + step: data.step, + }; + }) + .sort((a, b) => { + // Sort by step first, then by value descending + if (a.step !== b.step) return a.step - b.step; + return b.value - a.value; + }); + + // Sanity check: Ensure all link endpoints exist in nodes + const nodeIds = new Set(finalNodes.map((n) => n.id)); + const invalidLinks = filteredLinks.filter( + (link) => !nodeIds.has(link.source) || !nodeIds.has(link.target), + ); + if (invalidLinks.length > 0) { + console.warn( + `UserJourney: Found ${invalidLinks.length} links with missing nodes`, + ); + // Remove invalid links + const validLinks = filteredLinks.filter( + (link) => nodeIds.has(link.source) && nodeIds.has(link.target), + ); + return { + nodes: finalNodes, + links: validLinks, + }; + } + + // Sanity check: Ensure steps are monotonic (should always be true, but verify) + const stepsValid = finalNodes.every((node, idx, arr) => { + if (idx === 0) return true; + return node.step! >= arr[idx - 1]!.step!; + }); + if (!stepsValid) { + console.warn('UserJourney: Steps are not monotonic'); + } + + return { + nodes: finalNodes, + links: filteredLinks, + }; + } } export const overviewService = new OverviewService(ch); diff --git a/packages/trpc/src/routers/overview.ts b/packages/trpc/src/routers/overview.ts index fd4b69c0..83b58517 100644 --- a/packages/trpc/src/routers/overview.ts +++ b/packages/trpc/src/routers/overview.ts @@ -11,6 +11,7 @@ import { zGetMetricsInput, zGetTopGenericInput, zGetTopPagesInput, + zGetUserJourneyInput, } from '@openpanel/db'; import { type IChartRange, zRange } from '@openpanel/validation'; import { format } from 'date-fns'; @@ -301,6 +302,33 @@ export const overviewRouter = createTRPCRouter({ timezone, )(overviewService.getTopGeneric.bind(overviewService)); + return current; + }), + + userJourney: publicProcedure + .input( + zGetUserJourneyInput.omit({ startDate: true, endDate: true }).extend({ + startDate: z.string().nullish(), + endDate: z.string().nullish(), + range: zRange, + steps: z.number().min(2).max(10).default(5).optional(), + }), + ) + .use(cacher) + .query(async ({ input }) => { + const { timezone } = await getSettingsForProject(input.projectId); + const { current } = await getCurrentAndPrevious( + { ...input, timezone }, + false, + timezone, + )(async (input) => { + return overviewService.getUserJourney({ + ...input, + steps: input.steps ?? 5, + timezone, + }); + }); + return current; }), }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c45412d5..840bf6c5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -416,6 +416,9 @@ importers: '@hyperdx/node-opentelemetry': specifier: ^0.8.1 version: 0.8.1 + '@nivo/sankey': + specifier: ^0.99.0 + version: 0.99.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@number-flow/react': specifier: 0.3.5 version: 0.3.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -4803,6 +4806,16 @@ packages: peerDependencies: react: ^16.14 || ^17.0 || ^18.0 || ^19.0 + '@nivo/legends@0.99.0': + resolution: {integrity: sha512-P16FjFqNceuTTZphINAh5p0RF0opu3cCKoWppe2aRD9IuVkvRm/wS5K1YwMCxDzKyKh5v0AuTlu9K6o3/hk8hA==} + peerDependencies: + react: ^16.14 || ^17.0 || ^18.0 || ^19.0 + + '@nivo/sankey@0.99.0': + resolution: {integrity: sha512-u5hySywsachjo9cHdUxCR9qwD6gfRVPEAcpuIUKiA0WClDjdGbl3vkrQcQcFexJUBThqSSbwGCDWR+2INXSbTw==} + peerDependencies: + react: ^16.14 || ^17.0 || ^18.0 || ^19.0 + '@nivo/text@0.99.0': resolution: {integrity: sha512-ho3oZpAZApsJNjsIL5WJSAdg/wjzTBcwo1KiHBlRGUmD+yUWO8qp7V+mnYRhJchwygtRVALlPgZ/rlcW2Xr/MQ==} peerDependencies: @@ -8347,6 +8360,9 @@ packages: '@types/d3-interpolate@3.0.4': resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + '@types/d3-path@1.0.11': + resolution: {integrity: sha512-4pQMp8ldf7UaB/gR8Fvvy69psNHkTpD/pVw3vmEi8iZAB9EPMBruB1JvHO4BIq9QkUUd2lV1F5YXpMNj7JPBpw==} + '@types/d3-path@3.1.0': resolution: {integrity: sha512-P2dlU/q51fkOc/Gfl3Ul9kicV7l+ra934qBFXCFhrZMOL6du1TM0pm1ThYvENukyOn5h9v+yMJ9Fn5JK4QozrQ==} @@ -8359,6 +8375,9 @@ packages: '@types/d3-random@3.0.3': resolution: {integrity: sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==} + '@types/d3-sankey@0.11.2': + resolution: {integrity: sha512-U6SrTWUERSlOhnpSrgvMX64WblX1AxX6nEjI2t3mLK2USpQrnbwYYK+AS9SwiE7wgYmOsSSKoSdr8aoKBH0HgQ==} + '@types/d3-scale-chromatic@3.0.3': resolution: {integrity: sha512-laXM4+1o5ImZv3RpFAsTRn3TEkzqkytiOY0Dz0sq5cnd1dtNlk6sHLon4OvqaiJb28T0S/TdsBI3Sjsy+keJrw==} @@ -8371,6 +8390,9 @@ packages: '@types/d3-selection@3.0.10': resolution: {integrity: sha512-cuHoUgS/V3hLdjJOLTT691+G2QoqAjCVLmr4kJXR4ha56w1Zdu8UUQ5TxLRqudgNjwXeQxKMq4j+lyf9sWuslg==} + '@types/d3-shape@1.3.12': + resolution: {integrity: sha512-8oMzcd4+poSLGgV0R1Q1rOlx/xdmozS4Xab7np0eamFFUYq71AU9pOCJEFnkXW2aI/oXdVYJzw6pssbSut7Z9Q==} + '@types/d3-shape@3.1.6': resolution: {integrity: sha512-5KKk5aKGu2I+O6SONMYSNflgiP0WfZIQvVUMan50wHsLG1G94JlxEVnCpQARfTtzytuY0p/9PXXZb3I7giofIA==} @@ -9916,6 +9938,9 @@ packages: resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} engines: {node: '>=12'} + d3-path@1.0.9: + resolution: {integrity: sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==} + d3-path@3.1.0: resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} engines: {node: '>=12'} @@ -9932,6 +9957,9 @@ packages: resolution: {integrity: sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==} engines: {node: '>=12'} + d3-sankey@0.12.3: + resolution: {integrity: sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==} + d3-scale-chromatic@3.0.0: resolution: {integrity: sha512-Lx9thtxAKrO2Pq6OO2Ua474opeziKr279P/TKZsMAhYyNDD3EnCffdbgeSYN5O7m2ByQsxtuP2CSDczNUIZ22g==} engines: {node: '>=12'} @@ -9947,6 +9975,9 @@ packages: resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==} engines: {node: '>=12'} + d3-shape@1.3.7: + resolution: {integrity: sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==} + d3-shape@3.2.0: resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} engines: {node: '>=12'} @@ -19762,6 +19793,36 @@ snapshots: transitivePeerDependencies: - react-dom + '@nivo/legends@0.99.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@nivo/colors': 0.99.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@nivo/core': 0.99.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@nivo/text': 0.99.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@nivo/theming': 0.99.0(react@19.2.3) + '@types/d3-scale': 4.0.8 + d3-scale: 4.0.2 + react: 19.2.3 + transitivePeerDependencies: + - react-dom + + '@nivo/sankey@0.99.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@nivo/colors': 0.99.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@nivo/core': 0.99.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@nivo/legends': 0.99.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@nivo/text': 0.99.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@nivo/theming': 0.99.0(react@19.2.3) + '@nivo/tooltip': 0.99.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@react-spring/web': 9.7.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@types/d3-sankey': 0.11.2 + '@types/d3-shape': 3.1.6 + d3-sankey: 0.12.3 + d3-shape: 3.2.0 + lodash: 4.17.21 + react: 19.2.3 + transitivePeerDependencies: + - react-dom + '@nivo/text@0.99.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@nivo/core': 0.99.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -23841,6 +23902,8 @@ snapshots: dependencies: '@types/d3-color': 3.1.3 + '@types/d3-path@1.0.11': {} + '@types/d3-path@3.1.0': {} '@types/d3-polygon@3.0.2': {} @@ -23849,6 +23912,10 @@ snapshots: '@types/d3-random@3.0.3': {} + '@types/d3-sankey@0.11.2': + dependencies: + '@types/d3-shape': 1.3.12 + '@types/d3-scale-chromatic@3.0.3': {} '@types/d3-scale@4.0.8': @@ -23859,6 +23926,10 @@ snapshots: '@types/d3-selection@3.0.10': {} + '@types/d3-shape@1.3.12': + dependencies: + '@types/d3-path': 1.0.11 + '@types/d3-shape@3.1.6': dependencies: '@types/d3-path': 3.1.0 @@ -25754,6 +25825,8 @@ snapshots: dependencies: d3-color: 3.1.0 + d3-path@1.0.9: {} + d3-path@3.1.0: {} d3-polygon@3.0.1: {} @@ -25762,6 +25835,11 @@ snapshots: d3-random@3.0.1: {} + d3-sankey@0.12.3: + dependencies: + d3-array: 2.12.1 + d3-shape: 1.3.7 + d3-scale-chromatic@3.0.0: dependencies: d3-color: 3.1.0 @@ -25779,6 +25857,10 @@ snapshots: d3-selection@3.0.0: {} + d3-shape@1.3.7: + dependencies: + d3-path: 1.0.9 + d3-shape@3.2.0: dependencies: d3-path: 3.1.0